文章目录
- 概述
- 时间复杂度
- 空间复杂度
- 总结
概述
算法的效率分析,主要从「时间」和「空间」两个维度进行分析。
- 时间维度顾名思义就是算法需要消耗的时间,用术语 - “时间复杂度” 表示。
- 空间维度代表算法需要占用的内存空间,用术语 - “空间复杂度” 表示。
在计算机发展的早期,计算机的存储容量很小,所以对空间复杂度很是在乎。但是经过计算机行业的迅速发展,计算机的存储容量已经达到了很高的程度。所以我们如今已经不需要再特别关注一个算法的空间复杂度。
算法本质上是一连串的计算步骤。对于同一个问题,我们可以使用不同的算法来获得相同的结果,可是在计算过程中电脑消耗的时间和资源可能有很大的区别;使用相同的算法,在不同性能的电脑中运行消耗的时间和资源也有差别。那我们如何来比较不同算法之间的优劣性呢?
答案是:大O表示法。
大O表示法 (Big O notaion),也叫大O渐进表示法。是一种数学表示法,描述在参数趋向于特定值或无穷大时函数的极限行为。O
代表Ordnung
,意思是近似阶数。
在计算机科学中,大O表示法被用来表示算法的时间复杂度或空间复杂度。
- 时间复杂度,全称叫"渐进时间复杂度":表达式为 T ( n ) = O ( f ( n ) ) T(n) = O(f(n)) T(n)=O(f(n))
- 空间复杂度,全称叫"渐进空间复杂度":表达式为 S ( n ) = O ( f ( n ) ) S(n) = O(f(n)) S(n)=O(f(n))。
大O表示法表示在参数N趋近于无穷大时的算法的极限行为,即时间的上限,通常这意味着最差(最费时间、最耗内存)的情况,这也是我们最关心的一点。(大O不表示具体的时间或空间,我们也不需要考虑具体的时间或空间,因为这在不同性能的电脑上是不一样的)
因此,如果f(n)
是由几个项组成的,则应该按照N趋近于无穷大的原则进行简化,只保留一项:
- 如果
f(n)
是几个项的总和,如1+n+n²
,则只需要取最大阶n²
,省略其它所有的项。 - 如果
f(n)
是多个因子的乘积,如3*n
,则可以省略任何常数,保留n
。 - 如果
f(n)
是几个常数的总和,如1+3+2
,则用1
表示任何常数,BigO表达式为 O ( 1 ) O(1) O(1)。
“复杂度指标”:大O表示法是一种复杂度指标,它表示最差情况;除此之外还有 Ω(Big Omega)-最好情况、Θ(Big theta)-情况区间。通常来说,考虑最差的情况更有意义。
利用大O表示法,我们可以在设计程序之前就能知道某种算法的运行效果,如果这种算法的效果不适用,就没有必要浪费时间去写代码了。
时间复杂度
时间复杂度
T
(
n
)
=
O
(
f
(
n
)
)
T(n) = O(f(n))
T(n)=O(f(n)),通常通过计算算法中基本操作的执行次数来估计,假设每个基本操作需要固定的时间来执行。
基本操作:指算法中的每条语句,语句的执行次数又被称为语句的频度。
常见的时间复杂度量级如下,按照复杂度从低到高排序。(参考Order of growth)
名称 | BigO表达式 | 直观现象 | 效率评价 |
---|---|---|---|
常数阶 | O ( 1 ) O(1) O(1) | 数据独立 (独立于n,与n无关,恒定时间) | 很棒 |
对数阶 | O ( l o g n ) O(logn) O(logn) | 迭代减半 (如二分查找) | 好 |
线性阶 | O ( n ) O(n) O(n) | 每个项迭代一次 | 可以 |
线性对数阶 | O ( n ∗ l o g n ) O(n*logn) O(n∗logn) | 嵌套迭代:一次线性,一次对数 | 差 |
平方阶 | 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) | 嵌套迭代:k次线性 | 糟糕 |
指数阶 | O ( 2 n ) O(2^n) O(2n) | 组合 | 糟糕 |
阶乘 | O ( n ! ) O(n!) O(n!) | 全排列 | 糟糕 |
函数曲线 (渐进趋势)
常数阶 O ( 1 ) O(1) O(1)
int x = 0;
int y = 1;
int temp = x;
x = y;
y = temp;
没有循环或递归等复杂逻辑,无论代码执行多少行,代码复杂度都为 O ( 1 ) O(1) O(1)。
1
表示常数,这个代码有5行,不正确的表示是
O
(
5
)
O(5)
O(5),但用1
表示常数,因此简化为
O
(
1
)
O(1)
O(1)。
O
(
1
)
O(1)
O(1)的例子有:查找数组中指定下标的元素、从哈希表取出元素;+ - * /
运算、位运算;等等。
线性阶 O ( n ) O(n) O(n)
for (int i = 1; i <= n; i++) {
x++;
}
在这段代码中,for循环会执行n遍,因此计算消耗的时间(基本操作的执行次数)是随着n的变化而变化的,此类代码都可以用 O ( n ) O(n) O(n)来表示其时间复杂度。
表达式的简化过程为: O ( 1 + 3 n ) O(1+3n) O(1+3n) -> O ( n ) O(n) O(n)。
1
表示int i = 1
语句只执行一次。3n
表示随着n的每次迭代都分别执行一次i<=n
、i++
、x++
。- 当n趋向于无穷大时,1和3的意义都不大,所以简化为 O ( n ) O(n) O(n) 。
O ( n ) O(n) O(n)的例子有:遍历数组、桶排序(桶排序时间复杂度为什么是O(n)) 等等。
对数阶 O ( l o g n ) O(logn) O(logn)
int i = 1;
while(i < n) {
i = i * 2;
}
在这段代码中,每次i都会被乘以2,意味着每次i都离n更进一步。那需要多少次循环 i 才能等于或大于 n 呢,也就是求解2的x次方等于n,答案是 x=log₂n 。也就是说循环 log₂n 次之后,出现i >= n
,这段代码就结束了。所以此段代码的复杂度为:
O
(
l
o
g
n
)
O(logn)
O(logn)。
log₂n 的核心是"迭代减半、对半分"。
log₂2=1 log₂8=3 log₂32=5 log₂1024=10
# 可以看出来,越到后面(n越大),消耗的时间相对就越少(增速缓慢)。
log₂n 复杂度的BigO表示法为
O
(
l
o
g
n
)
O(logn)
O(logn),省略了底数₂
。那么有没有
O
(
l
o
g
3
n
)
O(log_3n)
O(log3n)、
O
(
l
o
g
4
n
)
O(log_4n)
O(log4n)呢 – 没有!所有的底数都被省略,都表示为
O
(
l
o
g
n
)
O(logn)
O(logn)。底数对时间当然是有影响的,但无论底数是什么,对数阶的渐进意义(函数曲线)是一样的。
O ( l o g n ) O(logn) O(logn)的例子有:二分查找、二叉查找树、gcd欧几里得的辗转相除 等等。
线性对数阶 O ( n ∗ l o g n ) O(n*logn) O(n∗logn)
线性对数阶 O ( n ∗ l o g n ) O(n*logn) O(n∗logn)很好理解,就是将复杂度为 O ( l o g n ) O(logn) O(logn)的代码循环n遍:
for(int i = 0; i <= n: i++) {
int x = 1;
while(x < n) {
x = x * 2;
}
}
因为每次循环的复杂度为 O ( l o g n ) O(logn) O(logn),所以n * logn = O ( n ∗ l o g N ) O(n*logN) O(n∗logN)
O ( n ∗ l o g n ) O(n*logn) O(n∗logn)的例子有:
- 对长度为n的数组进行高效的排序:经典三大 O ( n ∗ l o g n ) O(n*logn) O(n∗logn)排序算法:快排、堆排、归并。
- 贪心题目的时间复杂度多为此。因为通常需要排序。
- 克鲁斯卡尔根据图的边生成最小生成树,边的数量n,时间复杂度 O ( n ∗ l o g n ) O(n*logn) O(n∗logn)。
次方阶 O ( n 2 ) O(n^2) O(n2) O ( n 3 ) O(n^3) O(n3) O ( n k ) O(n^k) O(nk)
O ( n 2 ) O(n^2) O(n2) 就是将循环次数为n的代码再循环n遍:
for (int i = 1; i <= n; i++) {
for (int j = 1; j <= n; j++) {
x++;
}
}
再看一个例子:
for (int i = 1; i <= n; i++) {
x++;
}
for (int i = 1; i <= n; i++) {
for (int j = 1; j <= n; j++) {
x++;
}
}
这个例子的时间复杂度是 O ( n + n 2 ) O(n + n^2) O(n+n2),在n趋向于无穷大的情况下,第一个n的意义就不大了,因此BigO表达式应该简化为 O ( n 2 ) O(n^2) O(n2)。
关于更高阶的次方阶,都是类似的意思, O ( n 3 ) O(n^3) O(n3)相当于三层循环, O ( n k ) O(n^k) O(nk)相当于k层循环。
O ( n 2 ) O(n^2) O(n2)的例子有:冒泡排序、选择排序、普里姆的最小生成树算法(节点为n) 等等。
O ( n 3 ) O(n^3) O(n3)的例子有:遍历3D世界(三维地图)的所有方块(即三维数组) 等等。
O ( n m ) O(nm) O(nm)
O ( n 2 ) O(n^2) O(n2)的本质就是 n * n,如果我们将内层的循环次数改为m:
for (int i = 1; i <= n; i++) {
for (int j = 1; j <= m; j++) {
x++;
}
}
那么复杂度就变为 n * m = O ( m n ) O(mn) O(mn),因为BigO表达的是上限,所以这种表达式属于 O ( n 2 ) O(n^2) O(n2),它并没有脱离这种渐进意义。
但是, O ( n 2 ) O(n^2) O(n2)可能并不是表达 O ( m n ) O(mn) O(mn)的最佳方式,如果m是一个范围从0到n的可调参数,那么 O ( m n ) O(mn) O(mn)更能表达此算法的复杂度。
stack overflow - Is time Complexity O(nm) equal to O(n^2) if m <= n
指数阶 O ( 2 n ) O(2^n) O(2n)
func fib(n int) int {
if n == 0 {
return 0
} else if n == 1 {
return 1
} else {
return fib(n-1) + fib(n-2)
}
}
这是一个"斐波那契函数",用来计算第n个斐波那契数。(此函数对应的"斐波那契数列"从0开始,有的是从1开始,写法上略有区别,但这并不重要)
如下图,要想得到 fib(6)
需要先计算 fib(5)
和 fib(4)
; 想要得到 fib(5)
需要先计算 fib(4)
和 fib(3)
;以此类推。将红色框内的 fib(2)
和 fib(1)
移动到上一层的最右侧,就能够明显看出来总的执行的次数是一个等比数列求和。根据求和公式
S
n
=
a
n
q
−
a
1
q
−
1
,
(
q
≠
1
)
S_n=\frac{a_nq-a_1}{q-1},(q≠1)
Sn=q−1anq−a1,(q=1),得总执行次数为
2
n
−
1
2^n-1
2n−1,省略常数1
,因此时间复杂度为
O
(
2
n
)
O(2^n)
O(2n)。
O ( 2 n ) O(2^n) O(2n)的例子是:斐波那契函数、汉诺塔。这都是很糟糕的递归。
有一个比较有意思的使用场景是,在进行网络开发时,将斐波那契或汉诺塔函数作为一个耗时的响应任务,使其能够根据请求参数n的大小改变响应时长。在RabbitMQ官方教程中的RPC工作模式的示例中,就使用了这种方法。
阶乘 O ( n ! ) O(n!) O(n!)
阶乘就是全排列的所有可能数量。就不再例举代码了。
O ( n ! ) O(n!) O(n!)的例子是:获得n个数字的所有排列方式、猴子排序🐒(最奇葩的排序方式之一)。
空间复杂度
空间复杂度 S ( n ) = O ( f ( n ) ) S(n) = O(f(n)) S(n)=O(f(n)),是对算法 输入使用的内存空间 和 执行期间临时占用的任何内存空间(称为辅助空间) 的度量,这些存储空间是通过参数来表现的。因此,一个算法的空间复杂度是通过它的形参和局部变量所使用的存储空间数估计的。
空间复杂度一般只有三种情况
名称 | BigO表达式 | 直观现象 | 效率评价 |
---|---|---|---|
常数阶 | O ( 1 ) O(1) O(1) | 创建了常数个变量 | 很棒 |
线性阶 | O ( n ) O(n) O(n) | 创建了n个变量 | 可以 |
平方阶 | O ( n 2 ) O(n^2) O(n2) | 嵌套迭代:两次线性 | 糟糕 |
常数阶 O ( 1 ) O(1) O(1)
int x = 0;
int y = 0;
x++;
y++;
这段代码执行所需要的空间不会随着某个变量的大小而发生变化,x, y
所分配的空间是确定的,不随着处理数据量变化,因此空间复杂度为
O
(
1
)
O(1)
O(1)。
线性阶 O ( n ) O(n) O(n)
int[] array = new int[n];
在这段代码中,创建了一个长度为n的数组,这个数组占用的大小为n。这段代码的空间复杂度取决于数组的长度,也就是n,所以空间复杂度为 O ( n ) O(n) O(n)。
平方阶 O ( n 2 ) O(n^2) O(n2)
int[][] array = new int[n][n];
在这段代码中,创建了一个二维数组(矩阵),这个数组占用的大小为n*n。所以空间复杂度为 O ( n 2 ) O(n^2) O(n2)。
不断增加数组的维度,则还有 O ( n 3 ) O(n^3) O(n3)、…、 O ( n k ) O(n^k) O(nk)。
总结
对于一个算法,其时间复杂度和空间复杂度往往是相互影响的。
- 当追求一个较好的时间复杂度时,可能会使空间复杂度的性能变差,即可能导致占用较多的存储空间;
- 反之,当追求一个较好的空间复杂度时,可能会使时间复杂度的性能变差,即可能导致占用较长的运行时间。
因此,当设计一个算法(特别是大型算法)时,要综合考虑算法的各项性能,算法的使用频率,算法处理的数据量的大小,算法描述语言的特性,算法运行的机器系统环境等各方面因素,才能够设计出比较好的算法 – 考虑并采取的方法有"时间换空间"、“空间换时间”。
算法的时间复杂度和空间复杂度合称为算法的复杂度。