0题目原文(本地存了一份)
原题在洛谷上(页头有链接)。别人的网站不归我们管,打不开、改版、题号调整都可能发生。 所以每个解析页都把题面转录一份存在本地,跟着仓库一起进版本库 —— 图也要存。
转录自洛谷 P1216,日期见页头。两边不一致时信原站。
题目描述
观察下面的数字金字塔。
写一个程序来查找从最高点到底部任意处结束的路径,使路径经过数字的和最大。 每一步可以走到左下方的点也可以到达右下方的点。

在上面的样例中,从 7 → 3 → 8 → 7 → 5 的路径产生了最大权值。
输入格式
第一个行一个正整数 r,表示行的数目。
后面每行为这个数字金字塔特定行包含的整数。
输出格式
单独的一行,包含那个可能得到的最大的和。
数据规模与约定
对于 100% 的数据,1 ≤ r ≤ 1000,其他输入在 [0, 100] 范围内。
题目翻译来自 NOCOW。IOI1994 Day1T1 / USACO Training Section 1.5。
输入输出样例
输入
5 7 3 8 8 1 0 2 7 4 4 4 5 2 6 5
输出
30
就是题面那张图。⚠ 注意数据范围里那句「其他输入在 [0, 100] 范围内」—— 0 是合法的,第 ④ 步整节都在讲这个 0。
第 17 章从头到尾讲的就是这道题:暴力怎么写、慢在哪、 记忆化那三行加在哪里。那些都不重复了,这一页只做两件章节里没做的事:
- ★ 回头查正文:第 17 章写了四组数字(调用次数
2ⁿ - 1、每格被调用C(i,j)次、n = 20最热的格子 92378 次、三个「白算的比例」)—— 而它们此前一条断言都没有 (第 17 章是全书 53 章里唯一一个在check:viz里没有段落的章)。这一页把它们全钉住了。 - ★★★ 一个章节里没有的错法:把记忆化的
vis数组省掉。 它答案永远对、对拍永远抓不到,可它能把O(n²)打回O(2ⁿ)—— 而触发条件就藏在上面那句「[0, 100]」里。
1交哪一版:本章那两份原样就能过
暴力(纯递归)—— 交上去是 TLE,但它是理解这道题的起点:
// 数字三角形 —— 暴力:纯递归,每条路都走一遍//// 输入:第一行 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;}点「运行 ▶」看结果
记忆化 —— 这一版原样就能 AC:
// 数字三角形 —— 记忆化搜索//// 和 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;}点「运行 ▶」看结果
- 记忆化:格子一共
1000 × 1001 / 2 = 500500个,每个只算一次 —— 半毫秒的事。 - 纯递归:调用次数是
2¹⁰⁰⁰ - 1。那是一个 302 位的十进制数。
⇒ 这里没有「快一点慢一点」的余地。而两版的差别,就是本章说的那三行。
2★ 回头查第 17 章正文:那四组数字对不对
第 17 章第 5 步写了一张表和两句话。这一页把它们逐条跑了一遍:
层数 n |
格子数 | 正文写的调用次数 | 实测 | 2ⁿ - 1 |
白算的比例 |
|---|---|---|---|---|---|
| 10 | 55 | 1 023 | 1 023 | 1 023 | 94.6237% |
| 20 | 210 | 1 048 575 | 1 048 575 | 1 048 575 | 99.9800% |
| 30 | 465 | 1 073 741 823 | 1 073 741 823 | 1 073 741 823 | 99.99996% |
还有两句:
- 「位置
(i,j)恰好被调用了C(i,j)次」——n = 20的 210 个格子逐格比对, 对不上的有 0 个; - 「
n = 20时最热的那个格子被调用了 92 378 次」—— 实测正是 92378(也就是C(19,9))。
这不是走过场。硬规矩第 1 条写着「正文里的每个数字都必须是实测的,
而且能写成断言的都要写进 scripts/check-viz.mjs」——
而第 17 章是全书 53 章里唯一一个在 check:viz 里没有段落的章
(node scripts/check-viz.mjs --ch=17 会直接报「这个脚本里没有它们的段落」)。
⇒ 这一轮顺手把这个洞补上了:上面那张表、那两句话,现在都是断言。 ★ 这也是「本章原题的解析页该去查一遍正文」这条规矩第二次派上用场 (第一次是第 9 章 P1182,那次查出正文有两句是错的)。这次正文全对。
3★ 一个章节里没提的问题:那个 `vis` 数组能不能省掉?
第 17 章说记忆化「只多了三行:一个 f 数组、一个 vis 数组、一个查表就返回」。
很自然会想:能不能省掉 vis? 反正没算过的时候 f 是 0,算过之后是个正数 ——
拿 f[i][j] != 0 当「算过了」不就行了?
// P1216 的**错法**:记忆化省掉了 `vis` 数组 —— 用「`f[i][j] != 0` 就算算过了」当标记。//// 本章正文说记忆化「只多了三行:一个 f 数组、一个 vis 数组、一个查表就返回」。// 很多人会想省掉那个 `vis`:反正没算过的时候 `f` 是 0,算过之后是个正数,// **拿 0 当「还没算过」不就行了?**//// ⚠⚠ 这道题**不行**,而理由就写在题面里:**「其他输入在 [0, 100] 范围内」——// 允许 0**。于是一格的答案真的可能是 0(一条全 0 的路),// 而这一版会把它当成「还没算过」,**每次问到都重算一遍**。//// ★★★ 它最难缠的地方是:**答案永远是对的**。// 重算得到的还是同一个 0,输出一个字节都不差 —— **对拍一辈子抓不到**。// 它坏的只有复杂度:最坏(整个三角形全是 0)直接退回 `O(2ⁿ)`。// ⇒ 这一类 bug 只能靠**数调用次数**或者秒表现形,页面第 ④ 步量了那条曲线。
#include <bits/stdc++.h>using namespace std;
static int n;static vector<vector<long long>> a, f;static long long calls = 0;
static long long best(int i, int j) { calls++; if (f[i][j] != 0) return f[i][j]; // ★ 错在这里:0 既是「没算过」也是一个合法答案 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)); f[i][j] = res; return res;}
int main(int argc, char** argv) { 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)); cout << best(0, 0) << "\n"; if (argc > 1 && string(argv[1]) == "count") cerr << "calls=" << calls << "\n"; return 0;}点「运行 ▶」看结果
这道题不行,而理由就写在数据范围里:「其他输入在 [0, 100] 范围内」—— 允许 0。
一格的答案真的可能是 0,而这一版会把它当成「还没算过」,每次问到都重算一遍。
重算得到的还是同一个 0,输出一个字节都不差:
| 对拍 | 300 轮里答案不一致 |
|---|---|
| 默认档生成器 | 0 |
| 零占 60% 那一档 | 0 |
⇒ 这是第 53 章那条的又一次现场:有一类 bug 对拍永远抓不到 —— 它只坏复杂度,不坏答案。 想让它现形,只能数调用次数或者看秒表。
4★★★ 它到底坏多少:我猜的旋钮是错的
第一反应:坏多少应该由零的比例决定。零越多,被误判成「没算过」的格子越多。 把 20 层三角形里零的比例一档一档往上加:
| 零占 | 0% | 10% | 30% | 50% | 70% | 90% | 100% |
|---|---|---|---|---|---|---|---|
| 记忆化 | 381 | 381 | 381 | 381 | 381 | 381 | 381 |
省掉 vis |
381 | 381 | 381 | 385 | 383 | 599 | 1 048 575 |
这条曲线几乎是平的,只在最后一档炸了。 撒到 90% 的零也只让调用次数涨 1.6 倍 —— 说明「零的比例」根本不是那个旋钮。
best(i,j) = a[i][j] + max(best(左下), best(右下)),而所有值都非负。
⇒ best(i,j) = 0 要求 a[i][j] = 0 并且两个儿子的 best 都是 0 ——
一路推下去就是:(i,j) 底下那一整个三角锥,必须全是零。
零撒得再多,只要锥里有一个非零格,这一格就不会被误判。
换一个旋钮:底部连续 k 行全是 0(其余随机),同样 20 层:
底部全 0 的行数 k |
0 | 1 | 2 | 3 | 5 | 8 | 12 | 20 |
|---|---|---|---|---|---|---|---|---|
| 零占整个三角形 | 0.5% | 10.0% | 19.0% | 27.1% | 42.9% | 62.9% | 82.9% | 100% |
省掉 vis 的调用次数 |
381 | 381 | 421 | 511 | 1 141 | 6 253 | 65 577 | 1 048 575 |
| 相对记忆化 | 1.0× | 1.0× | 1.1× | 1.3× | 3.0× | 16.4× | 172× | 2752× |
把两张表并排看一眼:
| 零占整个三角形 | 省掉 vis 的调用次数 |
|
|---|---|---|
| 随机撒 90% 的零 | 90.0% | 599 |
| 底部 12 行全 0 | 82.9% | 65 577 |
零更少的那一档,调用次数多了 109.5 倍。
⇒ 零的「位置」比零的「比例」重要得多 —— 而这句话是被实测打出来的,
我的草稿写的是「坏多少由零的比例决定」。
★ 这是第 13 章 P1596 那条「抓获率是一张曲面不是一个数」的一个更尖锐的版本:
那道题的两个旋钮都有效,这道题第一个旋钮几乎完全无效,
而顺手写的生成器(rng() % 101,零占 1%)正好待在最平的那一头。
5再往前一步:递推版,以及它唯一的好处
// P1216 的递推版(自底向上填表)—— 本章第 10 步说的「这就是 DP」,写出来就是它。//// 记忆化是「从上往下问,问到底再往回填」;递推是「从底往上填,填到顶就是答案」。// 两者**填的是同一张表**,只是顺序反了过来,而且递推**没有递归**://// f[n-1][j] = a[n-1][j]// f[i][j] = a[i][j] + max(f[i+1][j], f[i+1][j+1])// 答案 = f[0][0]//// ★ 这道题 `r ≤ 1000`,两种写法都能过。递推的好处只有一个,而且和速度无关:// **它没有递归深度**。记忆化那版的栈深就是 `r`,题面给到 1000 还很安全,// 可要是哪天题面给到 10⁶,先炸的是栈不是时间(第 45 章那一章讲的就是这件事)。//// ⚠ 顺带一个只在**自顶向下**那种递推里才有的坑:// 如果反过来写成「f[i][j] = 从顶走到 (i,j) 的最大和」,// 那么答案是**最后一行的最大值**,不是 f[n-1][0] —— 题面写的是「到底部**任意处**结束」。// 自底向上这个方向没有这个坑:f[0][0] 就是答案。
#include <bits/stdc++.h>using namespace std;
int main() { 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]; } vector<long long> f(a[n - 1].begin(), a[n - 1].end()); for (int i = n - 2; i >= 0; i--) for (int j = 0; j <= i; j++) f[j] = a[i][j] + max(f[j], f[j + 1]); cout << f[0] << "\n"; return 0;}点「运行 ▶」看结果
它和记忆化填的是同一张表,只是顺序反了过来。这道题 r ≤ 1000,两种写法都能过。
记忆化那版的栈深就是 r。题面给到 1000 还很安全,
可要是哪天题面给到 10⁶,先炸的是栈,不是时间 —— 那正是第 45 章讲的事。
⚠ 顺带一个只在「自顶向下」那种递推里才有的坑:
如果反过来写成「f[i][j] = 从顶走到 (i,j) 的最大和」,
那么答案是最后一行的最大值,不是 f[r-1][0] —— 题面写的是「到底部任意处结束」。
自底向上这个方向没有这个坑:f[0][0] 就是答案。
6度量程序和生成器
上面两张曲线表、查正文那一段、以及「答案永远相同」那两行,都是这一份跑出来的,
并且逐条写进了 scripts/check-viz.mjs(第 17 章从这一轮起才有段落)。
// P1216 的度量程序 —— 这一页所有数字都出自这一份,// ★ 其中第 ① 段是**回头查第 17 章正文**:那一章的四组数字此前一条断言都没有。//// `./p1216Count` 人看的版本// `./p1216Count csv` 一行一项,给 scripts/check-viz.mjs 写断言用//// 四段:// ① ★ 查正文:纯递归的调用次数是不是正好 `2ⁿ - 1`、每个格子是不是正好被调用 `C(i,j)` 次、// `n = 20` 最热的那格是不是 92378、正文那三个「白算的比例」对不对;// ② ★★★ 「省掉 vis」那个错法的调用次数,随**零的比例**怎么走;// ③ ★ 它的答案和正解逐字节相同 —— 对拍 0 次,只能靠数次数;// ④ 顶格 `r = 1000`:记忆化只要 500500 次,而纯递归是一个 302 位数。
#include <bits/stdc++.h>using namespace std;
static bool CSV = false;static void row(const char* key, const vector<long long>& v) { if (!CSV) return; printf("%s", key); for (long long x : v) printf(",%lld", x); printf("\n");}
static int n;static vector<vector<long long>> a, f;static vector<vector<char>> vis;static vector<vector<long long>> hit; // 每个格子被调用了几次static long long calls;
/** mode 0 = 纯递归;1 = 记忆化(带 vis);2 = 省掉 vis(拿 0 当「没算过」) */static long long best(int i, int j, int mode) { calls++; hit[i][j]++; if (mode == 1 && vis[i][j]) return f[i][j]; if (mode == 2 && f[i][j] != 0) 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, mode), best(i + 1, j + 1, mode)); if (mode == 1) vis[i][j] = 1; if (mode) f[i][j] = res; return res;}
static long long run(int mode, long long& outCalls) { f.assign(n, vector<long long>(n + 1, 0)); vis.assign(n, vector<char>(n + 1, 0)); hit.assign(n, vector<long long>(n + 1, 0)); calls = 0; long long r = best(0, 0, mode); outCalls = calls; return r;}
/** 造一个三角形(和 p1216Gen.cpp 逐字一致)。 */static void gen(int seed, int layers, int zeroPct, int zeroRows = 0) { mt19937 rng((unsigned)seed * 2654435761u + 17u); n = layers > 0 ? layers : (int)(rng() % 8 + 5); a.assign(n, {}); for (int i = 0; i < n; i++) { a[i].resize(i + 1); for (int j = 0; j <= i; j++) a[i][j] = (i >= n - zeroRows || (int)(rng() % 100) < zeroPct) ? 0 : (int)(rng() % 101); }}
int main(int argc, char** argv) { CSV = (argc > 1 && string(argv[1]) == "csv");
/* ① ★ 回头查第 17 章正文 */ { if (!CSV) printf("① 查第 17 章正文:纯递归的调用次数 / 杨辉三角 / 白算的比例\n"); vector<long long> out; for (int L : {10, 20, 30}) { gen(1, L, 0); long long c; run(0, c); // 纯递归 long long cells = (long long)L * (L + 1) / 2; // 正文写「调用次数正好是 2ⁿ - 1」 long long pow2 = (1LL << L) - 1; // 白算的比例(万分之几,取整到小数点后四位 × 10000) long long wasteE6 = (c - cells) * 1000000LL / c; out.push_back(c); out.push_back(pow2); out.push_back(cells); out.push_back(wasteE6); if (!CSV) printf(" n = %2d:格子 %lld 个,调用 %lld 次(2ⁿ-1 = %lld),白算 %.6f%%\n", L, cells, c, pow2, (c - cells) * 100.0 / c); } row("text", out);
// 正文写「位置 (i,j) 恰好被调用了 C(i,j) 次」,以及「n = 20 最热的格子 92378 次」 gen(1, 20, 0); long long c; run(0, c); long long bad = 0, hottest = 0; vector<vector<long long>> C(20, vector<long long>(20, 0)); for (int i = 0; i < 20; i++) { C[i][0] = 1; for (int j = 1; j <= i; j++) C[i][j] = C[i - 1][j - 1] + (j <= i - 1 ? C[i - 1][j] : 0); for (int j = 0; j <= i; j++) { if (hit[i][j] != C[i][j]) bad++; hottest = max(hottest, hit[i][j]); } } if (!CSV) printf(" ★ 每个格子的调用次数 ≡ C(i,j):对不上的有 %lld 个;最热的一格 %lld 次\n", bad, hottest); row("pascal", {bad, hottest}); }
/* ② ★★★ 「省掉 vis」的调用次数,随零的比例怎么走 */ { if (!CSV) printf("\n② 「省掉 vis」那个错法:20 层三角形,零的比例一档一档往上加\n"); vector<long long> out; for (int z : {0, 10, 30, 50, 70, 90, 100}) { gen(1, 20, z); long long cOk, cBad; run(1, cOk); run(2, cBad); out.push_back(cBad); if (!CSV) printf(" 零占 %3d%%:记忆化 %lld 次,省掉 vis %lld 次(%.1f 倍)\n", z, cOk, cBad, cBad * 1.0 / cOk); } row("zeroCurve", out); }
/* ②' ★★★ 真正的触发条件:底部连续 k 行全 0(草稿以为是「零的比例」,猜错了) */ { if (!CSV) printf("\n②' 换一个旋钮:20 层三角形,**底部 k 行全 0**\n"); vector<long long> out; for (int k : {0, 1, 2, 3, 5, 8, 12, 20}) { gen(1, 20, 0, k); long long cOk, cBad; run(1, cOk); run(2, cBad); long long zeros = 0; for (int i = 0; i < n; i++) for (int j = 0; j <= i; j++) if (a[i][j] == 0) zeros++; out.push_back(cBad); if (!CSV) printf(" 底部 %2d 行全 0(零占 %.1f%%):记忆化 %lld 次,省掉 vis %lld 次(%.1f 倍)\n", k, zeros * 100.0 / 210, cOk, cBad, cBad * 1.0 / cOk); } row("rowCurve", out); }
/* ③ ★ 答案完全相同 —— 对拍抓不到 */ { int diff = 0; for (int s = 1; s <= 300; s++) { gen(s, 0, 0); long long c1, c2; if (run(1, c1) != run(2, c2)) diff++; } int diffZ = 0; for (int s = 1; s <= 300; s++) { gen(s, 12, 60); // ★ 连「零很多」那一档也一样对 long long c1, c2; if (run(1, c1) != run(2, c2)) diff += 0, diffZ += (run(1, c1) != run(2, c2)); } if (!CSV) printf("\n③ 答案对不对:默认档 300 轮不一致 %d 次;零占 60%% 那档 300 轮不一致 %d 次\n", diff, diffZ); row("same", {diff, diffZ}); }
/* ④ 顶格 r = 1000 */ { long long cells = 1000LL * 1001 / 2; // 2^1000 的十进制位数 int digits = (int)floor(1000 * log10(2.0)) + 1; if (!CSV) printf("\n④ 顶格 r = 1000:记忆化最多算 %lld 个格子;" "而纯递归要 2¹⁰⁰⁰ - 1 次调用 —— 那是一个 %d 位数\n", cells, digits); row("full", {cells, digits}); } return 0;}点「运行 ▶」看结果
// P1216 对拍生成器:`./p1216Gen <seed> [层数] [零的百分比] [底部全 0 的行数]`// 层数 默认 5 ~ 12// 零的百分比 随机撒零,默认 0// ★★★ 底部全 0 的行数 —— **这个旋钮才是这一页的全部**//// 题面写的是「其他输入在 [0, 100] 范围内」——**0 是合法的**。// 而顺手写的生成器几乎都是 `rng() % 101`,于是零只占 1% 左右。//// ★★★ 这一页要抓的那个 bug(`p1216Zero.cpp`:省掉 vis、拿 0 当「没算过」)// **答案永远是对的**,所以它在对拍里是**精确的 0**,加多少轮都没用。// 它只坏复杂度 —— 而「坏多少」由什么决定,我猜错了一次://// ⚠ 草稿以为是**零的比例**。实测:20 层里随机撒 90% 的零,调用次数只从 381 涨到 599(1.6 倍),// 只有撒到 **100%** 才炸(1048575 次)。// ★★★ 真正的触发条件是**「底部连续 k 行全是 0」**:`best(i,j) = 0` 要求// **(i,j) 下面那一整个三角锥全是零**(因为值都非负,max 为 0 意味着两个儿子都为 0)。// ⇒ 底部 k 行全 0 时调用次数大约是 `2^k`,实测 k = 3/5/8/12 依次是 511 / 1141 / 6253 / 65577。// ⚠⚠ 而「底部 12 行全 0」那一档零只占 **82.9%**,**比随机撒 90% 的零还少**,// 却比它炸得多 **110 倍** —— **零的位置比零的比例重要得多。**
#include <bits/stdc++.h>using namespace std;
int main(int argc, char** argv) { unsigned seed = argc > 1 ? (unsigned)atoi(argv[1]) : 1; mt19937 rng(seed * 2654435761u + 17u); int n = argc > 2 ? atoi(argv[2]) : (int)(rng() % 8 + 5); int zeroPct = argc > 3 ? atoi(argv[3]) : 0; int zeroRows = argc > 4 ? atoi(argv[4]) : 0; n = max(1, min(1000, n)); zeroPct = max(0, min(100, zeroPct)); zeroRows = max(0, min(n, zeroRows));
printf("%d\n", n); for (int i = 0; i < n; i++) { for (int j = 0; j <= i; j++) { int v = (i >= n - zeroRows || (int)(rng() % 100) < zeroPct) ? 0 : (int)(rng() % 101); printf("%d%c", v, j == i ? '\n' : ' '); } } return 0;}点「运行 ▶」看结果
7一页纸
| 关键的一步 | 就是本章那三行:一个 f、一个 vis、一个「查表就返回」 |
| 哪一版能 AC | 第 17 章那份 fast.cpp 原样(递推版 p1216Down.cpp 也行) |
| 顶格是什么概念 | r = 1000:记忆化 500500 个格子;纯递归 2¹⁰⁰⁰ - 1 次 —— 一个 302 位数 |
| 最容易写错的一处 | 省掉 vis、拿 0 当「没算过」 —— 题面明说值可以是 0 |
| 这一页的主线 | 那个错法答案永远对、对拍永远 0 次,只坏复杂度; 而触发它的是零的「位置」不是「比例」:随机撒 90% 的零只值 1.6 倍, 底部 12 行全 0(零反而只占 82.9%)就是 172 倍 |
| 顺手补的账 | 第 17 章正文那四组数字此前一条断言都没有(全书唯一没有 viz 段落的章),这一轮补上了 |