递归函数
递归是函数直接或间接调用自身来解决问题的方法。它适合处理能够不断缩小、并且结构本身具有层级的问题,例如目录树、组织结构、嵌套数据和分治算法。
本文使用 Python 3.10 及以上版本。读完后,你应该能够:
- 写出包含终止条件和递归步骤的函数;
- 跟踪递归调用栈与返回过程;
- 识别无限递归、重复计算和栈深度风险;
- 判断何时应改用循环、缓存或显式栈。
一个最小递归函数
下面的函数从指定数字倒数到 1:
def countdown(number):
if number <= 0:
print("结束")
return
print(number)
countdown(number - 1)
countdown(3)
输出结果:
3
2
1
结束
递归函数通常由两部分组成:
- 终止条件:问题已经足够简单,直接返回结果,不再继续调用;
- 递归步骤:把当前问题缩小,再调用自身处理更小的问题。
这里的终止条件是 number <= 0,递归步骤是 countdown(number - 1)。如果输入没有持续靠近终止条件,递归就不会正常结束。
递归如何执行
以阶乘为例。正整数 n 的阶乘可以写成:
n! = n * (n - 1)!
0! = 1
对应的函数如下:
def factorial(number):
if number == 0:
return 1
return number * factorial(number - 1)
print(factorial(4)) # 24
调用 factorial(4) 时,计算先逐层展开:
factorial(4)
4 * factorial(3)
4 * 3 * factorial(2)
4 * 3 * 2 * factorial(1)
4 * 3 * 2 * 1 * factorial(0)
到达 factorial(0) 后,再从最内层逐层返回:
factorial(0) -> 1
factorial(1) -> 1 * 1
factorial(2) -> 2 * 1
factorial(3) -> 3 * 2
factorial(4) -> 4 * 6
最终得到 24。理解“先深入、后返回”是读懂递归的关键。
调用栈
每次函数调用都需要保存本次调用的参数、局部变量和返回位置,这些信息组成一个栈帧。递归调用会不断把新栈帧压入调用栈,返回时再按后进先出的顺序移除。
因此递归不是没有成本的循环。递归过深会占用大量栈空间,并最终触发 RecursionError:
def never_end(number):
return never_end(number + 1)
# never_end(0) # RecursionError: maximum recursion depth exceeded
Python 设置递归深度限制是为了避免解释器栈无限增长。可以通过 sys.getrecursionlimit() 查看当前限制,但不应把提高限制当作默认解决方案,因为系统栈仍然有限。
写递归函数的四个步骤
面对一个递归问题,可以按下面的顺序设计:
- 明确函数对一个输入应该返回什么;
- 找到无需继续拆分的最小问题;
- 把大问题缩小为同类的小问题;
- 使用小问题的结果组合出当前结果。
例如计算列表中所有数字的和:
def recursive_sum(numbers):
if not numbers:
return 0
return numbers[0] + recursive_sum(numbers[1:])
print(recursive_sum([2, 4, 6])) # 12
空列表是最小问题,其总和为 0;非空列表的结果等于“第一个元素 + 剩余列表的总和”。
这个写法每次切片都会创建新列表,适合教学但不适合大型数据。实际代码可以传递索引,或直接使用内置的 sum()。
先验证输入边界
前面的阶乘函数没有处理负数。负数会一直递减,永远到不了 0:
def factorial(number):
if not isinstance(number, int):
raise TypeError("number 必须是整数")
if number < 0:
raise ValueError("number 不能是负数")
if number == 0:
return 1
return number * factorial(number - 1)
终止条件不仅要存在,还要覆盖所有合法输入;非法输入则应在递归开始前尽早拒绝。
若不希望每层递归都重复校验,可以用外层函数负责校验,内部函数负责递归:
def factorial(number):
if not isinstance(number, int):
raise TypeError("number 必须是整数")
if number < 0:
raise ValueError("number 不能是负数")
def calculate(current):
if current == 0:
return 1
return current * calculate(current - 1)
return calculate(number)
递归处理嵌套结构
递归特别适合层级数量不固定的数据。下面的函数计算任意嵌套列表中的数字总和:
def nested_sum(value):
if isinstance(value, int | float):
return value
if isinstance(value, list):
return sum(nested_sum(item) for item in value)
raise TypeError("只支持数字和列表")
data = [1, [2, 3], [4, [5]]]
print(nested_sum(data)) # 15
每个值只有两种情况:数字可以直接返回,列表则递归处理其中的每个元素。这种写法能自然对应数据结构。
递归遍历树
文件目录、菜单和评论回复都可以看作树。树节点通常包含自身数据与若干子节点:
tree = {
"name": "根目录",
"children": [
{"name": "docs", "children": []},
{
"name": "src",
"children": [
{"name": "main.py", "children": []},
],
},
],
}
def print_tree(node, depth=0):
print(" " * depth + node["name"])
for child in node["children"]:
print_tree(child, depth + 1)
print_tree(tree)
输出中的缩进反映节点深度。递归函数不需要预先知道树有多少层,只需处理“当前节点”和“它的子节点”。
分治:二分查找
分治算法把问题拆成更小的一部分。下面用递归在有序列表中查找目标:
def binary_search(numbers, target, left=0, right=None):
if right is None:
right = len(numbers) - 1
if left > right:
return -1
middle = (left + right) // 2
current = numbers[middle]
if current == target:
return middle
if target < current:
return binary_search(numbers, target, left, middle - 1)
return binary_search(numbers, target, middle + 1, right)
values = [2, 5, 8, 12, 16]
print(binary_search(values, 12)) # 3
每次调用都把搜索范围缩小约一半。输入必须已经排序,否则算法的判断依据不成立。
重复计算与缓存
朴素的斐波那契递归会反复计算相同子问题:
def fibonacci(number):
if number < 2:
return number
return fibonacci(number - 1) + fibonacci(number - 2)
例如计算 fibonacci(5) 时,fibonacci(3)、fibonacci(2) 会被多次调用,耗时会随输入快速增长。
可以用 functools.cache 保存已经计算过的结果:
from functools import cache
@cache
def fibonacci(number):
if number < 0:
raise ValueError("number 不能是负数")
if number < 2:
return number
return fibonacci(number - 1) + fibonacci(number - 2)
print(fibonacci(40)) # 102334155
缓存适合“相同输入总是得到相同结果”的函数。参数必须可以哈希;缓存也会占用内存,不适合无限增长或依赖外部状态的输入。
斐波那契数列用循环通常更加直接,并且不会受递归深度限制:
def fibonacci(number):
if number < 0:
raise ValueError("number 不能是负数")
previous, current = 0, 1
for _ in range(number):
previous, current = current, previous + current
return previous
图结构要防止循环引用
树不会回到已经访问过的父节点,但一般的图可能包含环。递归遍历图时要记录已访问节点,否则会无限循环:
graph = {
"A": ["B"],
"B": ["C"],
"C": ["A"],
}
def visit(node, visited=None):
if visited is None:
visited = set()
if node in visited:
return
visited.add(node)
print(node)
for neighbor in graph.get(node, []):
visit(neighbor, visited)
visit("A")
这里使用 None 创建每次顶层调用独立的集合,避免可变默认参数在多次调用间共享状态。
直接递归与间接递归
函数调用自身称为直接递归。函数 A 调用函数 B,而函数 B 又调用函数 A,称为间接递归:
def is_even(number):
if number == 0:
return True
return is_odd(number - 1)
def is_odd(number):
if number == 0:
return False
return is_even(number - 1)
间接递归更难跟踪,仍然必须保证输入持续靠近共同的终止条件。上例只是演示机制,实际判断奇偶应直接使用 % 2。
尾递归在 Python 中不会自动优化
如果递归调用是函数返回前的最后一步,就称为尾递归:
def factorial_tail(number, result=1):
if number == 0:
return result
return factorial_tail(number - 1, result * number)
有些语言会复用尾递归的栈帧,但 Python 不保证尾调用优化。这个函数仍会逐层增加调用栈,输入很大时仍可能产生 RecursionError,因此不要把尾递归当作突破深度限制的方法。
递归与循环如何选择
| 场景 | 通常更合适的方式 |
|---|---|
| 遍历树、嵌套结构 | 递归通常更贴合结构 |
| 重复固定次数 | for 循环 |
| 状态逐步更新直到满足条件 | while 循环 |
| 数据层级可能很深且不可控 | 循环配合显式栈或队列 |
| 子问题会大量重复 | 缓存、动态规划或循环 |
| 分治后自然组合结果 | 递归 |
递归并不天然比循环高级。优先选择能清楚表达问题、不会造成深度风险且性能足够的写法。
用显式栈替代深递归
层级可能非常深时,可以自己维护列表作为栈:
def print_tree_iterative(root):
stack = [(root, 0)]
while stack:
node, depth = stack.pop()
print(" " * depth + node["name"])
for child in reversed(node["children"]):
stack.append((child, depth + 1))
显式栈使用堆内存,不受 Python 调用栈深度的同一限制,也更容易暂停或控制遍历过程。
调试递归函数
遇到结果错误时,可以记录输入和深度:
def factorial(number, depth=0):
print(f"{' ' * depth}进入 factorial({number})")
if number == 0:
result = 1
else:
result = number * factorial(number - 1, depth + 1)
print(f"{' ' * depth}返回 {result}")
return result
测试时至少覆盖:
- 最小合法输入,也就是终止条件;
- 比终止条件大一步的输入;
- 一个普通输入;
- 空结构、负数或错误类型等边界输入;
- 足以暴露性能或深度问题的较大输入。
常见错误
没有终止条件
函数会一直调用自身,直到触发 RecursionError。
输入没有靠近终止条件
例如目标是到达 0,却错误地让正数不断增加。每条递归路径都必须向可结束的状态推进。
忘记返回递归结果
def factorial(number):
if number == 0:
return 1
factorial(number - 1) # 忘记 return 和当前层计算
此时外层调用会得到 None。要明确当前层如何使用并返回子问题结果。
在递归过程中反复复制大对象
列表切片、字符串拼接等操作可能让代码产生大量临时对象。可以传递索引、使用共享结果容器,或改为迭代实现。
为普通循环强行使用递归
对简单计数、求和等线性任务,循环通常更快、更直观,也没有调用栈限制。
练习
- 编写递归函数,计算
1 + 2 + ... + n,并处理n = 0和负数。 - 编写递归函数,反转一个字符串;再比较切片写法与索引写法的开销。
- 统计嵌套列表中所有非列表元素的数量。
- 为朴素斐波那契函数添加缓存,并比较较大输入的运行时间。
- 使用递归遍历目录树,再用显式栈实现同样功能。
小结
- 递归函数必须同时具备终止条件和让问题不断缩小的递归步骤。
- 调用会先逐层深入,再沿调用栈逐层返回;每层都会占用栈空间。
- 树、嵌套结构和分治问题通常适合递归,简单线性任务通常更适合循环。
- 重复子问题可以用缓存减少计算;图结构需要记录已访问节点以避免环。
- Python 不保证尾调用优化,深度不可控时应使用循环或显式栈。
- 边界验证、返回值和性能测试是保证递归函数正确性的关键。