数据结构和算法本身解决的是“快”和“省”的问题,即如何让代码运行得更快,更省存储空间。所以,执行效率是非常重要的考量指标。那如何来衡量你编写的算法代码的执行效率呢?这就要用到本篇博客内容:时间、空间复杂度分析。
一、为什么需要复杂度分析?
把代码运行一遍,通过统计、监控,就能得到算法执行的时间和占用的内存大小。为什么还要做时间、空间复杂度分析呢?这种分析方法能比我实实在在跑一遍得到的数据更准确吗?首先,这确实是一种有效的分析方法,它还有个学名——事后统计法。但是,事后统计法有非常大的局限性:
1. 测试环境对测试结果影响很大
测试环境中硬件的差异对测试结果有很大的影响。譬如,同样一段代码,i9 处理器比 i3 处理器执行的速度快很多。再如,在某台机器上 a 代码执行的速度比 b 代码要快。然而换到另一台机器上时,结果可能截然不同。
2. 数据规模对测试结果影响很大
我们以排序算法为例说明。对同一个排序算法,待排序数据的有序度不一样,排序的执行时间就会有很大的差别。极端情况下,如果数据已经是有序的,那排序算法不需要做任何操作,执行时间就会非常短。除此之外,如果测试数据规模太小,测试结果可能无法真实地反应算法的性能。比如,对于小规模的数据排序,插入排序可能反倒会比快速排序要快。
综上,我们需要一个不依赖具体测试数据,就可以粗略地估计算法执行效率的方法。这就是时间、空间复杂度分析方法。
二、大 O 复杂度表示法
示例一
所谓算法的执行效率,就是指算法执行的时间。如何在不运行代码的前提下,粗略计算执行时间呢?我们以下面的代码为例进行分析:
1 int cal(int n) 2 { 3 int sum = 0; 4 int i = 1; 5 6 for (; i <= n ; ++i) 7 { 8 sum = sum + i; 9 } 10 11 return sum; 12 }
从 CPU 的角度来看,这段代码的每一行都执行着类似的操作:读数据-运算-写数据。尽管每行代码对应的 CPU 执行的个数、执行的时间都不一样,但是,我们这里只是粗略估计,所以可以假设每行代码执行的时间都一样,为 unit_time。在这个假设的基础之上,这段代码的总执行时间是多少呢?
第 3、4 行代码分别需要 1 个 unit_time 的执行时间,第 6、8 行都运行了 n 遍,所以需要 2n*unit_time 的执行时间,所以这段代码总的执行时间就是 (2n+2)*unit_time。可以看出来,所有代码的执行时间 T(n) 与每行代码的执行次数成正比。
示例二
按照上述分析思路,我们再来看下面的代码:
1 int cal(int n) 2 { 3 int sum = 0; 4 int i = 1; 5 int j = 1; 6 7 for (; i <= n; ++i) 8 { 9 j = 1; 10 11 for (; j <= n; ++j) 12 { 13 sum = sum + i * j; 14 } 15 } 16 }
第 3、4、5 行代码分别需要一个 unit_time 的执行时间。第 7、9 行执行 n 遍,需要 2n*unit_time 的执行时间。第 11、13 行执行 n2 遍,需要 2n2*unit_time 的执行时间。所以这段代码总的执行时间就是 (2n2+2n+3)*unit_time。
规律总结
尽管我们不知道 unit_time 的具体值,但是通过这两段代码执行时间的推导过程,我们可以得到一个非常重要的规律,那就是:所有代码的执行时间 T(n) 与每行代码的执行次数 n 成正比。
将上述规律总结成一个公式:T(n) = O(f(n))。
- T(n) 表示代码执行的时间;
- n 表示数据规模的大小;
- f(n) 表示每行代码执行的次数总和。因为这是一个公式,所以用 f(n) 来表示。
- 公式中的 O,表示代码的执行时间 T(n) 与 f(n) 表达式成正比。
所以,第一个例子中的 T(n) = O(2n+2),第二个例子中的 T(n) = O(2n2+2n+3)。这就是大 O 时间复杂度表示法。大 O 时间复杂度实际上并不具体表示代码真正的执行时间,而是表示代码执行时间随数据规模增长的变化趋势,所以,也叫作渐进时间复杂度(asymptotic time complexity),简称时间复杂度。
当 n 很大时,公式中的低阶、常量、系数三部分并不左右增长趋势,所以都可以忽略。我们只需要记录最大量级即可。所以以上两个示例的时间复杂度大 O 表示法描述应为:T(n) = O(n)、T(n) = O(n2)。
三、时间复杂度分析
如何分析时间复杂度?下面是三个比较实用的技巧:
1. 只关注执行次数最多的一段代码
前面提到,大 O 表示法表示的是变化趋势。所以我们可以忽略公式中的常量、低阶、系数,只关心最大阶的量级。在分析一段代码的时间复杂度时,只关注循环执行次数最多的部分即可。我们可以将其称为“核心代码”,它的量级就是整段代码的时间复杂度。
2. 加法法则:总复杂度等于量级最大的那段代码的复杂度
我们还是以一段代码作为示例来分析:
1 int cal(int n) 2 { 3 int sum_1 = 0; 4 int p = 1; 5 6 for (; p < 100; ++p) 7 { 8 sum_1 = sum_1 + p; 9 } 10 11 int sum_2 = 0; 12 int q = 1; 13 14 for (; q < n; ++q) 15 { 16 sum_2 = sum_2 + q; 17 } 18 19 int sum_3 = 0; 20 int i = 1; 21 int j = 1; 22 23 for (; i <= n; ++i) 24 { 25 j = 1; 26 27 for (; j <= n; ++j) 28 { 29 sum_3 = sum_3 + i * j; 30 } 31 } 32 33 return sum_1 + sum_2 + sum_3; 34 }
不难看出,这段代码由三个部分组成,分别是求 sum_1、sum_2、sum_3。求 sum_1 的部分,执行次数与 n 无关,是常量执行时间。当 n 无限大时,常量的时间可以忽略。求 sum_2、sum_3 的部分,时间复杂度分别为 O(n)、O(n2)。
根据加法法则,我们取其中量级最大的部分作为整段代码的时间复杂度。综上,整段代码的时间复杂度即为 O(n2)。
要牢记,总的时间复杂度就等于量级最大的那段代码的时间复杂度。抽象成公式即为:
若 T1(n) = O(f(n)),T2(n) = O(g(n)),则 T(n) = T1(n) + T2(n) = max(O(f(n)) + O(g(n))) = O(max(f(n), g(n)))。
3. 乘法法则:嵌套代码的复杂度等于嵌套内外代码复杂度的乘积
若 T1(n)=O(f(n)),T2(n)=O(g(n)),则 T(n)=T1(n)*T2(n)=O(f(n))*O(g(n))=O(f(n)*g(n))。例如:T1(n) = O(n),T2(n) = O(n2),则 T1(n) * T2(n) = O(n3)。
落实到具体的代码上,我们可以把乘法法则看成是嵌套循环。还是以一个示例来分析:
1 int cal(int n) 2 { 3 int ret = 0; 4 int i = 1; 5 6 for (; i < n; ++i) 7 { 8 ret = ret + f(i); 9 } 10 } 11 12 int f(int n) 13 { 14 int sum = 0; 15 int i = 1; 16 17 for (; i < n; ++i) 18 { 19 sum = sum + i; 20 } 21 22 return sum; 23 }
假设 f() 只是常数时间的操作,那么第 6~9 行代码的时间复杂度为 T1(n) = O(n)。但我们分析 f() 可知,它的时间复杂度不是常数,而是 T2(n) = O(n)。所以,cal() 函数的时间复杂度就是 T(n) = T1(n)*T2(n) = O(n*n) = O(n2)。
四、几种常见时间复杂度实例分析
下图基本涵盖了常见的时间复杂度:
我们把时间复杂度为非多项式量级的算法问题叫做 NP(Non-Deterministic Polynomial,非确定多项式)问题。当数据规模 n 越来越大时,非多项式量级算法的执行时间会急剧增加,是非常低效的算法。
我们主要看几种常见的多项式时间复杂度。
1. O(1)
首先要明确,O(1) 表示常量级时间复杂度,而非只执行一行代码。
只要代码的执行时间不随 n 的增大而增大,其时间复杂度即为 O(1)。一般而言,只要算法中没有循环语句、递归语句,行数再多也是 O(1)。
2. O(log n)
对数阶很常见,但又是最难分析的一种时间复杂度。我们还是通过示例来分析:
1 i=1; 2 while (i <= n) 3 { 4 i = i * 2; 5 }
第四行代码的执行次数最多,所以只要计算出这行代码执行了多少次,就知道了整段代码的时间复杂度。这其实是个等比数列,不难算出,其时间复杂度为 O(log2 n)。
接下来,我们把代码稍作改动:
1 i=1; 2 while (i <= n) 3 { 4 i = i * 3; 5 }
此时,时间复杂度变为 O(log3 n)。
实际上,不管以哪个数字为底,我们都将其时间复杂度记为 O(log n)。此处可以使用换底公式进行推倒。以不同数字为底,会导致常系数的差异。但我们只看最大量级,忽略系数差异,所以都可以用 O(log n) 表示。
3. O(nlog n)
理解了 O(log n),O(nlog n) 就很容易理解了。我们前面讲了乘法法则,如果一段代码的时间复杂度是 O(log n),循环执行 n 遍,时间复杂度就是 O(nlog n)。
O(nlog n) 是一种很常见的时间复杂度,譬如归并排序、快速排序。
4. O(m+n)、O(m*n)
有时候,代码的复杂度由两个数据的规模来决定。我们还是通过示例来说明:
1 int cal(int m, int n) 2 { 3 int sum_1 = 0; 4 int i = 1; 5 6 for (; i < m; ++i) 7 { 8 sum_1 = sum_1 + i; 9 } 10 11 int sum_2 = 0; 12 int j = 1; 13 14 for (; j < n; ++j) 15 { 16 sum_2 = sum_2 + j; 17 } 18 19 return sum_1 + sum_2; 20 }
可以看出,代码中有 m 和 n 两个数据规模。我们无法评判谁的量级比较大,所以在表示时间复杂度时,就不能简单地利用加法法则,省略其中一个。所以,上面代码的时间复杂度就是 O(m + n)。在这种情况下,原来的加法法则就不适用了,需要修改为:T1(m) + T2(n) = O(f(m) + g(n))。但是乘法法则依然有效。
五、空间复杂度分析
前面提到,时间复杂度的全称是渐进时间复杂度,表示算法的执行时间与数据规模之间的增长关系。类似地,空间复杂度全称就是渐进空间复杂度(asymptotic space complexity),表示算法的存储空间与数据规模之间的增长关系。
我们还是通过具体的示例来说明:
1 void print(int n) 2 { 3 int i = 0; 4 int[] a = new int[n]; 5 6 for (i; i <n; ++i) 7 { 8 a[i] = i * i; 9 } 10 11 for (i = n-1; i >= 0; --i) 12 { 13 print out a[i] 14 } 15 }
第 3 行代码中,我们申请了一个空间,用于存储变量 i。但这个空间是常量级别的,与 n 无关,所以可以忽略。
第 4 行代码中,申请了一个长度为 n 的 int 类型数组。除此之外,其他代码没有占用更多空间,所以整段代码的空间复杂度为 O(n)。
常见的空间复杂度有 O(1)、O(n)、O(n2),像 O(logn)、O(nlogn) 这样的对数阶复杂度平时都用不到。
六、内容小结
复杂度也叫渐进复杂度,包括时间复杂度和空间复杂度,用来分析算法执行效率与数据规模之间的增长关系。越高阶复杂度的算法,执行效率越低。
常见的复杂度并不多,从低阶到高阶有:O(1)、O(logn)、O(n)、O(nlogn)、O(n2)。
思考题:
我们项目之前都会进行性能测试,再做代码的时间复杂度、空间复杂度分析,是不是多此一举呢?
我的看法
渐进复杂度分析,给我们提供了一个理论的算法效率评判指标,它是和宿主平台无关的。渐进复杂度能够让我们对不同算法的效率有一个直观认识。在实际编程中,如果具有空间复杂度、时间复杂度的意识,有助于写出效率更高的代码。
但我们也要看到,渐进复杂度只是一个理论模型,我们前面也多次提到这是“粗略分析”。在不同的宿主环境、数据集、数据量的场景下,实测得到的结果可能和渐进复杂度有所差异。所以对不同的情况进行性能基准测试是很有必要的。我们需要结合特定应用场景,选出最优算法。
综上,性能测试和渐进式复杂度分析是相辅相成的,二者并不冲突。