【数据结构与算法之美】02-复杂度分析(上):如何分析、统计算法的执行效率和资源消耗?

时间:2020-04-12
本文章向大家介绍【数据结构与算法之美】02-复杂度分析(上):如何分析、统计算法的执行效率和资源消耗?,主要包括【数据结构与算法之美】02-复杂度分析(上):如何分析、统计算法的执行效率和资源消耗?使用实例、应用技巧、基本知识点总结和需要注意事项,具有一定的参考价值,需要的朋友可以参考一下。

  数据结构和算法本身解决的是“快”和“省”的问题,即如何让代码运行得更快,更省存储空间。所以,执行效率是非常重要的考量指标。那如何来衡量你编写的算法代码的执行效率呢?这就要用到本篇博客内容:时间、空间复杂度分析。


一、为什么需要复杂度分析?

  把代码运行一遍,通过统计、监控,就能得到算法执行的时间和占用的内存大小。为什么还要做时间、空间复杂度分析呢?这种分析方法能比我实实在在跑一遍得到的数据更准确吗?首先,这确实是一种有效的分析方法,它还有个学名——事后统计法。但是,事后统计法有非常大的局限性:

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)。

  

思考题:

  我们项目之前都会进行性能测试,再做代码的时间复杂度、空间复杂度分析,是不是多此一举呢?

我的看法

  渐进复杂度分析,给我们提供了一个理论的算法效率评判指标,它是和宿主平台无关的。渐进复杂度能够让我们对不同算法的效率有一个直观认识。在实际编程中,如果具有空间复杂度、时间复杂度的意识,有助于写出效率更高的代码。

  但我们也要看到,渐进复杂度只是一个理论模型,我们前面也多次提到这是”粗略分析“。在不同的宿主环境、数据集、数据量的场景下,实测得到的结果可能和渐进复杂度有所差异。所以对不同的情况进行性能基准测试是很有必要的。我们需要结合特定应用场景,选出最优算法。

  综上,性能测试和渐进式复杂度分析是相辅相成的,二者并不冲突。

原文地址:https://www.cnblogs.com/murongmochen/p/12680961.html