这一章的正解只有一行公式:
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 暴力:杨辉三角
点「运行 ▶」看结果
从 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 拿它和动画逐行比。
点「运行 ▶」看结果
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 正解,以及那个必须写明的前提
点「运行 ▶」看结果
一个个求 (i!)⁻¹ 要 n 次快速幂(O(n log p))。其实只要一次:
先求 (n!)⁻¹,然后 ((i−1)!)⁻¹ = (i!)⁻¹ · i ← 从大往小倒着推(因为 (i−1)! = i! / i,取逆元就是乘 i。)
点「运行 ▶」看结果
次数表(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 章那个「对拍看不见慢」的第八次现场。
点「运行 ▶」看结果
「阶乘 + 逆元」里有一句话没写出来:k! 和 (n−k)! 都得有逆元。
而逆元存在 ⟺ gcd(b, p) = 1 ——
⇒ 只要
p ≤ n,n!里就含有 p 这个因子 ⇒n! ≡ 0,而 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 ⚠ 顺带一个「同一个手滑,在两章里一死一活」的例子
点「运行 ▶」看结果
第 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 步会看到代价。
点「运行 ▶」看结果
点「运行 ▶」看结果
点「运行 ▶」看结果
点「运行 ▶」看结果
点「运行 ▶」看结果
点「运行 ▶」看结果
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) | 226 / 300 | 第 3 轮 |
invPow / sameInit(只是慢 / 完全等价) | ★ 0 / 300 | — |
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 | 266 | ⚠ 61 | 61 |
| 8(最终档) | 7 + ★ k 专门塞 0 / n | 297 | 297 | 296 | 259 | 226 | ★ 260 | ✓ 226 |
| 9(对照) | 8 减去「k 独立取」 | 300 | 300 | 298 | 286 | ★ 0 | 276 | 0 |
| 10(对照) | 8 减去「p 跨过溢出线」 | 298 | 298 | 291 | ★ 0 | 224 | 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),一个不多一个不少。
点「运行 ▶」看结果
n = m = 6 时:叶子加起来 924 次 = C(12,6),总调用 1847 次。
把动画里的 n 拧到 7 看看那个总数 ——
⚠ 这就是第 17 章说的「不记忆化要重算多少遍」,而这一章给了它一个精确的数。
11 自测
- 取模之下不能除,只能乘逆元 —— 而逆元存在 ⟺
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 章全部完成。 回头看这一路,真正反复出现的不是某个算法,是三件事:
① 先跑再写 —— 教材里每一个数字都得是实测出来的; ② 对拍只能证伪,而且有四个盲区(看不见慢、看不见没漏报、碰不到最坏、抓不到某些溢出); ③ 生成器里那句「顺手」的写法,比算法本身更容易骗过你。