两次。
一次是第 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 |
「一步都不走」也是一种走法 —— 这不是抠字眼,是边界必须这么定,递推才对。
如果你把 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暴力:把这句话直接翻译成递归
// 爬楼梯 —— 朴素递归(第 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;}点「运行 ▶」看结果
本机实测:
| n | 纯递归 | 递推 |
|---|---|---|
| 35 | 0.044 秒 | 0.006 秒 |
| 40 | 0.458 秒 | 0.006 秒 |
| 42 | 1.29 秒 | 0.007 秒 |
| 45 | 5.03 秒 | 0.007 秒 |
(递推那一栏还是「量不出来」:本机空跑一个 C++ 程序就要 5 毫秒左右。)
4慢在哪:把次数数出来
耗时会随机器变,次数不会。所以直接数:
// 爬楼梯 —— 把三种写法各自「算了多少次」数出来//// 为什么要有这份代码:耗时会随机器变,**计算次数不会**。// 想说清楚 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递推:三件套翻译成代码
// 爬楼梯 —— 递推填表,这就是「标准长相」的动态规划//// 把 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;}点「运行 ▶」看结果
盯住绿色那两格:它们永远在蓝色格子的左边。 这就是「从左往右填」的全部理由 —— 换个方向填,读到的就是空格子。
把上面代码框里的 45 改成 92,再点运行。
你会得到 -6246583658587674878。
f(91) = 7540113804746346429 已经顶到 long long 的上限,f(92) 直接溢出成负数 ——
没有任何报错。这就是第 20 章说的「对拍抓不住的那类错误」:
小数据下 int / long long 表现完全一样。
洛谷 P1255「数楼梯」要算到 n = 5000,答案有一千多位 —— 那题必须写高精度。
7记忆化和递推,到底差在哪
到这里为止,记忆化和递推看起来完全等价:算的东西一样、复杂度一样、答案一样。
它们只有一个实质差别:递归有多深,栈就有多深。
// 记忆化 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 章你已经用记忆化解决过它,章末还给了四行递推。这里把那四行讲清楚:
// 数字三角形 —— 从下往上填表(第 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;}点「运行 ▶」看结果
三件套:
- 状态:
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++) —— 只改一个字。
// 数字三角形 —— 填表顺序写反了会怎样(这份代码是**故意错的**)//// 它和 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;}点「运行 ▶」看结果
样例三角形上:正确顺序给出 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动画:三种填法并排看
下拉框里三种填法,别的什么都不用动:
- 从下往上(正确):绿色的来源格永远已经算好;
- 从上往下(正确,但状态换了):看每行头尾那两格 —— 它们只有一个来源,这就是要多写的
if; - 顺序写反(错的):来源格变成红色,右下角「读到还没填的格子」一路涨。
11同一道题,换个状态换个方向
// 数字三角形 —— 从上往下填表(同一道题,反着填)//// 为什么要写第二遍:**因为它更难写。**// 对着 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;}点「运行 ▶」看结果
注意这里的状态定义变了:g[i][j] 是「从顶点走到这一格的最大和」,
和 triDown.cpp 的「从这一格走到底边」完全是两回事。
代价有两个:
- 边界要单独伺候:每行最左只能从正上方来,最右只能从左上方来 —— 两个
if; - 答案要多扫一遍:终点是底边任意一格,得取最大值。
同一道题可以有好几种状态定义,选哪个决定了后面全部的难度。
triDown 一个 if 都不用写,triUp 要写两个还要多扫一遍 —— 而它们解的是同一道题。
所以设状态的时候多花三分钟,问自己: 哪个方向的边界更少?哪个方向的答案更直接(是某一个固定格子,还是要再扫一遍)? 选错不会错,但会让你多写一倍的代码,也多一倍出错的机会。
12★ 对拍:直接用第 17 章的暴力和生成器
数字三角形的输入输出格式和第 17 章一模一样,所以标准答案(那份 2ⁿ 暴力) 和生成器一个字都不用改,直接拿过来用。
这不是偷懒 —— 这正是「标准答案要用完全不同的思路」的最好实现: 第 17 章那份暴力是枚举所有路径,和这里的递推填表毫无关系。
// 数字三角形 —— 从下往上填表(第 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;}爬楼梯也来一台(标准答案是纯递归):
// 爬楼梯 —— 递推填表,这就是「标准长相」的动态规划//// 把 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 题,按这个顺序做
- 先写出会超时的递归。(第 17 章反复强调过:写不出递归就别想 DP。)
- 加一张表变成记忆化。 纯机械操作,没有难度。
- 把三件套写在草稿纸上:状态是什么(一句人话)、转移怎么来(问「最后一步是什么」)、 边界和顺序(依赖谁就先填谁)。
- 翻成递推循环。 循环方向由第 3 步的依赖方向决定,不是背的。
- 对拍。 标准答案用暴力或记忆化 —— 反正你第 1 步已经写好了,白捡一个。
卡在第 3 步是正常的,那说明状态设错了 —— 回到第 1 步,看看递归函数的参数是什么, 那几个参数通常就是状态的维度。
转移、边界、顺序都是有章可循的机械活。真正难的永远是第一件:状态是什么。
接下来六章就是在练这一件事:
- 第 22 章:状态里塞一个「以 i 结尾」(最长上升子序列)
- 第 23、24、25 章:状态多一维「容量 / 费用」(背包)
- 第 26 章:状态是一段区间(石子合并)
- 第 27 章:状态挂在树的节点上(树形 DP)
- 第 28 章:状态是一个集合,压成一个整数(状压 DP)
每一章的新东西都只有「状态长什么样」,其余三件套的用法一模一样。
14自测
- 洛谷 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 章的二分查找 ——
到时候你会看到一个漂亮的事实:那个用来二分的数组,天然就是单调的。