这一章的正解只有一行公式:
C(n,k) = n! · (k!)⁻¹ · ((n−k)!)⁻¹ (mod p)
可这一行里,每一个零件都是前三章的:
| 零件 | 出处 |
|---|---|
逆元存在 ⟺ gcd(b, p) = 1 |
第 40 章(裴蜀定理) |
| p 得是质数,费马小定理才成立 | 第 41 章 |
算 b^(p−2) 靠快速幂 |
第 42 章 |
★ 而这一章自己贡献的,是那句「取模之下不能除」 —— 以及它带出来的一整套前提。
⚠ 收尾还有一件事要办:第 17 章讲记忆化时留过一句话 —— 「不带记忆的递归,调用次数正好排成杨辉三角」。 这一章有了组合数,第 10 步把它证掉,并且跑出来对一遍。
1一句话问题
给定一个质数 p(
10⁶ < p ≤ 10⁹)和 q 组询问(q ≤ 10⁵),每组两个数n、k(0 ≤ n, k ≤ 10⁶,两个各自独立):
- 输出
C(n,k) mod p(k > n时 C(n,k) = 0);- ★ 最后再输出一行:有多少组的答案是 0。
10⁶ < p 这一句写得很刻意,理由在第 6 步会展开,先说结论:
只要
p ≤ n,n!里就含有 p 这个因子 ⇒n! ≡ 0 (mod p),而 0 没有逆元。 整套公式当场失效,而且不报错、不崩溃,只是安静地给你一串 0。
⚠ 而 0 ≤ n, k ≤ 10⁶ 里那句「两个各自独立」同样刻意 ——
它意味着 k 可以大于 n。第 9 步会看到:顺手写的生成器永远造不出这种数据。
2手算一遍:默认那 10 组
p = 999999937
0 0 → 1 C(0,0) = 1
5 0 → 1 ★ k = 0
5 5 → 1 ★ k = n
5 2 → 10
10 3 → 120
21 10 → 352716 ★ n = 21:21! 已经超过 long long
30 15 → 155117520
3 7 → 0 ★★ k > n
25 25 → 1 ★ k = n
40 1 → 40
末行 → 1 (答案是 0 的只有一组)四处特意安排,每一处都指着一个 bug:
k = 0和k = n(三组)—— 那是「取全部 / 一个都不取」,用到的是inv[0];k > n(3 7)—— 题面规定答案是 0,而套公式会把n−k变成负数;n = 21——21! ≈ 5.1×10¹⁹已经撞破 long long,阶乘那一行必须取模;p ≈ 10⁹—— 三个[0,p)里的数连乘就是 10²⁷,中间必须取模。
3暴力:杨辉三角
// 标准答案 —— 杨辉三角递推:**C(n,k) = C(n−1,k−1) + C(n−1,k)**//// ★ 这条递推为什么成立(一句话,而且是这一章的地基):// 从 n 个东西里挑 k 个,盯住**第 n 个东西**:// · 挑它 ⇒ 剩下的从前 n−1 个里挑 k−1 个 ⇒ C(n−1, k−1);// · 不挑它 ⇒ 从前 n−1 个里挑 k 个 ⇒ C(n−1, k)。// 两类不重不漏,加起来就是全部。∎// ⚠ 注意它**全程只有加法** —— 于是「取模」这件事在这里毫无难度(加完取一次就行)。// 第 6 步会看到:正解那条路是乘法和除法,取模的坑全在那边。//// 为什么它存在:// ① 它是对拍里的标准答案,想法和「阶乘 + 逆元」**完全不同**// (一个是递推,一个是公式;第 9、15 章那条规矩);// ② ★ 它同时是这一章的反面教材:要 O(n²) 的时间和空间 ——// n = 10⁶ 时那张表有 10¹² 个格子,**开都开不出来**。//// ⚠ 边界(题面写死的,也是这一章两个 bug 的落点):// · C(n, 0) = C(n, n) = 1;// · **k > n 时 C(n,k) = 0**。//// 复杂度 O(n²)。
#include <bits/stdc++.h>using namespace std;
using ll = long long;
int main() { ios::sync_with_stdio(false); cin.tie(nullptr);
ll p; int q; if (!(cin >> p >> q)) return 0;
vector<pair<int, int>> qs(q); int maxn = 0; for (auto& [n, k] : qs) { cin >> n >> k; maxn = max(maxn, n); }
// 杨辉三角:一行一行往下推 vector<vector<ll>> c(maxn + 1); for (int i = 0; i <= maxn; i++) { c[i].assign(i + 1, 0); c[i][0] = 1 % p; for (int j = 1; j <= i; j++) c[i][j] = (c[i - 1][j - 1] + (j <= i - 1 ? c[i - 1][j] : 0)) % p; }
string out; int zeros = 0; for (auto& [n, k] : qs) { ll v = (k > n) ? 0 : c[n][k]; // ⚠ k > n 时是 0 if (v == 0) zeros++; out += to_string(v); out += '\n'; } out += to_string(zeros); // ★ 末行:答案是 0 的有几组 out += '\n';
cout << out; return 0;}点「运行 ▶」看结果
从 n 个东西里挑 k 个,盯住第 n 个东西:
- 挑它 ⇒ 剩下的从前 n−1 个里挑 k−1 个 ⇒
C(n−1, k−1); - 不挑它 ⇒ 从前 n−1 个里挑 k 个 ⇒
C(n−1, k)。
两类不重不漏,加起来就是全部。∎
⚠ 注意它全程只有加法 —— 于是「取模」这件事在这里毫无难度(加完取一次就行)。 ★ 这一章所有的坑,都长在另一条路上(乘法和除法)。
4动画一:三角形一行一行长出来
- 每一格 = 上一行的「左上 + 正上」,就是上面那条递推。
- ★ 右边那个计数器:到第 n 行,加法次数正好是
n(n+1)/2。
⚠ 而这正是它的死穴:
| n | 加法次数 | 要开多少格子 |
|---|---|---|
| 10³ | 50 万 | 10⁶ |
| 10⁴ | 5 000 万 | 10⁸ |
| 10⁶ | 5 000 亿 | ⚠ 10¹²(开都开不出来) |
不是慢,是根本装不下。 所以必须换成一行公式。
⚠ trace.cpp 是这个动画的文字版,check:viz 拿它和动画逐行比。
// 动画的文字版 —— 杨辉三角一行一行长出来,以及每一格是谁加出来的//// 为什么它存在:动画是用 TypeScript 重写一遍算法画出来的,// 万一两边不一致,学生看到的画面就是在骗人(第 27 章那次就是这么被抓住的)。// `check:viz` 拿它和动画**逐行**比。//// 输出每一行是杨辉三角的一行(对 p 取模),并标出这一行做了几次加法、累计几次。//// 用法:./trace [n] [p] 默认 8 1000000007
#include <bits/stdc++.h>using namespace std;using ll = long long;
int main(int argc, char** argv) { const int n = (argc > 1) ? atoi(argv[1]) : 8; const ll p = (argc > 2) ? atoll(argv[2]) : 1000000007LL;
vector<ll> row(n + 1, 0); row[0] = 1 % p; ll total = 0; printf("行 0 [%lld] 加法 0 累计 0\n", row[0]); for (int i = 1; i <= n; i++) { ll adds = 0; for (int j = i; j >= 1; j--) { row[j] = (row[j] + row[j - 1]) % p; adds++; } total += adds; printf("行 %d [", i); for (int j = 0; j <= i; j++) printf("%lld%s", row[j], j == i ? "" : " "); printf("] 加法 %lld 累计 %lld\n", adds, total); } printf("收尾 n = %d 总加法 %lld = n(n+1)/2 = %lld %s\n", n, total, (ll)n * (n + 1) / 2, total == (ll)n * (n + 1) / 2 ? "✓" : "✗ 对不上"); return 0;}点「运行 ▶」看结果
5★ 关键一步:取模之下不能除
C(n,k) = n! / (k! · (n−k)!)。可取模之下不能直接除 ——
(a / b) mod p 和 (a mod p) / (b mod p) 根本不是一回事(6/3 = 2,可 6 mod 5 = 1、3 mod 5 = 3)。
★ 办法是把除法换成乘法:
b⁻¹是那个满足b · b⁻¹ ≡ 1 (mod p)的数,于是a / b ≡ a · b⁻¹。
① 它什么时候存在?(第 40 章)
b · x ≡ 1 (mod p) 就是 b·x + p·y = 1 有整数解 ——
按裴蜀定理,这等价于 gcd(b, p) = 1。
⇒ p 是质数时,只要 b 不是 p 的倍数就一定有逆元。
② 怎么算?(第 41、42 章)
费马小定理:p 是质数、b 不是 p 的倍数 ⇒ b^(p−1) ≡ 1 ⇒ b⁻¹ ≡ b^(p−2)。
⇒ 一次快速幂(第 42 章那五行)就够了。
⚠ 是 p−2,不是 p−1 —— 差一个数,b^(p−1) 算出来恒等于 1,整张表全废。
★ 数学这一块四章,到这里正好收口: 第 40 章给了「存不存在」,第 41 章给了「p 是质数」这个前提,第 42 章给了「怎么算」。
6正解,以及那个必须写明的前提
// 正解 —— 阶乘 + 乘法逆元:**C(n,k) = n! · (k!)⁻¹ · ((n−k)!)⁻¹ (mod p)**//// ============ ★ 关键一步:除法在模意义下怎么做 ============//// `C(n,k) = n! / (k! · (n−k)!)`。可**取模之下不能直接除** ——// `(a / b) mod p` 和 `(a mod p) / (b mod p)` 根本不是一回事。//// ★ 办法是把「除以 b」换成「乘以 b 的**逆元**」:// **b⁻¹ 是那个满足 `b · b⁻¹ ≡ 1 (mod p)` 的数。**//// 而这个逆元存在的**充要条件**是 `gcd(b, p) = 1`(第 40 章的裴蜀定理:// `b·x + p·y = 1` 有整数解 ⟺ `gcd(b,p) | 1`)。// ⇒ **p 是质数**的时候,只要 b 不是 p 的倍数就一定有逆元。//// 怎么求?**费马小定理**(第 42 章 verify.cpp 验过的那条):// p 是质数、b 不是 p 的倍数 ⇒ `b^(p−1) ≡ 1` ⇒ **`b⁻¹ ≡ b^(p−2)`**。// ⚠ 是 **p−2**,不是 p−1 —— 差一个数,答案就恒等于 1。// 于是逆元 = 一次快速幂(第 42 章那五行原样搬过来)。//// ============ ★ 这一章串起了前面三章 ============//// · 第 40 章:逆元存在 ⟺ gcd = 1(裴蜀定理);// · 第 41 章:p 得是**质数**,费马小定理才成立;// · 第 42 章:算 `b^(p−2)` 靠**快速幂**。// ⇒ 数学这一块四章,到这里正好收口。//// ⚠⚠ 而这套办法有一个必须写明的前提:**p 必须大于 n**。// 否则 `n!` 里含有 p 这个因子 ⇒ `n! ≡ 0`,而 0 没有逆元,整套公式当场失效// (smallP.cpp 把这件事跑出来给你看,正解是 Lucas 定理 —— 这一章只点到为止)。// 所以题面写的是 `10⁶ < p ≤ 10⁹` 而 `n ≤ 10⁶`。//// ============ ★ 逆元阶乘的那个倒推,值得单独看一眼 ============//// 一个个求 `(i!)⁻¹` 要 n 次快速幂(O(n log p))。其实只要**一次**:// 先求 `(n!)⁻¹`,然后 **`((i−1)!)⁻¹ = (i!)⁻¹ · i`** —— 从大往小倒着推。// (因为 `(i−1)! = i! / i`,取逆元就是乘 i。)// ⇒ 预处理 O(n + log p),每次询问 O(1)。//// 复杂度 O(n + log p + q)。
#include <bits/stdc++.h>using namespace std;
using ll = long long;using i128 = __int128;
ll p;
/** 快速幂 —— 第 42 章那五行 */ll power(ll a, ll b) { ll res = 1 % p; a %= p; if (a < 0) a += p; while (b) { if (b & 1) res = (i128)res * a % p; a = (i128)a * a % p; b >>= 1; } return res;}
int main() { ios::sync_with_stdio(false); cin.tie(nullptr);
int q; if (!(cin >> p >> q)) return 0;
vector<pair<int, int>> qs(q); int maxn = 0; for (auto& [n, k] : qs) { cin >> n >> k; maxn = max(maxn, n); }
vector<ll> fac(maxn + 1), inv(maxn + 1); fac[0] = 1 % p; for (int i = 1; i <= maxn; i++) fac[i] = (i128)fac[i - 1] * i % p;
inv[maxn] = power(fac[maxn], p - 2); // ⚠ 是 p−2,不是 p−1 for (int i = maxn; i >= 1; i--) // ★ 从大往小倒推,只要一次快速幂 inv[i - 1] = (i128)inv[i] * i % p;
string out; int zeros = 0; for (auto& [n, k] : qs) { ll v; if (k > n) v = 0; // ⚠ k > n 时是 0 else v = (ll)((i128)fac[n] * inv[k] % p * inv[n - k] % p); if (v == 0) zeros++; out += to_string(v); out += '\n'; } out += to_string(zeros); // ★ 末行:答案是 0 的有几组 out += '\n';
cout << out; return 0;}点「运行 ▶」看结果
一个个求 (i!)⁻¹ 要 n 次快速幂(O(n log p))。其实只要一次:
先求 (n!)⁻¹,然后 ((i−1)!)⁻¹ = (i!)⁻¹ · i ← 从大往小倒着推(因为 (i−1)! = i! / i,取逆元就是乘 i。)
// ⚠ 这不是错误版本 —— 它对**每一个** `i!` 都单独跑一次快速幂求逆元。//// 答案和正解**逐字节相同**,只是把预处理从 `O(n + log p)` 变成了 `O(n log p)` ——// n = 10⁶、p ≈ 10⁹ 时那就是 **三千万次乘法** 对 **一百万次**。//// ★ 它在这一章的角色和第 38 章的 buildSlow、第 39 章的 noLazy、第 41 章的 slowErat 一样:// **一个只影响复杂度、不影响答案的写法** —— 对拍原理上一个字都看不见// (第 36 章那个第四盲区,这是第八次现场)。// ⇒ 尺子只能换成「数乘法次数」:count.cpp 数的就是这个。//// ⚠ 而正解那个倒推只用了**一次**快速幂,靠的是一句 `((i−1)!)⁻¹ = (i!)⁻¹ · i` ——// ★ 「把一个 log 摊掉」这件事,第 38 章的 O(n) 建树、第 37 章的自底向上建堆都干过。
#include <bits/stdc++.h>using namespace std;
using ll = long long;using i128 = __int128;
ll p;
/** 快速幂 —— 第 42 章那五行 */ll power(ll a, ll b) { ll res = 1 % p; a %= p; if (a < 0) a += p; while (b) { if (b & 1) res = (i128)res * a % p; a = (i128)a * a % p; b >>= 1; } return res;}
int main() { ios::sync_with_stdio(false); cin.tie(nullptr);
int q; if (!(cin >> p >> q)) return 0;
vector<pair<int, int>> qs(q); int maxn = 0; for (auto& [n, k] : qs) { cin >> n >> k; maxn = max(maxn, n); }
vector<ll> fac(maxn + 1), inv(maxn + 1); fac[0] = 1 % p; for (int i = 1; i <= maxn; i++) fac[i] = (i128)fac[i - 1] * i % p;
for (int i = 0; i <= maxn; i++) // ⚠ 每一个都单独跑一次快速幂 inv[i] = power(fac[i], p - 2);
string out; int zeros = 0; for (auto& [n, k] : qs) { ll v; if (k > n) v = 0; // ⚠ k > n 时是 0 else v = (ll)((i128)fac[n] * inv[k] % p * inv[n - k] % p); if (v == 0) zeros++; out += to_string(v); out += '\n'; } out += to_string(zeros); // ★ 末行:答案是 0 的有几组 out += '\n';
cout << out; return 0;}点「运行 ▶」看结果
次数表(n = 10⁶,p = 10⁹+7,一次快速幂 45 次乘法):
| 做法 | 次数 | 是正解的几倍 |
|---|---|---|
| 杨辉三角(加法) | 500 000 500 000 | 249 995 × |
| 阶乘 + 逐个快速幂求逆元 | 46 000 045 | 23.0 × |
| ✓ 阶乘 + 倒推逆元 | 2 000 045 | 1 × |
★ 那一句
((i−1)!)⁻¹ = (i!)⁻¹ · i,把整整一个 log p 摊掉了。 (第 37 章自底向上建堆、第 38 章 O(n) 建树,都是同一种「把 log 摊掉」。)
⚠ 而三份代码的答案逐字节相同 —— 第 36 章那个「对拍看不见慢」的第八次现场。
// ★ 这一章的尺子:数**乘法 / 加法执行了多少次**//// 为什么它存在:杨辉三角 / 阶乘+倒推逆元 / 阶乘+逐个快速幂 —— 三份代码的**答案逐字节相同**,// 对拍在它们身上一个字都看不见(第 36 章那个第四盲区,这是第八次现场)。//// ★★ 而这一章的次数又是三个**等号**:// · 杨辉三角:加法次数 = **n(n+1)/2**(第 i 行填 i 个格子);// · 阶乘 + 倒推:乘法次数 = **2n**(n 次算阶乘 + n 次倒推逆元)+ **一次快速幂**(约 2·log₂p 次);// · 阶乘 + 逐个快速幂:乘法次数 = n 次算阶乘 + **(n+1) 次快速幂** ≈ **n · 2log₂p**。//// ⇒ 那一句 `((i−1)!)⁻¹ = (i!)⁻¹ · i`,把一个 **log p** 整个摊掉了。// (第 37 章自底向上建堆、第 38 章 O(n) 建树,都是同一种「把 log 摊掉」。)//// 用法:./count n p 打印那张表// ./count n p csv 同一批数字,给脚本和 check:viz 读
#include <bits/stdc++.h>using namespace std;using ll = long long;
static string padDisp(const string& s, int width) { int disp = 0; for (unsigned char c : s) { if ((c & 0xC0) == 0x80) continue; disp += (c < 0x80) ? 1 : 2; } return s + string(max(0, width - disp), ' ');}
int main(int argc, char** argv) { const ll n = (argc > 1) ? atoll(argv[1]) : 1000000; const ll p = (argc > 2) ? atoll(argv[2]) : 1000000007LL; const string mode = (argc > 3) ? argv[3] : "";
// 快速幂 b = p−2 的乘法次数 = popcount(p−2) + 位数(第 42 章那个等号) ll pc = 0, bits = 0; for (ll t = p - 2; t; t >>= 1) { pc += (t & 1); bits++; } const ll onePow = pc + bits;
const ll pascal = n * (n + 1) / 2; // 杨辉三角的加法次数 const ll fastMul = 2 * n + onePow; // 阶乘 n 次 + 倒推 n 次 + 一次快速幂 const ll invPowMul = n + (n + 1) * onePow; // 阶乘 n 次 + 每个逆元一次快速幂
if (mode == "csv") { printf("pascal,%lld\nfast,%lld\ninvPow,%lld\nonePow,%lld\n", pascal, fastMul, invPowMul, onePow); return 0; }
printf("n = %lld,p = %lld(一次快速幂要 %lld 次乘法)\n\n", n, p, onePow); printf("%s %s %s\n", padDisp("做法", 28).c_str(), padDisp("次数", 22).c_str(), padDisp("是正解的几倍", 16).c_str()); struct Row { const char* name; ll v; }; Row rows[3] = { {"杨辉三角(加法)", pascal}, {"阶乘 + 逐个快速幂求逆元", invPowMul}, {"✓ 阶乘 + 倒推逆元", fastMul}, }; for (auto& r : rows) printf("%s %s %s\n", padDisp(r.name, 28).c_str(), padDisp(to_string(r.v), 22).c_str(), padDisp(fastMul ? to_string((double)r.v / (double)fastMul).substr(0, 8) : "-", 16).c_str());
printf("\n★★ 杨辉三角是 n(n+1)/2 = %lld —— n = 10⁶ 时那是 **五千亿**,而且要开 10¹² 个格子。\n", pascal); printf("★★ 正解是 2n + 一次快速幂 = %lld;逐个快速幂是 n + (n+1)·%lld = %lld(%.1f 倍)。\n", fastMul, onePow, invPowMul, (double)invPowMul / (double)fastMul); printf("★ 那一句「((i−1)!)⁻¹ = (i!)⁻¹ · i」把整整一个 log p 摊掉了。\n"); return 0;}点「运行 ▶」看结果
「阶乘 + 逆元」里有一句话没写出来:k! 和 (n−k)! 都得有逆元。
而逆元存在 ⟺ gcd(b, p) = 1 ——
⇒ 只要
p ≤ n,n!里就含有 p 这个因子 ⇒n! ≡ 0,而 0 没有逆元。
// ★★ 这份代码是用来说清一条**必须写进正文的前提**的:**p 必须大于 n。**//// 「阶乘 + 逆元」那套公式里有一句话没写出来:`k!` 和 `(n−k)!` 都得**有逆元**。// 而逆元存在的充要条件是 `gcd(b, p) = 1`(第 40 章的裴蜀定理)——//// ⇒ **只要 p ≤ n,`n!` 里就含有 p 这个因子,于是 `n! ≡ 0 (mod p)`,而 0 没有逆元。**//// 这时候整套公式当场失效:`fac[n] = 0`,逆元也全是 0,答案恒等于 0。// ⚠ 而它**不报错、不崩溃** —— 只是安静地给你一串 0。//// ★ 正确的做法是 **Lucas 定理**(把 n、k 写成 p 进制,逐位算 C 再乘起来)——// 这一章点到为止,但这份代码把「为什么必须换个办法」跑出来给你看。//// 用法:./smallP 跑一批 p ≤ n 的例子,把「公式版」和「杨辉三角版」并排打出来
#include <bits/stdc++.h>using namespace std;using ll = long long;using i128 = __int128;
static ll power(ll a, ll b, ll p) { ll res = 1 % p; a %= p; if (a < 0) a += p; while (b) { if (b & 1) res = (i128)res * a % p; a = (i128)a * a % p; b >>= 1; } return res;}
/** 公式版:阶乘 + 费马小定理逆元 —— p ≤ n 时它是错的 */static ll formula(int n, int k, ll p) { if (k > n) return 0; vector<ll> fac(n + 1); fac[0] = 1 % p; for (int i = 1; i <= n; i++) fac[i] = (i128)fac[i - 1] * i % p; ll a = power(fac[k], p - 2, p), b = power(fac[n - k], p - 2, p); return (ll)((i128)fac[n] * a % p * b % p);}
/** 杨辉三角版:全程只有加法,p 是不是质数、大不大都无所谓 */static ll pascal(int n, int k, ll p) { if (k > n) return 0; vector<ll> row(n + 1, 0); row[0] = 1 % p; for (int i = 1; i <= n; i++) for (int j = i; j >= 1; j--) row[j] = (row[j] + row[j - 1]) % p; return row[k];}
int main() { printf(" p n k 公式版(阶乘+逆元) 杨辉三角版 对不对\n"); struct Case { ll p; int n, k; }; Case cs[] = { {1000003, 10, 3}, {1000003, 30, 15}, // p > n:公式版没问题 {7, 10, 3}, {7, 20, 10}, {11, 12, 6}, {13, 26, 13}, {5, 8, 4}, // ⚠ p ≤ n }; int bad = 0; for (auto& c : cs) { ll f = formula(c.n, c.k, c.p), g = pascal(c.n, c.k, c.p); if (f != g) bad++; printf("%9lld %8d %8d %21lld %12lld %s\n", c.p, c.n, c.k, f, g, f == g ? "✓" : "✗ 不一样"); } printf("\n★ 前两行 p > n,公式版没问题;后五行 p ≤ n,公式版**全错**(%d 处不一样)。\n", bad); printf("⚠ 原因只有一句:n! 里含有 p 这个因子 ⇒ n! ≡ 0,而 **0 没有逆元**。\n"); printf("★ 所以题面写死了 10⁶ < p ≤ 10⁹ 而 n ≤ 10⁶ —— 这不是凑数,是这套公式的前提。\n"); printf(" 真要处理 p ≤ n,得换 Lucas 定理(把 n、k 写成 p 进制逐位算)。\n"); return 0;}点「运行 ▶」看结果
| p | n | k | 公式版 | 杨辉三角版 | |
|---|---|---|---|---|---|
| 1 000 003 | 10 | 3 | 120 | 120 | ✓ |
| 1 000 003 | 30 | 15 | 117 055 | 117 055 | ✓ |
| 7 | 10 | 3 | ⚠ 0 | 1 | ✗ |
| 7 | 20 | 10 | ⚠ 0 | 5 | ✗ |
| 13 | 26 | 13 | ⚠ 0 | 2 | ✗ |
⚠⚠ 注意它不报错、不崩溃 —— 只是安静地给你一串 0。
★ 真要处理 p ≤ n,得换 Lucas 定理(把 n、k 写成 p 进制,逐位算 C 再乘起来)——
这一章点到为止,但「为什么必须换个办法」这件事得说清楚。
★ 这也是第 33 章那条的又一次:同一段代码成不成立,取决于数据的取值范围。
7⚠ 顺带一个「同一个手滑,在两章里一死一活」的例子
// ⚠⚠ 这不是错误版本 —— `fac[0]` 写成 1 而不是 `1 % p`,而它在这一章**完全无害**。//// 和第 42 章那个 `res = 1 % p` 是同一个坑 —— 只是这一章的 p 保证 > 10⁶,// 所以 `1 % p` 恒等于 1……//// ⚠⚠ **于是这份代码其实是对的**:它 300 轮和正解逐字节相同。// ★ 留着它是为了说一件事:**同一个手滑,在第 42 章是致命的,在这一章完全无害** ——// 因为题面把 p 的下界抬到了 10⁶。// > **同一句代码危不危险,取决于数据的取值范围**(第 33 章立的那条,这是第三次现场)。#include <bits/stdc++.h>using namespace std;
using ll = long long;using i128 = __int128;
ll p;
/** 快速幂 —— 第 42 章那五行 */ll power(ll a, ll b) { ll res = 1 % p; a %= p; if (a < 0) a += p; while (b) { if (b & 1) res = (i128)res * a % p; a = (i128)a * a % p; b >>= 1; } return res;}
int main() { ios::sync_with_stdio(false); cin.tie(nullptr);
int q; if (!(cin >> p >> q)) return 0;
vector<pair<int, int>> qs(q); int maxn = 0; for (auto& [n, k] : qs) { cin >> n >> k; maxn = max(maxn, n); }
vector<ll> fac(maxn + 1), inv(maxn + 1); fac[0] = 1; // ⚠ 这一章 p > 10⁶,所以它是对的 for (int i = 1; i <= maxn; i++) fac[i] = (i128)fac[i - 1] * i % p;
inv[maxn] = power(fac[maxn], p - 2); // ⚠ 是 p−2,不是 p−1 for (int i = maxn; i >= 1; i--) // ★ 从大往小倒推,只要一次快速幂 inv[i - 1] = (i128)inv[i] * i % p;
string out; int zeros = 0; for (auto& [n, k] : qs) { ll v; if (k > n) v = 0; // ⚠ k > n 时是 0 else v = (ll)((i128)fac[n] * inv[k] % p * inv[n - k] % p); if (v == 0) zeros++; out += to_string(v); out += '\n'; } out += to_string(zeros); // ★ 末行:答案是 0 的有几组 out += '\n';
cout << out; return 0;}点「运行 ▶」看结果
第 42 章里 res = 1 而不是 res = 1 % p 是个真 bug(p = 1 时答案该是 0)。
这一章同一个位置写 fac[0] = 1,300 轮和正解逐字节相同 ——
因为题面把 p 的下界抬到了 10⁶,1 % p 恒等于 1。
★★ 同一句代码危不危险,取决于数据的取值范围。 (第 33 章立的那条,这是第三次现场 —— 而这一次两个现场就隔着一章。)
8★ 对拍:六个错误版本
| 错误版本 | 靠什么现形 |
|---|---|
wrongFermat 逆元用 p−1 |
什么数据都行(整张逆元表全废)—— ★ 基线 |
wrongDir 逆元阶乘方向反 |
同上,只有 n = k = 0 那组它蒙对 |
wrongFacP 阶乘忘了取模 |
★ 要 n ≥ 21(21! > 9.22×10¹⁸) |
wrongOverflow 三个连乘不取模 |
★ 要 p > 2.1×10⁶(p³ > 9.22×10¹⁸) |
wrongKn k > n 不返回 0 |
★★ 要 k > n |
wrongInvEnd 逆元倒推少走一步 |
★★ 要 k ∈ {0, n} |
★★ 而 wrongInvEnd 那一条值得单看:它错的正好是 k ∈ {0,n} 那几组,别的一组不错
(check:viz 里钉着这条)。于是「k 取哪儿」这个旋钮有两头 —— 第 9 步会看到代价。
// ✗ 错误版本:逆元用 `b^(p−1)` 而不是 `b^(p−2)`//// 费马小定理说的是 `b^(p−1) ≡ 1`,所以 `b^(p−1)` 算出来**恒等于 1** ——// 于是所有的逆元阶乘都从 1 开始倒推,整张表全错。//// 靠什么现形:几乎什么数据都行 —— **整张逆元表都是从 1 开始倒推的**,全废。★ 它是这一章的基线。// ⚠ 唯一蒙对的是 `n = k = 0` 那一组(`C(0,0) = 1`,正好和「全 1」的表撞上)。#include <bits/stdc++.h>using namespace std;
using ll = long long;using i128 = __int128;
ll p;
/** 快速幂 —— 第 42 章那五行 */ll power(ll a, ll b) { ll res = 1 % p; a %= p; if (a < 0) a += p; while (b) { if (b & 1) res = (i128)res * a % p; a = (i128)a * a % p; b >>= 1; } return res;}
int main() { ios::sync_with_stdio(false); cin.tie(nullptr);
int q; if (!(cin >> p >> q)) return 0;
vector<pair<int, int>> qs(q); int maxn = 0; for (auto& [n, k] : qs) { cin >> n >> k; maxn = max(maxn, n); }
vector<ll> fac(maxn + 1), inv(maxn + 1); fac[0] = 1 % p; for (int i = 1; i <= maxn; i++) fac[i] = (i128)fac[i - 1] * i % p;
inv[maxn] = power(fac[maxn], p - 1); // ✗ 差一个数:这算出来恒等于 1 for (int i = maxn; i >= 1; i--) // ★ 从大往小倒推,只要一次快速幂 inv[i - 1] = (i128)inv[i] * i % p;
string out; int zeros = 0; for (auto& [n, k] : qs) { ll v; if (k > n) v = 0; // ⚠ k > n 时是 0 else v = (ll)((i128)fac[n] * inv[k] % p * inv[n - k] % p); if (v == 0) zeros++; out += to_string(v); out += '\n'; } out += to_string(zeros); // ★ 末行:答案是 0 的有几组 out += '\n';
cout << out; return 0;}点「运行 ▶」看结果
// ✗ 错误版本:逆元阶乘**从小往大推**(方向反了)//// 正确的是 `((i−1)!)⁻¹ = (i!)⁻¹ · i`(从大往小)。// 反过来写成 `(i!)⁻¹ = ((i−1)!)⁻¹ · i` 就完全不对了 —— 那个 i 该乘的是**逆元的方向**。//// 靠什么现形:★ 要有 `k ∉ {0, n}` 的询问,而且 **n ≥ 2**(n ≤ 1 时那张表太短,看不出来)。// ★ 和第 21 章那条「填表顺序由依赖方向决定」是同一件事的第 N 次:// **递推方向写反不报错、不崩溃,只是安静地给你错答案。**#include <bits/stdc++.h>using namespace std;
using ll = long long;using i128 = __int128;
ll p;
/** 快速幂 —— 第 42 章那五行 */ll power(ll a, ll b) { ll res = 1 % p; a %= p; if (a < 0) a += p; while (b) { if (b & 1) res = (i128)res * a % p; a = (i128)a * a % p; b >>= 1; } return res;}
int main() { ios::sync_with_stdio(false); cin.tie(nullptr);
int q; if (!(cin >> p >> q)) return 0;
vector<pair<int, int>> qs(q); int maxn = 0; for (auto& [n, k] : qs) { cin >> n >> k; maxn = max(maxn, n); }
vector<ll> fac(maxn + 1), inv(maxn + 1); fac[0] = 1 % p; for (int i = 1; i <= maxn; i++) fac[i] = (i128)fac[i - 1] * i % p;
inv[0] = 1 % p; for (int i = 1; i <= maxn; i++) // ✗ 方向反了:从小往大推 inv[i] = (i128)inv[i - 1] * i % p;
string out; int zeros = 0; for (auto& [n, k] : qs) { ll v; if (k > n) v = 0; // ⚠ k > n 时是 0 else v = (ll)((i128)fac[n] * inv[k] % p * inv[n - k] % p); if (v == 0) zeros++; out += to_string(v); out += '\n'; } out += to_string(zeros); // ★ 末行:答案是 0 的有几组 out += '\n';
cout << out; return 0;}点「运行 ▶」看结果
// ✗ 错误版本:阶乘那一行**忘了取模**//// `fac[i] = fac[i-1] * i` —— 不取模的话,`fac[21]` 就已经超过 long long 了。//// 靠什么现形:★ 只要 **n ≥ 21** 就必现(21! ≈ 5.1×10¹⁹ > 9.22×10¹⁸)。// ⚠ 而「顺手」写的生成器如果只造 `n ≤ 20` 的小数据,**它一轮都抓不到** ——// ★ 第 38、40、42 章那条:**先估一估多大的数据才溢出**,这一章的那条线是 **n = 21**。#include <bits/stdc++.h>using namespace std;
using ll = long long;using i128 = __int128;
ll p;
/** 快速幂 —— 第 42 章那五行 */ll power(ll a, ll b) { ll res = 1 % p; a %= p; if (a < 0) a += p; while (b) { if (b & 1) res = (i128)res * a % p; a = (i128)a * a % p; b >>= 1; } return res;}
int main() { ios::sync_with_stdio(false); cin.tie(nullptr);
int q; if (!(cin >> p >> q)) return 0;
vector<pair<int, int>> qs(q); int maxn = 0; for (auto& [n, k] : qs) { cin >> n >> k; maxn = max(maxn, n); }
vector<ll> fac(maxn + 1), inv(maxn + 1); fac[0] = 1 % p; for (int i = 1; i <= maxn; i++) fac[i] = fac[i - 1] * i; // ✗ 忘了 % p
inv[maxn] = power(fac[maxn], p - 2); // ⚠ 是 p−2,不是 p−1 for (int i = maxn; i >= 1; i--) // ★ 从大往小倒推,只要一次快速幂 inv[i - 1] = (i128)inv[i] * i % p;
string out; int zeros = 0; for (auto& [n, k] : qs) { ll v; if (k > n) v = 0; // ⚠ k > n 时是 0 else v = (ll)((i128)fac[n] * inv[k] % p * inv[n - k] % p); if (v == 0) zeros++; out += to_string(v); out += '\n'; } out += to_string(zeros); // ★ 末行:答案是 0 的有几组 out += '\n';
cout << out; return 0;}点「运行 ▶」看结果
// ✗ 错误版本:最后那一步**三个数连乘、中间不取模**(而且没有 `__int128`)//// `fac[n] * inv[k] * inv[n-k] % p`:三个因子都在 `[0, p)` 里。// · p ≈ 10⁶ 时三个一乘是 10¹⁸ —— **勉强还在 long long 里,它居然是对的**;// · p ≈ 10⁹ 时就是 **10²⁷** —— 当场绕回去。//// 靠什么现形:★★ 要 **p 大过一条具体的线**:`p³ > 9.22×10¹⁸ ⇒ p > 2.1×10⁶`。// ⚠ 题面给的范围是 `10⁶ < p ≤ 10⁹`,也就是说**这条线就在题面范围的最下面一点点** ——// 顺手挑一个「一百万零三」当模数,它就安全落地了。// ★ 第 42 章那条(溢出线要算到小数点后两位)的第二次现场,而这一次那条线**离下界只差一倍**。#include <bits/stdc++.h>using namespace std;
using ll = long long;using i128 = __int128;
ll p;
/** 快速幂 —— 第 42 章那五行 */ll power(ll a, ll b) { ll res = 1 % p; a %= p; if (a < 0) a += p; while (b) { if (b & 1) res = (i128)res * a % p; a = (i128)a * a % p; b >>= 1; } return res;}
int main() { ios::sync_with_stdio(false); cin.tie(nullptr);
int q; if (!(cin >> p >> q)) return 0;
vector<pair<int, int>> qs(q); int maxn = 0; for (auto& [n, k] : qs) { cin >> n >> k; maxn = max(maxn, n); }
vector<ll> fac(maxn + 1), inv(maxn + 1); fac[0] = 1 % p; for (int i = 1; i <= maxn; i++) fac[i] = (i128)fac[i - 1] * i % p;
inv[maxn] = power(fac[maxn], p - 2); // ⚠ 是 p−2,不是 p−1 for (int i = maxn; i >= 1; i--) // ★ 从大往小倒推,只要一次快速幂 inv[i - 1] = (i128)inv[i] * i % p;
string out; int zeros = 0; for (auto& [n, k] : qs) { ll v; if (k > n) v = 0; // ⚠ k > n 时是 0 else v = fac[n] * inv[k] * inv[n - k] % p; // ✗ 三个连乘、中间不取模、也没 __int128 if (v == 0) zeros++; out += to_string(v); out += '\n'; } out += to_string(zeros); // ★ 末行:答案是 0 的有几组 out += '\n';
cout << out; return 0;}点「运行 ▶」看结果
// ✗ 错误版本:**k > n 时没返回 0**(直接套公式)//// `n − k` 变成负数,`inv[n-k]` 就是越界访问 —— 跑出来是内存里的垃圾(或者直接崩)。//// 靠什么现形:★★ 询问里必须出现 **k > n**。// ⚠ 而「顺手」写的生成器多半是 `n = rnd(...); k = rnd(0, n);` ——// **它永远造不出 k > n**。★ 第 27~42 章那条「顺手写法」的第十四次:// 这次加的性质是「**k 一定不超过 n**」,而题面明明写着 `0 ≤ n, k ≤ 10⁶`(两个各自独立)。#include <bits/stdc++.h>using namespace std;
using ll = long long;using i128 = __int128;
ll p;
/** 快速幂 —— 第 42 章那五行 */ll power(ll a, ll b) { ll res = 1 % p; a %= p; if (a < 0) a += p; while (b) { if (b & 1) res = (i128)res * a % p; a = (i128)a * a % p; b >>= 1; } return res;}
int main() { ios::sync_with_stdio(false); cin.tie(nullptr);
int q; if (!(cin >> p >> q)) return 0;
vector<pair<int, int>> qs(q); int maxn = 0; for (auto& [n, k] : qs) { cin >> n >> k; maxn = max(maxn, n); }
vector<ll> fac(maxn + 1), inv(maxn + 1); fac[0] = 1 % p; for (int i = 1; i <= maxn; i++) fac[i] = (i128)fac[i - 1] * i % p;
inv[maxn] = power(fac[maxn], p - 2); // ⚠ 是 p−2,不是 p−1 for (int i = maxn; i >= 1; i--) // ★ 从大往小倒推,只要一次快速幂 inv[i - 1] = (i128)inv[i] * i % p;
string out; int zeros = 0; for (auto& [n, k] : qs) { ll v; v = (ll)((i128)fac[n] * inv[k] % p * inv[n - k] % p); if (v == 0) zeros++; out += to_string(v); out += '\n'; } out += to_string(zeros); // ★ 末行:答案是 0 的有几组 out += '\n';
cout << out; return 0;}点「运行 ▶」看结果
// ✗ 错误版本:逆元阶乘倒推**少走了最后一步**(循环写成 `i >= 2`)//// 于是 `inv[0]` 从来没被算出来,一直是 0 —— 而 `inv[0]` 正是// `C(n, 0)` 和 `C(n, n)` 要用的那一个。//// 靠什么现形:★★ 询问里必须出现 **k = 0 或 k = n**(那两种「取全部 / 一个都不取」的情况)。// ⚠ 而「顺手」写的生成器多半是 `k = rnd(1, n-1)`(觉得 0 和 n「太平凡了」)——// ★ 那正好把这个 bug 关在门外。这是「顺手写法」的第十五次。// ⚠ 和 wrongFermat 正好是一对:那个**只在 k ∉ {0,n} 时错**,这个**只在 k ∈ {0,n} 时错**。// ⇒ **两端都得造**(第 31、38 章那条)。#include <bits/stdc++.h>using namespace std;
using ll = long long;using i128 = __int128;
ll p;
/** 快速幂 —— 第 42 章那五行 */ll power(ll a, ll b) { ll res = 1 % p; a %= p; if (a < 0) a += p; while (b) { if (b & 1) res = (i128)res * a % p; a = (i128)a * a % p; b >>= 1; } return res;}
int main() { ios::sync_with_stdio(false); cin.tie(nullptr);
int q; if (!(cin >> p >> q)) return 0;
vector<pair<int, int>> qs(q); int maxn = 0; for (auto& [n, k] : qs) { cin >> n >> k; maxn = max(maxn, n); }
vector<ll> fac(maxn + 1), inv(maxn + 1); fac[0] = 1 % p; for (int i = 1; i <= maxn; i++) fac[i] = (i128)fac[i - 1] * i % p;
inv[maxn] = power(fac[maxn], p - 2); // ⚠ 是 p−2,不是 p−1 for (int i = maxn; i >= 2; i--) // ✗ 少走一步:inv[0] 永远是 0 inv[i - 1] = (i128)inv[i] * i % p;
string out; int zeros = 0; for (auto& [n, k] : qs) { ll v; if (k > n) v = 0; // ⚠ k > n 时是 0 else v = (ll)((i128)fac[n] * inv[k] % p * inv[n - k] % p); if (v == 0) zeros++; out += to_string(v); out += '\n'; } out += to_string(zeros); // ★ 末行:答案是 0 的有几组 out += '\n';
cout << out; return 0;}点「运行 ▶」看结果
// 正解 —— 阶乘 + 乘法逆元:**C(n,k) = n! · (k!)⁻¹ · ((n−k)!)⁻¹ (mod p)**//// ============ ★ 关键一步:除法在模意义下怎么做 ============//// `C(n,k) = n! / (k! · (n−k)!)`。可**取模之下不能直接除** ——// `(a / b) mod p` 和 `(a mod p) / (b mod p)` 根本不是一回事。//// ★ 办法是把「除以 b」换成「乘以 b 的**逆元**」:// **b⁻¹ 是那个满足 `b · b⁻¹ ≡ 1 (mod p)` 的数。**//// 而这个逆元存在的**充要条件**是 `gcd(b, p) = 1`(第 40 章的裴蜀定理:// `b·x + p·y = 1` 有整数解 ⟺ `gcd(b,p) | 1`)。// ⇒ **p 是质数**的时候,只要 b 不是 p 的倍数就一定有逆元。//// 怎么求?**费马小定理**(第 42 章 verify.cpp 验过的那条):// p 是质数、b 不是 p 的倍数 ⇒ `b^(p−1) ≡ 1` ⇒ **`b⁻¹ ≡ b^(p−2)`**。// ⚠ 是 **p−2**,不是 p−1 —— 差一个数,答案就恒等于 1。// 于是逆元 = 一次快速幂(第 42 章那五行原样搬过来)。//// ============ ★ 这一章串起了前面三章 ============//// · 第 40 章:逆元存在 ⟺ gcd = 1(裴蜀定理);// · 第 41 章:p 得是**质数**,费马小定理才成立;// · 第 42 章:算 `b^(p−2)` 靠**快速幂**。// ⇒ 数学这一块四章,到这里正好收口。//// ⚠⚠ 而这套办法有一个必须写明的前提:**p 必须大于 n**。// 否则 `n!` 里含有 p 这个因子 ⇒ `n! ≡ 0`,而 0 没有逆元,整套公式当场失效// (smallP.cpp 把这件事跑出来给你看,正解是 Lucas 定理 —— 这一章只点到为止)。// 所以题面写的是 `10⁶ < p ≤ 10⁹` 而 `n ≤ 10⁶`。//// ============ ★ 逆元阶乘的那个倒推,值得单独看一眼 ============//// 一个个求 `(i!)⁻¹` 要 n 次快速幂(O(n log p))。其实只要**一次**:// 先求 `(n!)⁻¹`,然后 **`((i−1)!)⁻¹ = (i!)⁻¹ · i`** —— 从大往小倒着推。// (因为 `(i−1)! = i! / i`,取逆元就是乘 i。)// ⇒ 预处理 O(n + log p),每次询问 O(1)。//// 复杂度 O(n + log p + q)。
#include <bits/stdc++.h>using namespace std;
using ll = long long;using i128 = __int128;
ll p;
/** 快速幂 —— 第 42 章那五行 */ll power(ll a, ll b) { ll res = 1 % p; a %= p; if (a < 0) a += p; while (b) { if (b & 1) res = (i128)res * a % p; a = (i128)a * a % p; b >>= 1; } return res;}
int main() { ios::sync_with_stdio(false); cin.tie(nullptr);
int q; if (!(cin >> p >> q)) return 0;
vector<pair<int, int>> qs(q); int maxn = 0; for (auto& [n, k] : qs) { cin >> n >> k; maxn = max(maxn, n); }
vector<ll> fac(maxn + 1), inv(maxn + 1); fac[0] = 1 % p; for (int i = 1; i <= maxn; i++) fac[i] = (i128)fac[i - 1] * i % p;
inv[maxn] = power(fac[maxn], p - 2); // ⚠ 是 p−2,不是 p−1 for (int i = maxn; i >= 1; i--) // ★ 从大往小倒推,只要一次快速幂 inv[i - 1] = (i128)inv[i] * i % p;
string out; int zeros = 0; for (auto& [n, k] : qs) { ll v; if (k > n) v = 0; // ⚠ k > n 时是 0 else v = (ll)((i128)fac[n] * inv[k] % p * inv[n - k] % p); if (v == 0) zeros++; out += to_string(v); out += '\n'; } out += to_string(zeros); // ★ 末行:答案是 0 的有几组 out += '\n';
cout << out; return 0;}300 轮实测(种子 1..300,最终档 8):
| 故意写错的地方 | 被抓 | 第几轮 |
|---|---|---|
wrongFermat(逆元用 p−1) |
297 / 300 | 第 2 轮 |
wrongDir(倒推方向反) |
297 / 300 | 第 2 轮 |
wrongFacP(阶乘忘取模) |
296 / 300 | 第 2 轮 |
wrongInvEnd(倒推少走一步) |
260 / 300 | 第 2 轮 |
wrongOverflow(连乘不取模) |
259 / 300 | 第 2 轮 |
wrongKn(k > n) |
208 / 300※ | 第 4 轮※ |
invPow / sameInit(只是慢 / 完全等价) |
★ 0 / 300 | — |
⚠ 带 ※ 的格子(wrongKn 那一行,以及后面两张表里 Kn 那一列的非零格)你未必能跑出一模一样的数。
它错在 k > n 时直接套公式,于是 inv[n − k] 是越界读 —— 读出来的是内存里的垃圾,
而垃圾长什么样取决于这台机器怎么摆那两个数组。三次实测:
| 跑在哪 | 最终档被抓 | 第几轮抓到 |
|---|---|---|
g++ -O2(本书这一列:g++ 15 + libstdc++ 15) |
208 / 300 | 第 4 轮 |
同一台机器,只把 -O2 换成 -O0 |
262 / 300 | — |
| 另一台机器:Ubuntu 24.04 + g++ 13.3 + glibc 2.39 | 226 / 300 | 第 3 轮 |
★★ 这本身就是一课,而且是两层: ① 未定义行为的 bug,连「被抓多少次」都不可复现。 别的五列都是确定的算术错误,换什么编译器、换哪台机器都是那个数 —— 只有它不是。 ② 不只是换个
-O会变 —— 换一台机器(编译器 + 标准库 + 分配器)一样会变。 上面第三行就是 GitHub Actions 那台跑出来的:同一份代码、同一批种子,208 变成了 226。
⇒ 所以对拍脚本(scripts/check-viz.mjs)对这一列不钉数字,只钉形状:
该是 0 的必须精确是 0 —— 那是「生成器造不造得出 k > n」决定的,和机器无关,
也正是下一步整整一节要讲的事;该非 0 的只要求「三百轮里大半都抓得住」。
★ 换句话说:你跑出来的数和书上不一样是正常的,跑出来是 0 才是出事了。
9★★ 生成器:三个 0,而三条都只差「一点点」
| 档位 | 相对档位 0 改了什么 | Fermat | Dir | FacP | Ovf | Kn | InvEnd |
|---|---|---|---|---|---|---|---|
| 0(顺手写法) | p = 1000003、n ∈ [1,20]、k = rnd(0,n) | 300 | 300 | ★ 0 | ★ 0 | ★ 0 | 226 |
| 1 | ★ k 独立取(于是能造出 k > n) | 292 | 291 | 0 | 0 | ★ 234※ | 128 |
| 2 | ★ n 放大到 [1,60](跨过 21) | 300 | 300 | ★ 293 | 0 | 0 | 149 |
| 3 | ★ p 取到 [2.1×10⁶, 10⁹](跨过溢出线) | 300 | 300 | 0 | ★ 284 | 0 | 236 |
| 4 | ★ k 专门塞 0 或 n | 300 | 300 | 0 | 0 | 0 | ★ 278 |
| 5 | ⚠ k 专门避开 0 和 n | 300 | 300 | 0 | 0 | 0 | ⚠ 62 |
| 6 | 询问条数拉长 | 300 | 300 | 0 | 0 | 0 | 300 |
★★★ 那三个 0,每一个都只差「一点点」:
| 顺手写的是 | 那条线在哪 | 差在哪 |
|---|---|---|
n ∈ [1,20] |
n = 21(21! > 9.22×10¹⁸) |
差 1 |
p = 1000003 |
p = 2.1×10⁶(p³ > 9.22×10¹⁸) |
差 2 倍 |
k = rnd(0, n) |
k > n | 差一个写法 |
★★ 不是「范围没覆盖」,是「覆盖到了那条线的错误一侧」。 第 42 章立的那条(别只问大不大,要问大过哪条线)在这一章连中三次 —— 而其中两条线(21 和 2.1×10⁶)都是三秒钟就能估出来的。
⚠ 而 k = rnd(0, n) 那一条最值得记:它甚至不是「取值范围」的问题,
是一句再自然不过的写法,悄悄给数据加了一条题目里没有的性质(「k 一定不超过 n」)——
★ 第 27~42 章那条「顺手写法」的第十四次。
对着看档位 4 和档位 5:
k 专门塞 0 / n →
wrongInvEnd278;k 专门避开 0 / n → 62。
⚠ 而「避开」正是很多人会做的事 —— 觉得 k = 0 和 k = n 太平凡了,测不出什么。
★ 可 wrongInvEnd 错的正好就是那几组。
| 档位 | 内容 | Fermat | Dir | FacP | Ovf | Kn | InvEnd | 最弱支 |
|---|---|---|---|---|---|---|---|---|
| 7 | 1 + 2 + 3(三条线全跨过去) | 290 | 290 | 290 | 286 | 239※ | ⚠ 61 | 61 |
| 8(最终档) | 7 + ★ k 专门塞 0 / n | 297 | 297 | 296 | 259 | 208※ | ★ 260 | ✓ 208※ |
| 9(对照) | 8 减去「k 独立取」 | 300 | 300 | 298 | 286 | ★ 0 | 276 | 0 |
| 10(对照) | 8 减去「p 跨过溢出线」 | 298 | 298 | 291 | ★ 0 | 209※ | 250 | 0 |
| 11(对照) | 8 减去「n 跨过 21」 | 300 | 300 | ★ 0 | 239 | 224※ | 270 | 0 |
① ⚠⚠ 档位 7 是一个很典型的陷阱:三条线全跨过去了,看着很完备 ——
可 wrongInvEnd 反而掉到 61。
因为「k 独立取」让 k 落在 [0, 60] 里,恰好等于 0 或 n 的概率反而变小了。
★ 补齐了几个边界,可能把另一个边界稀释掉 —— 又一次「某一支占得太多也是坑」(第 31 章)。
② 三个对照档说明三处改动各自不可替代:撤掉哪一处,对应那一列就回到 0。
10★★ 还第 17 章那笔账:调用次数正好排成杨辉三角
第 17 章讲记忆化时说过:不带记忆的递归,调用次数正好排成杨辉三角。
说的是这个递归 —— 从 (0,0) 走到 (n,m),每步只能往右或往下:
f(i, j) = f(i−1, j) + f(i, j−1),边界 f(0,·) = f(·,0) = 1它就是杨辉三角的递推(换了个方向摆),所以 f(n,m) = C(n+m, n)。
★ 那么「调用次数」是多少?设 g(i,j) = 求 f(n,m) 时 f(i,j) 被调用的次数。
只有 i,j 都 ≥ 1 的格子才会继续往下调(边界格直接返回 1),
而这样的 f(i,j) 会被 f(i+1,j) 和 f(i,j+1) 各调用一次 ——
于是在那一片上 g 满足同一条递推,只是方向反过来:
g(i,j) = C((n−i)+(m−j), n−i)(i, j ≥ 1)
★★ 而边界格就是递归的叶子,每一片返回 1 ——
叶子的调用次数之和 = f(n,m) = C(n+m, n),一个不多一个不少。
// ★★ 还第 17 章那笔账 —— 「记忆化搜索的调用次数,正好是杨辉三角」。//// 第 17 章讲记忆化时留过一句话:**不带记忆的递归,调用次数正好排成杨辉三角。**// 这一章有了组合数,正好把那句话**证掉**,并且当场跑出来对一遍。//// ============ 说的是哪个递归 ============//// 从 (0,0) 走到 (n,m),每步只能往右或往下,问有多少条路:// f(i, j) = f(i−1, j) + f(i, j−1),边界 f(0,·) = f(·,0) = 1。// 这**就是**杨辉三角的递推(换了个方向摆而已),所以 f(n,m) = C(n+m, n)。//// ============ ★ 那么「调用次数」是多少 ============//// 设 g(i,j) = 求 f(n,m) 时 f(i,j) 被调用的次数。// 倒过来看:**只有 i,j 都 ≥ 1 的格子才会继续往下调**(边界格直接返回 1),// 而这样的 f(i,j) 会被 f(i+1,j) 和 f(i,j+1) 各调用一次 ——// 于是在 **i,j ≥ 1 的那一片上,g 满足同一条递推**,只是方向反过来:// g(n,m) = 1,g(i,j) = g(i+1,j) + g(i,j+1)。// ⇒ **g(i,j) = C((n−i)+(m−j), n−i)**(i,j ≥ 1)—— 又是一个组合数。//// ★★ 而**边界格**(i = 0 或 j = 0)就是递归的**叶子**,每一片返回 1 ——// 所以 **叶子的调用次数之和 = f(n,m) = C(n+m, n)**,一个不多一个不少。//// ⚠ 这就是记忆化为什么值钱:**不记的话,光是叶子就要被访问 C(n+m,n) 次** ——// n = m = 15 时那是三亿多次。//// 用法:./pascal17 [n] [m] 默认 6 6
#include <bits/stdc++.h>using namespace std;using ll = long long;
int n, m;vector<vector<ll>> calls;
static ll f(int i, int j) { calls[i][j]++; if (i == 0 || j == 0) return 1; return f(i - 1, j) + f(i, j - 1);}
int main(int argc, char** argv) { n = (argc > 1) ? atoi(argv[1]) : 6; m = (argc > 2) ? atoi(argv[2]) : 6;
calls.assign(n + 1, vector<ll>(m + 1, 0)); ll ans = f(n, m);
// 组合数(不取模,n+m 小的时候 long long 够用) auto C = [](int a, int b) { if (b < 0 || b > a) return (ll)0; ll r = 1; for (int i = 1; i <= b; i++) r = r * (a - b + i) / i; return r; };
printf("f(%d,%d) = %lld,而 C(%d,%d) = %lld —— %s\n\n", n, m, ans, n + m, n, C(n + m, n), ans == C(n + m, n) ? "一样 ✓" : "✗ 不一样");
printf("★ 每一格 f(i,j) 被调用的次数(这张表就是杨辉三角,只是斜着摆):\n\n"); bool ok = true; for (int i = 0; i <= n; i++) { printf(" "); for (int j = 0; j <= m; j++) { printf("%8lld", calls[i][j]); if (i >= 1 && j >= 1 && calls[i][j] != C((n - i) + (m - j), n - i)) ok = false; } printf("\n"); } ll tot = 0, leaf = 0; for (int i = 0; i <= n; i++) for (int j = 0; j <= m; j++) { tot += calls[i][j]; if (i == 0 || j == 0) leaf += calls[i][j]; }
printf("\n★★ i,j ≥ 1 的每一格,调用次数都等于 C((n−i)+(m−j), n−i):%s\n", ok ? "全部对上 ✓" : "✗ 有对不上的"); printf("★★ 而边界格(递归的叶子)加起来是 %lld 次 = C(%d,%d) = %lld —— %s\n", leaf, n + m, n, C(n + m, n), leaf == C(n + m, n) ? "一个不多一个不少 ✓" : "✗ 对不上"); printf("★ 总调用次数 %lld —— 这就是第 17 章说的「不记忆化就要重算这么多遍」。\n", tot); return 0;}点「运行 ▶」看结果
n = m = 6 时:叶子加起来 924 次 = C(12,6),总调用 1847 次。
把动画里的 n 拧到 7 看看那个总数 ——
⚠ 这就是第 17 章说的「不记忆化要重算多少遍」,而这一章给了它一个精确的数。
11自测
- 洛谷 P1313 [NOIP 2011 提高组] 计算系数解析 → —— (ax+by)^k 里 x^n·y^m 的系数 = C(k,n)·a^n·b^m。模数 10007 是质数、k ≤ 1000 < 10007 —— 本章第 6 步那个「p > n」的前提在这里是满足的,可以放心用逆元那套,顺带把第 42 章的快速幂用上
- 洛谷 P1044 [NOIP 2003 普及组] 栈解析 → —— ★ 卡特兰数 C(2n,n)/(n+1)。第 1 章用递归数过它、第 21 章用 DP 递推过它,这一章给出闭形式 —— 三种做法摆在一起,是整条路线最完整的一次回看。n ≤ 18 不用取模,正好看清楚原始的样子
- 洛谷 P2822 [NOIP 2016 提高组] 组合数问题解析 → —— ★ 问 C(i,j) 里有多少个被 k 整除。用第 3 步那份杨辉三角边推边对 k 取模、判 0,再套一层二维前缀和(第 6 章)。⚠ 这道题恰恰不能用逆元那套 —— k 不保证是质数,想清楚为什么
- 洛谷 P3807 【模板】卢卡斯定理解析 → —— ★★ 本章第 6 步说「这套公式要求 p > n,p ≤ n 时会全错」,而这道题的 p ≤ 10⁵、n+m 可以更大 —— 它就是那个失效的场。Lucas 把 n 和 m 按 p 进制拆开,每一位都退回到 p > n 的情形。先跑一遍本章的 smallP.cpp 看它是怎么错的,再来读 Lucas 会顺很多
- 取模之下不能除,只能乘逆元 —— 而逆元存在 ⟺
gcd(b,p) = 1(第 40 章), p 是质数时可以用费马小定理b⁻¹ ≡ b^(p−2)(第 41、42 章)。 ⚠ 而这套公式有个必须写明的前提:p 必须大于 n。 - 那句
((i−1)!)⁻¹ = (i!)⁻¹ · i把一个 log p 整个摊掉了 —— 预处理从O(n log p)降到O(n + log p)(4600 万 → 200 万)。 - 三个 0 / 300,三条都只差一点点:n ≤ 20 差一个 21、p = 1000003 差两倍、
k = rnd(0,n)差一个写法。 ★ 别只问「范围够不够大」,要问「有没有跨过那条线」。
★ 写到这里,43 章全部完成。 回头看这一路,真正反复出现的不是某个算法,是三件事:
① 先跑再写 —— 教材里每一个数字都得是实测出来的; ② 对拍只能证伪,而且有四个盲区(看不见慢、看不见没漏报、碰不到最坏、抓不到某些溢出); ③ 生成器里那句「顺手」的写法,比算法本身更容易骗过你。