除了正确性,算法的另外一个重要的特就是效率(Efficiency)了。有两种算法效率:时间效率(Time Efficiency)和空间效率(Space Effiency)。时间效率也称为时间复杂度(Time Complexity),指出算法运行有多快;空间效率有称为空间复杂度(Space Complexity),指出算法需要多少额外的空间。
在电子计算机时代早期(大规模集成电路出现前),时间和空间是两种极其昂贵的资源。但经过半个多世纪的技术革新,计算机的执行速度和存储容量已经提升了好几个量级。现在,一个算法所需的额外空间已经不是重点关注的问题。然而,时间效率的重要性并没有减弱到这种程度。随着问题规模的扩大,时间效率则变得更加突出。需要说明的是,不重点关注空间并不代表不需要关注空间。如在编码中,如果可以使用integer类型存储数据,则没有必要使用long。可以使用set存储元素,则没有必要使用map,等等。由于对时间的关注度基本决定了算法的选择,所以本文将仅分析时间效率。注意,这里介绍的分析方法也适用于空间效率。
一般来说,一个问题通常有多个候选算法。通过算法效率分析,我们可以抛弃低效率的算法,选出一个合适现有场景的算法。
输入规模的度量
对于算法,需要考虑相同问题,更大规模的处理效率。一般情况下,将规模
n
n
n作为算法的输入参数。在大多数情况下,选择何种参数是非常直接的。如,对于排序、查找、寻找列表的最小元素等问题来说,这个参数就是列表的长度。
注意,有些情况下,选择哪个参数表示输入规模是由差别的。如,计算两个
n
n
n阶矩阵的乘积。对这个问题来说,有两种度量的方法。第一种方法,是用矩阵的阶
n
n
n。另一种方法是,参加乘法运算的矩阵中所有元素的个数
N
N
N。
如何恰当的选择输入规模的度量单位,还要受到所讨论算法的操作细节影响。如对于一个拼写检查算法,如果需要对于每个字符进行检查,则应使用字符的数量作为输入规模;如果需要对每个单位进行检查,则应使用单词的数量作为输入规模。
运行时间的度量
明确问题的输入规模后,接下来考虑算法运行时间的度量。我们的第一直觉是使用是时间来度量算法的运行时间。然而,这种方法有一些明显的缺陷:它依赖于特定计算机的运行速度,依赖于算法实现的质量,等等。因为我们寻求的是算法效率的度量标准,所以应选择一个不依赖于上述无关因素的度量标准。
然后,我们尝试考虑统计算法每一步操作的执行次数。从数学的角度来说,统计所有操作的执行次数没有必要。因为随着输入规模的不断变大,算法效率主要受执行量级最大的操作影响。举例来说,如果一个算法所有操作的执行次数是
a
n
x
n
+
.
.
.
+
a
1
x
1
+
a
0
a_nx^n +... +a_1x^1 + a_0
anxn+...+a1x1+a0,随着n的次数不断增大,其近似值为
x
n
x^n
xn。所以,我们应该找出算法中最重要的操作,即所谓的基本操作(basic operation),它们对总运行时间的贡献最大,然后计算它们的运行次数。
这样,我们就可建立一个算法时间效率的分析框架:对输入规模为n的算法,可以通过统计它的基本操作执行次数,来对其效率进行度量。该算法时间效率分析框架的应用如下:
如果我们需要计算运行在某台计算机上的某个算法的运行时间,我们可以使用如下公式:
T
(
n
)
=
c
o
p
C
(
n
)
T(n) = c_{op}C(n)
T(n)=copC(n)
其中,
c
o
p
c_{op}
cop表示指定计算机该算法上一个基本操作的执行时间,
C
(
n
)
C(n)
C(n)则是该算法需要执行基本操作的次数。
增长次数
对于大规模的输入,算法效率分析框架,会忽略乘法常量,而仅关注执行次数的增长次数(order of growth)及其常数倍。为什么要对大规模的输入强调执行次数的增长次数呢?这是因为小规模输入在运行时间上的差异不足以区分高效算法和低效算法。如计算两个数的gcd(最大公约数),如果两个数较小,多个算法之间的效率差异并不是很明显。只有当两个数较大时,算法效率的差异才变得明显。下图列举了常见的函数增长次数:
由上图可知,对数函数的增长次数要小于
f
(
n
)
=
n
f(n)=n
f(n)=n函数增长次数。另外,虽然特定的操作依赖对数的底,但因方程:
l
o
g
a
n
=
l
o
g
a
b
∗
l
o
g
b
n
log_a n = log_a b * log_b n
logan=logab∗logbn
允许对数在不同的底数进行转换,仅是新增一个乘法常量。所以,我们可以忽略对数的底,简写成
l
o
g
n
log n
logn。
另外需要说明的就是幂函数
2
n
2^n
2n和阶乘函数
n
!
n!
n!。这两种函数增长次数太大,以至于即使n的值相当小,函数的值也会成为天文数字。当n等于100时,对一台每秒执行1万亿次(
1
0
12
10^{12}
1012)操作的计算机来说,大约需要
4
∗
1
0
10
4*10^{10}
4∗1010年才能完成计算。即使使用多核、多计算机,其时间消耗也是难以接受的。另外,虽然幂函数
2
n
2^n
2n和阶乘函数
n
!
n!
n!的增长次数不同,但是,由于两个函数的增长次数已经无法容忍,所以常常将两者都作为"指数级增长的函数"。我们仅推荐在已知且很小的规模下使用这两种函数级别的算法。
最优、最差和平均效率
以算法输入规模作为参数的函数可以合理地度量算法的效率。但还有许多算法的运行时间不仅取决于输入的规模,还取决于特定输入的细节。接下来,将以顺序查找为例,简单说下输入细节对算法运行时间的影响。
假设有n个元素,存储在长度为n的列表中,其中每个元素有唯一KEY。为了找到特定的元素,需要检查列表中的每个元素,直到发现KEY匹配的元素为止或者到达列表的末尾,没有找到匹配的元素。该算法的伪代码实现如下:
A[0...n-1]表示存储所有元素的数组
K 表示待匹配的KEY
while i < n && A[i] != K
i++
if (i<=n-1) then
return i
else
return -1;
一个算法的最差效率(worst-case efficiency) 是指当输入规模为
n
n
n时,该算法在最坏情况下的运行效率。通过确定算法运行时间的上界,分析最坏情况为我们提供了算法效率的一个重要信息。换句话说,对于输入规模为
n
n
n的实例来说,算法的运行时间不会超过最坏输入情况下的运行时间——
C
w
o
r
s
t
(
n
)
C_{worst}(n)
Cworst(n)。对于顺序查找问题来说,最坏的情况就是表中没有匹配的元素或匹配的元素是行尾的元素,其运行时间为
C
w
o
r
s
t
(
n
)
=
n
C_{worst}(n) = n
Cworst(n)=n。
一个算法的最优效率(best-case efficiency) 是指当输入规模为
n
n
n时,该算法在最优情况下的运行效率。通过确定算法运行时间的下界,分析最优情况为我们提供了算法效率的一个重要信息。换句话说,对于输入规模为
n
n
n的实例来说,算法的运行时间一定会大于最优输入情况下的运行时间——
C
b
e
s
t
(
n
)
C_{best}(n)
Cbest(n)。对于顺序查找问题来说,最优的情况就是表中第一个元素匹配成功,其运行时间为
C
b
e
s
t
(
n
)
=
1
C_{best}(n) = 1
Cbest(n)=1。如果一个算法的最优效率都不能满足算法效率要求,那么该算法就没有必要进行考虑。
“最差效率分析"和"最优效率分析”,无法给出在"一般情况"或"典型情况"下算法的执行效率,这时需要考虑平均效率(average-case efficiency)。为了分析算法的平均效率,我们有必要对输入做一些合理的假设。以顺序查找来说,假设:(1)成功查找到匹配元素的概率是p(0≤p≤1);(2)任一元素,在列表中任一位置的匹配概率是相同的。基于这种假设,可以计算出平均运行时间
C
a
v
g
(
n
)
C_{avg}(n)
Cavg(n):
C
a
v
g
(
n
)
=
[
1
∗
p
/
n
+
2
∗
p
/
n
+
.
.
.
+
n
∗
p
/
n
]
+
n
∗
(
1
−
p
)
C_{avg}(n) = [1*p/n + 2*p/n+...+n*p/n] + n*(1-p)
Cavg(n)=[1∗p/n+2∗p/n+...+n∗p/n]+n∗(1−p)
其中
[
1
∗
p
/
n
+
2
∗
p
/
n
+
.
.
.
+
n
∗
p
/
n
]
[1*p/n + 2*p/n+...+n*p/n]
[1∗p/n+2∗p/n+...+n∗p/n]表示查找成功概率,
n
∗
(
1
−
p
)
n*(1-p)
n∗(1−p)表示查找失败概率。简单合并后,其结果是:
p
∗
(
n
+
1
)
/
2
+
n
∗
(
1
−
p
)
p*(n+1)/2 + n*(1-p)
p∗(n+1)/2+n∗(1−p)
如果待查找元素一定存在于元素列表中,则p=1,此时
C
a
v
g
(
n
)
=
(
n
+
1
)
/
2
C_{avg}(n) = (n+1)/2
Cavg(n)=(n+1)/2,表示,平均来说,需要查询列表中一半的元素。如果待查找元素一定不存在,则p=0,此时
C
a
v
g
(
n
)
=
n
C_{avg}(n) = n
Cavg(n)=n,表示,需要查询一遍所有元素。
相比"最差效率分析"和"最优效率分析","平均效率分析"的实现难度要更大(需要合理的建模),那么是否有必要知道一个算法的平均效率呢?答案是肯定的。这是因为:许多重要的算法的平均效率要好于最差效率。这对发现重要的算法很有价值。
渐进符号和基本效率类型
为了对算法的增长次数进行比较和归类,计算机科学家引入三种渐进符号:
O
O
O(读作字母"O"),
Ω
Ω
Ω(读作"omega"),
θ
θ
θ(读作"theta")。
在介绍这三种渐进符号前,先定义两个函数
t
(
n
)
t(n)
t(n)和
g
(
n
)
g(n)
g(n)。其中
t
(
n
)
t(n)
t(n)表示一个算法的运行时间,
g
(
n
)
g(n)
g(n)表示一个用来和该操作次数作比较的函数。
符号 O
O
(
g
(
n
)
)
O(g(n))
O(g(n))是增长次数小于等于
g
(
n
)
g(n)
g(n)的函数集合。其符号定义如下:
如果函数
t
(
n
)
t(n)
t(n)包含在
O
(
g
(
n
)
)
O(g(n))
O(g(n))中,记作
t
(
n
)
∈
O
(
g
(
n
)
)
t(n)∈O(g(n))
t(n)∈O(g(n))。它的成立条件是:对于所有足够大的n,
t
(
n
)
t(n)
t(n)的上界由
g
(
n
)
g(n)
g(n)的常数倍所确定。也就是说,存在大于0的常数c和非负的整数
n
0
n_0
n0,使得:
对于所有的n≥
n
0
n_0
n0,t(n)≤cg(n)
符号 Ω
Ω
(
g
(
n
)
)
Ω(g(n))
Ω(g(n))是增长次数大于等于
g
(
n
)
g(n)
g(n)的函数集合。其符号定义如下:
如果函数
t
(
n
)
t(n)
t(n)包含在
Ω
(
g
(
n
)
)
Ω(g(n))
Ω(g(n))中,记作
t
(
n
)
∈
Ω
(
g
(
n
)
)
t(n)∈Ω(g(n))
t(n)∈Ω(g(n))。它的成立条件是:对于所有足够大的n,
t
(
n
)
t(n)
t(n)的下界由
g
(
n
)
g(n)
g(n)的常数倍所确定。也就是说,存在大于0的常数c和非负的整数
n
0
n_0
n0,使得:
对于所有的n≥
n
0
n_0
n0,t(n)≥cg(n)
符号 θ
θ
(
g
(
n
)
)
θ(g(n))
θ(g(n))是增长次数等于
g
(
n
)
g(n)
g(n)的函数集合。其符号定义如下:
如果函数
t
(
n
)
t(n)
t(n)包含在
θ
(
g
(
n
)
)
θ(g(n))
θ(g(n))中,记作
t
(
n
)
∈
θ
(
g
(
n
)
)
t(n)∈θ(g(n))
t(n)∈θ(g(n))。它的成立条件是:对于所有足够大的n,
t
(
n
)
t(n)
t(n)的上界和下界都由
g
(
n
)
g(n)
g(n)的常数倍所确定。也就是说,存在大于0的常数
c
1
c_1
c1和
c
2
c_2
c2和非负的整数
n
0
n_0
n0,使得:
对于所有的n≥
n
0
n_0
n0,
c
1
c_1
c1g(n)≤t(n)≤
c
2
c_2
c2g(n)
基本效率类型对比
虽然渐进符号
O
O
O,
Ω
Ω
Ω,
θ
θ
θ可以证明算法效率函数的抽象性质,但是很好直接使用它们来比较同一个问题的两个算法效率函数的增长次数。有一种较为简单的比较方法,即基于两个函数的比率求极限(也即输入规模n无限大)。有三种极限情况发生:
第二种情况意味
t
(
n
)
∈
θ
(
g
(
n
)
)
t(n)∈θ(g(n))
t(n)∈θ(g(n))。前两种情况意味
t
(
n
)
∈
O
(
g
(
n
)
)
t(n)∈O(g(n))
t(n)∈O(g(n))。后两种情况意味
t
(
n
)
∈
Ω
(
g
(
n
)
)
t(n)∈Ω(g(n))
t(n)∈Ω(g(n))。
这样,我们就可以使用微积分计算极限,进而比较两个函数的增长次数。
以下是基本的渐进效率类型:
算法效率分析步骤说明
在对算法进行效率分析时,首先明确算法的输入规模。选择合适的输入规模度量单位,除了受选择的输入参数影响外,还可能受到所讨论算法的操作细节影响。明确算法的输入规模后,接下来考虑算法运行时间。该阶段需要明确算法的基本操作,并通过统计它的基本操作执行次数,来对其效率进行度量。一个问题的算法可能存在多个。多个算法通过比较其增长次数、最优效率、最差效率和平均效率来获取 最优的算法。常见算法的增长次数对比可以参考"基本的渐进效率类型"。
参考
《算法导论》第三版 Tomas H. Cormen etc. 殷建平 等译
《算法设计与分析基础》 第三版 Anany Levitin 著 潘彦 译