WXL's blog

Talk is cheap, show me your work.

0%

王争数据结构与算法学习笔记-复杂度分析

我们使用时间复杂度和空间复杂度来衡量算法代码的执行效率,那么为什么要这么麻烦需要自己去衡量一遍呢?把代码跑一遍,通过统计、监控,就能得到算法执行的时间和占用的内存大小。为什么还要做时间、空间复杂度分析呢?这种分析方法能比我实实在在跑一遍得到的数据更准确吗?

这种评估算法执行效率的方法是正确的。很多数据结构和算法书籍还给这种方法起了一个名字,叫事后统计法。但是,这种统计方法有非常大的局限性。

大O 表示法

公式:

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

代码分析示例:

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

假设每一个语句的执行时间为一个单位时间t0t_0​​​​那么,这段代码总的执行时间为:

T(n)=O(3+2n+2n2)T(n)=O(3+2n+2n^2)

这里的3指的是2~4行,这里的2n2n指的是5、6行,这里的2n22n^2指的是7、8行。即便我们不知道这里的t0t_0是多少,但是我们可以确定的是所有代码的执行时间 T(n)T(n) 与每行代码的执行次数 nn​​ 成正比。
大 O 时间复杂度实际上并不具体表示代码真正的执行时间,而是表示代码执行时间随数据规模增长的变化趋势,所以,也叫作渐进时间复杂度,简称时间复杂度。

当 n 很大时,可以把它想象成 10000、100000。而公式中的低阶、常量、系数三部分并不左右增长趋势,所以都可以忽略。我们只需要记录一个最大量级就可以了,如果用大 O 表示法表示上例的时间复杂度,就可以记为:T(n)=O(n2)T(n) = O(n2)​。几种常见的时间复杂度:

  • 常量阶:O(1)O(1)
  • 对数阶:O(lgn)O(lgn)
  • 线性阶:O(n)O(n)
  • 线性对数阶:O(nlgn)O(nlgn)
  • kk次方阶:O(xk)O(x^k)
  • 指数阶:O(2n)O(2^n)
  • 阶乘阶:O(!n)O(!n)

这几类可以分为多项式量级非多项式量级,其中非多项式量级只有最后两种。时间复杂度为非多项式量级的算法问题叫作 NP(Non-Deterministic Polynomial,非确定多项式)问题

空间复杂度

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

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

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

第 2 行代码中,申请了一个空间存储变量 i,但是它是常量阶的,跟数据规模nn​ 没有关系,所以可以忽略。第 3 行申请了一个大小为 nn​ 的 intint​ 类型数组,除此之外,剩下的代码都没有占用更多的空间,所以整段代码的空间复杂度就是 O(n)O(n)​。常见的空间复杂度就是 O(1)O(1)​、O(n)O(n)​、O(n2)O(n2)​,像 O(lgn)O(lgn)​、O(nlgn)O(nlgn)​​ 这样的对数阶复杂度平时都用不到。

最好、最坏、平均、均摊时间复杂度

看看这段代码:

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

代码可能需要查找的元素在数组内,可能不在,最好的时间复杂度就是第一次搜索就找到结果;最坏的时间复杂度是将数组遍历之后还没找到,或者说就是数组的最后一个元素。下面着重分析平均时间复杂度和均摊时间复杂度。

  • 平均时间复杂度:
    要查找的变量 xx​ 在数组中的位置,有 n+1n+1​ 种情况:在数组的 0n10~n-1​ 位置中和不在数组中。我们把每种情况下,查找需要遍历的元素个数累加起来,然后再除以 n+1n+1​​​,就可以得到需要遍历的元素个数的平均值,即:

    1+2+3++n+nn+1=n(n+3)2(n+1)\frac{1+2+3+···+n+n}{n+1}=\frac{n(n+3)}{2(n+1)}

时间复杂度的大 OO​ 标记法中,可以省略掉系数、低阶、常量,所以,把刚刚这个公式简化之后,得到的平均时间复杂度就是 O(n)O(n)​。

这样分析有问题,因为这n+1n+1​种情况出现的概率不一定是一样的,所以,要查找的变量 xx​,要么在数组里,要么就不在数组里。假设在数组中与不在数组中的概率都为 12\frac{1}{2}​。另外,要查找的数据出现在 0n10~n-1​ 这 nn​ 个位置的概率为 1n\frac{1}{n}​。所以要查找的数据出现在 0n10~n-1​ 中任意位置的概率就是12n\frac{1}{2n}​​​​​。这样时间复杂度就为:

112n+212n+312n++n12n+n12=3n+141*\frac{1}{2n}+2*\frac{1}{2n}+3*\frac{1}{2n}+···+n*\frac{1}{2n}+n*\frac{1}{2}=\frac{3n+1}{4}

这个值就是加权平均值,也叫作期望值,所以平均时间复杂度的全称应该叫加权平均时间复杂度或者期望时间复杂度。使用大OO​表示法,这段代码的加权平均时间复杂度仍然是 O(n)O(n)​​。

均摊时间复杂度(amortized time complexity)

例如如下代码段:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
// array表示一个长度为n的数组
// 代码中的array.length就等于n
int[] array = new int[n];
int count = 0;

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 == array.length 时,我们用 for 循环遍历数组求和,并清空数组,将求和之后的 sum 值放到数组的第一个位置,然后再将新的数据插入。但如果数组一开始就有空闲空间,则直接将数据插入数组。

在看均摊时间复杂度之前,可以使用平均时间复杂度来尝试着分析一下,这段代码所描述的插入数据的情况有n+1n+1​中情况,其中有nn​种情况是有空闲的位置可以插入的,第n+1n+1​种情况是数组已经满了,这nn​种情况出现的概率都是一样的,为1n+1\frac{1}{n+1}​​​。这样使用平均时间复杂度来分析的话就是:

11n+1+11n+1++n1n+1=O(1)1*\frac{1}{n+1}+1*\frac{1}{n+1}+···+n*\frac{1}{n+1}=O(1)

其实分析一下代码的逻辑特点,是有规律的,即:一个 O(n)O(n)​ 插入之后,紧跟着 n1n-1​ 个 O(1)O(1)​ 的插入操作,循环往复。这种类似的情况概括起来就是:周期性出现一个特殊的复杂度的情况,往往遇到这样的情况就可以考虑使用摊还分析法:每一次 O(n)O(n)​ 的插入操作,都会跟着 n1n-1​ 次 O(1)O(1)​ 的插入操作,所以把耗时多的那次操作均摊到接下来的 n1n-1​ 次耗时少的操作上,均摊下来,这一组连续的操作的均摊时间复杂度就是 O(1)O(1)​​。这就是均摊分析的大致思路。下面用一段话来概括使用均摊分析的情况:

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

习题

分析如下代码的时间复杂度:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
// 全局变量,大小为10的数组array,长度len,下标i。
int array[] = new int[10];
int len = 10;
int i = 0;

// 往数组中添加一个元素
void add(int element) {
if (i >= len) { // 数组空间不够了
// 重新申请一个2倍大小的数组空间
int new_array[] = new int[len*2];
// 把原来array数组中的数据依次copy到new_array
for (int 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;
}

代码的功能容易看出来,这里就不细说了,下面分析最好、平均、加权平均、最坏和均摊时间复杂度:

  • 最好时间复杂度:
    插入数据的时候有空间,那么使用大OO​表示法就为:O(1)O(1)

  • 最坏时间复杂度:
    插入数据的时候没有空间,需要重新申请一个长度为原来长度两倍的数组,假设需要执行的常量时间的语句条数为bb,那么使用大OO​​表示法为:

    n+b=O(n)n+b=O(n)

  • 平均时间复杂度:
    每一种情况认为概率相同,那么使用大OO​表示法为:

    11n+1+11n+1++n1n+1=O(1)1*\frac{1}{n+1}+1*\frac{1}{n+1}+···+n*\frac{1}{n+1}=O(1)

  • 加权平均复杂度:
    每一次插入的时候要么有空间,要么没有剩余的空间,设两种可能性均为12\frac{1}{2},而且在有剩余空间的情况下,插入哪一个位置的概率也一样,使用大OO​表示法即:

    121n+121n++12n=O(1)\frac{1}{2}*\frac{1}{n}+\frac{1}{2}*\frac{1}{n}+···+\frac{1}{2}*n=O(1)

将最后一种情况均摊到其他的nn​种情况中,那么每一种情况的复杂度均为O(1)O(1)​。

  • 均摊时间复杂度:
    每一次插入的时候要么有空间,要么没有剩余的空间,设两种可能性均为12\frac{1}{2}​​,而且在有剩余空间的情况下,插入哪一个位置的概率也一样,即:

    121n+121n++12n\frac{1}{2}*\frac{1}{n}+\frac{1}{2}*\frac{1}{n}+···+\frac{1}{2}*n

    将最后一种情况均摊到其他的nn​​种情况中,那么每一种情况的复杂度均为O(1)O(1)​​​​。

在王争的课程中的复杂度分析的后面留了一道思考题,题目如下:

有人说,我们项目之前都会进行性能测试,再做代码的时间复杂度、空间复杂度分析,是不是多此一举呢?而且,每段代码都分析一下时间复杂度、空间复杂度,是不是很浪费时间呢?

有一个回复非常的深刻:

由于做一个性能测试测试结果会受到数据规模测试机器等外界因素的影响,所以性能测试不是很具有代表性,但是时间复杂度和空间复杂度分析是比较客观的,不会受到外界因素的影响,只有先做了复杂度分析我们才能确实最优方案,最后得到最优解才开始做性能测试。 渐进式时间,空间复杂度分析与性能基准测试并不冲突,而是相辅相成的,但是一个低阶的时间复杂度程序有极大的可能性会优于一个高阶的时间复杂度程序,所以在实际编程中,时刻关心理论时间,空间度模型是有助于产出效率高的程序的,同时,因为渐进式时间,空间复杂度分析只是提供一个粗略的分析模型,因此也不会浪费太多时间,重点在于在编程时,要具有这种复杂度分析的思维。

行行好,赏一杯咖啡吧~