为什么需要分析时间复杂度
通常在运行一段代码之前,我们需要预测其需要的资源。虽然有时我们主要关心像内存、网络带宽或者计算机硬件这类资源,但是通常我们想度量的是计算时间。
接下来我们以插入排序算法为切入点一窥时间复杂度的计算方法。
时间复杂度分析
一般来说,算法需要的时间于输入的规模同步增长,所以通常把一个程序的运行时间描述成其输入规模的函数。为此,我们必须先给出术语运行时间和输入规模。
输入规模通常依赖于研究的问题。比如,对于排序问题来说,最自然的量度是需要排序元素的数量。又比如对于最短路算法而言,其输入是一个图,则输入规模可以用该图中的顶点数及边数来描述。
一个算法在特定输入上的运行时间是指执行的基本操作数或步数。首先我们假设执行一行代码需要常量时间。常量时间是指:无论输入规模如何变化,执行这一行代码的时间都不受输入规模的影响。我们记第 i 行代码的执行时间为 C i C_i Ci。
在用插入排序举例之前,我们先看下该算法的基本思想:每步将一个待排序的元素,按其值的大小插入前面已经排序的序列中适当位置上,直到全部元素插入完为止。以升序排序为例,具体代码如下:
template<typename Array>
void InsertionSort(Array &data) {
for(size_t i = 1, n = data.size(); i < n; i++) { // 1
auto key = data[i]; // 2
auto j = i-1; // 3
while(j >= 0 && data[j] > key) { // 4
data[j+1] = data[j]; // 5
j--; // 6
}
data[j+1] = key; // 7
}
}
我们设上述代码的 for 循环中七行代码的执行时间分别为:
C
0
,
C
1
,
C
2
,
C
3
,
C
4
,
C
5
,
C
6
C_0, C_1, C_2, C_3, C_4, C_5, C_6
C0,C1,C2,C3,C4,C5,C6
设枚举到第 i 个元素时,第四行代码的执行次数为
T
i
T_i
Ti。
每行代码的执行次数可表示为:
n
,
n
−
1
,
n
−
1
,
∑
i
=
2
n
T
i
,
∑
i
=
2
n
(
T
i
−
1
)
,
∑
i
=
2
n
(
T
i
−
1
)
,
n
−
1
n, n-1, n-1, \sum_{i=2}^nT_i, \sum_{i=2}^n(T_i-1), \sum_{i=2}^n(T_i-1),n-1
n,n−1,n−1,∑i=2nTi,∑i=2n(Ti−1),∑i=2n(Ti−1),n−1。
具体对应关系如下:
该算法的运行时间是执行每条语句的运行时间之和。为计算在具有 n 个元素的输入上该算法的运行时间S(n),我们将代价和次数列对应元素之积求和,得:
S
(
n
)
=
C
0
n
+
C
1
(
n
−
1
)
+
C
2
(
n
−
1
)
+
C
3
∑
i
=
2
n
T
i
+
C
4
∑
i
=
2
n
(
T
i
−
1
)
+
C
5
∑
i
=
2
n
(
T
i
−
1
)
+
C
6
(
n
−
1
)
\begin{aligned} S(n)=&C_0n + C_1(n-1)+C_2(n-1)\\ &+C_3\sum_{i=2}^nT_i\\ &+C_4\sum_{i=2}^n(T_i-1)\\ &+C_5\sum_{i=2}^n(T_i-1)\\ &+C_6(n-1) \end{aligned}
S(n)=C0n+C1(n−1)+C2(n−1)+C3i=2∑nTi+C4i=2∑n(Ti−1)+C5i=2∑n(Ti−1)+C6(n−1)
即使对给定规模的输入,一个算法的运行时间也有可能依赖于给定输入的一些特点。例如,对于插入算法来说,若输入数组已排好序,则出现最佳情况。这时,对每个
i
=
1
,
2
,
3
,
.
.
.
,
n
−
1
i = 1,2,3,...,n-1
i=1,2,3,...,n−1有
T
i
=
1
Ti = 1
Ti=1 ,该最佳情况的运行时间为
S
(
n
)
=
C
0
n
+
C
1
(
n
−
1
)
+
C
2
(
n
−
1
)
+
C
3
(
n
−
1
)
+
C
6
(
n
−
1
)
\begin{aligned} S(n) =&C_0n\\ &+ C_1(n-1)\\ &+C_2(n-1)\\ &+C_3(n-1)+C_6(n-1)\\ \end{aligned}
S(n)=C0n+C1(n−1)+C2(n−1)+C3(n−1)+C6(n−1)
整理一下上述式子得:
S
(
n
)
=
(
C
0
+
C
1
+
C
2
+
C
3
+
C
6
)
n
−
(
C
1
+
C
2
+
C
3
+
C
6
)
\begin{aligned} S(n)= &(C_0+C_1+C_2+C_3+C_6)n\\ &-(C_1+C_2+C_3+C_6) \end{aligned}
S(n)=(C0+C1+C2+C3+C6)n−(C1+C2+C3+C6)
我们可以把该运行时间表示为
a
n
+
b
an+b
an+b,其中常量 a 和 b 依赖于语句代价
C
i
Ci
Ci。因此,它是n的线性函数。
若输入数组已反向排序,即按递减序列排好序,则导致最坏情况。这时,对于
i
=
1
,
2
,
3
,
.
.
.
n
−
1
i=1,2,3,...n-1
i=1,2,3,...n−1 有
T
i
=
i
+
1
Ti=i+1
Ti=i+1。将
T
i
Ti
Ti 代入
S
(
n
)
S(n)
S(n) 得:
S
(
n
)
=
(
C
3
2
+
C
4
2
+
C
5
2
)
n
2
+
(
C
0
+
C
1
+
C
2
+
C
3
2
−
C
4
2
−
C
5
2
−
C
6
)
n
−
(
C
1
+
C
2
+
C
3
+
C
6
)
\begin{aligned} S(n) &=(\frac{C_3}{2}+\frac{C_4}{2}+\frac{C_5}{2})n^2\\ &+(C_0+C_1+C_2+\frac{C_3}{2}-\frac{C_4}{2}-\frac{C_5}{2}-C_6)n\\ &-(C_1+C_2+C_3+C_6) \end{aligned}
S(n)=(2C3+2C4+2C5)n2+(C0+C1+C2+2C3−2C4−2C5−C6)n−(C1+C2+C3+C6)
我们可以把最坏情况运行时间表示为
a
n
2
+
b
n
+
c
an^2+bn+c
an2+bn+c,其中常量
a
,
b
,
c
a,b,c
a,b,c依赖于语句执行耗时
C
i
C_i
Ci。因此,它是n的二次函数。
最坏情况与平均情况分析
在分析插入排序时,我们同时研究了最坏情况和最佳情况。然而我们往往集中于最坏情况运行时间,即规模为n的所有输入中,算法运行时间最长的情况。原因如下:
- 一个算法的最坏情况运行时间给出了运行时间的上界。从而可以保证在任何情况下,算法的运行时间绝对不会超过这个上界。
- 对于大多数算法来说,最佳情况出现的频率极低。
- "平均情况"往往和最坏情况大致一样差。
增长量级
我们使用某些简化的抽象来使插入排序的分析更加容易。
首先,通过使用常量
C
i
C_i
Ci表示每条语句的执行耗时以忽略每条语句的细节。
其次,我们进一步整合总体耗时的计算公式,使其表示为
a
n
2
+
b
n
+
c
an^2+bn+c
an2+bn+c,进一步忽略了每条语句的执行耗时。
现在我们做出一种更简化的抽象:即我们真正感兴趣的运行时间的增长率或增长量级。所以我们只考虑公式中最重要的项,因为当 n 的值很大时,低阶项相对来说并不重要。我们也忽略最重要的项的常系数,因为对大的输入,在确定计算效率时常量因子不如增长率重要。对于插入排序,当我们忽略掉低阶项和最重要的项的常系数时,只剩下最重要的项中的因子
n
2
n^2
n2。我们记插入排序的时间复杂度为
O
(
n
2
)
O(n^2)
O(n2)。
如果一个算法的最坏情况运行时间具有比另一个算法更低的增长量级,那么我们通常认为前者比后者更有效。由于常量因子和低阶项,对于小的输入,运行时间具有较高增长量级的一个算法与运行时间具有较低增长量级的另一个算法相比,其可能需要较少时间。但是当输入足够大时,例如,一个 O ( l o g 2 n ) O(log_2^n) O(log2n)算法在最坏情况下比另一个 O ( n 2 ) O(n^2) O(n2)的算法要运行的更快。