数据结构和算法解决的是“快”和“省”的问题。让代码更快,更省存储空间。

如何衡量执行效率和资源消耗呢?

  • 执行效率:渐进时间复杂度(时间复杂度)
  • 资源消耗:渐进空间复杂度(空间复杂度)

一般来说,越高阶复杂度的算法,执行效率越低。

事后统计法

一般来说,我们运行一遍代码,通过统计、监控就能得到算法的执行时间和占用的内存大小。这种方法其实也是正确的,被称为事后统计法

那我们为什么还需要复杂度分析呢?因为事后统计法有一定的局限性:

  1. 测试结果非常依赖测试环境
  2. 测试结构受数据规模影响大

因此,我们需要一个不需要具体的测试数据,就能粗略地得出算法执行效率的方法
这就是时间、空间复杂度分析法

复杂度量级

复杂度量级可以分为多项式量级非多项式量级
其中,非多项式量级只有两个:O(2**n**)O(n!)

image.png 常见的复杂度量级

时间复杂度为非多项式量级的算法问题称作为:NP(Non-Deterministic Polynomial,非确定多项式)问题

当数据规模 n 越来越大时,非多项式量级算法的执行时间会急剧增加,求解问题的执行时间会无限增长。所以,非多项式时间复杂度的算法是非常低效的算法。

大O复杂度表示法

时间复杂度:
全称是 渐进时间复杂度,它不代表代码真正的执行时间,而是表示算法的执行时间与数据规模之间的增长关系。

空间复杂度:
全称是 渐进空间复杂度,与时间复杂度同理,它表示的是算法的存储空间与数据规模之间的增长关系。

那我们如何用公式表示复杂度?使用大O复杂度表示法:

  • 时间复杂度:**T(n) = O(f(n))**
  • 空间复杂度:**S(n) = O(f(n))**

各项含义:

  • **T(n)** 代表代码执行的时间
  • **S(n)** 代表代码占用的存储空间
  • **n** 表示数据规模的大小
  • **f(n)** 表示每行代码执行的次数总和,由于是一个公式,因此用 f(n)
  • **O** 表示代码的执行时间 T(n)f(n) 表达式成正比

公式中的低阶、常量、系数三部分不左右增长趋势,因此都可以忽略,仅需要记录最大量级:

  • T(n) = O(3n) 剔除后就为 T(n) = O(n)
  • T(n) = O(3n²) 剔除后就为 T(n) = O(n²)

因此在进行分析完成后,我们需要剔除这些部分,才是我们要的复杂度表示法。

分析法则 - 时间复杂度

这里提供几个实用的分析法则。

1. 关注最大量级

大O复杂度表示法仅表示一种变化趋势,仅需记录最大量级。所以我们在分析一段代码时,只需要关注循环执行次数最多的一段代码

2. 加法法则

总复杂度等于量级最大的那段代码的复杂度。

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

但若是有两个数据规模m和n,我们就不能简单的套用上述法则,需要修改:
T1(m) + T2(n) = O(f(m) + 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(m)*T2(n) = O(f(m) * f(n))

时间复杂度分析示例(多项式量级)

示例1 - 线性阶 O(n)

如下,求 1,2,3,…n 的累加和,如何估算代码的执行时间:

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

假设每行代码执行时间为 ut,计算代码总执行时间 T(n)

  • 2、3行,各需要 1ut ,共 2ut 。
  • 4、5行,各运行了n遍,因此需要 2n*ut 的执行时间。

所以总代码执行时间为:
T(n) = (2n+2)*ut

使用大O表示法,剔除低阶、常数、系数后得出:
T(n)=O(n)

示例2 - 平方阶 O(n²)

按上面的思路,再来看一段代码:

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

假设每行代码执行时间为 ut,计算代码总执行时间 T(n)

  • 2、3、4行,各需要 1ut,共 3ut
  • 5、6行,各需要 nut,共 2nut
  • 7、8行,需要 n²ut,共 2n²ut

所以总代码执行时间为:
T(n) = (2n²+2n+3)*ut

使用大O表示法,剔除低阶、常数、系数后得出(仅保留最大量级):
T(n)=O(n²)

示例2 - 常量阶 O(1)

  1. var i = 8
  2. var j = 6
  3. var sum = i + j

假设每行代码执行时间为 ut,计算代码总执行时间 T(n)

  • 1、2、3行,各需要 1ut,共 3ut

所以总代码执行时间为:
T(n) = 3ut

使用大O表示法:
T(n)=O(1)

只要代码的执行时间不随n的增大而增长,这样代码的时间复杂度都记为 O(1)
即一般情况下,只要算法中不存在循环、递归语句,即使有上万行的代码,时间复杂度也为 O(1)

示例3 - 加法法则

  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. }
  7. int sum_2 = 0;
  8. int q = 1;
  9. for (; q < n; ++q) {
  10. sum_2 = sum_2 + q;
  11. }
  12. int sum_3 = 0;
  13. int i = 1;
  14. int j = 1;
  15. for (; i <= n; ++i) {
  16. j = 1;
  17. for (; j <= n; ++j) {
  18. sum_3 = sum_3 + i * j;
  19. }
  20. }
  21. return sum_1 + sum_2 + sum_3;
  22. }

这段代码可以分为三段来看,分别求 sum_1、sum_2、sum_3:

  • 得出,sum_1部分执行了100次,即 O(1)
  • sum_2部分执行了n次,即 O(n)
  • sum_3部分执行了n²次,即 O(n²)

根据前面的加法法则,总时间复杂度等于量级最大的那段代码的时间复杂度,即:
T(n) = T1(n)+T2(n) = max(O(f(n)), O(g(n))) = O(max(f(n), g(n)))

因此该代码的时间复杂度就为 O(n²)

示例4 - 乘法法则

  1. int cal(int n) {
  2. int ret = 0;
  3. int i = 1;
  4. for (; i < n; ++i) {
  5. ret = ret + f(i);
  6. }
  7. }
  8. int f(int n) {
  9. int sum = 0;
  10. int i = 1;
  11. for (; i < n; ++i) {
  12. sum = sum + i;
  13. }
  14. return sum;
  15. }

根据前面的乘法法则,嵌套代码的复杂度等于嵌套内外代码复杂度的乘积,即:
T(n)=T1(n)*T2(n)=O(f(n))*O(g(n))=O(f(n)*g(n))

f() 函数本身的时间复杂度为 O(n)
cal() 本身的复杂度也为 O(n)

f() 函数在 4~6 行中被调用了n次,因次整个时间复杂度为:
T(n) = T1(n)*T2(n) = O(n²)

示例5 - 对数阶 O(logn)

对数阶时间复杂度较为常见,同时也最难分析:

  1. i=1;
  2. while (i <= n) {
  3. i = i * 2;
  4. }

上述的变量 i 的取值如同一个等比数列:

image.png 等比数列

只需知道x值是多少,就能知道这行代码执行的次数。
通过2x=n求解得出:x=log2n
所以时间复杂度为:O(log2n)

再看一个例子:

  1. i = 1;
  2. while (i <= n){
  3. i = i * 3;
  4. }

很容易看出来,这段代码的时间复杂度为: O(log3n)

对数之间是能互相转换的,即 log3n = log32 log2n,
所以 O(log3n) = O(C
log2n),其中 C=log32 是一个常量,可以忽略,所以能忽略对数的底。

因此上述两例的时间复杂度统一表示为:
T(n) = O(logn)

示例6 - 线性对数阶 O(nlogn)

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

基于乘法法则,若一段代码的时间复杂度是 O(logn)
我们循环执行它n遍,时间复杂度就成 O(nlogn) 了。

示例7 - 两个数据的规模 O(m+n)、O(m*n)

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

  1. int cal(int m, int n) {
  2. int sum_1 = 0;
  3. int i = 1;
  4. for (; i < m; ++i) {
  5. sum_1 = sum_1 + i;
  6. }
  7. int sum_2 = 0;
  8. int j = 1;
  9. for (; j < n; ++j) {
  10. sum_2 = sum_2 + j;
  11. }
  12. return sum_1 + sum_2;
  13. }

存在m、n两个数据规模,但无法评估m、n谁的量级大,因此不能简单的利用加法法则,省略掉其中一个。所以上面代码的时间复杂度就是 O(m+n)

针对这种情况,原来的加法法则就不正确了,需要将加法规则改为:
T1(m) + T2(n) = O(f(m) + g(n))

但是乘法法则依然有效:
T1(m)*T2(n) = O(f(m) * f(n))

空间复杂度分析

示例1 - 线性阶 O(n)

  1. void print(int n) {
  2. int i = 0;
  3. int[] a = new int[n];
  4. for (i; i <n; ++i) {
  5. a[i] = i * i;
  6. }
  7. for (i = n-1; i >= 0; --i) {
  8. print out a[i]
  9. }
  10. }

第2行,申请了一个空间存储变量i,但是常量阶的,跟数据规模n没有关系,所以可以忽略。
第三行申请了一个大小为n的int类型数组,剩下的代码没有占用更多的空间。
因此整段代码的空间复杂度就是 O(n)

常见的空间复杂度就是O(1)、O(n)、O(n2 )。对数阶的复杂度平时都用不到。
且空间复杂度的分析比时间复杂度的分析要简单很多。

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

我们先分析如下代码:

  1. // n表示数组array的长度
  2. int find(int[] array, int n, int x) {
  3. int i = 0;
  4. int pos = -1;
  5. for (; i < n; ++i) {
  6. if (array[i] == x) pos = i;
  7. }
  8. return pos;
  9. }

很容易看出来,这段代码的时间复杂度是 O(n)。其中 n 代表数组的长度。

但是我们在数组中查找一个数据时,不需要遍历完整个数组,可能中途找到就能结束循环了。所以上面的代码写的不够高效,可以优化如下:

  1. // n表示数组array的长度
  2. int find(int[] array, int n, int x) {
  3. int i = 0;
  4. int pos = -1;
  5. for (; i < n; ++i) {
  6. if (array[i] == x) {
  7. pos = i;
  8. break;
  9. }
  10. }
  11. return pos;
  12. }

若数组第一个元素就等于 x,则不需要继续遍历剩下的 n-1 个数据。此时的时间复杂度就是 O(1)
若数组中不存在 x,则要遍历完整个数组,此时时间复杂度为 O(n)

最好情况时间复杂度:最理想的情况下,执行这段代码的时间复杂度。此处为 O(1)
最坏情况时间复杂度:最糟糕的情况下,执行这段代码的时间复杂度。此处为 O(n)

平均情况时间复杂度

最好、最坏情况时间复杂度都是极端情况下的代码复杂度,发生概率并不大。
为了更好表示平均情况下的复杂度,就需要平均情况时间复杂度。

上述的例子中,x 的位置共有 n + 1 种情况:

  • 在数组的 0~n-1 位置中
  • 不在数组中

我们将每种情况下,查找需要遍历的元素个数累加起来,再除以n+1,就能得到遍历的元素个数平均值:
image.png

时间复杂度中,可以省略系数、低阶、常量。因此简化后,平均时间复杂度就是 O(n)

上述结论虽然正确,但是推导过程存在一个问题:没有将各种情况发生的概率考虑进去。
x要么在数组中,要么不在数组中。

若将每种情况发生的概率也考虑进去,那么计算过程应该为:
image.png
这个值就是概率论中的加权平均值,也叫期望值。

因此平均情况时间复杂度的全称应为:

  • 加权平均时间复杂度
  • 期望时间复杂度

上面的结果使用大O表示法后,去掉系数与常量,该段代码的加权平均时间复杂度仍是O(n)

均摊时间复杂度

均摊时间复杂度对应的分析方法:摊还分析(平摊分析)

  1. // array表示一个长度为n的数组
  2. // 代码中的array.length就等于n
  3. int[] array = new int[n];
  4. int count = 0;
  5. void insert(int val) {
  6. if (count == array.length) {
  7. int sum = 0;
  8. for (int i = 0; i < array.length; ++i) {
  9. sum = sum + array[i];
  10. }
  11. array[0] = sum;
  12. count = 1;
  13. }
  14. array[count] = val;
  15. ++count;
  16. }

上述代码中:
最好情况时间复杂度为: O(1)
最坏情况时间复杂度为: O(n)
平均情况时间复杂度为: O(1)
image.png
平均情况时间复杂度的计算过程。

这个例子的 insert 与上个例子的 find 的例子,有两个区别:

  • find 在极端情况下,复杂度才为 O(1) 。但 insert 大部分情况下都为 O(1)
  • insertO(1)、O(n) 的时间复杂度的插入,出现的频率很有规律,且有一定的前后时序关系。一般都是一个 O(n) 插入后,紧跟着 n-1 个 O(1) 的插入操作。

因此针对这种特殊场景,不需要像分析平均复杂度那样,找出所有的输入情况及发生概率,然后再计算加权平均值。

针对这种特殊场景,有种更简单的分析方法:摊还分析法。通过其得到的时间复杂度为:均摊时间复杂度。

还是上面的例子,每次 O(n) 的插入操作,都会跟着 n-1 次 O(1) 的插入操作。
将耗时多的那次操作均摊到接下来的 n-1 次耗时少的操作上,均摊下来,一组连续的操作的均摊时间复杂度就是 O(1)

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