一文带你搞懂时间复杂度

28 阅读6分钟

一文带你搞懂时间复杂度

在开始学习之前我们先对思考两个问题。

Q1:What?什么是复杂度?

A1:在算法中复杂度分为空间复杂度时间复杂度,主要用来衡量算法占用空间和运算时间。

Q2:Why?为什么要学习复杂度

A2:首先数据结构与算法的意义本身就是为了让程序跑得快用得少,让代码运行的更快,占用的存储空间更 少。那我们就需要引入一个东西来衡量快慢与多少,那就是空间复杂度和时间复杂度。

代码的执行流程

Coding & Compilation ➡ Loading ➡ Runtime Execution ➡ Termination

对于任何一段代码的“生命周期”,基本上都可以看成这四步,那我们想知道代码跑得快不快需要去了解在 Runtime 阶段都花了多少时间,所以引入了时间复杂度。

时间复杂度计算

例子引入

func calculateSum(n int) int {
    sum := 0  // 1 time
    for i := 0; i < n; i++ {  // n time
       sum += i  // n time
    }
    return sum  // 1 time
}

以这段代码为例子,这是一个简单的累加运算代码,首先,我们统一一个时间单位一行代码执行一次的时间为 1 time。那么第二行执行时间为 1 time,第三、四行代码是一个 for 循环,每执行一次就算 1 time,那么他们都执行了 n 次,所以这个 for 循环执行所花的时间为 2n time,最后第六行执行的时间也为 1 time。那么这段代码运行总共需要花的时间为 T1(n) = (2n + 2)time,我们可以得出一个结论:一段代码执行所需要花的时间 T(n) 与每行代码执行的次数成正比

func calculateSumSquared(n int) int {
    sum := 0  // 1 time
    for i := 0; i < n; i++ {  // n time
       for j := 0; j < n; j++ {  // n × n = n² time
          sum += i + j  //  n × n = n² time
       }
    }

    return sum  // 1 time
}

再来看这段代码,同理,第二行代码的执行时间为 1 time,第三行代码执行了 n time,来到第四、五行,我们可以看到因为这是一个嵌套循环,所以外层循环每执行一次,我们内层循环就会执行一次,所以第四、五的代码执行时间都为 n²,那么这段代码运行总共需要花的时间为 T2(n) = (2n² + n + 2)time。

时间复杂度是什么?

通过上面的两个例子,我们就可以引出时间复杂度了,大 O。时间复杂度通常用大写的 O 来表示,它是一个函数,是一个用来描述算法运行时间在输入规模趋向无穷大时,其增长率的“渐近上界”(即最坏情况下的天花板)的数学记号,“渐近”的意思就表示这个函数不包括低阶项首项系数,“上界”则表示这个算法的最坏情况也就是表示性能的天花板了。大 O 是一个函数而不是具体时间是因为其实代码的运行时间不单单跟算法的本身有关系,我们还得考虑具体的数据量以及机器的性能。 实际运行时间 (Real Time)=动作总数量 (Operations)×机器处理每个动作的速度 (Hardware Speed)\text{实际运行时间 (Real Time)} = \text{动作总数量 (Operations)} \times \text{机器处理每个动作的速度 (Hardware Speed)} 所以上面两个例子的大 O 分别,O1 = n,O2 = n²,分别表示 O(n) 和 O(n²) 。

如何计算时间复杂度?

如果我们要计算一个程序的时间复杂度,其实只需要观察这程序代码的循环递归即可。 那么如果无循环和递归? 如果一个程序没有任何的递归和循环,我们大概率可以判断它的时间复杂度为 O(1)。比如下面这个代码:

func getSlice() []int {
  a := 1
  b := 2
  c := 3
  numberSlice := []int{a, b, c}
  return numberSlice
}

尽管这个代码有七行,我们不管它有几行,就算有 1000000 行这样的代码,只要代码的执行时间不随着输入规模 n 的增加而增加,无论它有多少行,它的时间复杂度也是 O(1)。

有循环和递归: 如果程序有循环和递归,大体可以分成4种情况:

  1. O(n):
func forPrint(n int) {  
    // 单个 for 循环  
    for i := 0; i < n; i++ {  // n
       println(1)  // n
    }  
}  

这段代码的执行所需时间 T(n) = 2n,那么抛去首项系数他的时间复杂度就是 O(n)。

func printAll(n int) {  
    // 单个递归  
    if n <= 0 {  // n + 1  
       return    // 1
    }  
    fmt.Println(n)   // n
    printAll(n - 1)  // n 
}

这段代码的执行所需时间 T(n) = 3n + 2,那么抛去首项系数和常数项时间复杂度也是 O(n)

  1. O(logn) 和 O(nlogn):
func logTest(n int) int {  
    i := 1             
    count := 0    
    for i < n {         
       count++        
       i = i * 2     
    }  
    return count       
}

这是相对来说比较难理解的一个点,我们直接看循环的内容,因为其他行的代码可以看出来的运行时间是常数项,所以直接忽略。我们来假设一下,如果 n = 8,那么 i 是如何变化的

  • 第 1 轮: i=1i = 1。满足 1<81 < 8。执行循环。ii 变成 1×2=21 \times 2 = 2
  • 第 2 轮: i=2i = 2。满足 2<82 < 8。执行循环。ii 变成 2×2=42 \times 2 = 4
  • 第 3 轮: i=4i = 4。满足 4<84 < 8。执行循环。ii 变成 4×2=84 \times 2 = 8
  • 第 4 轮: i=8i = 8。判断 8<88 < 8 (False)。跳出循环。 所以 i = 2,4,8,那么 i 和 n 之间的数学关系就是:2x=n2^x = n ,我们要求 x 也就是运行的时间,根据数学定义,这正是对数的定义:x=log2nx = \log_2 n 。如果我们把第六行的代码改一下,改成 i * 3,那运行时间 x=log3nx = \log_3n ,对于对数级的时间复杂度来说底数其实不那么重要,所以时间复杂度都统一为 O(logn)
func nLogN(n int) int {  
    count := 0  
    for i := 0; i < n; i++ {  
       for j := 1; j < n; j = j * 2 {  
          count++  
       }   
    }  
    return count  
}

我们再来看这个例子,这还是一个嵌套循环,其实刚才上面提到了,如果遇到嵌套循环,我们把两个循环拆开看,计算运行时间其实就是用外循环运行的次数乘上内循环运行的时间。那么这段代码的运行时间就是 T(n) = nlog2n,那么时间复杂度就是 O(nlogn)。

  1. O(m+n) 和 O(m×n)
// O(m+n)
func countTwoClasses(classA []string, classB []string) int {  
    total := 0  
    for i := 0; i < len(classA); i++ {    
				total++                           
		}  
    for j := 0; j < len(classB); j++ {    
				total++                          
		}  
    return total         
}

// O(m×n)
func matchMaking(boys []string, girls []string) int {  
    meetings := 0  
    for i := 0; i < len(boys); i++ {  
       for j := 0; j < len(girls); j++ {  
          meetings++  
       }    }  
    return meetings  
}

这算是一个比较特殊的情况,其实根本的逻辑没变,只不过是循环中有不同的数据量,所以会出现这两种时间复杂度。

其他更多的情况也是在这几类上的拓展,那么我们来总结一下,其实计算复杂度无非就是在做一些简单的加和乘的运算最后再化简。这差不多就是在学习数据结构与算法中,关于时间复杂度的我们可能会用到的内容。