递归思想
递归思想是算法中常用到的一种构造思路。递归允许函数或过程对自身进行调用,是对算法中重复过程的高度概括,从本质层面进行刻画,避免算法书写中过多的嵌套循环和分支语法。因此,是对算法结构很大的简化。
递归分为广义递归和狭义递归。狭义递归就是本文重点讨论的内容,即函数调用自身。广义递归还包括了A调用B–B调用C–C调用D 这样不同函数的调用。
从数学形式上,狭义递归形式可以写做一个函数表达式:
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)+n
f(n)=f(n−1)+n (1)
f
(
n
)
=
f
(
n
2
)
+
n
f(n)=f(\frac{n}{2})+n
f(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)可能会相对简单。
从算法本质上,不管是广义还是狭义递归函数,实际上都遵循了“先进后出”的思想。先以狭义递归(即调用自身)为例,我们希望把原问题分成若干个子问题,这些子问题再分,直到分到最小规模子问题,再从这些最小规模问题开始解决,基于这些已解决的子问题解决上一层的问题。每一层问题调用递归时候重要的假设是前一层的子问题已经解决。这也是上述递归表达式能写出所暗含的假设。
可以看出,最先开始处理的原问题直到所有递归调用完才结束处理;最晚开始处理的最小规模子问题最早被解决——这就是典型的先进后出。
再扩展到广义递归。实际上,递归表示了问题解决之间的依赖关系:开始处理问题A——A问题的解决依赖于B问题——开始处理问题B——B依赖于C问题——开始处理C问题——结束处理C——结束处理B——结束处理A。因此,A先开始处理,但最后结束处理;C最后开始处理,但最先结束处理。当B,C等问题只是A问题的一个分解时候,广义递归问题就退化到了其特例:狭义递归。
一个广义递归的例子是图计算中的深度优先搜索算法,该算法可以用于拓扑排序,即将一个DAG(有向无环)图的所有结点都在一条水平线上排开,这个新图中所有有向边都从左指向右,从而可以获得要完成一个事件,需要完成子事件的先后次序(如早上起来穿衣服,先穿哪件才能穿哪件),这在解决调度问题中至关重要。
如果把上述的A看成是C的母问题,可以直观的看出,子母问题的(开始处理时间,结束处理时间)之间存在一个严格嵌套的关系,也就是说,递归函数的调用顺序中,存在先进后出的规律。而由于“栈”这种数据结构也遵循先进后出的规则,因此,我们可以用函数栈(或用数据栈避免调用次数过多而溢出)的底层形式实现函数递归。
递归形式分为线性递归、二分递归、多分支递归等。
在文章最开始递归式形式的举例中,(1)式即为线性递归(该递归关系实际上描述了求和过程);(2)式即为二分递归(后面会看到,该关系实际描述了合并排序过程)。(3)式即为多分支回归,即每次分成的子问题规模不一定相同。将这些递归形式应用于算法中,就形成了遍历、减治、分治等算法策略。下文重点讨论分治法,并且仅关注分治法应用于排序问题中的一种经典算法:合并排序。
合并排序
基本思想:分治法
分治法是把一个规模较大的问题分成若干个规模较小的问题,并递归的对这些问题进行求解,当问题规模足够小时,可以直接求解。最终将这些规模小问题的求解结果递归的合并,建立原问题的解。
使用分治法的意义之一是,这样容易求出算法的时间复杂度,有很多方法可以套用。可以达成这样目标的一个前提是,1.运行时间可以用一个递归式表示;2.分解到最后,规模足够小的子问题应当是常数时间就可以求解的,否则还是没法简化时间复杂度的计算。
合并排序的思想是先将待排序序列用二分递归分解,直到每个子问题规模为1。再将这些子问题递归的合并,每次合并就是排序过程。为什么用二分递归呢?确实不一定,分成3,4…份其实都是可以的。感兴趣的同学可以求一下分成不同份数的子问题,时间复杂度有没有什么变化。
下面以一个排序问题举例:对A=(3,2,4,6,1,5,7,8)用合并排序从小到大排列。
分解过程
这是一个规模为8的排序问题。首先将问题不断二分直到8个规模为1的子问题。因为每步操作的结构类似,因此这几步操作可以用一个递归函数merge-sort表示:merge-sort(A,p,r),其中p为每步分解操作中子序列头的位置,r表示子序列尾的位置。如何保证若干次操作后子序列规模能降到1呢?这就需要对头和尾数字的位置进行比较。其操作过程是:若p<r,则在两数之间插入一个位置q,并调用merge-sort(A,p,q)与merge-sort(A,q+1,r),对两边子序列分别再进行上述头尾元素位置的比较。这样循环的结果是最终每个子序列的头和尾元素位置相同,即问题规模降为1。单元素本身就是排好序的,也就是说,对单元素排序是一个可以用常数时间解决的问题。
回到上述例子,分解之后,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_1
c1;其次,需要对分成的两个子问题分别调用该过程merge-sort(n/2)。求解这两个子问题的时间即为
T
(
n
2
)
T(\frac{n}{2})
T(2n)。最后,当子问题全部解完之后,需要对这两个子问题合并,合并的merge(n)过程需要
c
2
n
c_2n
c2n运行时间。因为合并是将每个堆最上面的元素进行比较,若要合并成n个元素,最坏情况下要比较n-1次,即每排出一个元素都要在两个堆之间比一次,最后一次比较同时确定了倒数第二个元素和最后一个元素(可以用两个堆之间连线理解)。每次比较只要常数时间
c
2
c_2
c2。由于
c
2
n
+
c
1
c_2n+c_1
c2n+c1仍然是n的一个线性函数,可以表示为
Θ
(
n
)
\Theta(n)
Θ(n),因此得到运行时间的递归表达式:
T
(
n
)
=
2
T
(
n
2
)
+
Θ
(
n
)
T(n)=2T(\frac{n}{2})+\Theta(n)
T(n)=2T(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<r:
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的问题运行时间=求解分解出的子问题运行时间+子问题合并时间。这样可以写出其运行时间的递归表达式:
T
(
n
)
=
3
T
(
n
4
)
+
Θ
(
n
)
T(n)=3T(\frac{n}{4})+\Theta(n)
T(n)=3T(4n)+Θ(n)。其中 ,
Θ
(
n
)
\Theta(n)
Θ(n)为规模为n问题的子问题的合并时间。到这里,我们可以使用一些数学技巧硬解。对于简单的式子,可以构造等比等差数列以用上已知递推公式再代换,但这对于复杂的递归式基本上不可行。而我们如果更全局的考虑这个问题,就可以用递归树的方法方便的求解问题。当递归一直进行下去到问题分解为最小规模,则求解规模为n的问题本质上可以转化成:求解最小规模子问题时间+整个递归过程中所有子问题合并的时间。而使用递归树后,对于某些问题,可以很方便的求出最小规模子问题的运行时间和所有子问题合并的时间。
应用例之一:合并排序
要求某个递归算法的复杂度,要先写出求解该问题的递归表达式。
上面已经给出了合并排序运行时间的递归表达式:
T
(
n
)
=
2
T
(
n
2
)
+
Θ
(
n
)
T(n)=2T(\frac{n}{2})+\Theta(n)
T(n)=2T(2n)+Θ(n)。称规模为1的问题所在层为底层。则底层必有n个问题(因为每个规模为1,由规模为n的问题分解得到),解决这些问题的复杂度为
O
(
1
)
∗
n
=
b
n
O(1)*n=bn
O(1)∗n=bn。由于n规模每次分解为1/2,因此每层的问题规模是
n
2
k
\frac{n}{2^k}
2kn,则
n
=
2
k
n=2^k
n=2k时可以分解到规模为1。此时树的深度(不包括顶层)为k。再看每层的合并时间。从顶层到第k-1层都有合并时间。顶层合并时间即为表达式中的
Θ
(
n
)
=
c
n
\Theta(n)=cn
Θ(n)=cn。其余层每个子问题套该式子再乘本层子问题数可得合并时间均为
c
n
cn
cn。因此总时间=
c
n
l
g
n
+
b
n
=
Θ
(
n
l
g
n
)
cnlgn+bn=\Theta(nlgn)
cnlgn+bn=Θ(nlgn)