文章目录
基础知识就像是一座大楼的地基,它决定了我们的技术高度。而要想快速做出点事情,前提条件一定是基础能力过硬,“内功”要到位。
1 复杂度分析的重要性
需要一个不用具体的测试数据来测试,就可以粗略地估计算法的执行效率的方法
线上代码通过统计、监控就能得到算法执行的时间和占用的内存大小,这种被称为事后统计法。
有些什么局限性?
-
测试结果非常依赖测试环境
例如同一段代码在Inter i9处理器和i3处理器上运行,执行速度会有很大不同。
-
测试结果受数据规模的影响很大
2 大O复杂度表示法
算法的执行效率,粗略地讲,就是算法代码执行的时间。
int cal(int n) {
int sum = 0;
int i = 1;
for (; i <= n; ++i) {
sum = sum + i;
}
return sum;
}
从CPU角度来说,上面每行代码的操作:读数据-运算-写数据。我们可以假设每行代码执行的时间都一样,为unit_time。
这样我们可以看出,第2,3行代码是1个unit_time,第4,5行都执行了n遍,所以总的执行时间是(2n+2)*unit_time。
得出一个结论:所有代码的执行时间T(n)与每行代码的执行次数n成正比。
T
(
n
)
=
O
(
f
(
n
)
)
,
(
T
(
n
)
是代码执行时间,
n
表示数据规模,
f
(
n
)
表示代码执行次数总和,
O
表示正比关系
)
T(n)=O(f(n)),(T(n)是代码执行时间,n表示数据规模,f(n)表示代码执行次数总和,O表示正比关系)
T(n)=O(f(n)),(T(n)是代码执行时间,n表示数据规模,f(n)表示代码执行次数总和,O表示正比关系)
所以上面代码可以表示为T(n) = O(2n+2),这就是大O时间复杂度表示法。它实际上表示着代码执行时间随数据规模增长的变化趋势,所以有另外一个名字叫做渐进时间复杂度(asymptotic time complexity),简称时间复杂度。
当n趋于无穷大时,公式中的低阶、常量、系数三部分并不左右增长趋势,所以都可以忽略。
所以上面代码的时间复杂度可以简化为:T(n) = O(n)
3 三种时间复杂度分析方法
下面是三种比较实用的分析方式:
- 只关注循环执行次数最多的一段代码
- 加法法则:总复杂度等于量级最大的那段代码的复杂度
- 乘法法则:嵌套代码的复杂度等于嵌套内外代码复杂度的乘积
3.1 只关注循环执行次数最多的一段代码
我们在分析一个算法、一段代码的时间复杂度的时候,也只关注循环执行次数最多的那一段代码就可以了。
比如下面这段代码:
int cal(int n) {
int sum = 0;
int i = 1;
for (; i <= n; ++i) {
sum = sum + i;
}
return sum;
}
2,3行都是常量级,与n大小无关,循环次数最多的事4,5行,所以只需要关注循环这段,这两行代码被执行了n次,所以总的时间复杂度就是O(n)。
3.2 加法法则:总复杂度等于量级最大的那段代码的复杂度
如下代码示例:
int cal(int n) {
int sum_1 = 0;
int p = 1;
for (; p < 100; ++p) {
sum_1 = sum_1 + p;
}
int sum_2 = 0;
int q = 1;
for (; q < n; ++q) {
sum_2 = sum_2 + q;
}
int sum_3 = 0;
int i = 1;
int j = 1;
for (; i <= n; ++i) {
j = 1;
for (; j <= n; ++j) {
sum_3 = sum_3 + i * j;
}
}
return sum_1 + sum_2 + sum_3;
}
这个代码分为三部分,分别是求sum_1、sum_2、sum_3。我们可以分别分析每一部分的时间复杂度,然后把它们放到一块儿,再取一个量级最大的作为整段代码的复杂度。
sum_1:T1(n)=O(1);
sum_2:T2(n)=O(n);
sum_3:T3(n)=O(n*n);
所以这段代码的时间复杂度为O(n*n)
- 当数据规模为n时加法法则公式如下:
若 T 1 ( n ) = O ( f ( n ) ) , T 2 ( n ) = O ( g ( n ) ) ; 则 T ( n ) = T 1 ( n ) + T 2 ( n ) = m a x ( O ( f ( n ) ) , O ( g ( n ) ) ) = O ( m a x ( f ( n ) , g ( n ) ) ) 若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))) 若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,则对应公式如下:
若 T 1 ( m ) = O ( f ( m ) ) , T 2 ( n ) = O ( g ( n ) ) ; 则 T ( n ) = T 1 ( m ) + T 2 ( n ) = O ( f ( m ) + f ( n ) ) 若T1(m)=O(f(m)),T2(n)=O(g(n));\\ 则\\ T(n)=T1(m)+T2(n)=O(f(m)+f(n)) 若T1(m)=O(f(m)),T2(n)=O(g(n));则T(n)=T1(m)+T2(n)=O(f(m)+f(n))
3.3 乘法法则:嵌套代码的复杂度等于嵌套内外代码复杂度的乘积
乘法法则公式如下:
若
T
1
(
n
)
=
O
(
f
(
n
)
)
,
T
2
(
n
)
=
O
(
g
(
n
)
)
;
则
T
(
n
)
=
T
1
(
n
)
∗
T
2
(
n
)
=
O
(
f
(
n
)
)
∗
O
(
g
(
n
)
)
=
O
(
f
(
n
)
∗
g
(
n
)
)
若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(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))
比如下面这段代码:
int cal(int n) {
int ret = 0;
int i = 1;
for (; i < n; ++i) {
ret = ret + f(i);
}
}
int f(int n) {
int sum = 0;
int i = 1;
for (; i < n; ++i) {
sum = sum + i;
}
return sum;
}
运用乘法法则,可以轻松得到T(n) = T1(n) * T2(n) = O(n*n) = O(n2)。
4 几种常见时间复杂度
复杂度量级可分为两类:
- 多项式量级
- 非多项式量级
- O(2^n)和O(n!)
- 当数据规模n越来越大时,非多项式量级算法的执行时间会急剧增加,求解问题的执行时间会无限增长。
4.1 O(1)
只是常量级时间复杂度的一种表示方法,并不是指只执行了一行代码。
int i = 8;
int j = 6;
int sum = i + j;
一般情况下,只要算法中不存在循环语句、递归语句,即使有成千上万行的代码,其时间复杂度也是Ο(1)。
4.2 O(logn)、O(nlogn)
对数阶时间复杂度非常常见,同时也是最难分析的一种时间复杂度。
先看下面这段代码的时间复杂度是多少?
i=1;
while (i <= n) {
i = i * 2;
}
根据加法法则,只需要计算出循环部分这行代码被执行多少次,就能知道整段代码的时间复杂度。
上面代码可以看出,i值的变化是一个等比数列,可以得到
i
k
=
a
1
∗
2
k
−
1
≤
n
,(
i
k
为第
k
次循环
i
的值,
a
1
为
i
的起始值,
k
为循环的次数,
n
为边界值)
i_k=a1*2^{k-1}\leq n,(i_k为第k次循环i的值,a1为i的起始值,k为循环的次数,n为边界值)
ik=a1∗2k−1≤n,(ik为第k次循环i的值,a1为i的起始值,k为循环的次数,n为边界值)
所以可以得到:
k
=
log
2
n
k=\log_2n
k=log2n
即时间复杂度为
O
(
log
2
n
)
O(\log_2n)
O(log2n)
现在我稍微改下代码:
i=1;
while (i <= n) {
i = i * 3;
}
可以轻松得到时间复杂度为:
O
(
log
3
n
)
O(\log_3n)
O(log3n)
不管是以几为底的对数,通过换底公式可以得到如下:
log
2
N
=
log
c
N
log
c
2
,
(
c
为常数
)
=
1
log
c
2
∗
log
c
N
,
(
c
为常数
)
\log_2N = \frac{\log_cN}{\log_c2}, (c为常数)= \frac{1}{\log_c2}*\log_cN,(c为常数)
log2N=logc2logcN,(c为常数)=logc21∗logcN,(c为常数)
n趋于无穷时系数可以忽略不计,所以对数阶时间复杂度就统一表示为O(logn)。
如果一段代码的时间复杂度是O(logn),我们循环执行n遍,运用乘法法则时间复杂度就是O(nlogn),这是一种很常见的时间复杂度,比如归并排序、快速排序。
4.3 O(m+n)、O(m*n)
代码的复杂度由两个数据的规模来决定。
比如下面这段代码:
int cal(int m, int n) {
int sum_1 = 0;
int i = 1;
for (; i < m; ++i) {
sum_1 = sum_1 + i;
}
int sum_2 = 0;
int j = 1;
for (; j < n; ++j) {
sum_2 = sum_2 + j;
}
return sum_1 + sum_2;
}
m和n是表示两个数据规模。我们无法事先评估m和n谁的量级大,时间复杂度就是O(m+n)。
5 最好、最坏时间复杂度
- 最好情况时间复杂度就是,在最理想的情况下,执行这段代码的时间复杂度。
- 最坏情况时间复杂度就是,在最糟糕的情况下,执行这段代码的时间复杂度。
比如下面这段代码:
// 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;
}
这段代码的实现功能是查找元素x在数组array首次出现的位置;下面在两个极端情况下分析下这段代码的时间复杂度。
- 最好情况时间复杂度:如果数组中第一个元素正好是要查找的变量x,那时间复杂度就是O(1);
- 最坏情况时间复杂度:如果数组中不存在变量x,那我们就需要把整个数组都遍历一遍,时间复杂度就成了O(n)。
6 平均时间复杂度
平均复杂度只在某些特殊情况下才会用到。
还是借助查元素x的例子:
// 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;
}
查找到元素x代码执行总共有n+1种情况(包含查不到x场景),我们把每种情况下,查找需要遍历的元素个数累加起来,然后再除以 n+1,就可以得到需要遍历的元素个数的平均值,即:
1
+
2
+
3
+
…
…
+
n
+
n
n
+
1
=
n
∗
(
n
+
3
)
2
∗
(
n
+
1
)
\frac{1+2+3+……+n+n}{n+1}=\frac{n*(n+3)}{2*(n+1)}
n+11+2+3+……+n+n=2∗(n+1)n∗(n+3)
使用大O标记法,去掉低阶、系数、常量,公式简化后平均时间复杂度就是O(n)。
其实这样分析还是会存在问题,没有考虑每一种情况出现的概率,我们先看下每种情况出现概率:
- 元素x在数组与不在数组的概率为1/2
- 元素x在0~n-1 这 n 个位置的出现概率一致,都是1/n,所以要查找的数据出现在 0~n-1 中任意位置的概率就是 1/(2n)。
这样平均时间复杂度的计算过程就变成了如下所示,即:
1
∗
1
2
n
+
2
∗
1
2
n
+
3
∗
1
2
n
+
…
…
+
n
∗
1
2
n
+
n
∗
1
2
=
3
n
+
1
4
1*\frac{1}{2n}+2*\frac{1}{2n}+3*\frac{1}{2n}+……+n*\frac{1}{2n}+n*\frac{1}{2}=\frac{3n+1}{4}
1∗2n1+2∗2n1+3∗2n1+……+n∗2n1+n∗21=43n+1
很明显,这就是加权平均值(期望值),所以平均时间复杂度的全称应该叫加权平均时间复杂度或者期望时间复杂度。
用大O表示法,这段代码的加权平均时间复杂度仍然是 O(n)。
7 均摊时间复杂度
均摊时间复杂度就是一种特殊的平均时间复杂度
先看下面这块代码,我们来分析下它的最好情况时间复杂度、最坏情况时间复杂度以及平均时间复杂度
// 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;
}
-
最好情况时间复杂度:数组中有空闲空间,直接insert,所以时间复杂度为O(1);
-
最坏情况时间复杂度:数组中没有空闲空间,需要遍历再insert,所以时间复杂度为O(n);
-
平均时间复杂度:数据insert分为n+1种情况,发生的概率都是1/(n+1),所以平均时间复杂度为:
1 ∗ 1 n + 1 + 1 ∗ 1 n + 1 + … … + 1 ∗ 1 n + 1 + n ∗ 1 n + 1 = O ( 1 ) 1*\frac{1}{n+1}+1*\frac{1}{n+1}+……+1*\frac{1}{n+1}+n*\frac{1}{n+1}=O(1) 1∗n+11+1∗n+11+……+1∗n+11+n∗n+11=O(1)
其实仔细看代码可以发现,insert是非常有规律的,一般都是一个 O(n) 插入之后,紧跟着 n-1 个 O(1) 的插入操作,循环往复,所以把耗时多的那次操作均摊到接下来的 n-1 次耗时少的操作上,均摊下来,这一组连续的操作的均摊时间复杂度就是 O(1),所得的时间复杂度又叫均摊时间复杂度。适用的场景:
对一个数据结构进行一组连续操作中,大部分情况下时间复杂度都很低,只有个别情况下时间复杂度比较高,而且这些操作之间存在前后连贯的时序关系,这个时候,我们就可以将这一组操作放在一块儿分析,看是否能将较高时间复杂度那次操作的耗时,平摊到其他那些时间复杂度比较低的操作上。
8 空间复杂度
空间复杂度全称就是渐进空间复杂度(asymptotic space complexity),表示算法的存储空间与数据规模之间的增长关系。
分析方式和时间复杂度类似,常见的空间复杂度就是O(1)、O(n)、O(n2 )。