“算法复杂度分析是整个算法学习的精髓,只要掌握了它,数据结构和算法的内容基本上就掌握了一半。”这句话虽然夸张了些,但是也表达出了复杂度分析的重要性。本篇博文介绍算法复杂度的大O表示法、最好、最坏、平均和均摊时间复杂度。
一、如何分析、统计算法的执行效率和资源消耗?
1. 为什么需要复杂度分析?
- 事后统计法 有很大的局限性
- 测试结果非常依赖于环境,不同配置的计算机,会产生截然不同的测试结果。
- 测试结果受数据规模的影响很大。例如,对于小规模的数据,插入排序可能会比快速排序还要快。
因此我们需要一个不用具体的测试数据来测试,就可以粗略的估计算法的执行效率的方法。即 时间、空间复杂度分析方法。
2. 大O复杂度表示法
计算机执行的代码时间 T ( n ) T(n) T(n) 与每行代码的执行次数 n n n 成正比 规律总结成公式如下:
T ( n ) = O ( f ( n ) ) T(n)=O(f(n)) T(n)=O(f(n))
其中 T ( n ) T(n) T(n) 表示代码执行时间; n n n 表示数据规模; f ( n ) f(n) f(n) 表示每行代码执行的次数总和;大O是一个数学符号,表示代码的执行时间 T ( n ) T(n) T(n) 与 f ( n ) f(n) f(n) 表达式成正比。
大O时间复杂度实际上并不具体表示代码真正的执行时间,而是表示 代码执行时间随数据规模增长的变化趋势 ,所以也叫做 渐进时间复杂度 (Asymptotic time complexity),简称 时间复杂度 。
3. 时间复杂度分析
三个实用分析方法
- 只关注循环执行次数最多的一段代码
- 加法法则:总复杂度等于量级最大的那段代码的复杂度
- 乘法法则:嵌套代码的复杂度等于嵌套内外代码复杂度的乘积
4. 几种常见的时间复杂度分析
代码千差万别,但是常见的复杂度量级并不多。
复杂度量级(按照数量级递增)
- 常数阶 O ( 1 ) O(1) O(1)
- 对数阶 O ( l o g n ) O(logn) O(logn)
- 线性阶 O ( n ) O(n) O(n)
- 线性对数阶 O ( n l o g n ) O(nlogn) O(nlogn)
- 平方阶 O ( n 2 ) O(n^2) O(n2)、立方阶 O ( n 3 ) O(n^3) O(n3) … k次方阶 O ( n k ) O(n^k) O(nk)
- 指数阶 O ( 2 n ) O(2^n) O(2n)
- 阶数阶 O ( n ! ) O(n!) O(n!)
以上的复杂度量级可以分为两类, 多项式量级 和 非多项式量级 。其中非多项式量级只有两个 O ( 2 n ) O(2^n) O(2n) 和 O ( n ! ) O(n!) O(n!) 。由于非多项式量级算法的执行时间会随着数据规模 n n n的增大而急剧增加,因此我们主要关注 多项式时间复杂度。
4.1 O ( 1 ) O(1) O(1)
一般情况下,只要算法中不存在循环语句、递归语句、即使有成千上万行的代码,其时间复杂度也是 O ( 1 ) O(1) O(1)。
4.2 O ( l o g n ) O(logn) O(logn)、 O ( n l o g n ) O(nlogn) O(nlogn)
举例
i = 1;
while (i <= n) {
i = i * 2;
}
变量从 i=1 开始,每循环一次就乘以2。当 i 大于 n 时循环结束。设循环进行了 x 次,可得 2 x = n 2^x=n 2x=n ,解得 x = l o g 2 n x=log_2n x=log2n ,所以这段代码的复杂度就是 O ( l o g 2 n ) O(log_2n) O(log2n) 。
当代码变为
i = 1;
while (i <= n) {
i = i * 3;
}
这段代码的时间复杂度为 O ( l o g 3 n ) O(log_3n) O(log3n) ,实际上不管是以 2 为底还是以 3 为底,或者以 10 为底,我们都将对数阶的时间复杂度记为 O ( l o g n ) O(logn) O(logn) 。因为对数之间可以利用 换底公式 互相转换。
对于 a , c ∈ ( 0 , 1 ) ∪ ( 1 , + ∞ ) a,c \in (0,1)\cup(1,+\infty) a,c∈(0,1)∪(1,+∞) 且 b ∈ ( 0 , + ∞ ) b \in (0,+\infty) b∈(0,+∞) ,有
l o g a b = l o g c b l o g c a log_ab=\frac{logcb}{logca} logab=logcalogcb
故 l o g 3 n log_3n log3n 就等于 l o g 3 2 ∗ l o g 2 n log_32*log_2n log32∗log2n ,所以 O ( l o g 3 n ) = O ( C ∗ l o g 2 n ) O(log_3n)=O(C*log_2n) O(log3n)=O(C∗log2n) ,其中 C = l o g 3 2 C=log_32 C=log32 是一个常量。在采用大O标记复杂度时可以忽略系数,且我们忽略对数的“底”,统一表示为 O ( l o g n ) O(logn) O(logn) 。
同理对于 O ( n l o g n ) O(nlogn) O(nlogn) 来说,其实就是把复杂度为 O ( l o g n ) O(logn) O(logn) 的代码循环了 n 遍。 O ( n l o g n ) O(nlogn) O(nlogn) 也是一种非常常见的算法时间复杂度。比如 归并排序 、 快速排序 的时间复杂度都是 O ( n l o g n ) O(nlogn) O(nlogn) 。
4.3 O ( m + n ) O(m+n) O(m+n) 、 O ( m ∗ n ) O(m*n) O(m∗n)
这个可以直接看出来,代码的复杂度是 由两个数据的规模 来决定的,m 和 n 表示的是两个数据的规模,当无法事先评估 m 和 n 的量级更大时,我们两个都保留。
5. 空间复杂度分析
空间复杂度全称为 渐进空间复杂度 (Asympotic space complexity),表示算法的存储空间与数据规模之间的增长关系。常见的空间复杂度也就只有
O
(
1
)
O(1)
O(1) 、
O
(
n
)
O(n)
O(n) 、
O
(
n
2
)
O(n^2)
O(n2) ,像
O
(
l
o
g
n
)
O(logn)
O(logn) 、
O
(
n
l
o
g
n
)
O(nlogn)
O(nlogn) 这样的对数阶平时用不到。
二、最好、最坏、平均、均摊时间复杂度
当然复杂度相关的知识点远不止上面这些,下面介绍最好情况时间复杂度(best case time complexity)、最坏情况时间复杂度(worst case time complexity)、平均情况 时间复杂度(average case time complexity)、均摊时间复杂度(amortized time complexity)。
1. 最好、最坏、平均情况时间复杂度
先看代码:
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;
}
由于 break 的存在,导致该算法的复杂度变得有些不同,当数组的第 1 个元素正好是要查找的变量 x ,那么就不需要遍历剩下的 n-1 个数据了,那么时间复杂度就是 O ( 1 ) O(1) O(1) 。如果数组中不存在变量 x ,那么就需要把整个数组遍历一遍,时间复杂度就成了 O ( n ) O(n) O(n) 。所以不同的情况下,这段代码的时间复杂度是不一样的。
此处引入三个概念,最好情况时间复 杂度、最坏情况时间复杂度和平均情况时间复杂度。
- 最好情况时间复杂度就是,在最理想的情况下,执行这段代码的时间复杂度。
- 最坏情况时间复杂度就是,在最糟糕的情况下,执行这段代码的时间复杂度。
- 根据不同情况的概率甲醛平均得出的复杂度称为平均时间复杂度,全称叫做加权平均时间复杂度。
2. 均摊时间复杂度
先举个例子
// 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 值放到数组的第一个位置,然后再将新的数据插入。但如果数组 一开始就有空闲空间,则直接将数据插入数组。
- 最好的情况时间复杂度为 O ( 1 ) O(1) O(1)
- 最坏的情况时间复杂度为 O ( n ) O(n) O(n)
- 平均的情况时间复杂度为 O ( 1 ) O(1) O(1)
利用摊还分析法分析,只有一种情况下该算法的时间复杂度为 O ( n ) O(n) O(n)即 count==array.length,在count!=array.length情况下,代码的时间复杂度为 O ( 1 ) O(1) O(1) 。因此该算法的均摊时间复杂度就为 O ( 1 ) O(1) O(1) 。均摊时间复杂度就是一种特殊的平均时间复杂度。这个概念了解一下即可。