0题目原文(本地存了一份)
原题在洛谷上(页头有链接)。别人的网站不归我们管,打不开、改版、题号调整都可能发生。 所以每个解析页都把题面转录一份存在本地,跟着仓库一起进版本库。
转录自洛谷 P1044,日期见页头。两边不一致时信原站。
★ 原题带两张图,两张都存了本地一份(public/sol/)—— 存档要连图一起存,不然存的还是半篇。
题目描述
一个操作数序列 1, 2, ..., n,栈 A 的深度大于 n。现在可以进行两种操作:
- 将一个数,从操作数序列的头端移到栈的头端(对应栈的 push 操作)
- 将一个数,从栈的头端移到输出序列的尾端(对应栈的 pop 操作)
使用这两种操作,由一个操作数序列就可以得到一系列的输出序列。
你的程序将对给定的 n,计算并输出由操作数序列 1, 2, ..., n 经过操作可能得到的输出序列的总数。

(原题图一,存档自洛谷:操作数序列从右边进来,压进中间的栈 A,再弹到左边的输出序列去。)

(原题图二:n = 3 的六张过程图 —— 注意一共只有 6 张,而 3 1 2 那种排列做不出来。)
输入格式:只含一个整数 n(1 ≤ n ≤ 18)。
输出格式:只有一行,即可能输出序列的总数目。
来源:NOIP 2003 普及组第三题。
输入输出样例
输入
3
输出
5
n = 3 的 5 种输出序列是:1 2 3、1 3 2、2 1 3、2 3 1、3 2 1。
(注意 3 1 2 是做不到的 —— 3 先出来说明 1、2 都还压在栈里,那 2 一定比 1 先出。)
上面那段输出是仓库里的 p1044.cpp 真跑出来的。
这道题考什么:递归 + 记忆化,状态要用两个量来说清。 看不出公式也没关系,先把过程写下来,记忆化就能过。
最容易错的两处
- 一上来就想找公式。找不到公式的题很多,先会写「说清状态 + 记忆化」,那是保底的本事。 这道题的公式(卡特兰数)和后面的闭形式都是锦上添花。
- 哨兵用
-1不用0。这道题用 0 也能碰巧过,但换一道答案可能是 0 的题就会栽: 缓存等于没加,只会看到一个莫名其妙的超时。
能 AC 的:第 ② 版(记忆化,二维数组)。考场上写到这里就可以去做下一题了。
下面每一步里灰色的「深入」块是折叠的:那是这些结论怎么实测出来的,第一遍可以跳过。
1先看清楚:这道题一眼看不出公式,但过程是清楚的
这道题和前面几道不一样 —— 你多半看不出它是什么数列。但没关系:
看不出公式,就把过程写下来。 每一步只有两种动作,那就两种都试一遍。
关键是先说清楚「函数负责什么」。这里需要两个量才说得清:
f(rest, in)= 「还有 rest 个数没进栈、栈里现在有 in 个,往后还能凑出多少种输出序列」
于是三条规则自己就出来了:
rest == 0 剩下的只能一个一个弹出来,顺序是定死的 -> 1 种
可以进栈 f(rest-1, in+1)
栈非空还可以出栈 f(rest, in-1)
2第 ① 版:把两种动作都搜一遍(对,但太慢)
// 洛谷 P1044 栈 —— 第 ① 版:把每一步「进栈还是出栈」都搜一遍(对,但太慢)//// 输入:n(1 <= n <= 18)// 输出:可能的输出序列总数//// 这份为什么存在:这道题第一眼看不出公式,但**过程是清清楚楚的** ——// 每一步只有两种动作,那就把两种都试一遍。这正是第 1 章讲的「信任函数」://// f(rest, in) = 「还有 rest 个数没进栈、栈里现在有 in 个,往后能凑出多少种输出序列」//// · rest == 0:剩下的只能一个一个弹出来,顺序是定死的 ⇒ 1 种// · 否则可以「进一个」:f(rest-1, in+1)// · 栈非空还可以「弹一个」:f(rest, in-1)//// ⚠ 它是对的,但**同一个 (rest, in) 会被不同的路径反复问到**。// n = 16 时要走 84 118 036 个节点,n = 18 已经到十亿量级 —— 一秒钟的时限扛不住。// ⇒ 病根和 P1255 一模一样:**子问题重叠**。下一版加个数组就好。
#include <bits/stdc++.h>using namespace std;
int n;
long long f(int rest, int in) { if (rest == 0) return 1; // 剩下的只能依次弹出,只有一种 long long total = f(rest - 1, in + 1); // 进栈 if (in > 0) total += f(rest, in - 1); // 出栈 return total;}
int main() { if (!(cin >> n)) return 0; cout << f(n, 0) << "\n"; return 0;}点「运行 ▶」看结果
这一版是对的,而且它把题意翻译得非常忠实。问题还是老一个:
「先进 1 再出 1」和「先进 1 再进 2 再出 2 再出 1」…… 很多条不同的走法, 最后都会走到同一个状态上。而这一版每碰到一次就从头重算一次。
n = 16 要走 84 118 036 个节点
n = 18 十亿量级 —— 一秒钟的时限扛不住3第 ② 版:加一张二维表(★ 这一版就能 AC)
- 一个子问题由哪几个量唯一决定? 这道题是
rest和in,两个。 - 这几个量的组合有多少种? 各自都不超过 n = 18 ⇒ 最多
19 × 19 = 361个格子。
361 个格子,每个只算一次 —— 记忆化在这里稳赢。
⇒ 这两句话就是记忆化的全部判断依据,比记住「什么题能用记忆化」有用得多。
long long memo[25][25]; // ⚠ 初值 memset 成 -1,不是 0
long long f(int rest, int in) {
if (rest == 0) return 1;
if (memo[rest][in] >= 0) return memo[rest][in]; // ★ 算过了
long long total = f(rest - 1, in + 1);
if (in > 0) total += f(rest, in - 1);
return memo[rest][in] = total; // ★ 存下来
}
// 洛谷 P1044 栈 —— 第 ② 版:同一个递归,加一张二维表(★ 这一版就能 AC)//// 输入:n// 输出:可能的输出序列总数(和第 ① 版完全一样)//// 这份为什么存在:第 ① 版慢在「同一个 (rest, in) 被问了很多遍」。// **状态只有 (rest, in) 两个量,各自不超过 n** ⇒ 一共才 19 × 19 = 361 个格子,// 每个只需要算一次。//// ★ 这就是记忆化的判断标准,值得背下来:// **先看清楚「一个子问题由哪几个量唯一决定」,再看这几个量的组合有多少种。**// 组合数不大(这里 361),记忆化就一定划算。//// 数字:n = 18 时第 ① 版要走十亿量级的节点,这一版只算 361 个格子。//// ⚠ memo 初值用 -1,不能用 0 —— 0 是**合法的答案**吗?这道题不是(答案恒 >= 1),// 但养成用 -1 的习惯更安全:换一道题,0 很可能就是个真答案了。
#include <bits/stdc++.h>using namespace std;
int n;long long memo[25][25];
long long f(int rest, int in) { if (rest == 0) return 1; if (memo[rest][in] >= 0) return memo[rest][in]; // ★ 算过了 long long total = f(rest - 1, in + 1); if (in > 0) total += f(rest, in - 1); return memo[rest][in] = total; // ★ 存下来}
int main() { if (!(cin >> n)) return 0; memset(memo, -1, sizeof memo); cout << f(n, 0) << "\n"; return 0;}点「运行 ▶」看结果
考场上写到这里就该去做下一题了。 后面那一版更漂亮,但一分都不多给。
P1028 那道题用 0 当「还没算过」是安全的,因为那道题的答案恒大于 0。
但这是个碰运气的前提。换一道答案可能是 0 的题,用 0 当哨兵会让 「算出来是 0」和「还没算过」永远分不开 —— 那个格子每次都重算,缓存等于没加, 而答案还是对的,你根本发现不了,只会看到一个莫名其妙的 TLE。
⇒ 养成习惯:memset(memo, -1, sizeof memo),判断写 >= 0。
4第 ③ 版:换个角度,它是卡特兰数
深入:为什么它是卡特兰数(第一遍可以跳过)
记忆化已经能过了。这一版不是为了更快(都是一瞬间),是为了看清这道题到底是什么。
盯住一个具体的东西:1 号数字是第几个被输出的?
假设 1 是第 k+1 个被输出的。那么在它之前必须先输出 k 个数 ——
而这 k 个数只能来自 2..k+1(1 还压在栈底,只有比它晚进栈的才可能先出来)。
于是整个问题被切成两段互不干扰的小问题:
[ 前面那 k 个数自己排 ] 1 [ 剩下 n-1-k 个数自己排 ]
f(k) f(n-1-k)
f(n) = f(0)·f(n-1) + f(1)·f(n-2) + ... + f(n-1)·f(0) f(0) = 1这就是卡特兰数。f(3) = 5,正好是样例。
// 洛谷 P1044 栈 —— 第 ③ 版:换个角度想,它是卡特兰数//// 输入:n// 输出:可能的输出序列总数//// 这份为什么存在:记忆化已经能过了。这一版不是为了更快(都是一瞬间),// 是为了**看清这道题到底是什么**。//// 换个角度:盯住「**1 号数字是第几个被输出的**」。// 假设 1 是第 k+1 个被输出的,那么在它之前必须先输出 k 个数 ——// 而这 k 个数只能来自 2..k+1(因为 1 还压在栈底,比它晚进栈的才可能先出来)。// 于是整个问题被切成两段互不干扰的小问题://// 前面那段:k 个数自己排 ⇒ f(k)// 后面那段:剩下 n-1-k 个数自己排 ⇒ f(n-1-k)//// f(n) = f(0)·f(n-1) + f(1)·f(n-2) + ... + f(n-1)·f(0) f(0) = 1//// 这就是**卡特兰数**(Catalan)。f(3) = 5,正好是样例。//// ★ 值得记住的不是「卡特兰数」这个名字,是那个动作:// **盯住某一个元素,按它的位置把问题劈成两半。** 第 26 章区间 DP 用的是同一招。//// ⚠ 但也别倒过来想:**先会写第 ② 版的记忆化,再去认公式。**// 认不出公式的题多得是,而「状态 + 记忆化」永远能用。
#include <bits/stdc++.h>using namespace std;
int main() { int n; if (!(cin >> n)) return 0;
vector<long long> f(n + 1, 0); f[0] = 1; for (int i = 1; i <= n; i++) for (int k = 0; k < i; k++) f[i] += f[k] * f[i - 1 - k];
cout << f[n] << "\n"; return 0;}点「运行 ▶」看结果
「卡特兰数」这个词你可能一辈子只用到几次。但下面这个动作会一直用:
盯住某一个元素,按它的位置把问题劈成互不干扰的两半。
第 26 章区间 DP 用的是同一招(盯住「最后合并的是哪一堆」)。
⚠ 但顺序千万别反:先会写第 ② 版的记忆化,再去认公式。 认不出公式的题多得是,而「说清状态 + 记忆化」永远能用 —— 那才是保底的本事。
5三个版本并排:换一把可复现的尺子
深入:这张表是怎么数出来的
// P1044 的两个递归版本各走多少个节点 —— 换一把可复现的尺子//// 输入:无(表是写死的几个 n;第 ① 列超过 n = 16 就跑不动了,所以表到 16 为止)// 输出:一张表,对每个 n 列出// 答案 f(n) 可能的输出序列总数// 1.没有记忆化 走过的节点数// 2.有记忆化 调用次数(含直接命中缓存的那些)// 真正算过的格子 记忆化下有多少个 (rest, in) 被真的算了一遍//// ★ 最后一列是这道题的题眼:**它正好是 n(n+1)/2**,因为能走到的状态就是// rest 取 1..n、in 取 0..n-rest —— 数一数就这么多,和答案有多大毫无关系。// ⇒ 「该不该上记忆化」这个判断,看的就是它:状态的组合数够小,就一定划算。//// ⚠ 第 ① 列的增长和答案本身一个量级(都是卡特兰数那条曲线),// 所以它和 P1255、P1028 是同一种慢:**工作量跟着答案一起爆炸**。
#include <bits/stdc++.h>using namespace std;
int n;long long naiveNodes = 0;
long long naive(int rest, int in) { naiveNodes++; if (rest == 0) return 1; long long total = naive(rest - 1, in + 1); if (in > 0) total += naive(rest, in - 1); return total;}
long long memo[25][25];long long memoCalls = 0, memoFilled = 0;
long long withMemo(int rest, int in) { memoCalls++; if (rest == 0) return 1; if (memo[rest][in] >= 0) return memo[rest][in]; memoFilled++; long long total = withMemo(rest - 1, in + 1); if (in > 0) total += withMemo(rest, in - 1); return memo[rest][in] = total;}
int main() { cout << " n 答案 f(n) 1.没记忆化 2.有记忆化 真正算的格子\n"; cout << "----- ------------- ------------- ----------- -----------\n";
for (int k : {3, 5, 10, 13, 15, 16}) { n = k; naiveNodes = 0; long long ans = naive(k, 0);
memset(memo, -1, sizeof memo); memoCalls = memoFilled = 0; withMemo(k, 0);
cout << setw(5) << k << setw(16) << ans << setw(16) << naiveNodes << setw(14) << memoCalls << setw(14) << memoFilled << "\n"; }
cout << "\n最后一列正好是 n(n+1)/2(自己对一对:3->6, 5->15, 10->55, 16->136)——\n"; cout << "因为能走到的状态就是 rest 取 1..n、in 取 0..n-rest,数一数就是这么多。\n"; cout << "题目的上界 n = 18:第 1 列到那时是十亿量级,而最后一列只有 171 个格子。\n"; return 0;}点「运行 ▶」看结果
n 答案 f(n) 1.没记忆化 2.有记忆化 真正算的格子
----- ------------- ------------- ----------- -----------
3 5 13 10 6
5 42 106 26 15
10 16796 40509 101 55
13 742900 1776311 170 91
15 9694845 23097541 226 120
16 35357670 84118036 257 136- 第 1 列跟着答案一起爆炸 —— 和 P1028、P1255 是同一种慢。
- 最后一列正好是
n(n+1)/2(自己对一对:3→6、5→15、10→55、16→136)。 因为能走到的状态就是rest取1..n、in取0..n-rest,数一数就这么多。 - 题目的上界
n = 18:第 1 列是十亿量级,而最后一列只有 171 个格子。
它和「答案有多大」完全无关 —— 答案是 4.7 亿,格子只有 171 个。
⇒ 所以判断记忆化划不划算,永远看状态数,不看答案大小。 这两个数在这道题里差了六个数量级,正好把它们分得清清楚楚。
6★ 第 ④ 版:闭形式(第 43 章补的一节)
深入:闭形式的溢出账(进阶,可以跳过)
| 章 | 怎么得到 f(18) = 477638700 |
|---|---|
| 第 1 章(本页第 ① ② 版) | 搜索:把两种动作都试一遍,加记忆化 |
| 第 21 章(本页第 ③ 版) | 递推:f(n) = Σ f(i)·f(n−1−i),O(n²) 次乘法 |
| ★ 第 43 章(这一节) | 闭形式:C(2n, n) / (n+1),O(n) 次乘法 |
⚠ 而「有闭形式」不等于「照着公式抄」 —— 下面那一版就是照着抄的,它从 n = 11 起全废。
// ★ 第 ④ 版:闭形式 —— 卡特兰数 C(2n, n) / (n+1)//// [第 43 章](/ch/43-combinatorics/)给出的那条闭形式。第 ③ 版是**递推**// (f(n) = Σ f(i)·f(n−1−i),O(n²) 次乘法),这一版**一个循环、n 次乘法**就完了。//// ⚠ 但「有闭形式」不等于「照着公式抄」:// 直接写 (2n)! / (n! · n! · (n+1)) 是**算不出来**的 —— 36! ≈ 3.7×10⁴¹,// long long 只能装到 20!(见 p1044Fact.cpp)。//// ★ 走得通的写法是**边乘边除**:// C(2n, n) = ∏(i = 1..n) (n + i) / i// 而且**每一步除法都是整除** —— 因为前 i 步乘完除完之后正好是 C(n+i, i),// 它本身就是个整数。⇒ 全程不掉精度、不用高精度、不用逆元。//// ⚠ 题面 n ≤ 18 ⇒ **不用取模**,可以直接看清楚原始的样子// (而中间值 C(36,18) = 9 075 135 300 已经撑破 int 了,见 p1044Int.cpp)。#include <bits/stdc++.h>using namespace std;typedef long long ll;
int main() { ll n; if (scanf("%lld", &n) != 1) return 0; ll c = 1; for (ll i = 1; i <= n; i++) c = c * (n + i) / i; // 每一步都恰好是 C(n+i, i) printf("%lld\n", c / (n + 1)); return 0;}点「运行 ▶」看结果
同一条公式,两种算法:
- ✗ 先把三个阶乘各算出来再除 ——
(2n)!在2n = 21时就撞破long long(21! ≈ 5.1×10¹⁹ > 9.22×10¹⁸)⇒ 第一个算错的n是 11, 而题面n ≤ 18⇒ 一半以上的合法输入它都答错(n = 13起干脆输出 0)。 - ★ 边乘边除:
C(2n, n) = ∏(i = 1..n) (n+i) / i。 它凭什么不掉精度?因为前i步做完之后,手上那个数正好是C(n+i, i)—— 本身就是整数。18 个n的每一步余数都验过:除不尽的 0 次。
⇒ ★★ 「这条公式能不能用」和「这条公式怎么算」是两件事。
n = 18 |
值 | 和 int 上限(2 147 483 647)比 |
|---|---|---|
答案 f(18) |
477 638 700 | ★ 装得下(只用了 22%) |
C(36, 18) |
9 075 135 300 | 4.22 倍 |
| 边乘边除路上最大的那个乘积 | 163 352 435 400 | ★ 76.07 倍 |
⇒ 前三版(搜索 / 二维表 / 卡特兰递推)用 int 都没事 —— 因为它们从来不去算 C(2n,n),
每一个中间量都 ≤ 答案。换一条路,就换一套溢出账。
⚠⚠ 而这里的溢出绕不回来,这一点值得和第 38 章 P3374 摆在一起看:
那道题「中间值越界了,答案照样对」,因为它全程只有加减 ⇒ 模 2³² 同余能一路带回来;
这一版中间有除法,一旦绕回,c / i 就不再是原来那个商。
⇒ ★★★ 同余绕回只对加、减、乘成立 —— 一句除法就把这条退路堵死了。
★ 实测那条线在 n = 16(不是 17):因为最先撑破 32 位的不是 C(2n,n) 本身,
是除法之前那个乘积。
// P1044 第 ④ 版(闭形式)那一节的度量程序:./p1044Cat csv//// line : ★ 三种写法各自的**溢出线**在 n 的哪一格(照着题面 n ≤ 18 全枚举,一格不落)。// exact : ★★ 「边乘边除每一步都整除」不是运气 —— 把每一步的余数都验一遍。// alt : ★★ 一条和除法完全无关的路:C(n) = C(2n,n) − C(2n,n+1),逐个对一遍。// ops : ★ 递推(O(n²))和闭形式(O(n))的乘法次数。//// ⚠ 这道题的输入空间只有 18 个值 ⇒ [「算一遍」比「对拍」又快又充分](/sol/p1002/)。#include <bits/stdc++.h>using namespace std;typedef long long ll;typedef unsigned long long ull;
const int NMAX = 18;
/** 第 ③ 版:卡特兰递推 f(n) = Σ f(i)·f(n−1−i) */static ll rec(int n, ll* opsOut) { ll f[NMAX + 1] = {0}; ll ops = 0; f[0] = 1; for (int i = 1; i <= n; i++) { f[i] = 0; for (int j = 0; j < i; j++) { f[i] += f[j] * f[i - 1 - j]; ops++; } } if (opsOut) *opsOut = ops; return f[n];}
/** 第 ④ 版:闭形式,边乘边除 */static ll closed(int n, ll* opsOut, ll* maxMid, bool* allExact) { ll c = 1, ops = 0, mx = 1; bool exact = true; for (int i = 1; i <= n; i++) { ll t = c * (n + i); if (t > mx) mx = t; if (t % i != 0) exact = false; // ★ 这一步除得尽吗 c = t / i; if (c > mx) mx = c; ops++; } if (opsOut) *opsOut = ops; if (maxMid) *maxMid = mx; if (allExact) *allExact = exact; return c / (n + 1);}
/** ✗ 三个阶乘各算一遍 */static ll byFact(int n) { ll f2n = 1, fn = 1; for (ll i = 1; i <= 2 * n; i++) f2n *= i; for (ll i = 1; i <= n; i++) fn *= i; return f2n / (fn * fn) / (n + 1);}
/** ✗ 边乘边除但用 32 位(unsigned,绕回是明确定义的) */static unsigned byInt(int n) { unsigned c = 1; for (unsigned i = 1; i <= (unsigned)n; i++) c = c * ((unsigned)n + i) / i; return c / ((unsigned)n + 1);}
/** 一条和除法完全无关的路:C(n) = C(2n, n) − C(2n, n+1)(杨辉三角推出来) */static ll byDiff(int n) { static ll C[2 * NMAX + 1][2 * NMAX + 2]; for (int i = 0; i <= 2 * n; i++) { C[i][0] = 1; for (int j = 1; j <= i; j++) C[i][j] = C[i - 1][j - 1] + C[i - 1][j]; for (int j = i + 1; j <= 2 * n + 1; j++) C[i][j] = 0; } return C[2 * n][n] - C[2 * n][n + 1];}
int main(int argc, char** argv) { bool csv = (argc > 1 && string(argv[1]) == "csv");
int firstFactBad = -1, firstIntBad = -1, diffBad = 0, exactBad = 0; ll maxMid18 = 0, opsRec18 = 0, opsClosed18 = 0, ans18 = 0;
if (!csv) printf(" n 答案 f(n) ③ 递推 ★ ④ 闭形式 ✗ 三个阶乘 ✗ 闭形式用 int 闭形式的最大中间值\n"); for (int n = 1; n <= NMAX; n++) { ll opsR = 0, opsC = 0, mx = 0; bool exact = true; ll r = rec(n, &opsR); ll c = closed(n, &opsC, &mx, &exact); ll f = byFact(n); unsigned it = byInt(n); ll d = byDiff(n); if (!exact) exactBad++; if (d != r) diffBad++; if (f != r && firstFactBad < 0) firstFactBad = n; if ((ll)it != r && firstIntBad < 0) firstIntBad = n; if (n == NMAX) { maxMid18 = mx; opsRec18 = opsR; opsClosed18 = opsC; ans18 = r; } if (!csv) printf("%3d %16lld %12lld %12lld %13lld %14u %20lld\n", n, r, r, c, f, it, mx); }
if (csv) { printf("firstFactBad,%d\n", firstFactBad); printf("firstIntBad,%d\n", firstIntBad); printf("diffBad,%d\n", diffBad); printf("exactBad,%d\n", exactBad); printf("ans18,%lld\n", ans18); printf("maxMid18,%lld\n", maxMid18); printf("intTop,%lld\n", 2147483647LL); printf("midOverInt,%.2f\n", (double)maxMid18 / 2147483647.0); printf("ansUnderInt,%s\n", ans18 <= 2147483647LL ? "yes" : "no"); printf("opsRec18,%lld\n", opsRec18); printf("opsClosed18,%lld\n", opsClosed18); return 0; }
printf("\n① 三条溢出线(题面 n ≤ 18,18 个值全试过)\n"); printf(" ✗ 三个阶乘各算一遍 第一个算错的 n = **%d**(2n = %d ⇒ 21! 装不下 long long)\n", firstFactBad, 2 * firstFactBad); printf(" ✗ 闭形式但用 32 位 第一个算错的 n = **%d**\n", firstIntBad); printf(" ★ 闭形式 + long long 18 个值**全对**\n");
printf("\n② n = 18 那一格:答案装得下 int,中间值装不下\n"); printf(" 答案 %13lld %s int(上限 2147483647)\n", ans18, ans18 <= 2147483647LL ? "≤" : ">"); printf(" 最大中间值 %13lld 是 int 上限的 **%.2f 倍**\n", maxMid18, (double)maxMid18 / 2147483647.0); printf(" ⇒ ★★ 前三版从来不算 C(2n,n),它们的每个中间量都 ≤ 答案 —— **换一条路就换一套溢出账**\n");
printf("\n③ 两条自检\n"); printf(" 「边乘边除每一步都整除」除不尽的 n:**%d 个**(前 i 步之后正好是 C(n+i, i))\n", exactBad); printf(" 一条和除法无关的路 C(2n,n) − C(2n,n+1),和递推对不上的 n:**%d 个**\n", diffBad);
printf("\n④ 次数(n = 18)\n"); printf(" ③ 卡特兰递推 %lld 次乘法(O(n²))\n", opsRec18); printf(" ★ ④ 闭形式 %lld 次乘法(O(n))—— 而两版都是一瞬间,选它的理由是「一行说得清」\n", opsClosed18); return 0;}点「运行 ▶」看结果
- 除法那条:
C(n) = C(2n, n) − C(2n, n+1)—— 用杨辉三角推出来,一次除法都没有。 18 个n和递推版对不上的 0 个。 - 整除那条:把边乘边除的每一步余数都验一遍,除不尽的 0 次。
⚠ 而闭形式在这道题上不值钱:递推 171 次乘法、闭形式 18 次,两版都是一瞬间。
⇒ 选它的理由不是快,是一行就说得清
(「这个技巧在这道题上成立」和「这道题该用它」是两句话)。
★ 顺带一个巧合:递推那 171 次乘法,和第 ⑤ 步里记忆化的 171 个格子是同一个数
(都是 n(n+1)/2)。
7回头看:这道题在教什么
- 看不出公式就写过程。 把「函数负责什么」说清楚,规则自己会冒出来 ——
这道题需要两个量
(rest, in)才说得清,而说清了就写完了一半。 - 记忆化的判断标准是状态数,不是答案大小。 这道题答案 4.7 亿、状态 171 个。
- 哨兵用 −1 不用 0。 用 0 那次你可能碰巧对,下一次就会栽。
⇒ 这道题在第 1 章的题单里标着「想不出来就先跳过,学完第 17 章再回来」—— 现在你已经不用等到第 17 章了:第 ② 版就是记忆化,而它一共只多了两行。