知识点

递归

递归把当前问题交给规模更小的同类问题处理,再用基线条件收口。本页说明递归的组成与终止条件,并给出递归加法、汉诺塔和归并三个典型写法。把递归用于“分解—求解—合并”的整体策略见分治法;每次调用在内存中的展开与回退见函数调用与栈帧。

定义

递归是指函数直接或间接调用自身。一个能终止的递归由两部分组成:

  • 基线条件(终止条件):规模小到可以直接得到答案,不再调用自身。
  • 递归步骤:把当前问题化为一个或多个规模更小的同类问题,调用自身求解,再用返回值组合出当前答案。

两部分缺一不可。只有递归步骤,调用不会停止;只有基线条件,函数处理不了基线以外的输入。

设当前问题的规模为 nn。每次递归调用都让规模严格减小、并最终到达基线条件,是递归能够终止的依据。若某条调用路径上的规模没有变小,程序会一直调用下去,直到耗尽调用栈。

递归的结构

写递归前先回答三个问题

  1. 基线是什么? 最小规模取什么值,答案是什么?
  2. 规模如何减小? 每一步把问题缩小多少,是否一定到达基线?
  3. 结果如何组合? 子问题的返回值怎样合成当前问题的答案?

执行顺序

递归调用不会立刻返回结果:当前调用先暂停,等子调用结束后再继续执行它后面的语句。因此,打印语句写在递归调用之前还是之后,会得到完全不同的输出顺序。汉诺塔的移动顺序就是由三次调用的先后关系决定的。

每次调用都会占用一个栈帧;调用链的展开与回退见函数调用与栈帧。

线性递归与树形递归

  • 线性递归:每次只调用一次自身,调用链是一条线。递归加法属于这一类,规模每步减少1。
  • 树形递归:每次分解成多个子调用,调用过程形成一棵树。汉诺塔每次产生两个规模 n−1n-1 的调用,移动次数满足 M(n)=2M(n−1)+1M(n)=2M(n-1)+1,M(1)=1M(1)=1,解得 M(n)=2n−1M(n)=2^n-1。

树形递归的调用次数可能随规模指数增长,写之前应先估计调用总量。

例子

递归加法

把列表的和定义为“第一个元素加上其余元素的和”,基线是只剩一个元素。

recursive-sum.py
def F(a):
if len(a) == 1:
return a[0] # 终止条件非常重要
return F(a[1:]) + a[0]
a = [1, 4, 9, 16]
print(F(a))

调用F([1, 4, 9, 16]) 时,先递归求出F([4, 9, 16]),返回后再加1,最终得到30。切片a[1:] 会复制剩余元素,所以这份实现除了调用栈之外还会产生 O(n2)O(n^2) 的复制开销;它适合说明递归结构,不适合作为求和的默认写法。

汉诺塔

Hanoi(n, A, C, B) 表示把 nn 个盘子从柱A移到柱C,以柱B作为辅助。移动 nn 个盘子可以拆成三步:先把上面 n−1n-1 个移到辅助柱,再把最大的盘子移到目标柱,最后把 n−1n-1 个从辅助柱移到目标柱。

hanoi.py
count = 1
def main():
n_str = input("请输入盘子个数:")
n = int(n_str)
Hanoi(n, "A", "C", "B")
def Hanoi(n, A, C, B):
global count
if n < 1:
print("False")
elif n == 1:
print("%d:\t%s -> %s" % (count, A, C))
count += 1
elif n > 1:
Hanoi(n - 1, A, B, C)
Hanoi(1, A, C, B)
Hanoi(n - 1, B, C, A)
if __name__ == "__main__":
main()

三次调用的顺序不能交换:第二次调用移动的是最大的盘子,它必须在两次 n−1n-1 规模的搬运之间执行。count记录当前是第几次移动,每输出一次加一;nn 个盘子共需 2n−12^n-1 次移动。

归并操作

归并(merge)把两个已经有序的列表合并成一个有序列表。比较两个列表的首元素,取出较小者,再对剩余部分递归归并。

merge.py
def merge(L1, L2):
if len(L1) == 0:
return L2
if len(L2) == 0:
return L1
if L1[0] < L2[0]:
return [L1[0]] + merge(L1[1 : len(L1)], L2)
else:
return [L2[0]] + merge(L1, L2[1 : len(L2)])
X = merge([1, 4, 9], [10])
print(X)

两个基线条件分别处理“一个列表已经取空”的情形,此时另一个列表的剩余部分已经有序,可以直接接上。每层递归至少消耗一个元素,所以最多递归 ∣L1∣+∣L2∣|L1|+|L2| 次;merge([1, 4, 9], [10]) 返回 [1, 4, 9, 10]。

交互探索

展开斐波那契递归树,查看每个调用如何缩小到基线,以及相同子问题何时重复出现。

递归怎样分解到基线?

展开斐波那契递归树,比较输入规模和调用次数。

正在准备演示…

题集

练习

  1. 对a = [1, 4, 9, 16] 调用F(a),返回值是 。

    参考解答

    F依次取出首元素并在递归返回后相加,等价于 1+4+9+16=301+4+9+16=30。

  2. Hanoi(4, "A", "C", "B") 一共输出多少次移动?答案是 。

    参考解答

    nn 个盘子的移动次数为 2n−12^n-1,代入 n=4n=4 得 16−1=1516-1=15。

  3. merge函数中,哪些情况会直接返回、不再递归?

    题 merge-base · choice
    参考解答

    两个基线条件检查的是空列表:一个列表为空时,另一个列表整体有序,直接返回它。两个列表各有一个元素时仍要走一次比较,不属于基线;选项D是递归步骤中的比较,不是终止条件。

  4. merge([1, 4, 9], [10]) 的返回值是:

    选择答案
    参考解答

    依次比较首元素,1、4、9都比10小,先进入结果;L1取空后把 [10] 接上,得到 [1, 4, 9, 10]。

  5. 下列哪一项最能保证递归函数一定终止?

    选择答案
    参考解答

    终止性取决于调用链上的规模是否递减到基线。返回值、全局计数和代码位置都不能阻止规模不变或增大的递归无限展开。