Day 020 — 递归 (Recursion) 🔄
概述
递归(Recursion) 是一种在函数定义中调用自身的编程技术。它通过将大问题分解成结构相同的子问题,使得代码简洁优雅,特别适合处理具有自相似结构的数据(如树、图、嵌套列表等)。
递归的核心哲学:"To understand recursion, you must first understand recursion." 🌀
📚 递归定义与数学基础
数学定义
递归函数通常定义为:
f(0) = base_case ← 基线条件
f(n) = f(n-1) + ... ← 递归关系
数学归纳法关联
递归与数学归纳法有着深厚的联系:
| 数学归纳法 | 递归实现 |
|---|---|
| 基础情况:证明 P(0) 成立 | 基线条件:处理最简单的情况 |
| 归纳步骤:假设 P(k) 成立,证明 P(k+1) 成立 | 递归步骤:假设规模更小的问题已解决 |
| 结论:P(n) 对所有 n 成立 | 结果:递归函数对所有输入产生正确结果 |
理解:数学归纳法是从小到大证明,递归是从大到小求解——但底层结构完全对应。
举例:证明
1 + 2 + ⋯ + n = n(n+1)/2
- 基础情况:n=1 时,左边=1,右边=1(2)/2=1 ✅
- 归纳步骤:假设 n=k 成立,证明 n=k+1 也成立
- 递归实现:def sum_n(n): return 0 if n==0 else n + sum_n(n-1)
- 两者完美对应:基线条件 = 基础情况,递归关系 = 归纳步骤
递推关系与闭式解
著名递推关系及其闭式解:
| 递推关系 | 通项公式 | 示例 |
|---|---|---|
| T(n) = T(n-1) + 1 | T(n) = n | 线性递归 |
| T(n) = T(n-1) + T(n-2) | 斐波那契数 | 分治递归 |
| T(n) = 2T(n/2) + n | T(n) = n log n | 归并排序 |
| T(n) = 2T(n-1) + 1 | T(n) = 2ⁿ - 1 | 汉诺塔 |
🏗️ 调用栈与栈帧模型
调用栈(Call Stack)
当程序调用一个函数时,系统会:
- 压栈:将当前函数的上下文(局部变量、返回地址等)推入调用栈
- 跳转:控制权转移到被调函数
- 执行:被调函数运行
- 返回:被调函数结束后,从栈顶弹出上下文
- 恢复:恢复调用者的执行
递归的特殊之处在于——同一个函数被多次压栈,每次压栈的是不同的调用实例。
栈帧结构
每个栈帧包含:
┌────────────────────────┐
│ 返回地址 │ ← 函数结束后回到哪里
├────────────────────────┤
│ 局部变量 │ ← n, result 等
├────────────────────────┤
│ 参数值 │ ← 调用时传入的值
├────────────────────────┤
│ 临时变量 │ ← 编译器优化用
└────────────────────────┘
Python 深递归的 CPython 实现细节
Python 的调用栈由 C 栈实现,每个 PyFrameObject 代表一个栈帧:
// CPython 内部结构(简化)
typedef struct _frame {
PyObject_VAR_HEAD
struct _frame *f_back; // 指向上一个栈帧
PyCodeObject *f_code; // 指向代码对象
PyObject *f_locals; // 局部变量字典
PyObject **f_valuestack; // 值栈底
Py_ssize_t f_stackdepth; // 当前栈深度
int f_lineno; // 当前行号
} PyFrameObject;
每次递归调用:
1. 分配新的 PyFrameObject(堆分配,约 512-1024 字节)
2. 通过 f_back 指针形成链表
3. Python 的 sys.getrecursionlimit() 检查的是框架对象的数量,而非 C 栈大小
4. 默认 1000 是保守值,CPython 2.7.9 后默认为 1000
5. 可通过 sys.setrecursionlimit() 调整,但增大后 C 栈溢出可能导致段错误而非优雅的异常
recursion tree (递归树) 模型
以 fib(5) 为例,递归调用形成一棵树:
fib(5)
/ \
fib(4) fib(3)
/ \ / \
fib(3) fib(2) fib(2) fib(1)
/ \ / \ / \
fib(2) fib(1) 1 1 1 1
/ \
1 1
每个节点代表一次函数调用,树的高度代表递归深度。
Python 递归深度限制
import sys
print(sys.getrecursionlimit()) # 默认 1000
sys.setrecursionlimit(3000) # 可调大,但小心栈溢出
为什么有上限? 每个 Python 函数调用消耗约 1KB 栈空间。1000 层 ≈ 1MB,再大可能导致段错误(Segmentation Fault)。
🎯 基线条件与递归条件
每个正确的递归函数必须包含两个部分:
⚓ 基线条件 (Base Case)
- 递归的终止条件
- 不调用自身,直接返回结果
- 通常是最简单的情况(n=0, n=1, 空列表等)
🔁 递归条件 (Recursive Case)
- 函数调用自身
- 每次调用必须向基线条件逼近
- 问题规模不断缩小
def countdown(n):
# 基线条件
if n <= 0:
return
print(n)
# 递归条件(向基线逼近:n → n-1)
countdown(n - 1)
常见错误
# ❌ 缺失基线条件 → RecursionError
def infinite(n):
return infinite(n + 1)
# ❌ 不向基线逼近 → 也是无限递归
def bad_fact(n):
if n == 0:
return 1
return n * bad_fact(n) # 没有减小 n
⚡ 递归 vs 迭代性能对比
| 维度 | 递归 | 迭代 |
|---|---|---|
| 代码简洁性 | ⭐⭐⭐ (数学公式直译) | ⭐⭐ (需要额外变量) |
| 可读性 | 嵌套结构清晰 | 循环直观 |
| 栈空间 | O(n) 额外栈空间 | O(1) 额外空间 |
| 时间复杂度 | 有时有重复计算 | 通常可控 |
| 适用场景 | 树、分治、回溯 | 线性遍历、累加 |
| 极限规模 | 受栈深度限制 (1000) | 无理论限制 |
典型对比:斐波那契
递归: fib(40) ≈ 0.8s, 3.3 亿次调用
迭代: fib(40) ≈ 0.00001s, 39 次循环
优化后: fib(40) ≈ 0.00005s, 40 次调用 (memoization)
🌀 尾递归(Tail Recursion)
定义
尾递归是指递归调用是函数的最后一个操作,并且函数的返回值直接就是递归调用的返回值。
# 非尾递归 — 需要保存 n,返回后再乘
def factorial(n):
if n == 0:
return 1
return n * factorial(n - 1) # 最后操作是乘,不是递归
# 尾递归形式 — 不需要保存额外状态
def factorial_tail(n, acc=1):
if n == 0:
return acc
return factorial_tail(n - 1, n * acc) # 最后操作就是递归
Python 为什么不支持尾递归优化?
Python 的设计哲学决定了它不支持尾递归优化(TCO,Tail Call Optimization):
-
栈帧调试信息:Python 的栈帧包含丰富的调试信息(文件名、行号、局部变量等)。TCO 会丢弃当前栈帧,使调试变得困难。
-
装饰器兼容性:尾递归优化会与装饰器、上下文管理器产生复杂的交互。
-
sys.settrace():Python 的追踪钩子依赖栈帧引用。 -
Guido 的立场:Python 之父 Guido van Rossum 曾明确表示不打算支持 TCO,原因是:
- TCO 会掩盖栈溢出错误,使调试更困难
- 鼓励人们使用迭代(更 Pythonic 的方式)
尾递归与迭代的转换
尾递归形式可以机械地转换为迭代:
# 尾递归版本
def fact_tail(n, acc=1):
if n == 0:
return acc
return fact_tail(n - 1, n * acc)
# 等价迭代版本
def fact_iter(n):
acc = 1
while n > 0:
acc = n * acc
n -= 1
return acc
转换规则:
- 递归参数 → 循环变量
- 累加器 → 循环中的累加操作
- 基线条件 → 循环终止条件
- 递归调用 → continue(回到循环开始)
模拟尾递归(手动实现 Trampoline)
虽然 Python 不支持 TCO,但可以使用 蹦床函数 (Trampoline) 手动模拟:
def trampoline(f):
"""将尾递归函数转换为迭代执行"""
def wrapped(*args, **kwargs):
result = f(*args, **kwargs)
while callable(result):
result = result()
return result
return wrapped
def fact_trampoline(n, acc=1):
if n == 0:
return acc
# 返回一个 lambda 而非调用自身
return lambda: fact_trampoline(n - 1, n * acc)
fact_trampoline = trampoline(fact_trampoline)
print(fact_trampoline(10000)) # ✅ 不会栈溢出!
注意:Trampoline 本质上是把递归转换成了迭代,虽然保持了递归的代码形式。
结论:在 Python 中不要依赖尾递归优化。如果递归深度可能超过 1000,请使用迭代或蹦床函数。
🚨 常见递归陷阱与调试技巧
陷阱 1️⃣ 栈溢出 (Stack Overflow)
# 深度 1001 → RecursionError
recurse(1000) # 刚好在边界
recurse(1001) # 💥 RecursionError: maximum recursion depth exceeded
解决:增大递归限制或改用迭代。
陷阱 2️⃣ 重复计算 (Redundant Computation)
# fib(5) 计算 fib(3) 两次,fib(2) 三次...
def fib(n):
if n <= 1:
return n
return fib(n-1) + fib(n-2)
# fib(40) 需要约 3.3 亿次调用!
解决:使用记忆化(Memoization)。
陷阱 3️⃣ 可变默认参数共享
# ❌ 共享同一个列表!
def traverse(node, visited=[]):
visited.append(node)
for child in node.children:
traverse(child, visited)
# ✅ 使用 None 并创建新列表
def traverse(node, visited=None):
if visited is None:
visited = []
visited.append(node)
...
调试技巧
技巧 1:打印调用深度(调用树可视化)
def factorial(n, depth=0):
prefix = " " * depth
print(f"{prefix}factorial({n}) called")
if n == 0:
print(f"{prefix}→ return 1")
return 1
result = n * factorial(n - 1, depth + 1)
print(f"{prefix}→ return {result}")
return result
# 输出:
# factorial(3) called
# factorial(2) called
# factorial(1) called
# factorial(0) called
# → return 1
# → return 1
# → return 2
# → return 6
技巧 2:使用 sys.setrecursionlimit 安全地增加深度
import sys
# 检查当前限制
print(sys.getrecursionlimit()) # 默认 1000
# 安全增加(不要超过系统栈大小)
sys.setrecursionlimit(10_000)
# ⚠️ 增大限制不是银弹:
# - 超过了系统 C 栈限制会导致 Segfault(段错误)
# - 更深的递归 = 更大的内存消耗
# - 更好的做法:改成迭代
技巧 3:装饰器辅助调试
def trace_calls(func):
"""装饰器:追踪递归调用次数和参数"""
def wrapper(*args, **kwargs):
wrapper.call_count += 1
return func(*args, **kwargs)
wrapper.call_count = 0
return wrapper
@trace_calls
def fib(n):
if n <= 1:
return n
return fib(n-1) + fib(n-2)
fib(10)
print(f"fib(10) 被调用了 {fib.call_count} 次")
# 输出: fib(10) 被调用了 177 次
技巧 4:memoization 优化(性能提升 10⁵ 倍)
from functools import lru_cache
# 基础版:O(2ⁿ) — fib(40) ≈ 3.3 亿次调用
def fib_naive(n):
if n <= 1: return n
return fib_naive(n-1) + fib_naive(n-2)
# 记忆化版:O(n) — fib(40) 仅 41 次调用
@lru_cache(maxsize=None)
def fib_memo(n):
if n <= 1: return n
return fib_memo(n-1) + fib_memo(n-2)
# 手动实现 memoization(理解原理)
def make_memoized(func):
cache = {}
def memoized(*args):
if args not in cache:
cache[args] = func(*args)
return cache[args]
return memoized
@make_memoized
def fib_manual(n):
if n <= 1: return n
return fib_manual(n-1) + fib_manual(n-2)
lru_cache VS 手动 memoization:
-@lru_cache是生产推荐(线程安全、有缓存上限)
- 手动实现更易理解原理,适合需求特殊的缓存策略
- 两者都使用dict做缓存,时间复杂度 O(1) 查找
🎨 Mermaid 图解
递归调用栈流程(factorial(4))
sequenceDiagram
participant Main as main()
participant f4 as fact(4)
participant f3 as fact(3)
participant f2 as fact(2)
participant f1 as fact(1)
participant f0 as fact(0)
Main->>f4: fact(4)
f4->>f3: 4 * fact(3)
f3->>f2: 3 * fact(2)
f2->>f1: 2 * fact(1)
f1->>f0: 1 * fact(0)
f0-->>f1: return 1
f1-->>f2: return 1 * 1 = 1
f2-->>f3: return 2 * 1 = 2
f3-->>f4: return 3 * 2 = 6
f4-->>Main: return 4 * 6 = 24
优化前 vs 优化后对比:朴素递归 vs 记忆化递归
flowchart TD
subgraph 朴素递归[朴素递归 - O(2ⁿ)]
A1[fib5] --> B1[fib4]
A1 --> C1[fib3]
B1 --> D1[fib3]
B1 --> E1[fib2]
C1 --> F1[fib2]
C1 --> G1[fib1]
D1 --> H1[fib2]
D1 --> I1[fib1]
E1 --> J1[fib1]
E1 --> K1[fib0]
end
subgraph 记忆化递归[记忆化递归 - O(n)]
A2[fib5] --> B2[fib4]
A2 --> C2[fib3]
B2 --> D2[fib3]
B2 --> E2[fib2]
D2 -.->|缓存命中| C2
E2 -.->|缓存命中| F2[fib1]
E2 -.->|缓存命中| G2[fib0]
end
朴素递归 -->|冗余计算| 记忆化递归
递归树(斐波那契 fib(5))
graph TD
fib5["fib(5)"] --> fib4["fib(4)"]
fib5 --> fib3_1["fib(3)"]
fib4 --> fib3_2["fib(3)"]
fib4 --> fib2_1["fib(2)"]
fib3_1 --> fib2_2["fib(2)"]
fib3_1 --> fib1_1["fib(1)"]
fib3_2 --> fib2_3["fib(2)"]
fib3_2 --> fib1_2["fib(1)"]
fib2_1 --> fib1_3["fib(1)"]
fib2_1 --> fib0_1["fib(0)"]
fib2_2 --> fib1_4["fib(1)"]
fib2_2 --> fib0_2["fib(0)"]
fib2_3 --> fib1_5["fib(1)"]
fib2_3 --> fib0_3["fib(0)"]
style fib1_1 fill:#f96
style fib1_2 fill:#f96
style fib1_3 fill:#f96
style fib1_4 fill:#f96
style fib1_5 fill:#f96
style fib0_1 fill:#9cf
style fib0_2 fill:#9cf
style fib0_3 fill:#9cf
📝 思考题
- 为什么 Python 的递归深度默认只有 1000?如何安全地增加?
- 哪些问题天然适合用递归解决?哪些问题绝不能用递归?
- 实现一个
flatten()函数,将任意嵌套的列表展开成一维列表。 - 如何用递归实现二分查找?和迭代版本相比优劣如何?
- 斐波那契函数的原始递归版本时间复杂度是多少?如何优化?
- 汉诺塔问题的步数为什么是 2ⁿ - 1?请用数学归纳法证明。
- Python 不支持尾递归优化的根本原因有哪些?
- 编写一个装饰器来统计递归函数被调用的总次数。