题单 · 习题解析

洛谷 P1044 栈

看不出公式就写过程:说清 (rest, in) 两个量就写完了一半,记忆化只多两行;★★ 第 43 章补了第 ④ 版**闭形式** C(2n,n)/(n+1) —— 同一个数这本书数了三遍(搜索 / 递推 / 闭形式),而这一节最值钱的是那笔**溢出账**:**答案装得下 int(477 638 700),中间值装不下**(C(36,18) 是 int 上限的 4.22 倍,边乘边除路上最大的乘积是 **76.07 倍**)⇒ 前三版从来不算 C(2n,n),**换一条路就换一套溢出账**;⚠⚠ 而这里的溢出**绕不回来** —— 和[第 38 章 P3374](/sol/p3374/)(全程加减 ⇒ 模 2³² 绕回还能带回来)正好一对:**同余绕回只对加减乘成立,一句除法就把退路堵死了**;★ 「有闭形式」也不等于「照着抄」:先算三个阶乘那版从 **n = 11** 起全废,而边乘边除**每一步都恰好整除**(18 个 n 全验过,除不尽 0 次)

原题:洛谷 P1044出自 第 1 章 递归入门:函数怎么调用自己 的题单出自 第 21 章 DP 入门:从记忆化到递推 的题单出自 第 43 章 组合数与递推 的题单题面本地存档:2026-08-25
⚠ 先自己写一遍,再往下看

这一页不是标准答案,是一条阶梯:把大多数人真实会写出的第一版摆出来, 看它错在哪、慢在哪,再一步一步改。所以它对「已经自己动手撞过一次墙」的人最有用。
还没写过的话,先去写 —— 写出来的哪怕是错的,也比直接看这一页收获大。

0题目原文(本地存了一份)

为什么要在这儿抄一份题面

原题在洛谷上(页头有链接)。别人的网站不归我们管,打不开、改版、题号调整都可能发生。 所以每个解析页都把题面转录一份存在本地,跟着仓库一起进版本库。

转录自洛谷 P1044,日期见页头。两边不一致时信原站。 ★ 原题带两张图,两张都存了本地一份(public/sol/)—— 存档要连图一起存,不然存的还是半篇。

题目描述

一个操作数序列 1, 2, ..., n,栈 A 的深度大于 n。现在可以进行两种操作:

  1. 将一个数,从操作数序列的头端移到栈的头端(对应栈的 push 操作)
  2. 将一个数,从栈的头端移到输出序列的尾端(对应栈的 pop 操作)

使用这两种操作,由一个操作数序列就可以得到一系列的输出序列。 你的程序将对给定的 n,计算并输出由操作数序列 1, 2, ..., n 经过操作可能得到的输出序列的总数。

原题图一:左边是输出序列,右边是操作数序列,中间竖着的是栈 A

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

原题图二:n = 3 时六种输出序列各自的操作过程

(原题图二: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. 一上来就想找公式。找不到公式的题很多,先会写「说清状态 + 记忆化」,那是保底的本事。 这道题的公式(卡特兰数)和后面的闭形式都是锦上添花。
  2. 哨兵用 -1 不用 0。这道题用 0 也能碰巧过,但换一道答案可能是 0 的题就会栽: 缓存等于没加,只会看到一个莫名其妙的超时。

能 AC 的:第 ② 版(记忆化,二维数组)。考场上写到这里就可以去做下一题了。

下面每一步里灰色的「深入」块是折叠的:那是这些结论怎么实测出来的,第一遍可以跳过。

1先看清楚:这道题一眼看不出公式,但过程是清楚的

这道题和前面几道不一样 —— 你多半看不出它是什么数列。但没关系:

看不出公式,就把过程写下来。 每一步只有两种动作,那就两种都试一遍。

关键是先说清楚「函数负责什么」。这里需要两个量才说得清:

f(rest, in) = 「还有 rest 个数没进栈、栈里现在有 in 个,往后还能凑出多少种输出序列」

于是三条规则自己就出来了:

rest == 0   剩下的只能一个一个弹出来,顺序是定死的      ->  1 种
可以进栈    f(rest-1, in+1)
栈非空还可以出栈   f(rest, in-1)

2第 ① 版:把两种动作都搜一遍(对,但太慢)

p1044Naive.cpp第 ① 版(对,但太慢)
输入 10 很快,13 慢一点,16 要等好几秒。题目的上界是 18。
// 洛谷 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;
}
点一下即可编辑
输入(stdin)
输出
点「运行 ▶」看结果

这一版是对的,而且它把题意翻译得非常忠实。问题还是老一个:

⚠ 同一个 (rest, in) 被不同的路径反复问到

「先进 1 再出 1」和「先进 1 再进 2 再出 2 再出 1」…… 很多条不同的走法, 最后都会走到同一个状态上。而这一版每碰到一次就从头重算一次。

n = 16   要走 84 118 036 个节点
n = 18   十亿量级 —— 一秒钟的时限扛不住

★ 病根和 P1028、P1255 一模一样:子问题重叠。 不一样的是:那两道题的状态只有一个量,这道题有两个。

3第 ② 版:加一张二维表(★ 这一版就能 AC)

★★ 判断该不该上记忆化,就问这两句
  1. 一个子问题由哪几个量唯一决定? 这道题是 rest 和 in,两个。
  2. 这几个量的组合有多少种? 各自都不超过 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;                    // ★ 存下来
}
p1044Memo.cpp第 ② 版(能 AC)
直接输入 18。上一版要等好几秒的东西,这一版一瞬间。
// 洛谷 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;
}
点一下即可编辑
输入(stdin)
输出
点「运行 ▶」看结果

考场上写到这里就该去做下一题了。 后面那一版更漂亮,但一分都不多给。

⚠ 哨兵为什么用 −1,不用 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.cpp第 ③ 版(卡特兰递推)
输入 18,和第 ② 版完全一样:477638700。
// 洛谷 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;
}
点一下即可编辑
输入(stdin)
输出
点「运行 ▶」看结果
★ 值得记住的不是那个名字,是那个动作

「卡特兰数」这个词你可能一辈子只用到几次。但下面这个动作会一直用:

盯住某一个元素,按它的位置把问题劈成互不干扰的两半。

第 26 章区间 DP 用的是同一招(盯住「最后合并的是哪一堆」)。

⚠ 但顺序千万别反:先会写第 ② 版的记忆化,再去认公式。 认不出公式的题多得是,而「说清状态 + 记忆化」永远能用 —— 那才是保底的本事。

5三个版本并排:换一把可复现的尺子

深入:这张表是怎么数出来的
p1044Count.cpp数次数
不用输入,直接跑(第 1 列在 n = 16 时要走八千万个节点,会跑几秒)。
// 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;
}
点一下即可编辑
输入(stdin)
输出
点「运行 ▶」看结果
    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 起全废。

p1044Fact.cpp✗ 照着公式逐字抄:(2n)! / (n!·n!·(n+1)) —— n ≥ 11 就废
p1044Closed.cpp★ 第 ④ 版:边乘边除算 C(2n,n),再除以 n+1
输入 18,和前三版完全一样:477638700。
// ★ 第 ④ 版:闭形式 —— 卡特兰数 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;
}
点一下即可编辑
输入(stdin)
输出
点「运行 ▶」看结果
★★ 差别只是「换一个计算顺序」,而它把溢出整个消掉了

同一条公式,两种算法:

  • ✗ 先把三个阶乘各算出来再除 —— (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 次。

⇒ ★★ 「这条公式能不能用」和「这条公式怎么算」是两件事。

★★★ 而这一版有一件前三版完全没有的账:答案装得下 int,中间值装不下
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) 本身, 是除法之前那个乘积。

p1044Int.cpp✗ 边乘边除是对的,可中间那个变量用了 32 位 —— n ≥ 16 就错
p1044Cat.cpp四种写法在 n = 1..18 上并排(输入空间只有 18 个值,直接算完)
// 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;
}
点一下即可编辑
输出
点「运行 ▶」看结果
★ 这一节的两条自检,都是「走一条无关的路」
  1. 除法那条:C(n) = C(2n, n) − C(2n, n+1) —— 用杨辉三角推出来,一次除法都没有。 18 个 n 和递推版对不上的 0 个。
  2. 整除那条:把边乘边除的每一步余数都验一遍,除不尽的 0 次。

⚠ 而闭形式在这道题上不值钱:递推 171 次乘法、闭形式 18 次,两版都是一瞬间。 ⇒ 选它的理由不是快,是一行就说得清 (「这个技巧在这道题上成立」和「这道题该用它」是两句话)。 ★ 顺带一个巧合:递推那 171 次乘法,和第 ⑤ 步里记忆化的 171 个格子是同一个数 (都是 n(n+1)/2)。

7回头看:这道题在教什么

✓ 三件带得走的东西
  1. 看不出公式就写过程。 把「函数负责什么」说清楚,规则自己会冒出来 —— 这道题需要两个量 (rest, in) 才说得清,而说清了就写完了一半。
  2. 记忆化的判断标准是状态数,不是答案大小。 这道题答案 4.7 亿、状态 171 个。
  3. 哨兵用 −1 不用 0。 用 0 那次你可能碰巧对,下一次就会栽。

⇒ 这道题在第 1 章的题单里标着「想不出来就先跳过,学完第 17 章再回来」—— 现在你已经不用等到第 17 章了:第 ② 版就是记忆化,而它一共只多了两行。