参考:

  1. 极客时间:数据结构与算法之美
  2. 《数据结构》——胡学钢(合肥工业大学)

1. 如何分析&统计算法的执行效率和资源消耗?

衡量算法的主要性能指标包括时间性能和空间性能。

为了使算法的时间复杂度便于比较,一般不宜采用某个具体机器上的运行时间的形式表示,而是以算法中基本语句的执行次数来衡量,然而在实际应用中执行次数也是难以衡量的,所以进一步采用基本语句的执行次数的数量级来表示。

1.1. 大 O 复杂度表示法

T(n) = O ( f(n) )

T(n)代表代码执行时间,O就是时间复杂度,表示代码执行时间与f(n)表达式成正比。n代表数据规模的大小,f(n)是一个表达式,表示每行代码执行的次数总和。

上面的式子就是大O时间复杂度表示法,实际上它并不能代表代码真正的执行时间,而是表示代码执行时间随数据规模增长的变化趋势,也称作渐进时间复杂度,简称时间复杂度。

当n的量级很大,那么f(n)表达式中的 低阶、常量、系数 就可以省略,因为他们并不左右增长趋势,故可以忽略。此时T(n) = O(2n+2)写成T(n) = O(n),T(n) = O(2n2+2n+3)写成T(n) = O(n2)。

这里面一个重要的点就是即便一段代码执行次数很多,对执行时间影响很大,但是执行次数已知时,它就是可以忽略,因为它并不左右增长趋势。所以不管常量的执行时间多大,我们都可以忽略掉。

1.2. 时间复杂度分析

如何进行时间复杂度分析呢?

  • 只关注循环执行次数最多的那段代码,这段核心代码执行次数的 n 的量级,就是整段要分析代码的时间复杂度。
  • 加法法则:总复杂度等于量级最大的那段代码的复杂度。如果 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)))
  • 乘法法则:嵌套的代码的复杂度等于嵌套内外代码复杂度的乘积。如果 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.3. 常见时间复杂度实例分析

复杂度阶级
O(1)常量阶
O(logn)对数阶
O(n)线性阶
O(nlogn)线性对数阶
O(n2)平方阶
......
O(nk)k次方阶
O(2n)指数阶
O(n!)阶乘阶

上面的阶可以分为多项式量级和非多项式量级,最后两种就是非多项式量级。当n的规模越来越大,非多项式量级算法的执行时间会急剧增加,是低效的算法,所以不讨论其时间复杂度。

1.3.1. O(1) 常量阶

它并不代表只执行了一次,这只是表示常量级时间复杂度的一种方法。
只要代码的执行时间不随n的增大而增大,则这样代码的时间复杂度都记为O(1)。一般情况下,只要算法中不存在循环语句、递归语句,即使有成千上万行的代码,其时间复杂度也是Ο(1)。

1.3.2. O(logn) 对数阶 & O(nlogn)线性对数阶

    i=1;
    while (i <= n)  {
    i = i * p;
    }

i = i * p;这行代码执行了多少次?先看 i 的取值是一个等比数列:

p0,p1,p2...pk...px = n,
恰好 x 代表了代码执行次数,x = logpn,实际上不管底数是多少,我们都可以把所有对数阶的时间复杂度都记为 O(logn),为什么?

举个例子:log3n 就等于 log32 log2n ,所以 O(log3n) = O(C log2n),再忽略系数就可以得到O(log3n) = O(log2n),因此在对数阶时间复杂度的表示方法里可以直接忽略底统一标识为 O(logn)。

O(nlogn)线性对数阶同理,即一段时间复杂度为O(logn)的代码执行了n遍。

归并排序、快速排序的时间复杂度都是O(nlogn)。

1.3.3. O(m+n) & O(m*n)

当代码的时间复杂度由两个数据的规模来决定。

    function cal(m, n) {
        let sum_1 = 0;
        let i = 1;
        for (; i < m; ++i) {
            sum_1 = sum_1 + i;
        }

        let sum_2 = 0;
        let j = 1;
        for (; j < n; ++j) {
            sum_2 = sum_2 + j;
        }
        console.log(sum_1 + sum_2);

    }
    cal(3, 4);

其时间复杂度就是O(m+n),因为我们无法评估m、n谁大谁小,所以在此我们不能简单地使用加法法则,不能省略其中一个。可以把加法法则改成:T1(m) + T2(n) = O(f(m) + g(n))。但是乘法法则继续有效:T1(m) * T2(n) = O(f(m) * f(n))

1.4. 附加

1.5. 空间复杂度分析

同理,空间复杂度全称渐进空间复杂度表示算法的存储空间与数据规模之间的增长关系,据说空间复杂度的分析比时间复杂度的简单,同样常量阶的空间存储可以忽略,因为它和数据规模n没有关系。

常见的空间复杂度就是O(1)、O(n)、O(n2),其他的像 O(logn)、O(nlogn)这样的空间复杂度基本用不上。

2. 小结

复杂度包括时间复杂度与空间复杂度,用来分析算法的执行效率与数据规模之间的增长关系。常见的复杂度其实就那几个,O(1)、O(logn)、O(n)、O(nlogn)、O(n2)。

3. 思考与讨论

【例1】在项目之前都会进行性能测试,再做代码的复杂度分析,这是否多此一举,分析复杂度又有什么意义?

回答1:复杂度分析为我们提供了理论分析的方向,并且它是宿主平台无关的,能够让我们对我们的程序或算法有一个大致的认识,让我们知道,比如在最坏的情况下程序的执行效率如何,同时也为我们交流提供了一个不错的桥梁,我们可以说,算法1的时间复杂度是O(n),算法2的时间复杂度是O(logN),这样我们立刻就对不同的算法有了一个“效率”上的感性认识。

回答2:性能测试更多的是一种实验结果。而复杂度分析,可以帮助我们分析内因

回答3:1、性能测试是依附于具体的环境,如SIT、UAT机器配置及实例数量不一致结果也有差别。
2、复杂度分析是独立于环境的,可以大致估算出程序所执行的效率。

回答4:有必要,基准测试是事后,也是理论验证,有时候O(n)未必一定比O(1)效率低。
复杂度分析是理论,整体趋势上反应了一个算法的时间或者空间利用率与数据规模的渐进关系,并且像程序员之间使用设计模式来讨论代码设计一样,说出名字就大致知道代码是如何组织的,大O也是一样。
随着自己使用大O分析代码复杂度的熟练程度增加,判断一段代码的复杂度可能分分钟

【例2】什么是复杂度分析?

1.数据结构和算法解决“如何让计算机更快时间、更省空间的解决问题”。
2.因此需从执行时间和占用空间两个维度来评估数据结构和算法的性能。
3.分别用时间复杂度和空间复杂度两个概念来描述性能问题,二者统称为复杂度

【例3】存储一个二进制数,输入规模(空间复杂度)是O(logn) bit。请问如何理解

比如8用二进制表示就是3个bit。16用二进制表示就是4个bit。以此类推 n用二进制表示就是log2n,可写为logn个bit。

4. 浅析最好、最坏、平均、均摊时间复杂度

    let arr = [2, 4, 7, 4, 6, 7, 2, 3, 0];

    function find(array, x) {
        let i = 0;
        let pos = -1;
        for (; i < array.length; ++i) {
            if (array[i] == x) {
                pos = i;
                break;
            }
        }
        console.log(pos);
    }
    find(arr, 6);

如上代码求的是指定的数字x在无序数组数组中出现的位置。按照之前的分析方法,这段代码时间复杂度好像是O(n),但是明显代码的执行次数不一定是n次,如果要查找的x出现在第一位,那么时间复杂度就应该是O(1),如果出现在最后一位才应该是O(n),这时候上文讲的分析方法就不足以应付这样的问题了。

  • 引入三个概念:最好情况时间复杂度、最坏情况时间复杂度和平均情况时间复杂度。

其中最好最坏时间复杂度很好理解,平均复杂度指的就是平均情况下的复杂度。

假设数组有n个元素,元素x在数组中的位置情况总共有n种,再加上不在其中的情况,总共有n+1种,由此每次遍历数组元素的个数也有n+1种情况,把他们加起来除以n+1就得到遍历的平均个数,计算如下:

1+2+3+...+n+n / n+1 = n(n+3) / 2(n+1)

然后忽略常量,系数,可得时间复杂度就是O(n),不过此处仍然存在问题,因为没有考虑x出现的某个地方的概率,假设不再数组中的概率是1/2,那么出现在数字中某个位置上的概率就是1/2n,计算如下:

1 ✖ 1/2n + 2 ✖ 1/2n + 3 ✖ 1/2n + ... + n ✖ 1/2n + n ✖ 1/2 = 3n + 1 / 4

最后一项的n ✖ 1/2代表需要查找n次,最后才发现不在数组中。

在概率论中这叫期望,就是平均值,不过这里表示出来时间复杂度仍然是O(n)。

4.1. 均摊时间复杂度

听起来和上面讲的平均复杂度很像,经过我的了解这里面有一种一种取多补少的思想,上面讲到的平均复杂度就是赤裸裸的数学期望的玩法,而这个均摊时间复杂度和他很像但又不是,因为我们并不一定要通过数学演算求出来,举个例子:

   let n = 3,
      count = 0,
      array = (new Array(n)).fill(0);

   function insert(val) {
      if (count == array.length) { //数组元素插满了
         let sum = 0;
         for (let i = 0; i < array.length; ++i) { //对数组元素求和结果放到第一位,再从数组第二位开始插
            sum = sum + array[i];
         }
         array[0] = sum;
         count = 1;
      }
      array[count] = val;
      ++count;
   }

这段代码要做的是向一个数组中插入数据,数组初始化为填充0的数组,长度为n,有以下规则:

  1. count记录当前插入的位置,如果数组已经插满了,就对当前数组元素求和并把求和结果放在数组第一位,然后从第2位开始继续插入
  2. 如果数组未满,则直接按照count插入即可

分析可知n为数组长度,插入时有n种可能情况,0~n-1时直接插入,时间复杂度为O(1);当数组满时count == n,这时候需要额外的操作求数组元素之和,时间复杂度为O(n)。根据加权平均的计算方法可得:

即便count == n时出现了复杂度为O(n)的操作,但是整段代码的时间复杂度却仍然是O(1),针对这样在极端情况下时间复杂度才为O(n)并且其复杂度的出现有一定顺序关系的代码,我们引入了一种更加简单的分析方法:摊还分析法。

像上面的例子,每一次 O(n) 的插入操作,都会跟着 n-1 次 O(1) 的插入操作,这是一个有规律可循的插入,所以把耗时多的那次操作均摊到接下来的 n-1 次耗时少的操作上,均摊下来,这一组连续的操作的均摊时间复杂度就是 O(1)。这就是均摊分析的大致思路。

均摊复杂度用到的不多,具体情况具体分析,只需明白摊还分析法的思路即可。

对一个数据结构进行一组连续操作中,大部分情况下时间复杂度都很低,只有个别情况下时间复杂度比较高,而且这些操作之间存在前后连贯的时序关系,这个时候,我们就可以将这一组操作放在一块儿分析,看是否能将较高时间复杂度那次操作的耗时,平摊到其他那些时间复杂度比较低的操作上。而且,在能够应用均摊时间复杂度分析的场合,一般均摊时间复杂度就等于最好情况时间复杂度,可以认为均摊时间复杂度就是一种特殊的平均时间复杂度。

4.2. 附加问题

【例】分析如下代码的时间复杂度,其中len应是一个未知量,在这里取10方便理解

    // 全局变量,大小为 10 的数组 array,长度 len,下标 i。
    let array = new Array(10);
    let len = 10; //len是一个不确定的量
    let i = 0;

    // 往数组中添加一个元素
    function add(element) {
        if (i >= len) { // 数组空间不够了
            // 重新申请一个 2 倍大小的数组空间
            let new_array = new Array(len * 2);
            // 把原来 array 数组中的数据依次 copy 到 new_array
            for (let j = 0; j < len; ++j) {
                new_array[j] = array[j];
            }
            // new_array 复制给 array,array 现在大小就是 2 倍 len 了
            array = new_array;
            len = 2 * len;
        }
        // 将 element 放到下标为 i 的位置,下标 i 加一
        array[i] = element;
        ++i;
    }

考虑摊还分析的时候想一想应该向前还是向后分摊?

【答】

  1. 最好情况数组不用扩容,直接插入即可,时间复杂度为O(1)
  2. 最坏情况,数组需要扩容,那么扩容那次的时间复杂度为O(n),其中n就是当前数组长度,也是数组元素的个数。
  3. 按照摊还分析法看,均摊时间复杂度应该是O(1),这一点不好理解,因为不想上面的insert那样刚好分摊,不过你列出前40位的插入示意图就可以明白,虽然每次数组都是倍数增长,也就是说越到后面扩容的越厉害,但是O(1)的数量也越来越多,也就分摊地越多,每次一需要扩容的时候都要向后分摊原数组长度次,恰好是2倍扩容,所以刚好分摊。。。算了,还是自己列个图清晰。

4.3. 测试

分析以下代码的时间复杂度

   for (let i = 0; i < n; i++) {
      for (let j = 0; j < n; j++) {
         x++;
      }
   }

答案:O( n2 )

   i = n;
   while (i > 1) i = i / 2;

答案:O( log2n )

   for (let i = 1; i < n; i++)
      for (let j = 1; j < n; j++) {
         x++;
         for (let k = 1; k < n; k++) {
            x++;
         }
      }

答案:O( n2 )

   for (let i = 1; i < n; i++) {
      j = i;
      while (j < n) j *= 2;
   }

答案:O( nlog2n )


HuiDT
19 声望0 粉丝

学技术、骑摩托