当递归算法遇到大规模输入时,运行速度常常慢得让人抓狂。明明逻辑很简单,程序却像卡住了一样,甚至直接爆掉。很多人第一反应是“递归太慢了,以后别用了”。但问题往往不在递归本身,而在于同一个结果被反复计算了无数次。这篇文章就来聊聊如何用记忆化递归,把那些重复计算彻底干掉,让算法跑得又快又稳。我们不讲虚头巴脑的理论,就用大白话和能跑的代码,一步一步把它落地。
一、先看一个让人头疼的经典例子
1.1 斐波那契数列的暴力递归
斐波那契数列大家都很熟悉:每个数字等于前两个数字之和,前两项是 0 和 1。公式写出来就是:
- F(0) = 0
- F(1) = 1
- F(n) = F(n-1) + F(n-2)
如果用递归直接写,代码非常简洁:
# 技术栈:Python 3.x
# 暴力递归求解斐波那契数列
def fib(n):
# 递归终止条件
if n <= 0:
return 0
if n == 1:
return 1
# 递归调用自身,把大问题拆成两个小问题
return fib(n - 1) + fib(n - 2)
# 测试一下
print(fib(10)) # 输出 55
print(fib(20)) # 输出 6765
print(fib(30)) # 输出 832040
看起来很正常,但当你试 fib(40) 的时候,电脑可能要等上一两秒,试 fib(50) 的时候,喝口水回来都没算完。为什么会这么慢?因为这里面有大量的重复计算。
1.2 画一棵树看看到底重复了多少次
我们以 fib(5) 为例:
fib(5)会去算fib(4)和fib(3)fib(4)又会去算fib(3)和fib(2)fib(3)又会去算fib(2)和fib(1)
发现没有?fib(3) 被调了两遍,fib(2) 被调了三遍。随着 n 变大,重复计算的次数呈爆炸式增长。理论上暴力递归的时间复杂度是 O(2^n),所以 fib(50) 就要算大约一千万亿次,能快才怪。
二、先搞明白重复计算到底有多疼
2.1 用计数器感受一下重复度
我们可以在代码里加一个计数器,看看 fib(30) 到底执行了多少次递归调用:
# 技术栈:Python 3.x
# 统计递归调用次数
call_count = 0
def fib_with_count(n):
global call_count
call_count += 1 # 每次进入函数就加一
if n <= 0:
return 0
if n == 1:
return 1
return fib_with_count(n - 1) + fib_with_count(n - 2)
# 计算 fib(30)
result = fib_with_count(30)
print(f"结果: {result}")
print(f"递归调用次数: {call_count}")
运行一下,输出结果会让你大吃一惊:
结果: 832040
递归调用次数: 1664079
为了算一个 fib(30),竟然执行了 166 万次函数调用。fib(40) 就要调用大约 3 亿多次。这么庞大的数字,性能自然好不了。
2.2 重复计算在现实场景里很常见
有人可能会说,斐波那契数列就是拿来教学用的,真实业务里谁会用?其实很多真实问题都有类似的结构,比如:
- 计算文件夹下所有文件的总大小,如果同一个文件夹被多个父目录引用(比如快捷方式、硬链接),递归遍历时会反复统计。
- 在电商系统中,计算商品的层层组合优惠价,如果一个商品同时参与多个组合,就会重复计算多次。
- 在游戏里,判断一个角色能否到达某个目标点,如果地图上有多个格子都依赖同一个中间状态,重复的路径搜索就会大量发生。
所以理解如何消除重复计算,能帮你在很多实战场景里省下大把时间。
三、记忆化递归的核心思想:算过就存,存了就用
3.1 核心思路一句话
在递归过程中,把每一次计算出的结果保存到一个“字典”里,下次再遇到同样的参数时,直接查字典,而不是重新计算。
这个“字典”用一个什么数据结构呢?Python 里最方便的就是 dict,键是函数的入参,值就是计算结果。
3.2 动手实现记忆化递归
我们直接改造斐波那契函数。为了让代码优雅,可以用嵌套函数和闭包来存储缓存:
# 技术栈:Python 3.x
# 记忆化递归实现斐波那契数列
def fib_memo(n):
# 缓存字典:键为 n,值为 fib(n) 的结果
memo = {}
# 定义内部递归函数
def helper(k):
# 如果已经在缓存里,直接返回
if k in memo:
return memo[k]
# 处理边界情况
if k <= 0:
result = 0
elif k == 1:
result = 1
else:
# 递归计算,并把结果保存到缓存
result = helper(k - 1) + helper(k - 2)
# 存入缓存
memo[k] = result
return result
return helper(n)
# 测试一下
print(fib_memo(50)) # 很快就能出结果
print(fib_memo(100)) # 依然秒出
运行之后你会有惊喜,fib_memo(100) 一瞬间就算完了。这就是记忆化的力量。
3.3 再验证一下调用次数
我们同样加一个计数器,看看记忆化之后到底调用了多少次:
# 技术栈:Python 3.x
# 记忆化版本的调用次数
call_count = 0
def fib_memo_with_count(n):
memo = {}
def helper(k):
global call_count
call_count += 1
if k in memo:
return memo[k]
if k <= 0:
result = 0
elif k == 1:
result = 1
else:
result = helper(k - 1) + helper(k - 2)
memo[k] = result
return result
result = helper(n)
return result
# 计算 fib(30)
result = fib_memo_with_count(30)
print(f"结果: {result}")
print(f"调用次数: {call_count}")
输出结果:
结果: 832040
调用次数: 31
从 166 万次降到了 31 次,这是多么恐怖的提升。这就是记忆化递归的魅力:通过空间换时间,把指数级的时间复杂度降到了线性级别。
四、自动记忆化:用现成的装饰器省心省力
4.1 Python 的 lru_cache 是什么
每次手动写缓存字典,变量多了容易乱,而且代码里总透着“手工感”。其实 Python 标准库已经给我们准备好了记忆化利器:functools.lru_cache。
lru_cache 是一个装饰器,它本身就是一个“缓存管理器”。你只要在递归函数上加上 @lru_cache,它就会自动缓存每次调用的结果。并且这个缓存是“有限大小”的,默认可以存 128 个不同的调用结果,如果缓存满了,它会自动淘汰最久没用的结果(LRU 策略)。
4.2 使用 lru_cache 改造斐波那契
来看代码:
# 技术栈:Python 3.x
# 使用 functools.lru_cache 实现记忆化
from functools import lru_cache
# maxsize=None 表示缓存不设上限
# 注意:装饰器要放在递归函数上方
@lru_cache(maxsize=None)
def fib_fast(n):
# 递归终止条件
if n <= 0:
return 0
if n == 1:
return 1
# 递归调用
return fib_fast(n - 1) + fib_fast(n - 2)
# 测试
print(fib_fast(50)) # 输出 12586269025
print(fib_fast(100)) # 输出 354224848179261915075
简洁到不行。lru_cache 内部使用的也是字典存储,只不过它帮我们把“判断是否存在”“存入缓存”“读取缓存”这些环节全部自动化了。
4.3 lru_cache 的适用场景
lru_cache 不仅适用于斐波那契,任何“入参可哈希(即可以作为字典键)”的递归函数都能用。比如:
- 爬楼梯问题:每次可以走 1 步或 2 步,问走到第 n 级有多少种走法。
- 背包问题的递归解法:给定重量和价值,求最大价值。
- 编辑距离问题:两个字符串之间最少需要多少次增删改。
对于这类问题,只需要在递归函数上方加一行 @lru_cache,性能立刻起飞。
4.4 小心 lru_cache 的一个大坑
lru_cache 要求函数的参数必须是可以哈希的,比如整数、字符串、元组。如果你直接传入一个列表作为参数,会立刻报错。解决办法是把列表转成元组再传进去。来看一个例子:
# 技术栈:Python 3.x
# 演示列表不能缓存,需要转成元组
from functools import lru_cache
# 错误示范:直接传列表会报错
# @lru_cache
# def total_sum(arr):
# return sum(arr)
# 正确姿势:参数改为元组
@lru_cache(maxsize=None)
def total_sum(tuple_arr):
return sum(tuple_arr)
# 测试时把列表转成元组
my_list = [1, 2, 3, 4, 5]
print(total_sum(tuple(my_list))) # 输出 15
一定要记住这个细节,否则调试起来会让你怀疑人生。
五、记忆化递归在真实场景中的落地:以“爬楼梯”为例
5.1 问题描述
有一个 n 级台阶的楼梯,你每次可以爬 1 级或 2 级台阶。请问爬到楼顶一共有多少种不同的方法?
5.2 递归思路
要把爬到第 n 级的方法数记为 climb(n),那么最后一步有两种可能:
- 从第 n-1 级爬上来的
- 从第 n-2 级爬上来的
所以 climb(n) = climb(n-1) + climb(n-2)。边界条件:climb(1) = 1,climb(2) = 2(一次跨两级,或者两级一级爬)。
这个结构和斐波那契几乎一样,直接用暴力递归,n 一大就会卡死。我们直接用记忆化来解决。
5.3 完整代码
# 技术栈:Python 3.x
# 爬楼梯问题:记忆化递归实现
from functools import lru_cache
@lru_cache(maxsize=None)
def climb(n):
# 边界情况
if n == 1:
return 1
if n == 2:
return 2
# 递归拆解
return climb(n - 1) + climb(n - 2)
# 写一个简单的循环测试
for n in [5, 10, 20, 40]:
print(f"爬到第 {n} 级台阶的方法数: {climb(n)}")
输出结果:
爬到第 5 级台阶的方法数: 8
爬到第 10 级台阶的方法数: 89
爬到第 20 级台阶的方法数: 10946
爬到第 40 级台阶的方法数: 165580141
数据非常大,但计算几乎在一瞬间完成。这就是记忆化的实际威力。
六、再举个例子:从二维矩阵走到右下角
6.1 问题描述
一个 m 行 n 列的网格,你从左上角出发,每次只能向右走一步,或者向下走一步。问走到右下角一共有多少条不同的路径?
6.2 递归分析
定义函数 unique_paths(m, n) 表示在 m 行 n 列的网格中,从左上角到右下角的路径数。实际上当前网格中,要么从上方格子往下走,要么从左边格子往右走,所以:
unique_paths(m, n) = unique_paths(m-1, n) + unique_paths(m, n-1)
当 m=1 或者 n=1 时,只有一条直线走法,递归终止。
6.3 记忆化实现
# 技术栈:Python 3.x
# 网格路径问题:记忆化递归
from functools import lru_cache
@lru_cache(maxsize=None)
def unique_paths(m, n):
# 只有一行或一列时,只能一直直走
if m == 1 or n == 1:
return 1
# 递归拆解:从上方下来 + 从左边过来
return unique_paths(m - 1, n) + unique_paths(m, n - 1)
# 测试一个稍大的网格
print(f"3 行 7 列的路径数: {unique_paths(3, 7)}") # 28
print(f"5 行 5 列的路径数: {unique_paths(5, 5)}") # 70
print(f"10 行 10 列的路径数: {unique_paths(10, 10)}") # 48620
如果没有记忆化,unique_paths(10, 10) 会重复计算无数次;加了 lru_cache 之后,每个 (m, n) 组合只算一次,速度飞快。
七、记忆化递归的适用场景、优缺点及注意事项
7.1 适用场景
从上面的例子可以看出,记忆化递归适合下面几种场景:
- 问题可以拆分成多个子问题,并且子问题之间大量重叠。
- 子问题的解会被反复使用,而不是只出现一次。
- 递归的状态可以用简单的参数表示,且参数能够唯一对应一个结果。
- 状态数量有限且能够在一个合理范围内被枚举完,这样缓存不会无限膨胀。
典型场景包括:斐波那契数列、爬楼梯、网格路径、背包问题、编辑距离、股票买卖状态机等。
7.2 优点
记忆化递归最大的优点就是简单自然。你只需要按着最直观的递归逻辑写,然后再加一层缓存,不需要像动态规划那样去想“状态转移表怎么填”“遍历方向怎么定”。它保留了递归的清晰性,同时把性能提升到与动态规划几乎一样的水平。另外,由于缓存是独立的,调试时你可以随时把缓存清掉测试,非常灵活。
7.3 缺点
记忆化递归也有自己的不足:
- 需要额外的空间来存储缓存。虽然时间复杂度降下来了,但空间复杂度可能变成 O(N) 甚至更高。如果状态非常多,内存会吃紧。
- 递归本身有函数调用栈的限制。Python 默认递归深度只有 1000 左右,如果递归层数太深,即使有记忆化也会触发 “RecursionError”。
- 缓存命中率不好的情况下,记忆化带来的收益有限,还可能因为缓存维护增加额外开销。
7.4 注意事项
在落地时,有几个细节需要注意:
- 确保入参可以被哈希。如前面所说,列表不能作为缓存键,遇到列表、字典这样的可变类型,要么转成元组,要么自己设计一个序列化后的键。
- 不要缓存依赖外部可变状态的结果。比如你的递归函数会读取一个全局变量,而这个变量在过程中可能会变化,那缓存结果就可能出错。
- 注意递归深度。如果问题规模很大,可以直接改用迭代加上一个字典来模拟栈,或者用动态规划数组来替代递归。
- 如果使用
lru_cache,要留意它的 128 默认上限。如果同一个函数会被用非常多的不同参数调用,记得设置maxsize为更大的数字,或者设置为None表示不限制。 - 在多线程环境下,
lru_cache是线程安全的,但你自己写的字典缓存不一定安全,需要加锁。
八、用迭代来替代递归的升级路线
8.1 为什么要提迭代
虽然记忆化递归已经很好用了,但有些时候我们还是想彻底避开递归带来的栈溢出风险。一种常见的做法是把递归改成“自底向上”的迭代写法,其实就是动态规划的前奏。比如斐波那契数列,用一个数组把前两项存下来,从小到大推算。
8.2 迭代版本示例
# 技术栈:Python 3.x
# 迭代方式实现斐波那契数列
def fib_iterative(n):
if n <= 0:
return 0
if n == 1:
return 1
prev1 = 1 # 代表 fib(1)
prev2 = 0 # 代表 fib(0)
current = 0
for _ in range(2, n + 1):
current = prev1 + prev2
prev2 = prev1
prev1 = current
return current
# 测试
print(fib_iterative(50)) # 12586269025
print(fib_iterative(100)) # 354224848179261915075
迭代写法不需要缓存,也不用担心递归深度,性能与记忆化递归几乎一致。但这篇文章的重点是记忆化递归,迭代只是提供另一个思路,让你在实际项目中有更多选择。
九、总结:把记忆化变成你的本能
回溯这一路,你会发现记忆化递归并不复杂,核心就三件事:
- 写一个正常的递归函数。
- 准备一个能存中间结果的地方(字典、缓存装饰器)。
- 在函数开头先查缓存,没命中再计算,算完再存回缓存。
这个套路可以应用到各种算法题和真实业务中,只要问题有“子问题重叠”的特征,记忆化就是最简单的性能救星。它不像优化细节那样让人头大,也不像手写动态规划那样需要复杂的思维跳跃。先用暴力递归想清楚逻辑,再加上记忆化,几乎是一套万能打法。
希望你在看完这篇文章后,真正体会到“消除重复计算”的价值。下次遇到递归卡死的时候,先别急着放弃递归,试试给递归加个缓存,说不定它就会立刻拥有飞翔的速度。记住,性能优化不是一个高大上的动作,只是把那些无意义的重复劳动砍掉而已。而这个技能,你完全能够轻松掌握。你看,连一个简单的字典都能拯救整个世界,这就是编程里最朴素的智慧。
评论
围绕“记忆化递归的落地方法:消除重复计算以提升递归算法的运行效率”参与讨论