AI 技术博客
返回首页
Python 基础 18 分钟阅读

递归:栈帧、回溯与尾递归优化思路

Python递归回溯算法调用栈

引言

这是第四章「函数」的第 6 篇,也是函数专题的收官之作。第 1 篇你学会了用 def 定义函数,第 2 篇搞清楚了作用域如何决定变量可见性,第 3 篇掌握了 *args / **kwargs 处理不确定参数,第 4 篇学习了 lambda 表达式,第 5 篇理解了装饰器如何包裹函数。现在,我们把目光转向函数的一个特殊能力:函数可以调用自己——这就是递归(recursion)。

递归是计算机科学中最核心的思想之一。它不仅仅是一种编程技巧,更是一种问题分解的策略:把一个复杂问题拆解成结构相同但规模更小的子问题,直到子问题小到可以直接求解。递归在算法设计(分治法、动态规划的记忆化搜索)、数据结构遍历(树的深度优先搜索)、数学证明(数学归纳法)中无处不在。

本篇先讲递归的三个必要组成部分(基线条件、递归步骤、终止保证),然后用 Python 的运行机制作解释递归背后的「栈帧」机制,接着对比递归和迭代在 Fibonacci 数列上的性能差异,再介绍回溯算法框架(全排列、迷宫求解),最后讨论尾递归和 Python 为何不支持尾递归优化(TCO)。读完本篇,你将能正确编写递归函数,并知道何时该用递归、何时该改用迭代。

概念与原理

递归的三要素

任何一个正确的递归函数都必须满足三个条件,缺一不可:

1. 基线条件(base case)

基线条件是递归停止的条件,也就是最简单、不需要再拆分的问题。没有基线条件的递归会无限进行下去,最终导致 RecursionError。

以计算阶乘为例,0! = 1 就是基线条件——当 n == 0 时直接返回 1,不再递归。

2. 递归步骤(recursive case)

递归步骤是把当前问题转化为规模更小的同类问题。每次递归调用都应该让问题的规模向基线条件靠近,而不是远离或保持不变。

在阶乘中,n! = n * (n-1)! 就是递归步骤——把 n! 转化为 (n-1)! 乘以 n,规模减小了 1。

3. 终止保证(termination guarantee)

必须保证无论传入什么合法输入,递归都最终会到达基线条件。这需要从数学上论证(归纳法)或从逻辑上验证。

在阶乘中,由于每次调用 n 严格递减,且 n >= 0 时最终会到达 n == 0,所以终止是有保证的。但如果调用方传入了负数,递归就会永远无法到达基线条件——这是一个典型的终止保证漏洞。

def factorial(n):
    if n < 0:
        raise ValueError("n must be non-negative")
    if n == 0:
        return 1
    return n * factorial(n - 1)

调用栈与栈帧

理解递归的关键是理解 Python 的调用栈(call stack)。每当函数被调用时,Python 会在内存中创建一个栈帧(stack frame),用于保存该次调用的局部变量、参数和返回地址。栈帧按照「后进先出」(LIFO)的方式组织,最新调用的函数对应的栈帧在最上面。

以 factorial(3) 为例,调用过程如下:

factorial(3)
  → factorial(2)
      → factorial(1)
          → factorial(0)   ← 到达基线条件,返回 1
          ← factorial(1)   返回 1 * 1 = 1
      ← factorial(2)       返回 2 * 1 = 2
  ← factorial(3)           返回 3 * 2 = 6

每一层递归调用都会在调用栈上压入一个新帧,直到基线条件触发后,帧逐层弹出,返回值逐层传递回来。调用栈的总深度决定了递归的最大允许深度——这也是 Python 设置递归限制的原因。

可以用以下代码观察递归过程中的调用栈:
import sys

def observe_depth(n):
    print(f"  " * (sys.getrecursionlimit() - n) + f"depth={n}, n={n}")
    if n == 0:
        return 1
    result = n * observe_depth(n - 1)
    print(f"  " * (sys.getrecursionlimit() - n) + f"return {result} from depth={n}")
    return result

observe_depth(5)

输出会显示调用深度从 0 到 5 的增长,以及返回值从内到外的传递。这种可视化有助于建立对调用栈的直观理解。

操作与实现

递归 vs 迭代:Fibonacci 对比

Fibonacci 数列是最经典的递归教学案例,但它同时也是递归性能问题的典型反面教材:

def fib_recursive(n):
    """纯递归版本"""
    if n <= 1:
        return n
    return fib_recursive(n - 1) + fib_recursive(n - 2)

def fib_iterative(n):
    """迭代版本"""
    if n <= 1:
        return n
    a, b = 0, 1
    for _ in range(2, n + 1):
        a, b = b, a + b
    return b

两段代码逻辑上计算的是同一个数列,但性能差异巨大:

import time

n = 30

start = time.time()
r1 = fib_recursive(n)
t1 = time.time() - start

start = time.time()
r2 = fib_iterative(n)
t2 = time.time() - start

print(f"recursive fib({n})={r1}, time={t1:.4f}s")
# recursive fib(30)=832040, time=0.1100s
print(f"iterative fib({n})={r2}, time={t2:.6f}s")
# iterative fib(30)=832040, time=0.000000s
print(f"match: {r1 == r2}")  # True

在我的环境中,fib_recursive(30) 耗时约 0.11 秒,而 fib_iterative(30) 接近瞬间完成(微秒级)。原因在于纯递归版本存在大量重复计算——fib(30) 需要计算 fib(29) 和 fib(28),而 fib(29) 又需要计算 fib(28) 和 fib(27),fib(28) 被重复计算了两次。这个时间复杂度是指数级的 O(2^n)。

用 functools.lru_cache 给递归版本加上记忆化(memoization)后,复杂度降为 O(n):

from functools import lru_cache

@lru_cache(maxsize=None)
def fib_memo(n):
    if n <= 1:
        return n
    return fib_memo(n - 1) + fib_memo(n - 2)

print(fib_memo(30))       # 832040
print(fib_memo.cache_info())
# CacheInfo(hits=28, misses=31, maxsize=None, currsize=31)

hits=28 说明有 28 次调用直接命中缓存,只花了 31 次实际计算就得到了结果。这是递归+缓存的典型用法。

回溯算法框架

回溯(backtracking)是一种基于递归的穷举搜索策略,核心思想是:尝试一个选择,递归求解,如果走不通则退回(回溯)上一步,尝试另一个选择。回溯的经典应用是全排列和迷宫求解。

全排列

给定一个列表,生成其所有可能的排列:

def permute(nums):
    result = []
    
    def backtrack(start):
        if start == len(nums):
            result.append(nums[:])  # 保存副本,不是引用
            return
        for i in range(start, len(nums)):
            nums[start], nums[i] = nums[i], nums[start]  # 交换
            backtrack(start + 1)
            nums[start], nums[i] = nums[i], nums[start]  # 回溯(撤销交换)
    
    backtrack(0)
    return result

print(permute([1, 2, 3]))
# [[1, 2, 3], [1, 3, 2], [2, 1, 3], [2, 3, 1], [3, 2, 1], [3, 1, 2]]

关键点:每次交换后递归,递归返回后再交换回来(回溯),确保下一次循环从正确的状态出发。result.append(nums[:]) 必须用切片创建副本,否则保存的只是同一对象的引用,后续修改会覆盖之前的结果。

迷宫求解

用一个二维矩阵表示迷宫,1 表示通路,0 表示墙壁:

def solve_maze(maze, x, y, solution):
    n = len(maze)
    # 到达右下角终点
    if x == n - 1 and y == n - 1:
        solution[x][y] = 1
        return True
    # 检查边界、是否可通行、是否已访问
    if 0 <= x < n and 0 <= y < n and maze[x][y] == 1 and solution[x][y] == 0:
        solution[x][y] = 1
        # 尝试向下
        if solve_maze(maze, x + 1, y, solution):
            return True
        # 尝试向右
        if solve_maze(maze, x, y + 1, solution):
            return True
        # 两条路都走不通,回溯
        solution[x][y] = 0
        return False
    return False

maze = [
    [1, 0, 0, 0],
    [1, 1, 0, 1],
    [0, 1, 0, 0],
    [1, 1, 1, 1]
]
solution = [[0] * 4 for _ in range(4)]
if solve_maze(maze, 0, 0, solution):
    for row in solution:
        print(row)
# [1, 0, 0, 0]
# [1, 1, 0, 0]
# [0, 1, 0, 0]
# [0, 1, 1, 1]

这条路径从左上角 (0,0) 出发,依次经过 (1,0)、(1,1)、(2,1)、(3,1)、(3,2)、(3,3) 到达右下角终点,共 7 个格子。迷宫中用 0 标记未走过的格子,用 1 标记通路。

迷宫问题的递归框架是通用的:判断当前位置是否是目标;如果是,返回成功;如果不是,标记当前位置并尝试每个可行方向(递归),任何方向成功就返回;所有方向都失败则回溯(取消标记)并返回失败。

递归深度与 sys.getrecursionlimit

Python 对递归深度有限制,默认值是 1000:

import sys
print(sys.getrecursionlimit())  # 1000

超过这个限制会抛出 RecursionError:

def count_down(n):
    if n == 0:
        return
    count_down(n - 1)

count_down(1001)  # RecursionError: maximum recursion depth exceeded

可以临时修改限制(谨慎使用):

sys.setrecursionlimit(2000)

但修改限制并不能解决根本问题——它只是让你能跑到更深的递归深度,最终仍然会遇到栈溢出(segfault)的风险。在生产代码中,应该优先改写为迭代或用 lru_cache 优化,而非依赖调大递归限制。

对于需要深层递归的场景(如处理深层嵌套的数据结构),可以考虑使用显式栈模拟递归,完全避开 Python 的递归限制。

尾递归与 Python 的 TCO 缺失

尾递归(tail recursion) 是指递归调用是函数体中的最后一个操作,其结果直接作为当前函数的返回值,不需要进行任何额外的计算:

# 尾递归版本:阶乘
def factorial_tail(n, accumulator=1):
    if n == 0:
        return accumulator
    return factorial_tail(n - 1, accumulator * n)  # 递归调用是最后一步

在支持**尾递归优化(Tail Call Optimization,TCO)**的语言中(如 Scheme、Rust、Erlang),编译器会将尾递归转换为等价的循环,不增加栈帧深度,从而避免栈溢出。上面这个 factorial_tail 在 Scheme 中可以安全地递归任意深度。

但 Python 不支持尾递归优化。这是 CPython 设计者的有意选择——Guido van Rossum 认为 TCO 会让调试变得困难(栈帧消失,无法查看中间状态),且不符合 Python 「可读性优先」的哲学。Python 官方文档明确说明不提供 TCO:

import sys
sys.setrecursionlimit(2000)

def factorial_tail(n, accumulator=1):
    if n == 0:
        return accumulator
    return factorial_tail(n - 1, accumulator * n)

print(factorial_tail(1500))  # 1500! 的前几位,递归深度 1500,需要预先调大限制

在 sys.setrecursionlimit(2000) 后,上述代码可以正常运行。但如果你尝试更大的深度,比如 5000,仍然会抛出 RecursionError。

Python 中没有 TCO,因此将尾递归改写为迭代是最佳实践:

def factorial_iterative(n, accumulator=1):
    while n > 0:
        accumulator *= n
        n -= 1
    return accumulator

迭代版本没有递归限制,可以处理任意大的 n(受限于整数本身的大小)。

易错点与陷阱

陷阱一:忘记基线条件

最常见的递归错误是缺少基线条件,导致无限递归:

# ❌ 没有基线条件,会无限递归
def count_up(n):
    print(n)
    count_up(n + 1)  # n 越来越大,永远无法到达基线

# ✅ 正确版本
def count_down(n):
    if n <= 0:
        return
    print(n)
    count_down(n - 1)

编写递归函数时,先问自己:这个问题的最小形式是什么?什么时候可以不再拆分?把这个条件写成 if 放在函数最前面。

陷阱二:递归步骤没有向基线条件靠近

即使有基线条件,如果递归步骤没有让问题规模减小,仍然会无限递归:

# ❌ 递归步骤没有缩小问题规模
def silly(n):
    if n == 0:
        return
    silly(n)  # 还是 n,永远不会到达 0

# ✅ 必须让参数变化并趋近基线
def okay(n):
    if n == 0:
        return
    okay(n - 1)  # n 每次减 1,最终到达 0

检查递归步骤时,要验证:每次递归调用后,输入是否在某种意义上「变小」了?

陷阱三:忽略递归的重复计算

纯递归在没有记忆化的情况下会产生大量重复计算,如 Fibonacci 的朴素递归版本。遇到这种情况,可以用 functools.lru_cache 自动缓存结果,或者手动维护一个备忘录字典。这是递归性能优化的标准做法。

陷阱四:递归深度超过限制

递归深度默认上限是 1000。如果你的递归可能超过这个深度(比如处理大型树结构或长序列),应该考虑:

  1. 用迭代替代递归
  2. 用 lru_cache 加记忆化减少调用次数
  3. 用显式栈模拟递归(将调用栈改为堆上的数据结构)

不要简单地通过 sys.setrecursionlimit() 来解决——这只是在推迟问题,并未消除根本风险。

小结

递归是用函数自身解决规模更小同类问题的编程范式。一个正确的递归函数必须包含基线条件(停止点)、递归步骤(缩小问题)和终止保证(确保能到达基线)。Python 的递归通过调用栈实现,每层递归对应一个栈帧,栈的深度受 sys.getrecursionlimit() 限制(默认 1000)。递归相比迭代的代价是额外的栈帧开销和潜在的重复计算,但在树遍历、回溯搜索等场景下,递归的代码结构往往比迭代更自然清晰。Python 不支持尾递归优化(TCO),因此尾递归应改写为显式循环;需要深层递归时优先用迭代或 lru_cache 记忆化。掌握这三点——三要素、调用栈本质、TCO 缺失——递归就不再是神秘的技巧,而是一种可以安全使用的问题分解工具。

练习与思考题

  1. 用递归实现一个函数 flatten(lst),将嵌套列表(如 [1, [2, [3, 4]], 5])展平为 [1, 2, 3, 4, 5]。提示:用 isinstance(x, list) 判断元素是否为列表。

  2. 手写一个不带 lru_cache 的递归 Fibonacci 函数,与迭代版本对比,分别在 n=20、n=35 时测量运行时间。你能观察到什么规律?为什么会有这个规律?

  3. 用回溯法实现一个函数 combinations(items, k),返回从 items 中选取 k 个元素的所有组合(不区分顺序)。提示:在每个位置决定是否选取当前元素,然后递归处理剩余元素。

  4. 讨论:在什么场景下递归比迭代更合适?在什么场景下应该避免使用递归?结合调用栈的内存开销和代码可读性两方面给出你的分析。