递归形式
递归形式是算法中常用到的一种构造思路。递归允许函数或过程对自身进行调用,是对算法中重复过程的高度概括,从本质层面进行刻画,避免算法书写中过多的嵌套循环和分支语法。因此,是对算法结构很大的简化。
递归形式实际可以看做一个函数表达式:
f ( n ) = G ( f ( g ( n ) ) ) f(n)=G(f(g(n)))f(n)=G(f(g(n))),即f ( n ) f(n)f(n)可以通过一个f ( g ( n ) ) f(g(n))f(g(n))的表达式间接推出。当然,如果递归式可解,则最后也能将f ( n ) f(n)f(n)直接用n表示出来。
如:
f ( n ) = f ( n − 1 ) + n f(n)=f(n-1)+nf(n)=f(n−1)+n (1)
f ( n ) = f ( n 2 ) + n f(n)=f(\frac{n}{2})+nf(n)=f(2n)+n (2)
f ( n ) = f ( n 1 ) + f ( n 2 ) + . . . + f ( n k ) f(n)=f(n_1)+f(n_2)+...+f(n_k)f(n)=f(n1)+f(n2)+...+f(nk) (3)
可以想到,如果运行时间可以用这样的递归表达式,那么求解f ( n ) f(n)f(n)可能会相对简单。
递归形式分为线性递归、二分递归、多分支递归等。
在上面举例中,(1)式即为线性递归(该递归关系实际上描述了求和过程);(2)式即为二分递归(后面会看到,该关系实际描述了合并排序过程)。(3)式即为多分支回归,即每次分成的子问题规模不一定相同。将这些递归形式应用于算法中,就形成了遍历、减治、分治等算法策略。此处重点讨论分治法,并且仅关注分治法应用于排序问题中的一种经典算法:合并排序。
合并排序
基本思想:分治法
分治法是把一个规模较大的问题分成若干个规模较小的问题,并递归的对这些问题进行求解,当问题规模足够小时,可以直接求解。最终将这些规模小问题的求解结果递归的合并,建立原问题的解。
使用分治法的意义之一是,这样容易求出算法的时间复杂度,有很多方法可以套用。可以达成这样目标的一个前提是,1.运行时间可以用一个递归式表示;2.分解到最后,规模足够小的子问题应当是常数时间就可以求解的,否则还是没法简化时间复杂度的计算。
在排序过程中,主要分成两个步骤:首先,将n个数用二分递归分解,直到每个子问题规模为1。再将这些子问题递归的合并,合并就是一个排序的过程。
下面以一个排序问题举例:对A=(3,2,4,6,1,5,7,8)用合并排序从小到大排列。
分解过程
这是一个规模为8的排序问题。首先将问题不断二分直到8个规模为1的子问题。因为每步操作的结构类似,因此这几步操作可以用一个递归函数merge-sort表示:merge-sort(A,p,r),其中p为每步分解操作中子序列头的位置,r表示子序列尾的位置。如何保证若干次操作后子序列规模能降到1呢?这就需要对头和尾数字的位置进行比较。其操作过程是:若p
回到上述例子,分解之后,A=({3},{2},{4},{6},{1},{5},{7},{8})
merge过程
当子问题变成单元素时,就开始调用merge过程,即两个已经排好序的子序列合并,在合并的过程中就要排序。因此,在每一次merge前,都有左右两个子序列已经排好序(这里和插入排序中,当选择新元素判断插入序列位置时,已有序列已经排好序这一思想有共通之处)。
此时思路仍可以类比抓牌。现在有两堆牌(A,B)。每堆都已经从小到大排列,小的放上面,大的放下面。先比较A和B堆中最上面的牌(A1,B1)。若A1>B1,则把B1拿出来放在第三堆(C)中,令为C1。接着,把留下的A1继续跟B2比,若A1还大则重复上述操作,若A1小则把A1取出,令为C2,而换B2和A堆中剩下元素比较。最终,某一堆的数字可能被全部取出,而另一堆还没取完,则此时直接把另一堆的数字追加到C堆后面即可。
可以看出,这样操作就不是插入排序那样的原地排序了,而是每层递归都要新生成一个空序列以存放每层排好序的元素。当然,为了节省空间复杂度,这个储存空间需要尽快释放。
总的来说,计算顺序是先对原序列递归分解→ \to→直到子序列为单元素→ \to→对子序列递归合并+排序,直到序列总长度等于原问题规模。
将解这个问题的过程写成merge-sort(n),问题规模为n。下面讨论如何将这个问题的运算时间T ( n ) T(n)T(n)用递归式表示,这里仍然是基于RAM模型的假设。解这个问题分为哪几个步骤呢?
首先,需要找到这个问题的中间元素,分解为两个子问题。而因为这一步只要计算中间的索引即可,其运算时间与问题规模无关,是常数c 1 c_1c1;其次,需要对分成的两个子问题分别调用该过程merge-sort(n/2)。求解这两个子问题的时间即为T ( n 2 ) T(\frac{n}{2})T(2n)。最后,当子问题全部解完之后,需要对这两个子问题合并,合并的merge(n)过程需要c 2 n c_2nc2n运行时间。因为合并是将每个堆最上面的元素进行比较,若要合并成n个元素,则一共最多要比较n次(回想抓牌过程),每次比较只要常数时间c 2 c_2c2。由于c 2 n + c 1 c_2n+c_1c2n+c1仍然是n的一个线性函数,可以表示为Θ ( n ) \Theta(n)Θ(n),因此得到运行时间的递归表达式:
T ( n ) = T ( n 2 ) + Θ ( n ) T(n)=T(\frac{n}{2})+\Theta(n)T(n)=T(2n)+Θ(n)
伪代码
首先看merge-sort伪代码,即假设两侧序列都已经排好序,如何对这两段序列合并排序。
有一个需要注意的构造技巧。排序总体应当分为两个阶段:1.所有堆中的元素均非空时,这样只要一直比较两堆中最上面的元素即可;2.当某堆中的元素被取完时,这样直接把非空的那堆追加到排好序的序列后面即可。因此,在选取某堆最上面元素的时候,需要先判断该堆元素是否为空。一种自然的想法是把这堆元素循环计数,看个数是否为0。但这样的话,每轮合并都要把所有元素循环一遍很费时间。因此,简化的操作是每轮合并后对每堆序列的最下层追加一个一定不属于该序列的“哨兵”,因此只要某轮轮合并排序的时候发现了“哨兵”,说明该堆已空。
“哨兵”如何选择?首先,需要排序的元素里一定不存在∞ \infty∞,并且,利用∞ \infty∞比所有数大的性质也可以进一步简化代码,直接与两堆数字比较的过程融合,不需要单独写一行代码判断下一个数是否是哨兵。
以下是merge过程伪代码:
MERGE(A, p, q, r)
1 n1 ← q - p + 1
2 n2 ← r - q
3 create arrays L[1 ‥ n1 + 1] and R[1 ‥ n2 + 1]
4 for i ← 1 to n1
5 do L[i] ← A[p + i - 1]
6 for j ← 1 to n2
7 do R[j] ← A[q + j] //将A分成两堆,用这两堆的比较更新A序列
8 L[n1 + 1] ← ∞ //新堆尾部插入哨兵
9 R[n2 + 1] ← ∞
10 i ← 1
11 j ← 1
12 for k ← p to r
13 do if L[i] ≤ R[j] //两个新堆最上面的元素比较。这里可以合并遇到∞的情况,是对代码很大的简化
14 then A[k] ← L[i]
15 i ← i + 1
16 else A[k] ← R[j]
17 j ← j + 1
> 引自清华计算机系武永卫老师课件
以下是合并排序merge-sort整个过程的伪代码:
merge-sort(A,p,r)
if p
q = (p+r)/2 //注意这里q是下取整,因此最后总能循环到p>=q
merge-sort(A,p,q)
merge-sort(A,q+1,r)
MERGE(A,p,q,r)
python代码实现
以下是merge过程python代码的实现:
A = [1,4,6,2,4,5,7]
def MERGE(A, p, q, r): ##其中,r为原序列末位数的索引,p为原序列首位数的索引,q为中间某个数的索引(q左右两侧的数已经顺序排列)
L = A[p:q+1]
R = A[q+1:r+1]
L.append(float("inf"))
R.append(float("inf"))
i = 0
j = 0
for k in range(p,r+1):
if L[i]<=R[j]:
A[k] = L[i]
i = i+1
else:
A[k] = R[j]
j = j+1
return A
MERGE(A, 0, 2, 6)
输出结果是一个已经排好序的序列:
>>> [1, 2, 4, 4, 5, 6, 7]
以下是整个合并排序merge-sort过程的代码实现(需要调用前面定义的MERGE函数):
def merge_sort(A, p, r):
if p < r:
q = math.floor((p+r)/2)
merge_sort(A,p,q)
merge_sort(A,q+1,r)
MERGE(A, p, q, r)
return A
A = [1,6,4,2,5,4,7]
merge_sort(A, 0, 6)
>>> [1, 2, 4, 4, 5, 6, 7]
用递归树猜测时间复杂度
如何确定合并排序算法的时间复杂度?虽然用主定理可以直接确定,但这里还是从递归树的角度给出猜测。因为对于一些无法用主定理直接确定的递归算法,还是需要将递归树和代换法结合确定复杂度。用递归树可以提出猜想,用代换法则可以给出该猜想结果的数学证明。
如图所示,是一个递归树的结构。问题总规模为n。这里为了简化问题,令n = 2 k n = 2^kn=2k。令从上到下为第1,2,…m层。用递归树求解总的运算时间,需要每层都看。这也是和写递归式的不同之处。递归式只要看其中任意一层即可。可以从上往下看。第1层的运算时间是从第1-2层的分解加第2-1层的合并,前面提到过,分解与合并时间之和是c 1 + c 2 n c_1+c_2nc1+c2n,因为低次项在算渐进界时不重要,可以直接简化为c n cncn。第2层每个问题规模n/2,分解与合并时间之和均为c n 2 \frac{cn}{2}2cn,因此,第2层总时间也为c n cncn。这样递推可得,所有m层每层运行时间均为c n cncn。如何计算总层数?从n = 2 k n = 2^kn=2k个元素二分降到1个元素,需要降k = l g n k=lgnk=lgn次,因此总层数为m = l g n + 1 m=lgn+1m=lgn+1,故有总运行时间T ( n ) = c n l g n + c n T(n)=cnlgn+cnT(n)=cnlgn+cn
我之前文章提到过,函数增长率只看高阶,因此,猜测该算法运行时间的渐进确界为n l g n nlgnnlgn。
用代换法可以证明,这一结论成立。则其时间复杂度即为Θ ( n l g n ) \Theta(nlgn)Θ(nlgn)。