• 【数据结构与算法之美】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)。

      

    思考题:

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

    我的看法

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

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

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

  • 相关阅读:
    Jdk 1.6 在线 API 中文版
    数据库的最简单实现
    互联网公司GitHub repo 语言使用情况
    Chrome浏览器查看 iframe信息 OpenFrame
    PostgreSQL 保存json,jsonb类型
    修改PS1变量
    postgres json
    PostgreSQL PL/Python 和 PL/Postgres 函数互相调用
    转:CentOS 6.x 挂载读写NTFS分区(fuse-ntfs-3g)
    CentOS 7 设置静态IP
  • 原文地址:https://www.cnblogs.com/murongmochen/p/12680961.html
Copyright © 2020-2023  润新知