目录
算法设计策略
算法中的基本设计策略包括:蛮力法、分治法、减治法、变治法、时空权衡等。本专题将一一介绍这些设计策略在一些重要问题中的应用,包括排序、查找、图问题、组合问题。本文首先介绍蛮力法和减治法的应用。
蛮力法
使用蛮力法的意义在于:首先这种解法一般体现了解决问题最直接的思路,几乎对于任何问题都适用(越简单越普适);其次,在问题规模不大的时候,没有必要使用高技巧性的方法,反而耗费更多代价。并且在某些基本问题,如求数组和,求列表最大元素等,蛮力法已经是一种高效的方法。
考虑蛮力法在排序问题中的应用。一种蛮力法的方法是选择排序。每次扫描整个列表,通过逐个比较,找到最小的元素(每次更新最小的元素,最后得到的即是整个序列中的最小元素),再把每次找到的最小元素和序列中的首位元素交换(不另外放一个序列,因为原地排序节省空间)。这样,为了排出有序列,需要比较
n
(
n
−
1
)
2
\frac{n(n-1)}{2}
2n(n−1)次,也就是说,其时间复杂度为
O
(
n
2
)
O(n^2)
O(n2)。
减治法
减治法思想
减治法利用了这样一种关系:原问题的解和一个同样问题但规模较小的问题的解之间的关系。这样的表述本质上采用了递归思想,但从具体的解法层面,可以用递归求解,也可以用非递归的方法求解。本文要讲述的两种方法,一种就采用了非递归,另一种用了递归。减治法可以分为3种:
- 问题规模减去一个常量:规模为n的问题的解 ↔ \leftrightarrow ↔规模为n-1问题的解,例:插入排序、深优和广优
- 问题规模减去常数因子:规模为n问题的解 ↔ \leftrightarrow ↔规模为 n a ( a > 1 ) \frac{n}{a}(a>1) an(a>1),例:折半查找
- 问题规模减去可变因子:每次迭代时,规模减小的模式和其他次迭代时可以不同,例:选择问题(找顺序统计量)
本文先讨论减一技术。另几种方法在本专题后续文章中会讨论到。
算法设计中的递推类型
上面的文字表述看起来不是很清晰,特别是其没有清楚的表现出减治法和分治法的区别和联系。下面,我们用递归表达式对几种常见的、用递归表述的算法设计思路进行更清晰的表述。
减一法
由于每次的子问题就是原问题的规模减1,并且对于一个规模为n的问题的求解时间,除了包括求解规模n-1的问题,还包括对规模为n的问题化简到规模n-1问题的时间,以及对规模n-1问题扩展为规模n问题的时间,后两者的时间可以合并为一个式子,因此可以得到递归式:
T
(
n
)
=
T
(
n
−
1
)
+
f
(
n
)
T(n)=T(n-1)+f(n)
T(n)=T(n−1)+f(n)
减常数因子
这种情况下,每次的子问题是原问题的规模的
n
b
\frac{n}{b}
bn,很多情况下,b为2(如折半查找,每次是和有序数组中间元素进行比较)
T
(
n
)
=
T
(
n
b
)
+
f
(
n
)
T(n)=T(\frac{n}{b})+f(n)
T(n)=T(bn)+f(n)
减可变因子
这种情况下,无法用递归表达式表示出来。因为每次的子问题规模取决于上一步进行的情况,而不完全取决于上一步的问题规模。如,在选择问题中,查找某个顺序统计量,对每个子问题先找中轴,再用快速排序的分区过程(两个方向相反的扫描)排出中轴两侧元素,通过此时中轴元素位置决定下一次分区的区域,亦即决定了下一个子问题的规模。
分治法
分治法的核心在于将一个问题分成若干个规模相同的子问题,每个子问题再继续分解,直到分解出可以常数时间求解的子问题,再从底向上逐层求解子问题,并对子问题解进行合并。可以看出,其与减治法最大的差别在于,每次分解出的子问题不是一个,而是多个。因此,个人理解,减治法可以看成是分治法的一种特例。分治法的递归式为:
T
(
n
)
=
a
T
(
n
b
)
+
f
(
n
)
T(n)=aT(\frac{n}{b})+f(n)
T(n)=aT(bn)+f(n)
减一法应用之一:插入排序
算法思想
插入排序法可以类比扑克牌抓牌时候的排序过程。每新抓一张牌,会把这张牌和已有的牌比较大小,依此选择插入点。因此,每次抓牌之前,手中的牌都是一个排好序的序列。归结到算法上,是:在每次排序之前,数组A中已经有部分已经排好序的子数组B。每次要做的是取一个剩下子数组C的数,将其与B中数比较,选一个合适的位置插入。
在C对B中元素的查找并插入中,实际上有多种实现方法。假设我们要将数组从左到右排成从小到大。一种是从左到右扫描B中元素,查到B中第一个大于等于
C
j
C_j
Cj的元素即进行插入操作。一种是从右到左扫描,查到B中第一个小于等于
C
j
C_j
Cj的元素即进行插入。还有一种做法是折半查找。三种情况的效率上界都相同,这里只讨论从右到左扫描的查找情况。
可以发现,对这一排序方法的表述本质上基于递归思想。也就是可以基于从顶到底的递归函数调用实现算法。但对于这一问题,从底到顶的非递归方法效率会更高。
过程
可以分析这一过程的具体步骤:先在序列
A
A
A中抽出第2个元素
a
2
a_2
a2,将其与
a
1
a_1
a1比较大小,若
a
2
<
a
1
a_2<a_1
a2<a1,将
a
2
a_2
a2放在
a
1
a_1
a1左侧,若
a
2
>
a
1
a_2>a_1
a2>a1,将
a
2
a_2
a2放在
a
1
a_1
a1右侧。这样,在
A
A
A中排好了两个元素的有序子列
(
a
1
′
,
a
2
′
)
(a_1^{'},a_2^{'})
(a1′,a2′),本次排序结束。再抽出
a
3
a_3
a3,将其与
a
2
′
a_2^{'}
a2′比较,若大于,令
a
3
′
=
a
3
a_3^{'}=a_3
a3′=a3,其他元素均不变,本次排序结束。若小于,先令
a
3
′
=
a
2
′
a_3^{'}=a_2^{'}
a3′=a2′,再将
a
3
a_3
a3与
a
1
′
a_1^{'}
a1′比较。若大于,令
a
2
′
=
a
3
a_2^{'}=a_3
a2′=a3,本次排序结束。若小于
a
1
′
a_1^{'}
a1′,因为
a
1
′
a_1^{'}
a1′左边没有其他数了,
a
3
a_3
a3只能放在
a
1
′
a_1^{'}
a1′左边,也就是令
a
2
′
=
a
1
′
a_2^{'}=a_1^{'}
a2′=a1′,
a
1
′
=
a
3
a_1^{'}=a_3
a1′=a3,本次排序结束。由特例可以发现,整个排序过程有两次循环。大循环是从序列A中依此抽元素
a
k
a_k
ak,小循环是将
a
k
a_k
ak与
A
′
A^{'}
A′中每个元素比较。小循环中止条件是
a
k
>
a
j
′
a_k>a_j^{'}
ak>aj′(
a
k
a_k
ak插到
a
j
′
a_j^{'}
aj′右侧,
a
j
+
1
′
a_{j+1}^{'}
aj+1′左侧),或是
j
−
1
=
0
j-1=0
j−1=0(
a
k
a_k
ak插到序列最左侧),循环继续条件则反过来。
据此,可以写出该过程的代码。
代码
伪代码
for j=2 to len(A)
//为了避免在赋值的过程中数字丢失,需要另设一个参数存放每次要插入的元素。且注意循环从2开始,因为每次循环涉及和前一数的比较,第一个数没有前一个数。
key = a[j]
//减一法思想:每次要对j排序之前,前提是其之前的数已经排好序。
//每次都是先和序列中前一个数比大小。这里的a[i]相当于A'序列中的数。
i = j-1
//小循环。这里用while书写,因为循环次数未知。根据本文第一部分的分析,可写出循环继续条件
while key < a[i] & i>0
//待排序元素值小于A'第i个值,则A’第i个值往后移一位,即A'中第i+1个元素被赋值为原第i个元素的值。注意待排序元素的原index不用动,可以认为是一个虚值,只在最终确定好位置之后再插进去。
a[i+1]=a[i]
//本方法是往A'的左侧扫描,故要规定减1的步长
i=i-1
//若新元素大于等于A'第i个值,则将该元素插到A'第i+1个位置,同时循环结束。新元素右侧的元素位置都已经更新过,左侧的则不用变。
//若新元素小于A'所有值,即扫描到了i=0,则循环结束,该元素插到A’第1个位置,也即i+1。故两种情况可以统一表示。
a[i+1]=key
//一个新元素的插入完成。从原序列中抽下一个元素(转到j的大循环)。
python代码
首先构造一个实例:对序列A=[1,4,6,3,5,10,7,3,8]从小到大排序。
当然,用python内置的sorted函数可以一次性完成排序:
sorted(A, reverse=FALSE)
也可以根据上述伪代码自己编一个sorted函数:
//升序排列
def sort(lista):
for j in range(1,len(lista)):
key = lista[j]
i = j-1
while key > lista[i] and i>=0:
lista[i+1]=lista[i]
i = i-1
lista[i+1]=key
print(lista)
sort(A)
//降序排列只要把key > lista[i]换成key<lista[i]即可
算法效率
这里讨论该算法的运行时间。估计运行时间一般要用RAM模型。该模型假定:1、语句只能是真实的基本计算机指令,而不能是一个封装好的包。2、每一语句的执行时间是一个常量。3、不同语句不能并行计算。
虽然这些条件不一定成立(如现在流行的并行计算),但在分析算法时间复杂度中有很大作用。
下图是插入排序算法每一步的执行时间与执行次数统计(图源自《算法导论》)。
为何第一句运行次数为n而非n-1?需要注意for,while循环语句执行测试次数比执行循环体次数多1。
t
j
t_j
tj指第j个元素进行插入时,进行while循环测试的次数(注意比循环体执行次数多1)。循环体执行次数即待插入元素与A‘序列元素比较的次数,取决于是序列排序程度。最好情况是完全升序,这样即不用执行循环体,
t
j
=
1
t_j=1
tj=1。最坏情况是完全降序,这样待插入元素需要和j之前的j-1个元素比较,则有
t
j
=
j
t_j=j
tj=j。可以总结出技巧:同一级循环体内的语句执行次数应当是相同的。while下面的语句实际是和for循环一级的,因此执行次数也是n-1。
根据RAM的假设,若要知道该算法耗费总时间,求这些步的时间次数乘积和即可。
计算可得,在最好情况下,
T
(
n
)
=
a
n
+
b
T(n)=an+b
T(n)=an+b
最坏情况下,
T
(
n
)
≈
n
(
n
−
1
)
2
T(n)\approx\frac{n(n-1)}{2}
T(n)≈2n(n−1)
平均情况下,
T
(
n
)
≈
n
2
4
T(n) \approx\frac{n^2}{4}
T(n)≈4n2
因此,该算法时间效率的渐进上界是
O
(
n
2
)
O(n^2)
O(n2)。同时,注意到该算法是原地排序,需要的额外空间仅为常数。
减一法应用之二:深优和广优查找
算法思想
对象:拓扑结构
拓扑结构是一种重要的数据结构,其基本组成为节点和节点之间的连线。在很多实际问题的解决中,通过构造拓扑图的数据结构往往可以高效的解决问题。比如,在线程的调度问题中,每个线程具有优先级,时常需要根据优先级进行查找、删除、添加等操作,这时,构造一个堆的数据结构,进行堆的构造、删除、排序等操作可以对这些问题进行很好的解决。而堆本质就是一颗完全二叉树,也就是一个拓扑结构。又比如,在旅行商问题中,我们希望在n个城市中找到一条从某个城市出发,经过所有城市并回到出发地的最短路径,虽然我们可以用蛮力法,将所有的排列组合都写出来,但一种更高效的办法是从某个点出发,对所做的选择构造一颗状态空间树。如果构造树的过程中不考虑任何简化方法,最后树的叶结点就是所有可能排列组合的结果。但我们在构造过程中可以做一系列简化,如直接抛弃不可能的选择所在节点及其子树,也就是进行剪枝,最后找到解的时候,我们可能只需要得到一棵较简单的状态空间树。这就是回溯法和分支定界法的核心思想,在之后的专题中会作更多说明。
拓扑结构可以用图G=(V,E)表示,其存储一般有邻接链表和邻接矩阵两种方式。不同的存储方式一般会导致相同算法的效率不同。对于图G的邻接链表,其长度之和为O(E),因此存储空间需求为节点数+链表长度=O(V+E)。G的邻接矩阵存储空间需求为 O ( V 2 ) O(V^2) O(V2)
BFS & DFS
在处理拓扑结构的算法中,经常会用到广度优先(BFS)和深度优先搜索(DFS)作为基本思路。这两种算法看似只是对一个拓扑结构进行遍历,但将其巧妙使用可以解决很多困难问题。比如,在前面所述旅行商问题中,可以把城市抽象成节点,路径及其距离抽象为连线,构造空间状态树的过程实际上就涉及对节点的遍历,在无约束条件下按照某种规则可以不遗漏的遍历所有节点,在有约束条件下也就一定不会遗漏(顶多是有意舍去某些节点),因此也就有可能找到全局最优解。因此,我们需要用DFS/BFS的思路构造一棵状态空间树。DFS和BFS在图算法中的应用在本专题之后的文章中会详细阐述。本文先介绍这两种算法本身。
广度优先搜索(BFS) 的基本思想是,从某个节点出发,先找出所有与其直接可达的节点,再对这些子节点分别找出所有与他们直接可达的节点,再重复上述过程。这样,搜索到的区域即像一个同心圆一样往外逐层往外扩张,直到一个节点无法找到未遍历过且直接可达的节点为止。再检查该过程有没有把所有的节点遍历完,如果没有,则用其他节点再进行一次这个过程。
深度优先搜索(DFS) 的基本思想是:从某个节点s出发,先任意找出一个与其直接可达的节点,再从该子节点出发,重复上述过程,直到不再有未遍历过且直接可达的节点。此时,后退到最后一个节点的母节点,从在这里出发,找到另一个之前未遍历过且直接可达的节点,重复上述过程,直到从源节点s也找不到未访问过的节点,则这时,源节点s的所有连通分量的所有顶点都被遍历过。
过程
广搜
为了跟踪算法的进展,广优和深优把节点在概念上涂上黑、白、灰色,以表示节点本身和其邻接节点的发现情况,因为根据上面描述可以发现,两种搜索探索的方向就是根据节点的发现情况。
灰色节点表示该节点第一次被发现,且尚未从该节点扫描其邻接点。白色节点表示该节点未被发现。黑色节点表示该节点被发现,且该节点的邻接节点已全部遍历,没有未被发现的邻接节点。
在广优搜索过程中,会形成一棵广度优先树,所谓广度优先树,就是一个描述节点被发现过程的树结构,其根节点即为搜索开始时的源节点s。广优树一个重要的性质就是可以确定最短路径。到达每个节点时经历的边数 d d d 即为源节点到该节点的最短路径。这个结论是由严谨的证明推导出来的,但从直观上也比较容易理解,关键要抓住这个结论成立的前提条件:图中各边的权重都是1,因此,使用广优时,由于该算法是一层层往外推,外层的路径数一定大于里层路径数。设节点u到s的最短路径为k,则u的邻接且未遍历节点一定是其外面一层节点,故其最短路径一定是k+1,若其最短路径小于等于k,则一定已经遍历过。
下面举例说明广优搜索的过程。下图就是一个广度优先树的生成过程。这个树和我们平常看见的从顶往下画的形式不太一样,改画成从顶往下的典型树形式,会损失一些连线的信息,但看节点遍历的先后次序可能会更清晰。这里,加粗的边是被BFS发现的边,黑色的点是被BFS发现的点。从图I可以看出,BFS可以发现所有点,但未必能发现所有的边。实际上,BFS只能发现从源节点s到图中任一点的某条最短路径,非最短路径发现不了;最短路径的所有可能也无法全部发现。
首先有个初始化的过程,将图中所有节点涂成白色。(a)中,开始搜索时,以s为源节点,第一次被发现,从白色成为灰色。(b)中,从s探索其邻接点,发现了r和l,则r和l由白转灰,s由灰转黑。再任从r,w中取一个继续搜索,取哪个会影响广度优先树的结构,但不会影响树的深度(可以自己按照节点探索的顺序画一个从顶向下的树形式,看同层节点改变探索顺序对树的影响)。(c )中从w继续探索其所有邻接点,发现了t和x,则w转黑,t,x转灰。这样重复下去。再讲一下(f)步之后。可以看到,此时,广优树中已经没有白色节点。此时从灰色节点u搜索所有邻接点,只能发现灰色或黑色的节点,说明其邻接点都遍历过了,因此也变黑。y也是同样的情况。
但不要忘记,我们进行广优搜索的目的是要遍历这个图中的节点,也就是我们要按一定顺序输出我们遍历过的所有节点。我们可以构造一个容器用来存储遍历的元素,每次从容器出来一个即输出一个元素。对于BFS来说,可以发现,先访问的节点也最先被涂黑(结束访问),因此,令这个容器保证元素从容器中出来的顺序即是其被访问的顺序会比较容易。很自然的联想到,队列即满足这种先进先出的要求。因此,我们可以构造一个队列,跟踪每个元素被访问的情况。队列初始为空,结束时也为空。
构造队列的另一个原因是我们在这里没有调用递归函数。通过队列,定义循环的起始,我们即可以实现从底到顶的迭代。若我们构造一个灰色节点队列,则用该队列还可以定义搜索的方向,即从哪个点开始搜索邻接点。我们可以从灰色节点队列的首位元素开始搜索,搜索时让其出队,并涂黑。也就是说,我们构造队列的目的之一就是让图中每个元素都能逐个出队,即输出。
深搜
深度优先搜索在算法上的处理方式和广优类似,如节点的颜色及其对应概念。但节点的属性有所区别。深优中的节点不再定义经过路径长度这个属性d,而是定义第一次发现节点u(u涂成灰色)的时间点 u.d 和完成对u的邻接链表搜索(u涂成黑色)的时间点 u.f 。
以下举例说明DFS的搜索过程。
代码
广搜
下面给出伪代码。广搜在表述上仍然是递归的形式,即搜索到子节点的前提是搜索到了其前驱节点。但本代码中没有采用递归函数调用的形式,因此我们需要进行循环迭代,也就需要规定循环何时开始、结束、循环方向、循环体。注意到,在BFS中,先访问的节点也最先被涂黑(结束访问),很自然的联想到,队列即满足这种先进先出的要求。因此,我们可以构造一个灰色节点队列。循环开始时队列为空;结束时队列也为空。该队列还可以确定循环方向:从灰色节点队列的首位元素开始搜索,搜索时让其出队,并涂黑。循环体便是队列中的每个元素。
代码中,G表示输入的图,s表示源节点,u,v均表示图中的某个节点,V表示图中的节点集合,v.d表示到达节点v时经历的边数,v.π表示节点v的前驱节点,即是从哪个点直接访问它的,v.color表示v节点颜色。定义π是为了确定访问路径,v的所有前驱节点构成的路径即为从源节点s到v的最短路径(也是路径上任一点访问v的最短路径)。对应到上面的图,实际上,只有u成为v的前驱顶点(u = v.π),u和v之间的边才能被访问到,也就是被涂黑,而并非只要u和v相邻,连接二者的边就一定能被发现。
BFS(G,s)
1 for each u in G.V-{s}:
2 u.color = white ##初始化,对源节点以外的各属性赋值
3 u.d = ∞
4 u.π = Nil
5 s.color = gray ##对源节点各属性赋值
6 s.d = 0
7 s.π = Nil
8 Q = ∅ ##构造队列。此处以灰色节点为例。实际上,因为所有节点都要经历白-灰-黑,因此构造不同颜色节点的队列没有区别
9 ENQUEUE(Q,s) ##源节点s入队
10 While Q ≠ ∅: ##此处即开始搜索节点的循环,知道储存灰色节点的集合成为空集,则此时图中所有节点颜色均为黑色。
11 u = DEQUEUE(Q) ##11步以后的逻辑解释见下面正文部分
12 for each v in Adj(u):
13 if v.color == white:
14 v.color = gray ##新发现的节点,属性相应赋值
15 v.d = u.d+1
16 v.π = u
17 ENEQUEUE(Q,v) ##将该灰色节点入队
18 u.color = black #出队的元素涂黑
代码中,11步之后的逻辑较难理解。首先,不管u的邻接点是什么颜色,灰色节点都要涂黑出队,因为灰色节点表示的一定是访问过的元素。但不能让灰色节点一次性都出队,因为需要对每个灰色节点的邻接点逐个访问一遍。因此,需要挨个出队,每个元素出队的时候访问一遍其邻接点。如果u的邻接点v中有白色,则v要涂成灰色。再将该灰色节点入队,并进行灰色节点队列中首位元素出队、搜索邻接点的循环。若v非白色,即都是发现过的元素,就不要动v了。因为:若v是黑,已经出队,不需再操作。若v是灰,则肯定在某时会出队(11步)并涂黑(18步),也就不需在12-17步的循环中对其操作。
也就是说,11-18步实际可以拆成两个部分:让灰色节点出队并变黑(11,18)以及确定灰色节点并将其入队(12-17)。
可以发现,虽然广优的解决思路本质使用了递归的思想,即为了访问v所在的一层,我们需要访问完上一层所有节点(变灰或黑),因此,可以用自顶向下递归调用函数的形式写算法,但这里用了效率更高的自底向上循环迭代的方式设计算法。
深搜
这里的代码进行了递归调用函数。深搜的递归表述是,为了扫描完u的邻接链表使u变黑,我们需要先扫描完u邻接点的所有邻接链表。
那么,如果不用递归调用函数,我们能否仿照广优,构造一个循环体,使用迭代写出代码呢?深优的特点是,发现u越早,结束对其邻接链表搜索的时间越晚,因为发现u之后不是对其邻接点一次性扫描,而是对邻接点中的一个往下探索,每次只探索一个邻接点,再自底往上补充那些没访问到的邻接点,因此最后才能把u的邻接点都访问完。这样就形成了一个典型的先进后出的模式,因此,我们可以用栈作为循环体,最先发现的元素最后出栈,最晚发现的元素最早出栈。
DFS(G,s)
1 for each u in G.V: ##初始化,对源节点以外的各属性赋值,同时定义一个全局变量time,用来确定每个节点的访问开始和结束时间d,f
2 u.color = white
3 u.π = Nil
4 time = 0
5 for each u in G.V:
6 if u.color == White:
7 DFS-VISiT(G,u)
DFS-VISiT(G,u)
1 time = time +1
2 u.d = time ##源节点的d为1。后面每调用一次DFS-VISIT即表示新发现了一个节点,而由于深搜中,节点u发现时间=从节点u开始访问下一个节点的时间,因此每次调用该函数,对应参数中节点的发现时间都要+1
3 u.color = Gray
4 for each v in Adj(u) :
5 if v.color == white:
6 v.π = u ##新发现的节点,属性相应赋值
7 DFS-VISIT(G,v)
8 u.color = black ##当在u的邻接点中已经找不到白色节点了,说明u的邻接表已经全部搜完,则u变成黑色,且其结束时间为发现时间+1
9 u.f = time+1
算法效率
从BFS的过程可以看出,要完成搜索,首先进行初始化,复杂度O(V)。然后伪代码的11-18步,即是逐层扫描每个点v的邻接链表Adj[v],每个点的邻接链表扫了一遍搜索即结束。加和,即需要执行
∑
v
∈
V
A
d
j
[
v
]
\sum_{v\in V}Adj[v]
∑v∈VAdj[v] 次。这个数字大小和数据存储格式有关。若以邻接链表的形式存储,上文中说到图G的邻接链表长度之和为O(E),则需执行O(E)次,故DFS过程总的复杂度为O(V+E)。对于邻接矩阵形式存储的数据,复杂度即为
O
(
V
2
)
O(V^2)
O(V2)
从DFS过程的伪代码中可以看出,第1-4行的for循环执行V次,复杂度为O(V)。第5行的for循环含义是对每个节点v调用1次DFS-VISIT,所以我们需要看每个v的DFS-VISIT时间,并在V上加总,即能得到DFS的5-7行总运行时间。而对于每个节点v的DFS-VISIT调用,复杂度只要看循环的次数。每次需要对邻接表中的所有元素执行一次for循环,即需要Adj[v]次。和上述讨论类似,则DFS过程总的复杂度为O(V+E)。对于邻接矩阵形式存储的数据,复杂度
O
(
V
2
)
O(V^2)
O(V2)。
附:算法效率度量详解
算法效率是比较不同算法优劣的重要指标。因此,我们需要知道如何衡量算法效率。算法效率分为时间和空间效率。时间效率指算法运行时间;空间效率指算法需要的额外空间(除了输入规模之外)。虽然传统教材讨论算法时间效率更多,但在大数据的情况下(如1PB以上的数据),空间效率的影响也许同样重要。这里还是主要讨论时间效率的衡量,空间效率衡量的逻辑和其基本一致。
直观的思考是,一般算法的运行时间和这些因素有关:输入规模、特定输入的细节,以及算法实现思路的不同。下面对这三个因素一一讨论。
输入规模
一般在算法分析中,我们会假定输入规模为无穷大,为了看运行时间随着输入规模增长的变化情况。因为当规模较小时,实际上不同算法的时间效率之间差别不会很大,比较起来意义不大。也因为这种特性,在算法运行时间是一个多项式,我们一般只会看有最高次数的项,或者说是增长速度更快的项。
对于某个特定的实例,在选择算法时,输入规模不是唯一的考虑因素。代码的紧凑性、简洁性、运行时间表达式中低阶项的系数都是可以考虑的因素。如,虽然快排是基于比较的排序算法中渐进效率最优的,但当输入规模较小时,完全可以使用插入排序等虽然在渐进效率上非最优但代码紧凑的算法,速度甚至可能更快。即使是选择排序这种蛮力算法,由于代码的简单性,使用也未为不可。
特定输入的细节
对于同一种算法、同样的输入规模,特定的输入情况决定了其运行时间效率存在不同情况,因此在非正式的讨论中,我们一般将算法的效率划分为三种:最差、最好和平均效率。最差情况效率的分析往往更重要,因为:1、最差情况确定了算法运行时间的下界,如果可以证明A算法的最坏情况都比B算法的最好情况快,那A在时间效率上一定是优于B算法的。2、大多数情况下,平均情况和最坏情况一样坏。如在插入排序中,平均情况是有一半数是升序排好的,
t
j
=
j
/
2
t_j=j/2
tj=j/2,这样算出的T(n)仍然有二次项。当然,也存在例外,即平均情况和最好情况差别不大,时间效率在一个数量级上,如快速排序。并且,在随机算法中,可以通过对输入的分布作一个随机化从而得到使我们可以更简单的分析平均情况运行时间。当然,作为一个严谨的分析,我们一般要把三种情况都讨论到。
算法实现思路
实际上,对于同一个算法的思路,其实现方法的不同也决定了其效率。比如在本文的插入排序中,都是基于减一法的思想。但在具体的实现上,可以采用递归或非递归;在元素查找的方法上,可以采用逐个扫描或者二分查找。使用不同的实现方法,即使效率的渐进上界可能相同,但系数的不同也是对算法效率的重要改进。
最后辨析两个概念:算法运行时间和算法时间复杂度。算法运行时间T(n)一般用基本操作的次数表示。给定某个输入规模,就可以用T(n)精确表示该算法基本操作的次数。而时间复杂度一般用T(n)的渐进表达式g(n)表示,可以理解成, ∃ n 0 , c > 0 \exists n_0, c>0 ∃n0,c>0,for ∀ n > n 0 \forall n>n_0 ∀n>n0, T ( n ) T(n) T(n)增长和 c g ( n ) cg(n) cg(n)的增长存在一个稳定的关系。这种表示实际上是对运行时间增长速度的刻画,也可以看成是运行时间的一种近似,在算法效率的比较中往往更有价值。常用的渐进关系有:渐进上界 O ( n ) O(n) O(n)、渐进确界 Θ ( n ) \Theta(n) Θ(n)、渐进下界 Ω ( n ) \Omega(n) Ω(n)。