前言
本文以插入排序为例,结合自身学习过程中遇到的问题,介绍如何分析算法的复杂度,因为掌握此方法后,就可以对遇到的任何算法做一个形式化的评估,从而了解算法的执行效率。 本文先给出计算插入排序算法运行时间的表示方式和计算方法,再给出其最好情况、最坏情况的分析过程,最后引出
Θ
\Theta
Θ(读作theta)表示。相信看完本文后,就可以更清楚的明白:时间复杂度、
Θ
\Theta
Θ、大
O
O
O等曾经困惑过你的概念。
具体插入排序的算法的思想请见之前的博文《插入排序》,或者可参考《算法导论》
P
9
P_9
P9~
P
12
P_{12}
P12,这里假设读者已经明白了插入排序的算法思路。
算法分析
算法的运行时间是指在特定输入时,所执行的基本操作数(或步数)。这里的基本操作数可这么理解,每执行一行伪代码(如下图为本文使用的伪代码)都要花一定量的时间,一般情况下各行执行时间是不同的,但这里假设每次执行第
i
i
i行所花的时间都是常量
c
i
c_i
ci。
一般情况下,做简要分析的时候肯定知道,两层循环对于
n
n
n个数排序,最坏情况大致为
n
2
n^2
n2,因为想当然的
n
∗
n
n*n
n∗n嘛!但实际上如果不深入学习里面知识,很多细节未掌握,那么理解的就不深,掌握的就不扎实。
此次分析就是要精细化分析,这个
n
2
n^2
n2是如何出来的,只有完全理解了它的来龙去脉后,再忽略掉次要部分后才能真正明白其中的真谛。
统计一个算法的执行时间,其实很简单,只要把每一行执行的时间和其次数相乘,然后相加即可。那么这里需要注意的几点如下:
-
每行消耗的时间是个常数 c i c_i ci, c i c_i ci是个假设的未知参数。
-
执行的次数和待排序的数组长度有关,这里假设为 n = l e n g t h [ A ] n=length[A] n=length[A],其中A为待排序的数组名。
-
循环判断本身要比循环体内多执行一次(精细化分析就要知道,如果是粗略估计可忽略)。比如第1行实际上从2到 l e n g t h [ A ] length[A] length[A]是n-1次,但因为最后还有一次判断,即当 j = l e n g t h [ A ] + 1 j=length[A]+1 j=length[A]+1时有一个判断,所以第1行执行的次数为 n − 1 + 1 = n n-1+1=n n−1+1=n,而其内部代码执行次数为 n − 1 n-1 n−1次。
同理,第5行的循环判断次数,假设为 t j t_j tj次,那么其内部代码第6、7行执行次数为 ( t j − 1 ) (t_j-1) (tj−1)次。
-
回顾下求和公式 S n = 1 2 n ( a 1 + a n ) = d 2 n 2 + ( a 1 − d 2 ) n S_n=\frac {1}{2}n(a_1+a_n)=\frac{d}{2}n^2+(a_1-\frac{d}{2})n Sn=21n(a1+an)=2dn2+(a1−2d)n,比如 ∑ x = 5 100 x = ( 100 − 5 + 1 ) ( 5 + 100 ) 2 = 5040 \sum_{x=5}^{100}x=\frac{(100-5+1)(5+100)}{2}=5040 ∑x=5100x=2(100−5+1)(5+100)=5040,其中 ( 100 − 5 + 1 ) (100-5+1) (100−5+1)表示一共有这么多项, ( 5 + 100 ) (5+100) (5+100)表示 a 1 + a n a_1+a_n a1+an。求和公式本身 ∑ x = 5 100 x \sum_{x=5}^{100}x ∑x=5100x,表示 x = 5 , 6 , 7 … … 100 x=5,6,7……100 x=5,6,7……100这些数相加。
-
第5行是最关键的,这里有个while循环,而且使用了一个求和 ∑ j = 2 n t j \sum _{j=2}^{n}t_j ∑j=2ntj,不太好理解,其中的 t j t_j tj表示在对应的 j j j时执行的次数。这是第二层循环,这层循环每次循环的次数和第4行有关系,初始状态当 j = 2 j=2 j=2, i = j − 1 = 2 − 1 = 1 i=j-1=2-1=1 i=j−1=2−1=1,由于循环的判断要多出一次,所以当 j = 2 j=2 j=2时,应该判断 2 2 2次,即 t j = j = 2 t_j=j=2 tj=j=2,这是通常情况下。但是上述描述是没有考虑 A [ i ] < k e y A[i]<key A[i]<key这句的,如果加上这句,就会发现如果输入的数组本来就是排好序的,那么其实判断 1 1 1次就够了(回想插入排序的特性),而且后续当 j j j自增时,while循环都只判断一次,即无论 j j j是多少, t j = 1 t_j=1 tj=1,这是最好的情况;如果输入的数组正好是逆序,即最坏的情况,每次 j j j自增时,while循环这句都要执行 i = j − 1 + 1 = j i=j-1+1=j i=j−1+1=j次,即对于 j = 2 , 3 , … , n j=2,3,\dots,n j=2,3,…,n,有 t j = j t_j=j tj=j,而while循环里面执行 j − 1 j-1 j−1次,即 t j − 1 t_j-1 tj−1次。所以最坏情况下while循环执行的次数为: ∑ t = 2 n t j = ∑ t = 2 n j = ( n − 2 + 1 ) ( 2 + n ) 2 = n ( n + 1 ) 2 − 1 \sum_{t=2}^{n}t_j=\sum_{t=2}^{n}j=\frac{(n-2+1)(2+n)}{2}=\frac{n(n+1)}{2}-1 ∑t=2ntj=∑t=2nj=2(n−2+1)(2+n)=2n(n+1)−1,内部执行: ∑ t = 2 n t j − 1 = ∑ t = 2 n j − 1 = n ( n − 1 ) 2 \sum_{t=2}^{n}t_{j-1}=\sum_{t=2}^{n}j-1=\frac{n(n-1)}{2} ∑t=2ntj−1=∑t=2nj−1=2n(n−1)
知道这些问题后,就可以计算算法消耗的总体时间
T
(
n
)
T(n)
T(n)了.
T
(
n
)
=
c
1
n
+
c
2
(
n
−
1
)
+
c
4
(
n
−
1
)
+
c
5
∑
j
=
2
n
t
j
+
c
6
∑
j
=
2
n
(
t
j
−
1
)
+
c
7
∑
j
=
2
n
(
t
j
−
1
)
+
c
8
(
n
−
1
)
T(n)=c_1n+c_2(n-1)+c_4(n-1)+c_5\sum_{j=2}^n t_j+c_6\sum_{j=2}^n (t_j-1) \\ +c_7\sum_{j=2}^n (t_j-1)+c_8(n-1)
T(n)=c1n+c2(n−1)+c4(n−1)+c5j=2∑ntj+c6j=2∑n(tj−1)+c7j=2∑n(tj−1)+c8(n−1)
如果输入数组是已经排好序的,那么第5行就永远不会成立,第6、7行就不会执行,那么此时就是最佳运行时间:
T
(
n
)
=
c
1
n
+
c
2
(
n
−
1
)
+
c
4
(
n
−
1
)
+
c
5
(
n
−
1
)
+
c
8
(
n
−
1
)
=
(
c
1
+
c
2
+
c
4
+
c
5
+
c
8
)
n
−
(
c
2
+
c
4
+
c
5
+
c
8
)
T(n) = c_1n+c_2(n-1)+c_4(n-1)+c_5(n-1)+c_8(n-1) \\ =(c_1+c_2+c_4+c_5+c_8)n-(c_2+c_4+c_5+c_8)
T(n)=c1n+c2(n−1)+c4(n−1)+c5(n−1)+c8(n−1)=(c1+c2+c4+c5+c8)n−(c2+c4+c5+c8)
这一运行时间可表示为
a
n
+
b
an+b
an+b,它是n的一个线性函数,常量a和b依赖于语句的代价
c
i
c_i
ci,从后面的学习可以看到
c
i
c_i
ci的影响很少,可以忽略。
如果输入的数组是按逆序排序的,那么就是最坏情况,此时必须将每个待插入的元素
A
[
j
]
A[j]
A[j](
A
[
j
]
A[j]
A[j]表示每次内部循环中待排序的数,可参考P10正确性验证一段)和已排序的部分
A
[
1
…
j
−
1
]
A[1…j-1]
A[1…j−1]中的每个元素做比较,而且还要将已排序部分每个元素挨个后移一个位置。此时最坏情况下的时间T(n)为:
T
(
n
)
=
c
1
n
+
c
2
(
n
−
1
)
+
c
4
(
n
−
1
)
+
c
5
(
n
(
n
+
1
)
2
−
1
)
+
c
6
(
n
(
n
−
1
)
2
)
+
c
7
(
n
(
n
−
1
)
2
)
+
c
8
(
n
−
1
)
=
(
c
5
2
+
c
6
2
+
c
7
2
)
n
2
+
(
c
1
+
c
2
+
c
4
+
c
5
2
−
c
6
2
−
c
7
2
+
c
8
)
n
−
(
c
2
+
c
4
+
c
5
+
c
8
)
T(n) = c_1n+c_2(n-1)+c_4(n-1)+c_5(\frac{n(n+1)}{2}-1) \\ +c_6(\frac{n(n-1)}{2})+c_7(\frac{n(n-1)}{2})+c_8(n-1) \\ =(\frac{c_5}{2}+\frac{c_6}{2}+\frac{c_7}{2})n^2 +(c_1+c_2+c_4+\frac{c_5}{2}-\frac{c_6}{2}-\frac{c_7}{2}+c_8)n -(c_2+c_4+c_5+c_8)
T(n)=c1n+c2(n−1)+c4(n−1)+c5(2n(n+1)−1)+c6(2n(n−1))+c7(2n(n−1))+c8(n−1)=(2c5+2c6+2c7)n2+(c1+c2+c4+2c5−2c6−2c7+c8)n−(c2+c4+c5+c8)
最后可简化为:
a
n
2
+
b
n
+
c
an^2+bn+c
an2+bn+c,常量
a
,
b
,
c
a,b,c
a,b,c之前提过,不管,所以这是一个关于
n
n
n的二次函数。接着书上还分析了平均情况,得出的运行结果也是关于
n
n
n的二次函数。
为了简化对插入排序的分析,做简化抽象(此部分内容都是非形式化描述,所以不是特别精确,记住就好):
- 忽略每条语句的真实代价,而用常量 c i c_i ci表示。
- 更进一步忽略抽象代码 c i c_i ci,用 a , b , c a,b,c a,b,c表示,即可表示为: a n 2 + b n + c an^2+bn+c an2+bn+c。
- 再进一步抽象,使用运行时间的增长率(rate of growth),或称增长的量级(order of growth)。这样仅考虑公式中的最高次项,因为当 n n n很大时,低阶项相对来说不太重要(这句话书上是这么说的,但具体的数学证明或者实验暂时没有参考)。
- 另外还忽略最高次项前面的系数,因为在大规模输入时,相对于增长率来说,系数是次要的
所以,插入排序最坏情况时间代码为 Θ ( n 2 ) \Theta(n^2) Θ(n2), Θ \Theta Θ符号在本节并未给出准确定义,在《导论》第3章"函数的增长"会给出具体的定义,实际上把 Θ \Theta Θ理解 为大 O O O即可。
到此为止,一个算法的运行时间,即效率是如何分析的已经很清楚了。
总结
本文参考《算法导论》对插入算法做了分析,主要加入了自己的理解和思考,为后续的进一步学习打下基础,另外也期待本文能给在算法入门过程中遇到和我一样类似困惑的朋友有点帮助。