复杂度分析

复杂度分析是整个算法学习的精髓,只要掌握了它,数据结构和算法的内容基本上就掌握了一半


时间复杂度分析

所有代码的执行时间T(n)与每行代码的执行次数f(n)成正比

T(n) = O(f(n)) : 大O时间复杂度表示法

不代表真正的时间,而是代表代码执行时间随数据规模增长的变化趋势,也叫渐进时间复杂度,简称时间复杂度

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

一行时间 = unit_time,2/3/4/5行:(2n+2) x unit_time 即 O(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;
}
}
}

2/3/4/5/6/7/8行 (2n^2+2n+3) x unit_time 即O(n^2)

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

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


常见时间复杂度:





O(1):

一般情况下,算法语句中不存在循环递归,即使成千上万代码,时间复杂度也等于O(1)


O(logn) / O(nlogn):
1
2
3
4
i=1;
while (i <= n) {
i = i * 2;
}

2^0 2^1 2^2 2^3 … 2^k … 2^x = n 即 x = log2\n = 循环次数,即时间复杂度 = O(log2\n) = O(logn)

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

时间复杂度 = O(log3\n) = O(logn),由于 log3\n = log3\2 * log2\n,O(log3\n)=O(log3\2 * log2\n),前面的系数可以忽略,所以O(log3\n)=O(log2\n),所以对数的底可以忽略,统一标识为O(logn)


空间复杂度分析

渐进空间复杂度,表示存储空间与数据规模之间的增长关系

1
int[] a = new int[n];

空间复杂度 = O(n)

常见的空间复杂度O(1),O(n),O(n^2)


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

  • 最好情况时间复杂度(best case time complexity)
  • 最坏情况时间复杂度(worst case time complexity)
  • 平均情况时间复杂度(average case time complexity)
  • 均摊时间复杂度(amortized time complexity)

n表示数组array的长度,在一个无序的数组(array)中,查找变量 x 最后出现的位置,时间复杂度=O(n)

1
2
3
4
5
6
7
8
int find(int[] array, int n, int x) {
int i = 0;
int pos = -1;
for (; i < n; ++i) {
if (array[i] == x) pos = i;
}
return pos;
}

n表示数组array的长度,这段代码的时间复杂度还是 O(n) 吗?很显然有问题。

1
2
3
4
5
6
7
8
9
10
11
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;
}
最好情况时间复杂度 = O(1)
最坏情况时间复杂度 = O(n)

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

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

即平均情况时间复杂度 = O(n)

如果我们把每种情况发生的概率也考虑进去,那平均时间复杂度的计算过程就变成了这样:
假设在数组中与不在数组中的概率都为 1/2。另外,要查找的数据出现在 0~n-1 这 n 个位置的概率也是一样
的,为 1/n。所以,根据概率乘法法则,要查找的数据出现在 0~n-1 中任意位置的概率就是 1/(2n)

1x1/2n + 2x1/2n + 3x1/2n + … + nx1/2n + nx1/2 = 3n+1 / 4

即平均情况时间复杂度 = O(n)

这个值就是概率论中的加权平均值,也叫作期望值,所以平均时间复杂度的全称应该叫加权平均时间复杂度或者期望时间复杂度。


均摊时间复杂度

array表示一个长度为n的数组,代码中的array.length就等于n

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
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;
}

最好情况时间复杂度为 O(1)
最坏情况时间复杂度为 O(n)

假设数组的长度是 n,根据数据插入的位置的不同,我们可以分为 n 种情况,每种情况的时间复杂度是 O(1)。除此之外,还有一种“额外”的情况,就是在数组没有空闲空间时插入一个数据,这个时候的时间复杂度是 O(n)。而且,这 n+1 种情况发生的概率一样,都是 1/(n+1)。所以,根据加权平均的计算方法,我们求得的平均时间复杂度就是:

1x1/n+1 + 1x1/n+1 + 1x1/n+1 + …+ 1x1/n+1 + nx1/n+1 = O(1)

即平均时间复杂度O(1)

首先,find() 函数在极端情况下,复杂度才为 O(1)。但 insert() 在大部分情况下,时间复杂度都为 O(1)。只有个别情况下,复杂度才比较高,为 O(n)。这是 insert()第一个区别于 find() 的地方。

我们再来看第二个不同的地方。对于 insert() 函数来说,O(1) 时间复杂度的插入和 O(n) 时间复杂度的插入,出现的频率是非常有规律的,而且有一定的前后时序关系,一般都是一个 O(n) 插入之后,紧跟着 n-1 个 O(1) 的插入操作,循环往复。所以,针对这样一种特殊场景的复杂度分析,我们并不需要像之前讲平均复杂度分析方法那样,找出所有的输入情况及相应的发生概率,然后再计算加权平均值。

针对这种特殊的场景,我们引入了一种更加简单的分析方法:摊还分析法,通过摊还分析得到的时间复杂度我们起了一个名字,叫均摊时间复杂度。

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

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

参考文章:
https://time.geekbang.org/column/article/40036