阶段 5 · 动态规划 · 第 21 章普及组 J

DP 入门:从记忆化到递推

你其实已经写过两次 DP 了。这一章要补的是最后那一步 —— 填表的顺序,以及顺序错了会怎样。

需要先学:第 17 章 记忆化搜索例题:爬楼梯 · 数字三角形建议用时:100 分钟
阶段 5 开场:你已经写过 DP 了

两次。

一次是第 17 章的记忆化搜索(数字三角形),一次是第 20 章的 coinDp.cpp (找零钱,用来当对拍的标准答案)。它们都是货真价实的动态规划。

所以这一章不教「什么是 DP」,只补最后那一步: 把「用到时再算」的递归,翻成「按顺序全算一遍」的循环。

这一步机械得几乎没有难度 —— 除了一件事:填表的顺序。 而填错顺序的代价,是这一章唯一想让你记住的东西: 它不报错、不崩溃、不警告,只是安安静静给你一个错的答案。

1一句话问题:爬楼梯

n 级台阶,每次能上 1 级或 2 级,一共有多少种走法?

输入

5

输出

8

第 1、2 章那个「数楼梯」就是它。当时我们写出了递归,也亲眼看着它超时。现在回来把它做完。

2先用手算一遍

n 走法 种数
0 站着不动 1
1 1 1
2 1+1、2 2
3 1+1+1、1+2、2+1 3
4 …… 5
5 …… 8
⚠ n = 0 的答案是 1,不是 0

「一步都不走」也是一种走法 —— 这不是抠字眼,是边界必须这么定,递推才对。

如果你把 f[0] 设成 0,那 f[2] = f[1] + f[0] 就变成 1,整条链全错。

DP 的边界不是「题目的特殊情况」,是「让转移方程成立的那个起点」。 这一章后面的对拍生成器专门多造 n = 0,就是为了抓这个。

规律一眼就能看出来:f[n] = f[n-1] + f[n-2]。为什么? 看最后一步:要么是从第 n-1 级迈 1 级上来的,要么是从第 n-2 级迈 2 级上来的。 这两类互不重叠,也没有遗漏。

3暴力:把这句话直接翻译成递归

stairsRec.cpp纯递归
// 爬楼梯 —— 朴素递归(第 1、2 章那个会超时的写法,这里再见一面)
//
// 题意:n 级台阶,每次能上 1 级或 2 级,一共有多少种走法?
//
// 递归三要素(第 1 章):
// 出口:走到第 0 级(正好走完)算 1 种;走过头(负数)算 0 种。
// 分解:最后一步要么迈了 1 级,要么迈了 2 级 —— 于是 f(n) = f(n-1) + f(n-2)。
// 收敛:n 每次至少减 1,一定会到出口。
//
// 这份代码是对的,而且它是这一章所有优化的出发点。**先写得出它,再谈优化。**
//
// 它的问题只有一个:**同一个 f(k) 被算了无数遍**。
// 调用次数正好是斐波那契数列本身 —— n = 40 要算一亿多次,n = 45 要十几亿次。
//
// 输入:一个整数 n(这份代码只适合 n <= 45 左右,再大等不起)
// 输出:走法数
//
// ⚠ 答案增长得很快:n = 91 时是 7 540 113 804 746 346 429,已经顶到 long long 的上限;
// n = 92 直接溢出成负数,**而且不会有任何报错**。本章可信的范围是 n <= 91。
// 真要算 n = 1000(洛谷 P1255),得写高精度。
#include <bits/stdc++.h>
using namespace std;
long long f(int n) {
if (n < 0) return 0; // 走过头了,这条路不算数
if (n == 0) return 1; // 正好走完,算一种走法
return f(n - 1) + f(n - 2);
}
int main() {
ios::sync_with_stdio(false);
cin.tie(nullptr);
int n;
if (!(cin >> n)) return 0;
cout << f(n) << "\n";
return 0;
}
点一下即可编辑
输入(stdin)
输出
点「运行 ▶」看结果
同题对比:纯递归 vs 递推填表
先跑 40,再改成 42、45 试试。别超过 45 —— 递归那份会让你等很久。
纯递归
递推填表

本机实测:

n 纯递归 递推
35 0.044 秒 0.006 秒
40 0.458 秒 0.006 秒
42 1.29 秒 0.007 秒
45 5.03 秒 0.007 秒

(递推那一栏还是「量不出来」:本机空跑一个 C++ 程序就要 5 毫秒左右。)

4慢在哪:把次数数出来

耗时会随机器变,次数不会。所以直接数:

stairsCount.cpp三种写法各算了多少次
三列分别是:纯递归的调用次数、记忆化真正算过的状态数、递推的循环次数。
// 爬楼梯 —— 把三种写法各自「算了多少次」数出来
//
// 为什么要有这份代码:耗时会随机器变,**计算次数不会**。
// 想说清楚 DP 到底省掉了什么,数次数比看秒表可靠得多(第 16 章数搜索节点也是这个道理)。
//
// 三种写法数的是同一件事「函数体 / 循环体执行了几次」:
// 纯递归:solve(n) 每被调用一次算一次 —— 它等于 2·f(n+1) − 1,也就是斐波那契量级
// 记忆化:真正算过的状态数(查表命中不算)—— 最多 n+1 个
// 递推 :循环体跑的次数 —— 正好 n−1 次
//
// 你会看到第二、三列几乎一样,而第一列在 n = 30 时已经是它们的十万倍。
// **这就是 DP 的全部价值:把「重复算」变成「算一次」。**
//
// 用法:直接运行,或者给一个参数指定最大的 n(默认 30,别超过 45 —— 第一列会爆炸)。
#include <bits/stdc++.h>
using namespace std;
long long recCalls;
long long recSolve(int n) {
recCalls++;
if (n < 0) return 0;
if (n == 0) return 1;
return recSolve(n - 1) + recSolve(n - 2);
}
long long memoCalcs;
vector<long long> f;
vector<char> vis;
long long memoSolve(int n) {
if (n < 0) return 0;
if (n == 0) return 1;
if (vis[n]) return f[n]; // 命中查表:不算「一次计算」
memoCalcs++; // 真正算一次
long long res = memoSolve(n - 1) + memoSolve(n - 2);
vis[n] = 1;
f[n] = res;
return res;
}
int main(int argc, char** argv) {
ios::sync_with_stdio(false);
int maxN = (argc > 1) ? atoi(argv[1]) : 30;
if (maxN < 1) maxN = 1;
if (maxN > 45) maxN = 45;
cout << " n 纯递归调用次数 记忆化真正算的次数 递推循环次数 答案\n";
for (int n = 1; n <= maxN; n++) {
recCalls = 0;
long long ans = recSolve(n);
memoCalcs = 0;
f.assign(n + 2, 0);
vis.assign(n + 2, 0);
memoSolve(n);
long long loops = (n >= 2) ? n - 1 : 0;
cout << setw(3) << n
<< setw(20) << recCalls
<< setw(21) << memoCalcs
<< setw(15) << loops
<< setw(22) << ans << "\n";
}
cout << "\n第一列是斐波那契量级,第二、三列是线性的。n 每加 1,第一列翻 1.6 倍,后两列加 1。\n";
return 0;
}
点一下即可编辑
输出
点「运行 ▶」看结果

本机跑出来的最后一行(n = 30):

写法 算了多少次
纯递归 4 356 617
记忆化(真正算过的状态) 30
递推(循环次数) 29

十四万倍的差距,而三者算的是同一个数。

原因第 17 章已经讲透了:f(28) 被算了无数遍。 DP 的全部价值就是把「重复算」变成「算一次」。

5★ 关键一步:DP 三件套

★ 关键的一步

写任何一道 DP,先在草稿纸上把这三句话写出来,再动手敲代码:

【1】状态:f[i] 是什么?—— 一句人话,不带任何代码。

f[i] = 走到第 i 级台阶的走法数

说不出这句话,就说明你还没想清楚,敲代码只会浪费时间。

【2】转移:f[i] 怎么从别的状态算出来?

f[i] = f[i-1] + f[i-2]

推转移的万能问法:「最后一步是什么?」 按最后一步分类, 要求这些类互不重叠(不重复计数)且没有遗漏(不漏解)。

【3】边界和顺序:起点是什么?按什么顺序填?

f[0] = 1, f[1] = 1;i 从小到大。

★ 顺序不是背的,是推出来的:f[i] 依赖 f[i-1] 和 f[i-2], 它们的下标更小 —— 所以必须先填小的。

依赖谁,就先填谁。 这一句是本章的中心,后面整章都在演示它。

6递推:三件套翻译成代码

stairsDp.cpp递推填表
代码末尾的注释里还有一个 O(1) 空间的滚动版本,第 23 章会正式用到它。
// 爬楼梯 —— 递推填表,这就是「标准长相」的动态规划
//
// 把 stairsMemo.cpp 的递归**反过来**:不再是「要 f(n) 就去问 f(n-1)」,
// 而是「先把 f(0)、f(1) 填好,再顺着往上填」。
//
// ============ DP 三件套 ============
// 写任何一道 DP,先在草稿纸上把这三句话写出来,再动手敲代码:
//
// 【状态】f[i] = 走到第 i 级台阶的走法数
// —— 一句人话,不带任何代码。说不出这句话就别往下写。
// 【转移】f[i] = f[i-1] + f[i-2]
// —— 最后一步要么迈 1 级要么迈 2 级,两类互不重叠、也没有遗漏。
// 【边界和顺序】f[0] = 1, f[1] = 1;i 从小到大填。
// —— ★ 顺序不是背的:f[i] 依赖 f[i-1] 和 f[i-2],
// 它们的下标更小,所以必须先填小的。**依赖谁,就先填谁。**
//
// 复杂度 O(n),空间 O(n)(下面还给了 O(1) 的版本)。
// 和记忆化算的东西一模一样,只是把「用到时再算」换成了「按顺序全算一遍」。
//
// 比记忆化好在哪:没有递归开销,而且**不会爆栈**(见 stairsDeep.cpp)。
// 代价是你必须自己想清楚填表顺序 —— 而这正是这一章要练的东西。
#include <bits/stdc++.h>
using namespace std;
int main() {
ios::sync_with_stdio(false);
cin.tie(nullptr);
int n;
if (!(cin >> n)) return 0;
if (n >= 95) { cout << "n 开得太大了,这份代码只到 94\n"; return 0; }
// ⚠ 只有 n <= 91 的答案是可信的:f(92) 超出 long long,会安静地变成负数(试试 92)。
vector<long long> f(n + 2, 0);
f[0] = 1; // 边界:站在原地,算一种走法
if (n >= 1) f[1] = 1;
for (int i = 2; i <= n; i++) // ★ 从小到大填,因为 f[i] 依赖更小的下标
f[i] = f[i - 1] + f[i - 2];
cout << f[n] << "\n";
// 顺带一提:f[i] 只用到前两格,所以整张表其实没必要留着 ——
// long long a = 1, b = 1;
// for (int i = 2; i <= n; i++) { long long c = a + b; a = b; b = c; }
// 答案就是 (n == 0 ? 1 : b);
// 这叫**滚动数组**,空间从 O(n) 降到 O(1)。第 23 章的 01 背包会正式用到它,
// 那里滚动不只是省空间,还牵扯到「循环该正序还是倒序」的经典坑。
return 0;
}
点一下即可编辑
输入(stdin)
输出
点「运行 ▶」看结果
爬楼梯:从左往右填,依赖的两格永远在左边
答案 89 · 最大 24(再大格子排不下)
第 1 / 13 步
·
f[0]
·
f[1]
·
f[2]
·
f[3]
·
f[4]
·
f[5]
·
f[6]
·
f[7]
·
f[8]
·
f[9]
·
f[10]
已经填好
0 格
答案 f[10]
…
纯递归要调用
287 次
蓝色 = 正在填的格子,绿色 = 它依赖的两格。 把 n 调到 20 以上,右边那个「纯递归要调用多少次」会涨到几万 —— 而左边永远只有 n+1 格。这就是记忆化和递推省下来的全部东西。
状态:f[i] = 走到第 i 级的走法数。转移:f[i] = f[i-1] + f[i-2]。下面按 i 从小到大填 —— 因为 f[i] 依赖的两格下标都更小。

盯住绿色那两格:它们永远在蓝色格子的左边。 这就是「从左往右填」的全部理由 —— 换个方向填,读到的就是空格子。

⚠ 一个不报错的坑:溢出

把上面代码框里的 45 改成 92,再点运行。

你会得到 -6246583658587674878。

f(91) = 7540113804746346429 已经顶到 long long 的上限,f(92) 直接溢出成负数 —— 没有任何报错。这就是第 20 章说的「对拍抓不住的那类错误」: 小数据下 int / long long 表现完全一样。

洛谷 P1255「数楼梯」要算到 n = 5000,答案有一千多位 —— 那题必须写高精度。

7记忆化和递推,到底差在哪

到这里为止,记忆化和递推看起来完全等价:算的东西一样、复杂度一样、答案一样。

它们只有一个实质差别:递归有多深,栈就有多深。

stairsDeep.cpp爆栈实验
第一个参数是 memo 或 dp,第二个是 n。先跑 memo 300000(会崩),再把 memo 改成 dp 跑同样的 n。
// 记忆化 vs 递推:一个记忆化**做不到**、而递推轻松做到的事 —— 不爆栈
//
// 到目前为止,记忆化和递推看起来是一回事(算的东西一样、复杂度一样)。
// 这份代码演示它们唯一的实质差别:**递归有多深,栈就有多深。**
//
// 用法(在网页上就是「命令行参数」那一栏):
// ./stairsDeep memo 100000 ← 记忆化,递归 10 万层
// ./stairsDeep dp 100000 ← 递推,同样的 n
// ./stairsDeep memo 1000000 ← 递归 100 万层:**这里会直接崩掉**
// ./stairsDeep dp 1000000 ← 递推:秒出
//
// 为了让 n 能开得很大又不溢出,这里对 1e9+7 取模(第 42 章会专门讲取模)。
// 取模不影响我们要看的东西:栈。
//
// 崩掉的时候你看到的是「段错误 / Segmentation fault」,**没有任何提示说是栈溢出**。
// 这是竞赛里最难查的一类错误之一:代码逻辑完全正确,数据一大就崩,
// 本地小数据还测不出来。记住这个场景,以后再遇到能省几个小时。
//
// 结论不是「记忆化不好」,而是:
// **递归深度和数据规模同阶的时候,要么改递推,要么手动开大栈。**
#include <bits/stdc++.h>
using namespace std;
const long long MOD = 1000000007;
vector<long long> f;
vector<char> vis;
long long solve(int n) {
if (n < 0) return 0;
if (n == 0) return 1;
if (vis[n]) return f[n];
long long res = (solve(n - 1) + solve(n - 2)) % MOD;
vis[n] = 1;
f[n] = res;
return res;
}
int main(int argc, char** argv) {
string mode = (argc > 1) ? argv[1] : "dp";
int n = (argc > 2) ? atoi(argv[2]) : 100000;
if (n < 0) n = 0;
if (mode == "memo") {
f.assign(n + 2, 0);
vis.assign(n + 2, 0);
cout << "记忆化(递归 " << n << " 层)… ";
cout.flush(); // 先把这句冲出去,不然崩了什么都看不见
cout << solve(n) << "\n";
} else {
vector<long long> g(n + 2, 0);
g[0] = 1;
if (n >= 1) g[1] = 1;
for (int i = 2; i <= n; i++) g[i] = (g[i - 1] + g[i - 2]) % MOD;
cout << "递推(循环 " << n << " 次)… " << g[n] << "\n";
}
return 0;
}
点一下即可编辑
输出
点「运行 ▶」看结果

本机实测(默认栈 8 MB):

写法 n = 250 000 n = 300 000 n = 10 000 000
记忆化(递归) 正常 段错误(崩溃) 想都别想
递推(循环) 正常 正常 正常(0.06 秒)
★ 什么时候必须用递推

崩掉的时候你看到的只有一句「段错误 / Segmentation fault」, 没有任何提示说是栈溢出。代码逻辑完全正确,本地小数据也测不出来 —— 这是竞赛里最难查的一类错误之一。

规矩:递归深度和数据规模同阶的时候(比如 n = 10⁵ 的线性 DP), 要么改成递推,要么手动开大栈。

反过来,状态空间很大但实际用到的很少的时候,记忆化更划算 —— 它只算你问到的那些状态(第 17 章的滑雪就是这种)。两种写法都要会,按题选。

8换一道题:数字三角形(第 17 章那道)

第 17 章你已经用记忆化解决过它,章末还给了四行递推。这里把那四行讲清楚:

triDown.cpp从下往上填
IOI1994 的原样例,答案是 30。
// 数字三角形 —— 从下往上填表(第 17 章结尾那四行,这里把它写完整)
//
// 输入输出和 code/17-memo-search/ 那几份完全一样,所以**可以直接拿第 17 章的
// brute.cpp 当标准答案、gen.cpp 当生成器来对拍**(对拍器是可以跨章节复用的)。
//
// ============ DP 三件套 ============
// 【状态】f[i][j] = 从第 i 行第 j 列出发,走到底边,能拿到的最大和
// 【转移】f[i][j] = a[i][j] + max(f[i+1][j], f[i+1][j+1])
// 【边界和顺序】最后一行 f[n-1][j] = a[n-1][j];
// i 从大到小填 —— ★ 因为 f[i][*] 依赖 f[i+1][*],
// 下面那一行必须先算好。**依赖谁,就先填谁。**
//
// 答案是 f[0][0],不用再取 max ——「从 (0,0) 出发」本身就是题目要求的。
//
// 这个方向的好处:**没有边界判断**。每一格都老老实实有两个来源,
// 不像从上往下那样要单独伺候每行的头和尾(见 triUp.cpp)。
// 同一道题,换个填表方向,代码难度可以差一截 —— 这也是「顺序」值得单独讲一章的原因。
#include <bits/stdc++.h>
using namespace std;
int main() {
ios::sync_with_stdio(false);
cin.tie(nullptr);
int n;
if (!(cin >> n)) return 0;
vector<vector<long long>> a(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 直接借用 a 的空间:算到第 i 行时,a[i+1][*] 里存的已经是 f[i+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]);
cout << a[0][0] << "\n";
return 0;
}
点一下即可编辑
输入(stdin)
输出
点「运行 ▶」看结果

三件套:

  • 状态:f[i][j] = 从第 i 行第 j 列出发,走到底边能拿到的最大和
  • 转移:f[i][j] = a[i][j] + max(f[i+1][j], f[i+1][j+1])
  • 边界和顺序:最后一行就是它自己;i 从大到小填 —— 因为它依赖下一行

答案是 f[0][0],而且一个边界判断都不用写。

9★ 填错顺序会怎样

把外层循环从 for (i = n-2; i >= 0; i--) 改成 for (i = 0; i < n-1; i++) —— 只改一个字。

triWrong.cpp两种顺序并排跑
同一份数据,正确顺序和错误顺序并排。
// 数字三角形 —— 填表顺序写反了会怎样(这份代码是**故意错的**)
//
// 它和 triDown.cpp 只差一个字:外层循环写成了 `for (int i = 0; i < n - 1; i++)`,
// 也就是**从上往下**去填一个「依赖下一行」的状态。
//
// 于是每算一格 a[i][j],读到的 a[i+1][*] 都还是**原始输入值**,而不是算好的 f[i+1][*]。
// 相当于只往下看了一层就下结论。
//
// ★ 这一章最该记住的一件事:
// **填错顺序不会报错、不会崩、不会警告,只会安安静静给你一个错的答案。**
// 编译器不知道你的 f[i][j] 依赖谁,那是你脑子里的东西。
//
// 这份代码会把两种顺序都跑一遍,并排打给你看差多少:
// 正确顺序(从下往上)和错误顺序(从上往下),以及它们是不是一样。
// 小三角形上两者常常碰巧相等(层数少的时候「只看一层」和「看到底」没区别),
// 层数一多就必然不同 —— 这又是一次「错误的写法经常蒙对」(第 20 章)。
//
// 输入输出格式和 triDown.cpp 一样。
#include <bits/stdc++.h>
using namespace std;
int main() {
ios::sync_with_stdio(false);
cin.tie(nullptr);
int n;
if (!(cin >> n)) return 0;
vector<vector<long long>> a(n);
for (int i = 0; i < n; i++) {
a[i].resize(i + 1);
for (int j = 0; j <= i; j++) cin >> a[i][j];
}
auto right = a, bad = a;
for (int i = n - 2; i >= 0; i--) // 正确:从下往上
for (int j = 0; j <= i; j++)
right[i][j] += max(right[i + 1][j], right[i + 1][j + 1]);
for (int i = 0; i < n - 1; i++) // 错误:顺序反了
for (int j = 0; j <= i; j++)
bad[i][j] += max(bad[i + 1][j], bad[i + 1][j + 1]);
cout << "正确顺序(从下往上):" << right[0][0] << "\n";
cout << "错误顺序(从上往下):" << bad[0][0] << "\n";
cout << (right[0][0] == bad[0][0]
? "这组数据上两者**碰巧相等** —— 别高兴,换个大一点的三角形再试。\n"
: "两者不同。错误顺序读到的是还没填好的格子,等于只往下看了一层。\n");
return 0;
}
点一下即可编辑
输入(stdin)
输出
点「运行 ▶」看结果

样例三角形上:正确顺序给出 30,错误顺序给出 15。

★ 为什么会错:读到了还没填好的格子

f[i][j] 依赖 f[i+1][*]。从上往下填的时候,轮到 f[0][0] 时, f[1][*] 还没被算过 —— 数组里存的还是输入里的原始数字。

于是 f[0][0] 拿到的是「原始的 a[1][0] 和 a[1][1]」,相当于只往下看了一层就下结论。

编译器不知道你的 f[i][j] 依赖谁 —— 那是你脑子里的东西。 所以填错顺序:不报错、不崩溃、不警告,只是答案错。

这也是为什么三件套的第三件叫「边界和顺序」,而不只是「边界」。

用第 17 章的生成器随机造 300 组三角形,263 组的答案不一样(第 3 组就出现了差异)。 剩下 37 组碰巧相等 —— 层数少的时候「只看一层」和「看到底」偶尔没区别。 又一次印证第 20 章那句话:错误的写法经常蒙对,所以蒙对不能当证据。

10动画:三种填法并排看

数字三角形:填表顺序由依赖方向决定
正确答案 30
第 1 / 12 步
7
3
8
8
1
0
2
7
4
4
4
5
2
6
5
这种填法给出
…
正确答案
30
读到还没填的格子
0 次
蓝色 = 正在填的格子,绿色 = 它依赖的、已经算好的格子, 红色 = 它依赖的格子还没算过(只有顺序错了才会出现)。 格子里的数字会随着填表被就地改写成状态值 —— 这也是为什么读到没填的格子时, 拿到的是输入里的原始数字。
状态:f[i][j] = 从这一格出发走到底边的最大和。它依赖下一行,所以要先填下面 —— 最后一行本身就是边界。

下拉框里三种填法,别的什么都不用动:

  1. 从下往上(正确):绿色的来源格永远已经算好;
  2. 从上往下(正确,但状态换了):看每行头尾那两格 —— 它们只有一个来源,这就是要多写的 if;
  3. 顺序写反(错的):来源格变成红色,右下角「读到还没填的格子」一路涨。

11同一道题,换个状态换个方向

triUp.cpp从上往下填
答案同样是 30,但代码明显长了一截。
// 数字三角形 —— 从上往下填表(同一道题,反着填)
//
// 为什么要写第二遍:**因为它更难写。**
// 对着 triDown.cpp 看,你会亲眼看到「填表方向」这个选择要付出什么代价。
//
// ============ DP 三件套 ============
// 【状态】g[i][j] = 从顶点 (0,0) 走到 (i,j),路上能拿到的最大和
// —— 注意这和 triDown.cpp 的状态**不是一回事**(那边是「从这里走到底边」)。
// 同一道题可以有好几种状态定义,选哪个决定了后面全部的难度。
// 【转移】g[i][j] = a[i][j] + max(g[i-1][j-1], g[i-1][j])
// 【边界和顺序】g[0][0] = a[0][0];i 从小到大填(依赖上一行)。
// 【答案】最后一行的最大值 —— 不是 g[n-1][0],因为终点可以是底边任意一格。
//
// ⚠ 代价一:**边界要单独伺候。**
// 每行最左边那格 (i,0) 只能从 (i-1,0) 来(左上方没有格子);
// 最右边那格 (i,i) 只能从 (i-1,i-1) 来。
// 写漏了会读到没填过的格子 —— 不报错,只是答案错。
//
// ⚠ 代价二:**答案要多扫一遍**取最大值。
//
// triDown.cpp 一个 if 都不用写。所以:
// **选状态和填表方向的时候,先想想哪个方向的边界更少。**
#include <bits/stdc++.h>
using namespace std;
int main() {
ios::sync_with_stdio(false);
cin.tie(nullptr);
int n;
if (!(cin >> n)) return 0;
vector<vector<long long>> a(n), g(n);
for (int i = 0; i < n; i++) {
a[i].resize(i + 1);
g[i].assign(i + 1, 0);
for (int j = 0; j <= i; j++) cin >> a[i][j];
}
g[0][0] = a[0][0];
for (int i = 1; i < n; i++) { // ★ 从上往下,依赖上一行
for (int j = 0; j <= i; j++) {
if (j == 0) g[i][j] = g[i - 1][j]; // 最左:只能从正上方来
else if (j == i) g[i][j] = g[i - 1][j - 1]; // 最右:只能从左上方来
else g[i][j] = max(g[i - 1][j - 1], g[i - 1][j]);
g[i][j] += a[i][j];
}
}
long long best = g[n - 1][0];
for (int j = 0; j <= n - 1; j++) best = max(best, g[n - 1][j]); // 终点是底边任意一格
cout << best << "\n";
return 0;
}
点一下即可编辑
输入(stdin)
输出
点「运行 ▶」看结果

注意这里的状态定义变了:g[i][j] 是「从顶点走到这一格的最大和」, 和 triDown.cpp 的「从这一格走到底边」完全是两回事。

代价有两个:

  • 边界要单独伺候:每行最左只能从正上方来,最右只能从左上方来 —— 两个 if;
  • 答案要多扫一遍:终点是底边任意一格,得取最大值。
★ 从这里得到一条实用经验

同一道题可以有好几种状态定义,选哪个决定了后面全部的难度。

triDown 一个 if 都不用写,triUp 要写两个还要多扫一遍 —— 而它们解的是同一道题。

所以设状态的时候多花三分钟,问自己: 哪个方向的边界更少?哪个方向的答案更直接(是某一个固定格子,还是要再扫一遍)? 选错不会错,但会让你多写一倍的代码,也多一倍出错的机会。

12★ 对拍:直接用第 17 章的暴力和生成器

★ 对拍器是可以跨章节复用的

数字三角形的输入输出格式和第 17 章一模一样,所以标准答案(那份 2ⁿ 暴力) 和生成器一个字都不用改,直接拿过来用。

这不是偷懒 —— 这正是「标准答案要用完全不同的思路」的最好实现: 第 17 章那份暴力是枚举所有路径,和这里的递推填表毫无关系。

对拍器
生成器来自第 17 章:层数只到 12(暴力是 2ⁿ 的),数字里混了负数 —— 全是正数的数据太温柔,抓不出「一路往大的走」这类贪心式错误。
// 数字三角形 —— 从下往上填表(第 17 章结尾那四行,这里把它写完整)
//
// 输入输出和 code/17-memo-search/ 那几份完全一样,所以**可以直接拿第 17 章的
// brute.cpp 当标准答案、gen.cpp 当生成器来对拍**(对拍器是可以跨章节复用的)。
//
// ============ DP 三件套 ============
// 【状态】f[i][j] = 从第 i 行第 j 列出发,走到底边,能拿到的最大和
// 【转移】f[i][j] = a[i][j] + max(f[i+1][j], f[i+1][j+1])
// 【边界和顺序】最后一行 f[n-1][j] = a[n-1][j];
// i 从大到小填 —— ★ 因为 f[i][*] 依赖 f[i+1][*],
// 下面那一行必须先算好。**依赖谁,就先填谁。**
//
// 答案是 f[0][0],不用再取 max ——「从 (0,0) 出发」本身就是题目要求的。
//
// 这个方向的好处:**没有边界判断**。每一格都老老实实有两个来源,
// 不像从上往下那样要单独伺候每行的头和尾(见 triUp.cpp)。
// 同一道题,换个填表方向,代码难度可以差一截 —— 这也是「顺序」值得单独讲一章的原因。
#include <bits/stdc++.h>
using namespace std;
int main() {
ios::sync_with_stdio(false);
cin.tie(nullptr);
int n;
if (!(cin >> n)) return 0;
vector<vector<long long>> a(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 直接借用 a 的空间:算到第 i 行时,a[i+1][*] 里存的已经是 f[i+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]);
cout << a[0][0] << "\n";
return 0;
}
点一下即可编辑

爬楼梯也来一台(标准答案是纯递归):

对拍器
生成器专门多造 n = 0、1、2 这三个边界 —— 这题最容易错的地方不是转移,是 f[0] 该等于几。
// 爬楼梯 —— 递推填表,这就是「标准长相」的动态规划
//
// 把 stairsMemo.cpp 的递归**反过来**:不再是「要 f(n) 就去问 f(n-1)」,
// 而是「先把 f(0)、f(1) 填好,再顺着往上填」。
//
// ============ DP 三件套 ============
// 写任何一道 DP,先在草稿纸上把这三句话写出来,再动手敲代码:
//
// 【状态】f[i] = 走到第 i 级台阶的走法数
// —— 一句人话,不带任何代码。说不出这句话就别往下写。
// 【转移】f[i] = f[i-1] + f[i-2]
// —— 最后一步要么迈 1 级要么迈 2 级,两类互不重叠、也没有遗漏。
// 【边界和顺序】f[0] = 1, f[1] = 1;i 从小到大填。
// —— ★ 顺序不是背的:f[i] 依赖 f[i-1] 和 f[i-2],
// 它们的下标更小,所以必须先填小的。**依赖谁,就先填谁。**
//
// 复杂度 O(n),空间 O(n)(下面还给了 O(1) 的版本)。
// 和记忆化算的东西一模一样,只是把「用到时再算」换成了「按顺序全算一遍」。
//
// 比记忆化好在哪:没有递归开销,而且**不会爆栈**(见 stairsDeep.cpp)。
// 代价是你必须自己想清楚填表顺序 —— 而这正是这一章要练的东西。
#include <bits/stdc++.h>
using namespace std;
int main() {
ios::sync_with_stdio(false);
cin.tie(nullptr);
int n;
if (!(cin >> n)) return 0;
if (n >= 95) { cout << "n 开得太大了,这份代码只到 94\n"; return 0; }
// ⚠ 只有 n <= 91 的答案是可信的:f(92) 超出 long long,会安静地变成负数(试试 92)。
vector<long long> f(n + 2, 0);
f[0] = 1; // 边界:站在原地,算一种走法
if (n >= 1) f[1] = 1;
for (int i = 2; i <= n; i++) // ★ 从小到大填,因为 f[i] 依赖更小的下标
f[i] = f[i - 1] + f[i - 2];
cout << f[n] << "\n";
// 顺带一提:f[i] 只用到前两格,所以整张表其实没必要留着 ——
// long long a = 1, b = 1;
// for (int i = 2; i <= n; i++) { long long c = a + b; a = b; b = c; }
// 答案就是 (n == 0 ? 1 : b);
// 这叫**滚动数组**,空间从 O(n) 降到 O(1)。第 23 章的 01 背包会正式用到它,
// 那里滚动不只是省空间,还牵扯到「循环该正序还是倒序」的经典坑。
return 0;
}
点一下即可编辑

值得故意写错、看对拍怎么抓的:

  • f[0] 写成 0 → 只要生成器造出 n = 0 或 n = 2 就立刻被抓
  • 循环从 i = 1 开始(漏了 f[1] 的边界)→ 被抓
  • 数字三角形的循环方向写反 → 被抓(而且 300 组里有 263 组会被抓)
  • long long 写成 int → 对拍抓不住(小数据不溢出),只能靠脑子

13拿到一道 DP 题,按这个顺序做

★ 关键的一步
  1. 先写出会超时的递归。(第 17 章反复强调过:写不出递归就别想 DP。)
  2. 加一张表变成记忆化。 纯机械操作,没有难度。
  3. 把三件套写在草稿纸上:状态是什么(一句人话)、转移怎么来(问「最后一步是什么」)、 边界和顺序(依赖谁就先填谁)。
  4. 翻成递推循环。 循环方向由第 3 步的依赖方向决定,不是背的。
  5. 对拍。 标准答案用暴力或记忆化 —— 反正你第 1 步已经写好了,白捡一个。

卡在第 3 步是正常的,那说明状态设错了 —— 回到第 1 步,看看递归函数的参数是什么, 那几个参数通常就是状态的维度。

✓ 这一章之后,DP 对你就只剩「状态怎么设」了

转移、边界、顺序都是有章可循的机械活。真正难的永远是第一件:状态是什么。

接下来六章就是在练这一件事:

  • 第 22 章:状态里塞一个「以 i 结尾」(最长上升子序列)
  • 第 23、24、25 章:状态多一维「容量 / 费用」(背包)
  • 第 26 章:状态是一段区间(石子合并)
  • 第 27 章:状态挂在树的节点上(树形 DP)
  • 第 28 章:状态是一个集合,压成一个整数(状压 DP)

每一章的新东西都只有「状态长什么样」,其余三件套的用法一模一样。

14自测

自测清单0 / 10
配套练习
  • 洛谷 P1216 数字三角形解析 → —— IOI1994。本章原题,先交记忆化版再交递推版,对比一下提交记录里的用时和内存
  • 洛谷 P1255 数楼梯解析 → —— 爬楼梯的原题,但 n 到 5000 —— 答案上千位,必须写高精度。递推部分你已经会了,这题练的是高精度加法
  • 洛谷 P1002 过河卒解析 → —— NOIP2002。二维递推,把 max 换成 +(计数)。注意马的控制点和边界,以及 long long
  • 洛谷 P1044 栈解析 → —— NOIP2003。卡特兰数。状态不好设 —— 先老实写搜索,再从搜索里找状态,正是本章第 13 步那套流程
  • 洛谷 P1077 摆花解析 → —— NOIP2012。状态要开二维(第几种花、已经摆了几盆),是通向第 23 章背包的过渡题
这一章我卡在哪(过一个月回来看,这几行比整章正文都值钱)
下一章预告

第 22 章:最长上升子序列,O(n²) → O(n log n)。

它的状态是「以 i 结尾的最长上升子序列长度」—— 「以某个位置结尾」是 DP 里最常用的状态设法之一,这一章会把它讲透。

而那个 O(n log n) 的优化会用到第 8 章的二分查找 —— 到时候你会看到一个漂亮的事实:那个用来二分的数组,天然就是单调的。