复杂度分析是整个算法学习的精髓,只要掌握了它,数据结构和算法的内容基本上就掌握了一半。数据结构和算法本身解决的是“快”和“省”的问题,即如何让代码运行得更快,如何让代码更省存储空间。所以,执行效率是算法一个非常重要的考量指标。那如何来衡量你编写的算法代码的执行效率呢?这里就要用到时间、空间复杂度分析方法,这是一个不用具体的测试数据来测试,就可以粗略地估计算法的执行效率的方法

image.png

大 O 复杂度表示法

  1. int cal(int n) {
  2. int sum = 0;
  3. int i = 1;
  4. for (; i <= n; ++i) {
  5. sum = sum + i;
  6. }
  7. return sum;
  8. }

从 CPU 的角度来看,上面代码的每一行都执行着类似的操作:读数据-运算-写数据。尽管每行代码对应的 CPU 执行的个数、执行的时间都不一样,但是,我们这里只是粗略估计,所以可以假设每行代码执行的时间都一样,为 unit_time。第 2、3 行代码分别需要 1 个 unit_time 的执行时间,第 4、5 行都运行了 n 遍,所以需要 2nunit_time 的执行时间,所以这段代码总的执行时间就是 (2n+2)unit_time。可以看出来,所有代码的执行时间 T(n) 与每行代码的执行次数成正比T(n) = O(2n+2)=> T(n) = O(n)

  1. int cal(int n) {
  2. int sum = 0;
  3. int i = 1;
  4. int j = 1;
  5. for (; i <= n; ++i) {
  6. j = 1;
  7. for (; j <= n; ++j) {
  8. sum = sum + i * j;
  9. }
  10. }
  11. }

上面第 2、3、4 行代码,每行都需要 1 个 unit_time 的执行时间,第 5、6 行代码循环执行了 n 遍,需要 2n unit_time 的执行时间,第 7、8 行代码循环执行了 n2遍,所以需要 2n2 unit_time 的执行时间。所以,整段代码总的执行时间 T(n) = (2n2+2n+3)unit_time。T(n) = O(2n2+2n+3) => T(n) = O(n2)
尽管我们不知道 unit_time 的具体值,但是通过这两段代码执行时间的推导过程,我们可以得到一个非常重要的规律,那就是,*所有代码的执行时间 T(n) 与每行代码的执行次数 n 成正比

将规律总结成一个公式:

  • T(n) 它表示代码执行的时间,n 表示数据规模的大小;
  • f(n) 表示每行代码执行的次数总和。因为这是一个公式,所以用 f(n) 来表示。
  • 公式中的 O,表示代码的执行时间 T(n) 与 f(n) 表达式成正比。

image.png
大 O 时间复杂度实际上并不具体表示代码真正的执行时间,而是表示代码执行时间随数据规模增长的变化趋势,所以,也叫作渐进时间复杂度(asymptotic time complexity),简称时间复杂度。当 n 很大时,你可以把它想象成 10000、100000。而公式中的低阶、常量、系数三部分并不左右增长趋势,所以都可以忽略。只需要记录一个最大量级就可以了,如果用大 O 表示法表示刚讲的那两段代码的时间复杂度,就可以记为:T(n) = O(n); T(n) = O(n2)。

时间复杂度分析

1、只关注循环执行次数最多的一段代码

大 O 这种复杂度表示方法只是表示一种变化趋势。我们通常会忽略掉公式中的常量、低阶、系数,只需要记录一个最大阶的量级就可以了。所以,我们在分析一个算法、一段代码的时间复杂度的时候,也只关注循环执行次数最多的那一段代码就可以了。这段核心代码执行次数的 n 的量级,就是整段要分析代码的时间复杂度。

2、加法法则:总复杂度等于量级最大的那段代码的复杂度

  1. int cal(int n) {
  2. int sum_1 = 0;
  3. int p = 1;
  4. for (; p < 100; ++p) {
  5. sum_1 = sum_1 + p;
  6. } // 这一段代码执行100 次,是一个常量的执行时间,跟 n 的规模无关,可以忽略
  7. // 一段代码循环 10000 次、100000 次,只要是一个已知的数,跟 n 无关,照样也是常量级的执行时间。当 n 无限大的时候,就可以忽略。尽管对代码的执行时间会有很大影响,但是回到时间复杂度的概念来说,它表示的是一个算法执行效率与数据规模增长的变化趋势,所以不管常量的执行时间多大,我们都可以忽略掉。因为它本身对增长趋势并没有影响。
  8. int sum_2 = 0;
  9. int q = 1;
  10. for (; q < n; ++q) {
  11. sum_2 = sum_2 + q;
  12. } // O(n)
  13. int sum_3 = 0;
  14. int i = 1;
  15. int j = 1;
  16. for (; i <= n; ++i) {
  17. j = 1;
  18. for (; j <= n; ++j) {
  19. sum_3 = sum_3 + i * j;
  20. }
  21. } // O(n²)
  22. return sum_1 + sum_2 + sum_3;
  23. }

综合这三段代码的时间复杂度,我们取其中最大的量级,所以整段代码的时间复杂度就为 O(n2)。总的时间复杂度就等于量级最大的那段代码的时间复杂度

3、乘法法则:嵌套代码的复杂度等于嵌套内外代码复杂度的乘积

int cal(int n) {
   int ret = 0; 
   int i = 1;
   for (; i < n; ++i) {
     ret = ret + f(i); // f()的时间复杂度是O(n),所以整个cal()时间复杂度就是O(n²)
   } 
 } 

 int f(int n) {
  int sum = 0;
  int i = 1;
  for (; i < n; ++i) {
    sum = sum + i;
  } 
  return sum;
 }

几种常见时间复杂度分析

image.png
对上面罗列的复杂度量级可以粗略地分为两类:多项式量级非多项式量级。其中,非多项式量级只有两个:指数阶O(2n) 和 阶乘阶O(n!)。当数据规模 n 越来越大时,非多项式量级算法的执行时间会急剧增加,求解问题的执行时间会无限增长。所以,非多项式时间复杂度的算法其实是非常低效的算法

1. O(1)

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

2. O(logn)、O(nlogn)

对数阶时间复杂度非常常见,同时也是最难分析的一种时间复杂度。

 i=1;
 while (i <= n)  {
   i = i * 2; // 实际上,变量 i 的取值就是一个等比数列2⁰2¹2² ···2^x=n,只要知道 x 值是多少,就知道这行代码执行的次数了,2^x=n求解x=log₂n;这段代码的时间复杂度就是O(log₂n)
 }


 while (i <= n)  {
   i = i * 3; // 时间复杂度是O(log₃n)
 }

不管是以 2 为底、以 3 为底,还是以 10 为底,可以把所有对数阶的时间复杂度都记为 O(logn)。因为对数之间是可以互相转换的,log3n = log32 log2n, log32是常量忽略,*在采用大 O 标记复杂度的时候,可以忽略系数,对数阶忽略对数的“底”,统一表示为 O(logn)。 O(logn)循环执行 n 遍,时间复杂度就是 O(nlogn) ,归并排序、快速排序的时间复杂度都是 O(nlogn)。

3. O(m+n)、O(m*n)

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

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

  int sum_2 = 0;
  int j = 1;
  for (; j < n; ++j) {
    sum_2 = sum_2 + j;
  }

  return sum_1 + sum_2;
}
// 无法事先评估 m 和 n 谁的量级大,所以在表示复杂度的时候,就不能简单地利用加法法则,省略掉其中一个。所以,上面代码的时间复杂度就是 O(m+n)。

空间复杂度分析

时间复杂度的全称是渐进时间复杂度表示算法的执行时间与数据规模之间的增长关系。类比一下,空间复杂度全称就是渐进空间复杂度(asymptotic space complexity),表示算法的存储空间与数据规模之间的增长关系

void print(int n) {
  int i = 0;
  int[] a = new int[n]; // 申请了一个大小为 n 的 int 类型数组,除此之外,剩下的代码都没有占用更多的空间,所以整段代码的空间复杂度就是 O(n)。
  for (i; i <n; ++i) {
    a[i] = i * i;
  }

  for (i = n-1; i >= 0; --i) {
    print out a[i]
  }
}

常见的空间复杂度就是 O(1)、O(n)、O(n2 ),像 O(logn)、O(nlogn) 这样的对数阶复杂度平时都用不到。

最好、最坏情况时间复杂度

最好情况时间复杂度就是,在最理想的情况下,执行某段代码的时间复杂度。比如要查找一个变量 x在长度为n的数组中位置索引,最好的情况是第一个元素,这个时候对应的时间复杂度就是最好情况时间复杂度O(1)。最坏情况时间复杂度就是,在最糟糕的情况下,执行某段代码的时间复杂度。如果数组中没有要查找的变量 x,我们需要把整个数组都遍历一遍才行,所以这种最糟糕情况下对应的时间复杂度就是最坏情况时间复杂度O(n)。

平均情况时间复杂度

最好情况时间复杂度和最坏情况时间复杂度对应的都是极端情况下的代码复杂度,发生的概率其实并不大。为了更好地表示平均情况下的复杂度,引入另一个概念:平均情况时间复杂度,后面简称为平均时间复杂度。平均时间复杂度涉及概率论的知识,需要把所有情况出现的概率加起来取平均值,所以平均时间复杂度的全称应该叫加权平均时间复杂度或者期望时间复杂度。(加权平均值,也叫作期望值

在大多数情况下,并不需要区分最好、最坏、平均情况时间复杂度三种情况。很多时候,使用一个复杂度就可以满足需求了。只有同一块代码在不同的情况下,时间复杂度有量级的差距,才会使用这三种复杂度表示法来区分。

均摊时间复杂度

 // array 表示一个长度为 n 的数组
 int[] array = new int[n];
 int count = 0;

 // 实现了一个往数组中插入数据的功能。当数组满了之后,用 for 循环遍历数组求和,并清空数组,将求和之后的 sum 值放到数组的第一个位置,然后再将新的数据插入。
 void insert(int val) {
    if (count == array.length) {
       int sum = 0;
       for (int i = 0; i < array.length; ++i) {
          sum = sum + array[i];
       }
       array[0] = sum;
       count = 1;
    }

    array[count] = val;
    ++count;
 }

上述代码,最理想的情况下,数组中有空闲空间,只需要将数据插入到数组下标为 count 的位置即可,所以最好情况时间复杂度为 O(1)。最坏的情况下,数组中没有空闲空间了,我们需要先做一次数组的遍历求和,然后再将数据插入,所以最坏情况时间复杂度为 O(n)平均时间复杂度是 O(1),需要用概率论分析。
对一个数据结构进行一组连续操作中,大部分情况下时间复杂度都很低,只有个别情况下时间复杂度比较高,而且这些操作之间存在前后连贯的时序关系,这个时候,就可以将这一组操作放在一块儿分析,看是否能将较高时间复杂度那次操作的耗时,平摊到其他那些时间复杂度比较低的操作上。而且,在能够应用均摊时间复杂度分析的场合,一般均摊时间复杂度就等于最好情况时间复杂度
均摊时间复杂度和摊还分析方法应用场景比较特殊,所以并不会经常用到。均摊时间复杂度就是一种特殊的平均时间复杂度,没必要花太多精力去区分它们。最应该掌握的是它的分析方法,摊还分析。至于分析出来的结果是叫平均还是叫均摊,这只是个说法,并不重要。