cv.cm

编码 · 算法

递归详解:原理与 Python 示例

阅读约 6 分钟

简短回答

递归是一种让函数通过调用自身来解决同一问题的更小版本的技巧。每个递归函数都需要一个不再递归、直接返回的基准情形,以及一个朝基准情形推进的递归情形。每次调用都在调用栈中等待,直到更小的那次调用返回。递归天然适合树、嵌套数据,以及归并排序这类分治算法。

递归解决什么问题

有些问题本身就是用自身来定义的。文件夹里有文件,也有其他文件夹;树节点有子树;n 的阶乘等于 n 乘以 n − 1 的阶乘。用普通循环来写这些问题,往往需要手动维护一个存放待处理任务的栈;递归则让语言替你保管这个栈。

递归函数由两部分组成:

  • 基准情形:最小的输入,直接给出答案。对阶乘来说,0! = 1。
  • 递归情形:缩小问题,对更小的部分调用函数,再合并结果。对阶乘来说,n! = n × (n − 1)!。
def factorial(n):
    if n == 0:                  # base case
        return 1
    return n * factorial(n - 1) # recursive case
print(factorial(5))   # 120

逐步追踪调用栈

每次调用都会在调用栈上获得自己的栈帧,其中保存着自己那份 n。调用一路向下直到基准情形,然后结果按相反的顺序逐层返回。

步骤调用状态
1factorial(3)等待 factorial(2)
2factorial(2)等待 factorial(1)
3factorial(1)等待 factorial(0)
4factorial(0)基准情形,返回 1
5factorial(1)返回 1 × 1 = 1
6factorial(2)返回 2 × 1 = 2
7factorial(3)返回 3 × 2 = 6

这里的栈深度是 n + 1 个栈帧,所以即使没有创建任何列表,额外空间也是 O(n)。

对嵌套数据的递归

当数据本身的结构就是递归的,递归最能发挥作用。下面的函数对一个可能包含其他列表(任意深度)的列表求和。

def deep_sum(items):
    total = 0
    for x in items:
        if isinstance(x, list):
            total += deep_sum(x)   # same problem, smaller piece
        else:
            total += x
    return total
print(deep_sum([1, [2, 3], [4, [5, [6]]]]))   # 21

同样的模式也可用来遍历目录树、JSON 文档、HTML DOM 或二叉树:先处理当前节点,再递归处理每个子节点。

def power_set(items):
    if not items:
        return [[]]
    rest = power_set(items[1:])
    return rest + [[items[0]] + s for s in rest]
print(power_set([1, 2, 3]))
# [[], [3], [2], [2, 3], [1], [1, 3], [1, 2], [1, 2, 3]]

递归函数的复杂度

代价可以估算为(调用次数)×(每次调用的工作量)。空间复杂度至少等于最大栈深度。

函数调用次数时间栈空间
factorial(n)n + 1O(n)O(n)
递归版二分查找约 log₂ nO(log n)O(log n)
归并排序约 2nO(n log n)栈为 O(log n),缓冲区为 O(n)
朴素斐波那契约 1.6ⁿ上界 O(2ⁿ)O(n)
幂集n + 1O(n · 2ⁿ)栈为 O(n),输出是 2ⁿ 个列表

分治类的递推式,例如归并排序的 T(n) = 2T(n/2) + O(n),解得 O(n log n)。而对 n − 1 做两次调用的函数则呈指数级增长。

记忆化:避免重复计算

朴素的斐波那契函数会把相同的值重复计算很多次:fib(5) 会调用两次 fib(3)、三次 fib(2)。把每个结果在首次算出时缓存起来,就能把指数时间变成线性时间。Python 的 functools.lru_cache(或 Python 3.9+ 的 functools.cache)只需一个装饰器就能做到。

from functools import lru_cache
def fib_slow(n):
    return n if n < 2 else fib_slow(n - 1) + fib_slow(n - 2)
@lru_cache(maxsize=None)
def fib(n):
    return n if n < 2 else fib(n - 1) + fib(n - 2)
print(fib_slow(20))   # 6765, about 22,000 calls
print(fib(90))        # 2880067194370816120, 91 distinct calls

带记忆化的递归是动态规划的自顶向下形式;自底向上的形式则是用循环去填表。

递归深度限制与改写为循环

可以用 sys.setrecursionlimit() 调高上限,但过深的递归仍可能让解释器崩溃。对于可能很深的输入,例如一百万个节点的链表或退化的树,请改用循环加显式栈来重写。

def deep_sum_iter(items):
    total, stack = 0, [items]
    while stack:
        current = stack.pop()
        for x in current:
            if isinstance(x, list):
                stack.append(x)
            else:
                total += x
    return total
print(deep_sum_iter([1, [2, 3], [4, [5, [6]]]]))   # 21

常见误区

  • 缺少基准情形,或基准情形永远到不了。例如用上面的代码调用 factorial(-1),永远不会到达 0,最终以 RecursionError 结束。
  • 递归情形没有缩小输入,比如在 f(n) 里又调用 f(n)。
  • 忘记 return 递归的结果:写成 factorial(n - 1) 而不加 return,得到的是 None。
  • 每次调用都复制数据。items[1:] 会复制列表,因此在长列表的切片上递归,时间是 O(n²);应改为传递下标。
  • 用可变默认参数(如 def f(x, seen=[]))在多次调用之间传递状态;该函数的每次调用都共享同一个列表。

何时使用递归

当数据或问题天然具有递归结构时使用递归:树、图(深度优先搜索)、嵌套结构、表达式解析、回溯(排列、N 皇后、数独),以及分治(归并排序、快速排序、二分查找)。如果问题只是简单的序列,或者在 Python 中递归深度会随输入规模增长,就用循环。

面试中,递归题常考察你能否说清基准情形、递归情形、包含栈在内的时间与空间代价,以及记忆化在什么情况下有用。

常见问题

递归中的基准情形是什么?

基准情形是递归函数不再调用自身、直接给出答案的输入。它让递归停下来;没有它,函数会一直调用自己,直到栈溢出。

递归比循环慢吗?

在 Python 中通常会慢一点,因为每次函数调用都有开销,还要占用一个栈帧。复杂度往往相同,实际中的主要风险是递归深度限制。

Python 的最大递归深度是多少?

CPython 的默认上限是 1000 个栈帧,可以用 sys.getrecursionlimit() 查看,用 sys.setrecursionlimit() 修改。超过上限会抛出 RecursionError。

什么是尾递归?

如果一次调用是函数返回之前做的最后一件事,它就是尾调用。有些语言会为尾调用复用栈帧,但 CPython 不会,所以 Python 中的尾递归仍会使栈增长。

所有递归函数都能改写成循环吗?

可以。任何递归都能改写成管理显式栈的循环。对阶乘这样简单的线性递归,用带累加器的普通循环就够了。

全部编码教程