当递归算法遇到大规模输入时,运行速度常常慢得让人抓狂。明明逻辑很简单,程序却像卡住了一样,甚至直接爆掉。很多人第一反应是“递归太慢了,以后别用了”。但问题往往不在递归本身,而在于同一个结果被反复计算了无数次。这篇文章就来聊聊如何用记忆化递归,把那些重复计算彻底干掉,让算法跑得又快又稳。我们不讲虚头巴脑的理论,就用大白话和能跑的代码,一步一步把它落地。

一、先看一个让人头疼的经典例子

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

迭代写法不需要缓存,也不用担心递归深度,性能与记忆化递归几乎一致。但这篇文章的重点是记忆化递归,迭代只是提供另一个思路,让你在实际项目中有更多选择。

九、总结:把记忆化变成你的本能

回溯这一路,你会发现记忆化递归并不复杂,核心就三件事:

  • 写一个正常的递归函数。
  • 准备一个能存中间结果的地方(字典、缓存装饰器)。
  • 在函数开头先查缓存,没命中再计算,算完再存回缓存。

这个套路可以应用到各种算法题和真实业务中,只要问题有“子问题重叠”的特征,记忆化就是最简单的性能救星。它不像优化细节那样让人头大,也不像手写动态规划那样需要复杂的思维跳跃。先用暴力递归想清楚逻辑,再加上记忆化,几乎是一套万能打法。

希望你在看完这篇文章后,真正体会到“消除重复计算”的价值。下次遇到递归卡死的时候,先别急着放弃递归,试试给递归加个缓存,说不定它就会立刻拥有飞翔的速度。记住,性能优化不是一个高大上的动作,只是把那些无意义的重复劳动砍掉而已。而这个技能,你完全能够轻松掌握。你看,连一个简单的字典都能拯救整个世界,这就是编程里最朴素的智慧。