阶段 3 · 搜索 · 第 17 章普及组 J

记忆化搜索:从递归通向 DP 的那座桥

只加了三行代码,十亿次调用变成四百六十五次。

需要先学:第 1 章 递归入门:函数怎么调用自己例题:数字三角形建议用时:100 分钟
这一章是整条路线的枢纽

往回看,它是第 1 章递归的直接应用;往前看,它是阶段 5 整个动态规划的入口。

很多人学 DP 卡死,是因为一上来就被灌「状态、转移方程、填表顺序」这套术语。 其实正确的顺序是:先会写递归 → 再加个数组别重复算 → 最后才是 DP。 这一章就是中间那一步。走通了,后面八章 DP 会顺理成章。

1一句话问题

一个数字三角形,从顶上走到底下,每步只能走到正下方或右下方,求路径上数字之和的最大值。

        7
      3   8
    8   1   0
  2   7   4   4

答案 25,走法是 7 → 3 → 8 → 7

输入格式:第一行 n,接下来 n 行,第 i 行有 i 个整数。

2先用纸笔手算一遍

请照着做,这一步决定你能不能顺理成章地写出递归。

不要从上往下想,从下往上想。问自己:站在某个格子上,往下走能拿到的最大和是多少?

最后一行:哪儿也去不了,就是自己
  2   7   4   4

倒数第二行:自己 + max(正下方, 右下方)
  8 + max(2,7) = 15
  1 + max(7,4) = 8
  0 + max(4,4) = 4

倒数第三行:
  3 + max(15,8) = 18
  8 + max(8,4)  = 16

顶上:
  7 + max(18,16) = 25   ← 答案

现在把这个手算过程写成一句话:

best(i,j) = a[i][j] + max( best(i+1,j), best(i+1,j+1) )

这就是递归。和第 1 章一样:说清职责(从 (i,j) 出发走到底的最大和)、 找到边界(最后一行就是自己)、写出递推(上面那行)。

3暴力:把这句话直接翻译成代码

brute.cpp暴力
// 数字三角形 —— 暴力:纯递归,每条路都走一遍
//
// 输入:第一行 n,接下来 n 行,第 i 行有 i 个整数
// 输出:从顶端走到底端,每步只能走到「正下方」或「右下方」,路径上数字之和的最大值
//
// 例如:
// 7
// 3 8
// 8 1 0
// 2 7 4 4
// 最大和是 7 + 3 + 8 + 7 = 25
//
// 这个递归写得非常自然,几乎是把题目念了一遍:
// best(i,j) = 从格子 (i,j) 出发走到底,能拿到的最大和
// = a[i][j] + max( best(i+1, j), best(i+1, j+1) )
// 出口:最后一行哪儿也去不了,best = a[i][j] 自己。
//
// 思路完全正确。问题是它慢得离谱:每个格子都要往下分出两个叉,
// 一共 2^(n-1) 条路径,总调用次数是 2^n - 1。
// n = 30 时是十亿七千万次 —— 而这个三角形一共才 465 个格子。
//
// 慢在哪?慢在「重复计算」。看 best(2,1):
// 从 (0,0) 走到 (2,1) 有两条路(先左后右、先右后左),
// 于是 best(2,1) 会被完完整整地重算两遍。
// 而它下面的每个格子,又会因此被多算一倍……越往下重复得越离谱。
// 拿 trace.cpp 跑一下,你能亲眼看到每个格子被调用了多少次 —— 正好是一个杨辉三角。
#include <bits/stdc++.h>
using namespace std;
int n;
vector<vector<long long>> a;
long long best(int i, int j) {
if (i == n - 1) return a[i][j]; // 出口:已经在最后一行
return a[i][j] + max(best(i + 1, j), best(i + 1, j + 1));
}
int main() {
if (!(cin >> n)) return 0;
a.assign(n, {});
for (int i = 0; i < n; i++) {
a[i].resize(i + 1);
for (int j = 0; j <= i; j++) cin >> a[i][j];
}
cout << best(0, 0) << "\n";
return 0;
}
点一下即可编辑
输入(stdin)
输出
点「运行 ▶」看结果

代码几乎就是那句话的逐字翻译,思路完全正确。用样例跑一下,答案 25。

4实测:它有多慢

点右上角的 「开始对比 ▶」。注意:30 层的三角形一共只有 30×31/2 = 465 个格子 —— 少得可怜。

同题对比:纯递归 vs 记忆化
30 层只有 465 个格子。跑完再把层数改成 32、34 试试 —— 每加一层,暴力就翻一倍。
纯递归
记忆化

本机实测,层数每加 1 暴力就翻一倍,而记忆化毫无反应:

层数 格子数 纯递归 记忆化
27 378 0.20 秒 0.003 秒
30 465 1.41 秒 0.003 秒
32 528 5.41 秒 0.003 秒
34 595 20.1 秒 0.003 秒
⚠ 请盯着这个事实看三秒

465 个格子的问题,暴力要跑一秒半。

这里没有什么「数据规模太大」——数据小得不能再小了。 慢的原因只有一个:它在疯狂地重复计算同一批东西。

5慢在哪:把重复次数数出来

不要猜,直接数。下面这份代码在每次调用时给对应格子计一次数,最后把整张表打出来。

trace.cpp过程演示
先跑 n=10。把层数换成 15、20 再跑,看「白算了多少次」那一行的变化。
// 数字三角形 —— 数一数每个格子到底被算了多少次
//
// 用小一点的 n 跑(10 ~ 16 都行),看输出的那张「调用次数表」。
// 你会看到一个杨辉三角:第 i 行第 j 列的格子被调用了 C(i,j) 次。
//
// 最底下一行中间的那个格子,调用次数是整张表里最大的。
// n = 20 时它会被重复计算十几万次 —— 每一次算出来的结果都一模一样。
//
// 看完这张表,「加个数组把算过的存起来」就不是什么需要背的技巧了,
// 而是任何人看到这么多重复都会立刻想到的事。
#include <bits/stdc++.h>
using namespace std;
int n;
vector<vector<long long>> a;
vector<vector<long long>> cnt; // cnt[i][j] = best(i,j) 被调用了几次
long long totalCalls = 0;
long long best(int i, int j) {
cnt[i][j]++;
totalCalls++;
if (i == n - 1) return a[i][j];
return a[i][j] + max(best(i + 1, j), best(i + 1, j + 1));
}
int main() {
if (!(cin >> n)) return 0;
a.assign(n, {});
for (int i = 0; i < n; i++) {
a[i].resize(i + 1);
for (int j = 0; j <= i; j++) cin >> a[i][j];
}
cnt.assign(n, vector<long long>(n + 1, 0));
long long ans = best(0, 0);
cout << "答案 = " << ans << "\n\n";
cout << "每个格子被调用的次数:\n";
for (int i = 0; i < n; i++) {
cout << string((n - i) * 4, ' ');
for (int j = 0; j <= i; j++) printf("%8lld", cnt[i][j]);
cout << "\n";
}
long long cells = (long long)n * (n + 1) / 2;
cout << "\n格子总数 = " << cells << "\n";
cout << "实际调用次数 = " << totalCalls << "\n";
cout << "白算了 = " << (totalCalls - cells) << " 次"
<< "(占 " << fixed << setprecision(1)
<< (totalCalls ? 100.0 * (totalCalls - cells) / totalCalls : 0.0) << "%)\n";
cout << "\n记忆化要做的,就是把这 " << (totalCalls - cells) << " 次白算全部省掉。\n";
return 0;
}
点一下即可编辑
输入(stdin)
输出
点「运行 ▶」看结果

你会看到一个杨辉三角:位置 (i,j) 恰好被调用了 C(i,j) 次。

原因很直观:从顶点走到 (i,j) 有多少条路,best(i,j) 就被完完整整地重算多少遍。 而它下面的每个格子又会因此被多算一倍…… 越往下越离谱。

具体的数字(可以自己跑出来验证):

层数 n 格子数 实际调用次数 白算的比例
10 55 1 023 94.6%
20 210 1 048 575 99.98%
30 465 1 073 741 823 99.99996%

调用次数正好是 2ⁿ - 1,而格子只有 n(n+1)/2 个。 n = 20 时最热的那个格子被调用了 92 378 次 —— 每一次算出来的结果都一模一样。

6★ 关键的一步

★ 关键的一步

best(i,j) 的值,只取决于 (i,j)。

不管你是从哪条路摸到 (2,1) 的,「从 (2,1) 出发走到底的最大和」永远是同一个数字。 它跟「你怎么来的」没有任何关系。

既然如此 —— 第一次算出来之后把它记下来,下次再问到 (2,1),直接把记下的值还回去。

于是每个格子最多只被真正计算一次。格子一共 n(n+1)/2 个:

O(2ⁿ) → O(n²)

这一步小到让人不敢相信:算法思想没变,递归结构没变,改的只是「别重复算」。 相比暴力,代码只多了三行 —— 一个 f 数组、一个 vis 数组、一句「查表就返回」。

✓ 「无后效性」这个词的真身

教科书上说 DP 要求「无后效性」,听起来很唬人。它说的就是上面那句话:

一个状态的结果,只由这个状态本身决定,和「怎么走到这个状态的」无关。

满足这一条,就能记忆化;不满足,记下来的值就是错的。 以后判断一道题能不能 DP,先问自己这一句,比背定义管用。

7记忆化写法

fast.cpp正解
// 数字三角形 —— 记忆化搜索
//
// 和 brute.cpp 解同一道题,答案必须一样。
// 但 n = 27 时暴力要跑一秒多,它只要几微秒。
//
// 关键的那一步想法 —— 而且这一步小得让人不敢相信:
//
// best(i,j) 的答案,只跟 (i,j) 有关。
// 不管你是从哪条路摸到 (2,1) 的,best(2,1) 永远是同一个数。
// 既然如此,第一次算出来之后把它记下来,
// 下次再问到 (2,1),直接把记下的值还回去就行了 —— 不用再往下递归。
//
// 于是每个格子最多只会被真正计算一次。
// 格子一共 n(n+1)/2 个,所以复杂度从 O(2^n) 一下掉到 O(n²)。
//
// 相比 brute.cpp,代码只多了三行:一个 f 数组、一个 vis 数组、一个「查表就返回」。
// 算法思想没变,递归结构没变,改的只是「别重复算」。
//
// 这就是「记忆化搜索」,它是从递归通向动态规划的那座桥:
// 你先按最自然的方式写出递归(这一步靠的是把题目念清楚),
// 再加一张表把结果存起来(这一步是纯机械的)。
// 等你熟练之后,会发现把递归改写成「从底往上填表」的循环,就是所谓的 DP。
// 但顺序永远是:先会递归,再会记忆化,最后才是 DP。反过来学,必卡。
#include <bits/stdc++.h>
using namespace std;
int n;
vector<vector<long long>> a;
vector<vector<long long>> f; // f[i][j] = best(i,j) 算出来的结果
vector<vector<char>> vis; // vis[i][j] = 这一格算过了没有
long long best(int i, int j) {
if (vis[i][j]) return f[i][j]; // ★ 查表命中,直接还回去,不再往下递归
long long res;
if (i == n - 1) res = a[i][j]; // 出口
else res = a[i][j] + max(best(i + 1, j), best(i + 1, j + 1)); // 和暴力一模一样
vis[i][j] = 1; // ★ 算完之后才记下来
f[i][j] = res;
return res;
}
// 记忆化的标准骨架就是这三段:开头查表 → 中间照抄暴力 → 结尾存表。
// 注意「存表」要放在算完之后,不要放在算之前 —— 养成这个习惯,
// 以后遇到状态之间可能互相依赖的题目才不会踩坑。
int main() {
if (!(cin >> n)) return 0;
a.assign(n, {});
for (int i = 0; i < n; i++) {
a[i].resize(i + 1);
for (int j = 0; j <= i; j++) cin >> a[i][j];
}
f.assign(n, vector<long long>(n + 1, 0));
vis.assign(n, vector<char>(n + 1, 0));
cout << best(0, 0) << "\n";
return 0;
}
点一下即可编辑
输入(stdin)
输出
点「运行 ▶」看结果

把它和 brute.cpp 并排看,差别只有开头和结尾:

long long best(int i, int j) {
    if (vis[i][j]) return f[i][j];        // ← 新增:开头查表

    long long res;
    if (i == n - 1) res = a[i][j];                                 // 和暴力
    else res = a[i][j] + max(best(i+1, j), best(i+1, j+1));        // 一模一样

    vis[i][j] = 1;  f[i][j] = res;        // ← 新增:结尾存表
    return res;
}
⚠ 存表要放在「算完之后」

不要图省事写成 vis[i][j] = 1; 放在函数开头。

这道题里放前面碰巧也对(因为状态只往下依赖,不会绕回来),但这是个坏习惯。 一旦遇到状态之间可能互相依赖的题目,「还没算完就标记成算过了」会让别人读到一个空值, 而且这种 bug 极难查。

养成习惯:开头查表 → 中间照抄暴力 → 结尾存表。 三段式,顺序别乱。

⚠ 另一个坑:用「特殊值」当没算过的标记

很多人图省事,不开 vis 数组,而是把 f 初始化成 -1,用 if (f[i][j] != -1) 判断算过没有。

这在本题是错的 —— 数字可以是负数,答案本身就可能等于 -1, 那个状态会被反复重算(结果仍对,但退化回指数级),更糟的题里会直接算错。

只有当你能 100% 确认某个值不可能是合法答案时,才可以拿它当哨兵。 拿不准就老老实实开一个 vis 数组,多几个字节换一个不用担心的晚上。

8看看省掉了什么

数字三角形的递归树
圆圈里是格子坐标 (i,j),颜色相同就是同一个格子。 注意有多少个同色的圆圈 —— 每一个都在把同样的东西重算一遍。
0,01,02,03,04,04,13,14,14,22,13,14,14,23,24,24,31,12,13,14,14,23,24,24,32,23,24,24,33,34,34,4i=0i=1i=2i=3i=4
三角形里的格子
15
纯递归的调用次数
31

这才 5 层就要调用 31 次。层数每加一,调用次数翻倍: n = 30 时是 2³⁰−1 = 1,073,741,823 次(十亿七千万), 而格子一共才 465 个。慢的从来不是规模,是重复。

先看「记忆化未开启」:颜色相同的圆圈就是同一个格子。 数一数有多少个同色圆圈 —— 每一个都在把同样的东西重算一遍。

然后点「开启记忆化」。所有重复出现的节点变成灰色的「查表」小方块, 它下面那一整片子树凭空消失了。虚线是原来的树,用来对照省掉了多少。

把层数调到 6 再切换一次,省掉的比例会更吓人。 底下那行统计里,「真正算了几次」永远不会超过「三角形里的格子数」—— 这就是 O(n²) 的来历。

9★ 对拍验证

★ 正确的用法

把「记忆化版」那一栏换成你自己默写的,再点开始。

对拍器
生成器造 n ≤ 12 的小三角形,并且故意混入负数 —— 全是正数的数据太温柔,查不出「以为一路往大的走就对了」这类贪心式错误。
// 数字三角形 —— 记忆化搜索
//
// 和 brute.cpp 解同一道题,答案必须一样。
// 但 n = 27 时暴力要跑一秒多,它只要几微秒。
//
// 关键的那一步想法 —— 而且这一步小得让人不敢相信:
//
// best(i,j) 的答案,只跟 (i,j) 有关。
// 不管你是从哪条路摸到 (2,1) 的,best(2,1) 永远是同一个数。
// 既然如此,第一次算出来之后把它记下来,
// 下次再问到 (2,1),直接把记下的值还回去就行了 —— 不用再往下递归。
//
// 于是每个格子最多只会被真正计算一次。
// 格子一共 n(n+1)/2 个,所以复杂度从 O(2^n) 一下掉到 O(n²)。
//
// 相比 brute.cpp,代码只多了三行:一个 f 数组、一个 vis 数组、一个「查表就返回」。
// 算法思想没变,递归结构没变,改的只是「别重复算」。
//
// 这就是「记忆化搜索」,它是从递归通向动态规划的那座桥:
// 你先按最自然的方式写出递归(这一步靠的是把题目念清楚),
// 再加一张表把结果存起来(这一步是纯机械的)。
// 等你熟练之后,会发现把递归改写成「从底往上填表」的循环,就是所谓的 DP。
// 但顺序永远是:先会递归,再会记忆化,最后才是 DP。反过来学,必卡。
#include <bits/stdc++.h>
using namespace std;
int n;
vector<vector<long long>> a;
vector<vector<long long>> f; // f[i][j] = best(i,j) 算出来的结果
vector<vector<char>> vis; // vis[i][j] = 这一格算过了没有
long long best(int i, int j) {
if (vis[i][j]) return f[i][j]; // ★ 查表命中,直接还回去,不再往下递归
long long res;
if (i == n - 1) res = a[i][j]; // 出口
else res = a[i][j] + max(best(i + 1, j), best(i + 1, j + 1)); // 和暴力一模一样
vis[i][j] = 1; // ★ 算完之后才记下来
f[i][j] = res;
return res;
}
// 记忆化的标准骨架就是这三段:开头查表 → 中间照抄暴力 → 结尾存表。
// 注意「存表」要放在算完之后,不要放在算之前 —— 养成这个习惯,
// 以后遇到状态之间可能互相依赖的题目才不会踩坑。
int main() {
if (!(cin >> n)) return 0;
a.assign(n, {});
for (int i = 0; i < n; i++) {
a[i].resize(i + 1);
for (int j = 0; j <= i; j++) cin >> a[i][j];
}
f.assign(n, vector<long long>(n + 1, 0));
vis.assign(n, vector<char>(n + 1, 0));
cout << best(0, 0) << "\n";
return 0;
}
点一下即可编辑

值得一试的错误:

  • 把 f 初始化成 0,用 if (f[i][j] != 0) 判断算过没有 → 数据里有 0 时就挂
  • 把 f 初始化成 -1,用 if (f[i][j] != -1) 判断 → 生成器造了负数,会被抓
  • vis[i][j] = 1 挪到函数开头,同时把 f[i][j] 的赋值删掉 → 读到空值,直接错
  • max(best(i+1,j), best(i+1,j+1)) 写成 max(best(i+1,j), best(i+1,j-1)) → 越界
✓ 为什么生成器要混负数

如果数字全是正数,「每次都往大的那个方向走」这种贪心做法碰巧经常也对, 错误代码就容易蒙混过关。

加了负数之后,「眼前大」和「最终大」就分家了 —— 贪心必错,一对拍就现原形。

造数据的原则:让「错误的直觉」在你的数据上必定失败。

10再往前一步:这就是 DP

记忆化搜索是从上往下算的:要 best(0,0),就去问 best(1,0) 和 best(1,1),一路递下去。

既然每个格子最终都要算一次,那干脆从下往上直接填表,连递归都省了:

// 从倒数第二行往上推
for (int i = n - 2; i >= 0; i--)
    for (int j = 0; j <= i; j++)
        a[i][j] += max(a[i+1][j], a[i+1][j+1]);
// 答案就是 a[0][0]

这就是动态规划。四行,没有递归,没有 vis。

它和记忆化算的东西一模一样,只是把「用到时再算」换成了「按顺序全算一遍」。 好处是没有递归开销、不会爆栈;代价是你必须自己想清楚填表顺序 (这里是从下往上,因为 a[i][j] 依赖 a[i+1][*])。

★ 学 DP 的正确顺序
  1. 先把题意写成递归(这一步靠的是把问题说清楚,第 1 章的功夫)
  2. 加个数组变成记忆化(纯机械操作,没有难度)
  3. 想清楚依赖方向,改写成递推填表(这才是 DP)

绝大多数人卡在 DP,是因为跳过 1 和 2 直接学 3 —— 于是「状态怎么设」「方程怎么列」全靠背,题型一变就废。

遇到不会的 DP 题,永远先退回第 1 步:先写出那个会超时的递归。 写得出递归,记忆化就是免费的;记忆化能跑对,递推只是换个循环方向。

11自测

自测清单0 / 7
配套练习
这一章我卡在哪(过一个月回来看,这几行比整章正文都值钱)
接下来

你已经走完了阶段 3。第 21 章开始的阶段 5(动态规划)会大量用到这一章的思路 —— 遇到不会的 DP 题,永远先退回来写那个会超时的递归。

另外别忘了:第 1 章那道「数楼梯」(斐波那契)当时会 TLE,现在你有能力解决它了。 回去做掉它,是对这一章最好的检验。