本文为极客时间《数据结构与算法之美》专栏的笔记
专栏链接:http://gk.link/a/100Lw
文章目录
为什么需要复杂度分析
我们实际项目中很多时候要由测试人员测试其性能,甚至每段代码都必须测试。我们叫做事后统计法,但是这个方法有它的缺点:
- 测试结果严重依赖测试环境,就是说不同的机器跑出来的结果会不一样。
- 测试结果受数据规模影响较大,不同的数据规模很可能得到不同的结果。
大O 复杂度表示法
对于以下代码:
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;
}
}
}
我们假设每个语句的执行时间都是 unite_time , 2 、 3 、 4 行各自需要一个 unite_time , 5 、 6 行循环执行了 n 遍,需要 2nunite_time , 7 、 8 行循环执行了 nn 遍,需要 2 n 2 2n^2 2n2*unite_time 。总的执行时间 T ( n ) = ( 2 n 2 + 2 n + 3 ) ∗ T(n)=(2n^2+2n+3)* T(n)=(2n2+2n+3)∗unite_time
首先看一个公式:
T
(
n
)
=
O
(
f
(
n
)
)
T(n)=O(f(n))
T(n)=O(f(n))
式中, n 表示数据规模大小,
f
(
n
)
f(n)
f(n) 表示每行代码执行的次数总和,
T
(
n
)
T(n)
T(n) 表示所有代码的执行时间。公式中的 O 表示代码的执行时间
T
(
n
)
T(n)
T(n) 与
f
(
n
)
f(n)
f(n) 表达式成正比。公式表示,所以代码执行次数 T(n)与每行代码执行次数成正比。
大 O 时间复杂度实际上并不具体表示代码真正的执行时间,而是表示代码执行时间随数据规模增长的变化趋势,称为渐进时间复杂度,也称为时间复杂度。
当 n 很大时,公式中的低阶、常量、系数并不左右增长趋势,所以可以忽略。我们只需要记录最大量就可以了。
时间复杂度分析实用方法:
- 只关注循环执行次数最多的一段代码
- 加法法则:总复杂度等于量级最大的那段时间的复杂度
即在采用大 O 复杂度标记的时候可以忽略系数 - 乘法法则:嵌套代码的复杂度等于嵌套内外代码复杂度的乘积
几种常见的时间复杂度案例分析:
分为多项式量级和非多项式量级
非多项式量级
指数阶 O ( n 2 ) O(n^2) O(n2)
二叉树的遍历就是一个指数阶的问题,其中 n 是层数。
阶乘阶 O(n!)
例如使用定义法求矩阵行列式,其中 n 指的是矩阵的阶数。
我们把时间复杂度为非多项式量级的算法问题叫做NP 问题
多项式量级
O(1)
算法中不存在循环和递归时
对数阶 O(logn)、 O(nlogn)
如下代码中:
i=1;
while(i <= n){
i = i * 2;
}
变量 i 的取值实际是是一个等比数列 2 0 , 2 1 , 2 2 , . . . , 2 x = n 2^0,2^1,2^2,...,2^x=n 20,21,22,...,2x=n 求解 x 得到 x = log 2 n x=\log_{2} {n} x=log2n ,所以这段代码的时间复杂度就是 O ( log 2 n ) O(\log_{2} {n}) O(log2n)
如果将代码改为:
i=1;
while(i <= n){
i = i * 3;
}
根据刚刚的思路,所以这段代码的时间复杂度就是 O ( log 3 n ) O(\log_{3} {n}) O(log3n) 。
我们可以将所有对数阶的时间复杂度都记为
O
(
l
o
g
n
)
O(logn)
O(logn) ,原因如下:
因为对数的底可以互换。
log
3
n
=
log
3
2
∗
log
2
n
\log_{3} {n}=\log_3 2*\log_2 n
log3n=log32∗log2n ,因为在采用大 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(m+n)、 O(m*n)
当代码的复杂度由两个数据的规模决定的时候,一个数据规模是 m ,一个数据规模是 n 。
几种时间复杂度的趋势变化关系图:
空间复杂度
与时间复杂度一样,空间复杂度的全称是渐进空间复杂度。表示算法的存储空间与数据规模之间的增长关系。分析时主要考虑申请空间的大小,与时间复杂度类似,相对较简单。
最好、最坏情况时间复杂度
如下面代码所示:
// 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;
}
return pos;
}
时间复杂度是 O ( n ) O(n) O(n),空间复杂度是 O ( n ) O(n) O(n) 。但是上述代码并不高效,因为并不需要遍历整个数组,而是找到就可以提前结束循环了。
优化代码:
// 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;
}
我们在优化之后,对于数据规模为 n 的不同数据,算法需要的就不同了(这也是较为普遍的情况)。此时时间复杂度就不再是 O ( n ) O(n) O(n) 了.
最好情况时间复杂度是指在最理想的情况下,执行这段代码的时间复杂度。在上面例子中,就指的是 x 在数组第一个元素的时候。最好情况时间复杂度就是 O ( 1 ) O(1) O(1) 。
同理,最坏情况时间复杂度是 O ( n ) O(n) O(n) 。
平均情况时间复杂度
平均情况时间复杂度也叫做加权平均时间复杂度或者期望时间复杂度。这里就可以看出它和数学期望有关。
维基百科中关于数学期望的描述:
在概率论和统计学中,一个离散性随机变量的期望值(或数学期望、或均值,亦简称期望,物理学中称为期待值)是试验中每次可能的结果乘以其结果概率的总和。
换句话说,期望值像是随机试验在同样的机会下重复多次,所有那些可能状态平均的结果,便基本上等同“期望值”所期望的数。
所以平均情况时间复杂度和具体问题有很大是关联,考虑上面查找变量 x 的例子,一共有 n+1 种情况:在数组 0~n-1 位置中和不在数组中。
如果我们认为这 n+1 种情况发生的概率是相同的,那么就应该把所有情况需要的计算次数乘上该情况的概率
1
∗
1
n
+
1
+
2
∗
1
n
+
1
+
.
.
.
+
n
∗
1
n
+
1
+
n
∗
1
n
+
1
=
n
(
n
+
3
)
2
(
n
+
1
)
1*\frac 1 {n+1}+2*\frac 1 {n+1}+...+n*\frac 1 {n+1}+n*\frac 1 {n+1}=\frac {n(n+3)} {2(n+1)}
1∗n+11+2∗n+11+...+n∗n+11+n∗n+11=2(n+1)n(n+3)
在时间复杂度的大 O 标记法中,可以忽略系数、低阶、常量,所以平均复杂度是 O(n)。
如果这 n+1 中情况并非均匀分布,比如在数组中和不在数组中的概率相等,而在数组 0~n-1 位置中的概率是相同的时候。
1
∗
1
2
n
+
2
∗
1
2
n
+
.
.
.
+
n
∗
1
2
n
+
n
∗
1
2
=
3
n
+
1
4
1*\frac 1 {2n}+2*\frac 1 {2n}+...+n*\frac 1 {2n}+n*\frac 1 {2}=\frac {3n+1} 4
1∗2n1+2∗2n1+...+n∗2n1+n∗21=43n+1
在这种情况下,时间复杂度也是
O
(
n
)
O(n)
O(n) 。
均摊时间复杂度
均摊时间复杂度可以看做某一类特殊场景的平均时间复杂度。这种场景下可以使用一种叫摊还分析的方法计算平均时间复杂度。
如下场景:
// 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;
}
这里是指数组插入满了之后清空再插入。最好、最坏情况时间复杂度分别为
O
(
1
)
O(1)
O(1) 和
O
(
n
)
O(n)
O(n) ,平均时间复杂度按照之前的计算方法是:
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)
但是实际上这个例子的平均时间复杂度分析并不需要这么复杂,对于这个例子中,在大部分情况下,时间复杂度都为
O
(
1
)
O(1)
O(1) ,极个别的情况下时间复杂度为
O
(
n
)
O(n)
O(n) 。
O
(
1
)
O(1)
O(1) 时间复杂度和
O
(
n
)
O(n)
O(n) 时间复杂度出现的频率是有规律的,而且有一定的时序关系,一个
O
(
n
)
O(n)
O(n) 的插入紧跟着 n-1 个
O
(
1
)
O(1)
O(1) 的插入,循环往复。
这样我们就可以使用摊还分析计算。我们将耗时多的那一次 O ( n ) O(n) O(n) 的操作分摊到耗时少的 n-1 次的 O ( 1 ) O(1) O(1) 的操作中,均摊下来,这一组的连续操作的均摊时间复杂度就是 O ( 1 ) O(1) O(1) 。
均摊时间复杂度的应用场景:
- 对一个数据结构进行一组连续操作
- 大部分的时间复杂度都很低,只有个别情况下时间复杂度比较高
- 这些操作存在前后连贯的时序关系
此时,我们选定一组操作放在一起一块分析,看能够将较高的时间复杂度的耗时平摊到其他的那些时间复杂度比较低的操作上去。
而且在能够应用摊还分析的场景下,一般均摊时间复杂度就等于最好时间复杂度。这个可以作为检验的参考。
联系:
对于如下代码,分析最好、最坏时间复杂度和平均时间复杂度:
// 全局变量,大小为 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;
}
最好情况时间复杂度是 O ( 1 ) O(1) O(1) ,最坏情况时间复杂度是 O ( n ) O(n) O(n),平均时间复杂度是 O ( 1 ) O(1) O(1) 。
平均时间复杂度为:
1
∗
1
n
+
1
∗
1
n
+
.
.
+
10
∗
1
n
+
.
.
.
+
20
∗
1
n
+
.
.
.
+
40
∗
1
n
+
.
.
.
+
n
∗
1
n
≤
n
∗
1
n
+
(
10
+
20
+
40
+
.
.
.
+
n
)
∗
1
n
1*\frac 1 n+1*\frac 1 n+..+10*\frac 1 n+...+20*\frac 1 n+...+40*\frac 1 n+...+n*\frac 1 n\leq n*\frac 1 n+(10+20+40+...+n)*\frac 1 n
1∗n1+1∗n1+..+10∗n1+...+20∗n1+...+40∗n1+...+n∗n1≤n∗n1+(10+20+40+...+n)∗n1
由等比数列求和公式可知:
10
+
20
+
40
+
.
.
.
+
n
=
10
−
n
∗
2
1
−
2
10+20+40+...+n=\frac {10-n*2} {1-2}
10+20+40+...+n=1−210−n∗2
所以平均时间复杂度为 O(1)。
如果使用摊还分析,
–10 个 1-- 10 --10 个 1-- 20 --20 个 1-- 40 --40 个 1-- 80
我们可以如此分组:
–10 个 1–20
–20 个 1–40
–40 个 1–80
我们可以将 20 分摊到第 11 ~ 20 , 40 分摊到第 21 ~ 40 , 80 分摊到第 41 ~ 80 ,平均每个分摊 2 ,所以平均复杂度为
O
(
1
)
O(1)
O(1) 。
本文深入解析算法的时间和空间复杂度,包括大O表示法、常见复杂度类型及案例分析,如对数阶、多项式阶等。同时,探讨了最好、最坏及平均情况下的时间复杂度,并介绍了均摊时间复杂度的概念。
698

被折叠的 条评论
为什么被折叠?



