0012 · 递归:函数调用自己

递归:函数调用自己

本节目标:能写出带正确基例与递归关系的递归函数,并说清每层调用在调用栈里留下了什么。

六行解完汉诺塔

汉诺塔的题面是这样:三根柱子 A、B、C,A 柱上从下到上按大小顺序摞着 n 个盘子,大的在下。每次只允许移动一个盘子,且任何时刻大盘都不能压在小盘上面。要求把 n 个盘子整体搬到 C 柱。

n = 64 时,移动步数是 2^{64} - 1,约 1.8 \times 10^{19} 步。把每一步都显式列出来,在数量上就不可能。

下面这段代码只有六行,它给出了完整的移动顺序:

def hanoi(n, a, c, b):
    if n == 0:
        return
    hanoi(n - 1, a, b, c)
    print(f'盘{n}:{a} -> {c}')
    hanoi(n - 1, b, c, a)

取 n = 3,把这段代码跑一遍,输出是:

盘1:A -> C
盘2:A -> B
盘1:C -> B
盘3:A -> C
盘1:B -> A
盘2:B -> C
盘1:A -> C

七步,与 2^3 - 1 = 7 相符。函数体里没有循环,没有列表,也没有别处保存中间状态;它只是调用了自己两次。

一个函数调用自己,居然能把每一步都做完——这一课要把这件事讲清楚,讲清它的代价,以及它在什么地方会崩。

这一课的位置

上一课讲了栈只在同一端进出。这一课回答的问题是:一个函数调用自己的时候,这种「后进先出」发生在机器的哪一处。树、分治、动态规划的代码骨架全部写成递归,这一课的机制没弄明白,后面每一课都会在同一个位置卡住。

递归的三个要素

「递归」的英文是 recursion,源自拉丁语 recurrere:re- 表示「回」,currere 表示「跑」,字面意思是「往回跑」。

设一个函数的定义里出现了对自身的调用,则称这个函数是递归函数,这种定义方式称为递归定义。

一个能停下来的递归由三件事构成:

要素含义汉诺塔里的对应
基例不再调用自身的那些输入n == 0 时直接 return
递归关系把规模为 n 的问题归结为规模更小的问题把 n 个盘子拆成「n-1 个搬到中转柱、搬第 n 个、n-1 个搬去目标柱」
向基例收敛每一步递归都让输入严格变小每次调用里 n 减一,有限步后必然到 0

基例是三者里唯一不能省的一项。它允许有两条或更多(例如 n = 0 与 n = 1 各一条),前提是覆盖所有会终止的分支。

汉诺塔的递归关系值得单独看一眼。要把 n 个盘子从 A 搬到 C:先把上面 n-1 个搬到 B,再把第 n 个(此时它上面已经空了)搬到 C,最后把 n-1 个从 B 搬到 C。这三步里,前一步与后一步都是同一个问题的更小规模,中间那一步是一次直接移动。

按这个关系数一下步数:设 T(n) 为搬 n 个盘子的步数,则 T(n) = T(n-1) + 1 + T(n-1) = 2T(n-1) + 1,且 T(0) = 0。依次代入得 T(n) = 2^n - 1。n = 64 时就是前面那个 1.8 \times 10^{19}。

调用栈里留下了什么

上一课讲过,栈是只在一端进出的结构,最后压进去的最先被取出。函数调用正是用栈实现的:每发生一次调用,解释器就把一张栈帧(stack frame)压到调用栈的栈顶。栈帧里存着这次调用的形参与局部变量,以及一项容易被忽略的内容——这一层返回之后,控制权交回上一层的哪一行。

函数返回时,栈顶那张帧被弹出,控制权交回帧里记下的那一行。

由此得到两条结论:

汉诺塔的两层调用,栈的变化过程如下:

栈帧里的「返回到哪一行」是理解递归返回路径的关键。同一段代码被调用多次,每次进入时执行到哪、返回后接着执行哪,靠的是每张帧各自记一份,而不是靠某处的一个全局标记。

这条性质也解释了递归的一个代价:每进入一层就多一张帧,帧里的局部变量各存一份。递归深度越大,占用的内存越多。

亲手推一遍两层

把上面的代码加一层缩进打印,栈的进出就变成可见的输出:

def hanoi_trace(n, a, c, b, depth=0):
    pad = '    ' * depth
    print(f'{pad}进入 hanoi({n}, {a} -> {c})')
    if n == 0:
        print(f'{pad}返回:n 为 0,什么都不做')
        return
    hanoi_trace(n - 1, a, b, c, depth + 1)
    print(f'{pad}移动 盘{n}:{a} -> {c}')
    hanoi_trace(n - 1, b, c, a, depth + 1)
    print(f'{pad}返回 hanoi({n}, {a} -> {c})')

hanoi_trace(2, 'A', 'C', 'B')
进入 hanoi(2, A -> C)
    进入 hanoi(1, A -> B)
        进入 hanoi(0, A -> C)
        返回:n 为 0,什么都不做
    移动 盘1:A -> B
        进入 hanoi(0, C -> B)
        返回:n 为 0,什么都不做
    返回 hanoi(1, A -> B)
移动 盘2:A -> C
    进入 hanoi(1, B -> C)
        进入 hanoi(0, B -> A)
        返回:n 为 0,什么都不做
    移动 盘1:B -> C
        进入 hanoi(0, A -> C)
        返回:n 为 0,什么都不做
    返回 hanoi(1, B -> C)
返回 hanoi(2, A -> C)

缩进的格数就是递归深度。挑几行对一下栈里的内容:

输出行深度栈里自底向顶
进入 hanoi(2, A -> C)0hanoi(2)
进入 hanoi(1, A -> B)1hanoi(2) · hanoi(1)
进入 hanoi(0, A -> C)2hanoi(2) · hanoi(1) · hanoi(0)
返回:n 为 0,什么都不做2hanoi(2) · hanoi(1)
移动 盘1:A -> B1hanoi(2) · hanoi(1)
返回 hanoi(1, A -> B)0hanoi(2)

这里能看到基例的实际作用:hanoi(0, ...) 什么也不做就返回,它的存在是为了让「n-1 个盘子」这件事在 n = 1 时有个终止处。每一次「进入」后面都必然跟着一次同层的「返回」,两者配对出现,数量相等。

练习 亲手推一遍三层

这一节的动作都在本机 Python 里做,写一个 hanoi.py 就够。

第一步 · 先手推再看答案。 拿三张纸片标上 1、2、3 当盘子,在纸上挪一遍 n = 3 的七步,把移动顺序写下来(形如 盘1:A -> B)。写完再跑一遍本文开头那六行代码,对答案。

第二步 · 数一数栈有多深。 把 hanoi_trace(2, 'A', 'C', 'B') 改成 hanoi_trace(3, 'A', 'C', 'B'),在输出里找出深度为 3 的那几行,写下那一刻栈里自底向顶是哪些调用。

第三步 · 挪一行看变化。 把 print(f'{pad}移动 盘{n}:{a} -> {c}') 这一行挪到两次递归调用之前,再跑 hanoi(2, 'A', 'C', 'B')。写出新的三步,然后判断它还是不是一个合法解(提示:第一步想搬的是哪个盘子,它当时在不在源柱上)。

三个函数与循环版

递归不只在移动盘子上用。下面两个函数是同一件事的两种写法:

def fact_rec(n):
    if n <= 1:
        return 1
    return n * fact_rec(n - 1)

def fact_loop(n):
    r = 1
    for i in range(2, n + 1):
        r *= i
    return r

print(fact_rec(10), fact_loop(10))
3628800 3628800

求和也一样,再加上第三种写法:

def sum_rec(xs):
    if not xs:
        return 0
    return xs[0] + sum_rec(xs[1:])

def sum_loop(xs):
    s = 0
    for x in xs:
        s += x
    return s

def sum_stack(xs):
    st = [(0, 0)]
    total = 0
    while st:
        i, acc = st.pop()
        if i == len(xs):
            total = acc
            continue
        st.append((i + 1, acc + xs[i]))
    return total

a = [3, 1, 4, 1, 5, 9, 2, 6]
print(sum_rec(a), sum_loop(a), sum_stack(a))
31 31 31

三个结果一致。三种写法里只有第一种是递归,第三种把「还没处理到哪」与「已经累加到多少」显式地压进了自己维护的栈——它做的是解释器在递归版里替你做的事。

两种写法在代价上并不对等,这一点值得说清:

写法时间额外空间说明
sum_loopO(n)O(1)只用一个累加变量
sum_stackO(n)O(n)栈里最多存 n 项
sum_recO(n)O(n^2)每层都写 xs[1:],第 3 课讲过切片会复制一份新列表

第三行是这一节最该记住的一条:递归版看起来最短,代价却最高。xs[1:] 每进入一层就复制一次剩余全部元素,n 层加起来是 O(n^2) 次复制。

缺了基例会怎样

把基例删掉,函数里只剩递归关系:

def broken(n):
    return n + broken(n - 1)

print(broken(5))

跑它的现场是这样(中间重复的行省略):

  File "broken.py", line 2, in broken
    return n + broken(n - 1)
               ~~~~~~^^^^^^^
  [Previous line repeated 996 more times]
RecursionError: maximum recursion depth exceeded

报错的名字说明了原因:broken(5) 调用 broken(4),再调用 broken(3),n 一路减下去却永远不会停,因为没有任何一条输入能让它不调用自己。栈里堆到解释器允许的上限,解释器就把它掐断。

[Previous line repeated 996 more times] 这一行给出的是层数。加上最外几层,总数正好是解释器允许的上限。

基例覆盖不全,与没有基例同一种后果

只有一条 if n == 1 的基例、却用 n = 0 去调用时,n 会继续减到负数,同样一路调用下去。写基例时要想的是「所有会终止的输入」,不只是最常用的那一个。递归关系不向基例收敛(例如 broken(n - 1) 写成 broken(n))也是同一种崩法。

递归深度有上限

这个上限是可以查的:

import sys
print(sys.getrecursionlimit())
1000

本机(Python 3.14.7,free-threading 构建)实测:默认上限 1000,实际能进入的最深层数是 998(本机实测、非通用值,换机器换版本都会变)。它与上面报错里的 996 是同一件事的不同侧面。

上限用 sys.setrecursionlimit() 可以调高。调高之前要看两件事:

调高上限不是解决办法

递归深度随 n 线性增长、而 n 达到 10^4 以上时,正确的做法是把它改写成循环加显式栈(像 sum_stack 那样),或者改写成深度只有 O(\log n) 的形状(分治就是这样,后面会讲)。把上限调到一万只是把撞墙的位置往后挪,规模再大一次照样撞。

指数级的递归陷阱

有些递归不只是慢,它会随规模指数级地变慢。斐波那契数列的朴素递归就是一个典型:

calls = 0

def fib_rec(n):
    global calls
    calls += 1
    if n < 2:
        return n
    return fib_rec(n - 1) + fib_rec(n - 2)

def fib_loop(n):
    x, y = 0, 1
    for _ in range(n):
        x, y = y, x + y
    return x

两个函数都正确,代价差得极远。把调用次数和耗时数出来(计时用第 2 课的 time.perf_counter() 前后相减):

import time

for n in (20, 25, 30):
    calls = 0
    t0 = time.perf_counter()
    v = fib_rec(n)
    t1 = time.perf_counter()
    print(n, v, calls, f'{t1 - t0:.6f} 秒')

本机跑出来的三行是:

n递归版调用次数递归版耗时循环版耗时
2021 8910.001483 秒—
25242 7850.016244 秒—
302 692 5370.175716 秒0.0000074 秒

n 每增加 5,调用次数约乘 11。把 n 从 20 加到 30,规模只增加一半,调用次数却从两万涨到两百七十万——这是指数级增长的形状。n = 30 时循环版是 0.0000074 秒,递归版是 0.175716 秒,相差约 23 700 倍(本机实测、非通用值)。

按这个倍率往下估:n = 45 时递归版约需 0.175716 \times 11^3 \approx 234 秒,n = 50 时约 43 分钟。这道题在 n = 45 上就已经越过了常见的一秒时限。

为什么会重复这么多次?画出来就清楚了:

虚线画的那几个节点是同一个子问题:fib(2) 在这里被算了三次,fib(1) 被算了三次。每往上一层,重复的次数翻一倍,调用总数就这样涨成指数级。

这道题的正确做法是把它改成循环(上表最后一列),或者把算过的结果记下来(动态规划那一课讲这件事)。这里的重点是判断方法:看到「一个函数调用自己两次或更多次、且两次的输入只差常数」时,先把调用次数估出来——n 取一个能跑的小值数一数,看它随 n 增长的速度是不是指数级。

递归改成循环与栈

任何递归都能改写成循环加显式栈,sum_stack 就是一个例子。改写的一般形状是:

这样写出来的版本深度不再受解释器上限约束,代价是代码更长、可读性更低。

这一课用到的判断

没看懂?随时问 把看不懂的段落(或报错)原样复制回会话,说一句哪里不懂就行,我只解释这一段,不打断你的进度。