阶段 8 · 数学 · 第 43 章

组合数与递推

★ 关键一步是「取模之下不能除,只能乘逆元」—— 而逆元存在的条件(第 40 章)、费马小定理要的质数(第 41 章)、算逆元用的快速幂(第 42 章),到这里正好收口。⚠ 顺带还清第 17 章那笔账:不带记忆的递归,调用次数正好排成杨辉三角。

例题:多组 C(n,k) mod p 建议用时:110 分钟
最后一章:把数学这一块四章收口,顺带还第 17 章那笔账

这一章的正解只有一行公式:

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⁵),每组两个数 nk0 ≤ n, k ≤ 10⁶两个各自独立):

  • 输出 C(n,k) mod pk > n 时 C(n,k) = 0);
  • ★ 最后再输出一行:有多少组的答案是 0
⚠ p 的下界不是凑数的:它是这套公式的前提

10⁶ < p 这一句写得很刻意,理由在第 6 步会展开,先说结论:

只要 p ≤ nn! 里就含有 p 这个因子 ⇒ n! ≡ 0 (mod p),而 0 没有逆元。 整套公式当场失效,而且不报错、不崩溃,只是安静地给你一串 0。

⚠ 而 0 ≤ n, k ≤ 10⁶ 里那句「两个各自独立」同样刻意 —— 它意味着 k 可以大于 n。第 9 步会看到:顺手写的生成器永远造不出这种数据

2 手算一遍:默认那 10 组

★ 这 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 = 0k = n(三组)—— 那是「取全部 / 一个都不取」,用到的是 inv[0]
  • k > n3 7)—— 题面规定答案是 0,而套公式会把 n−k 变成负数;
  • n = 21 —— 21! ≈ 5.1×10¹⁹ 已经撞破 long long,阶乘那一行必须取模;
  • p ≈ 10⁹ —— 三个 [0,p) 里的数连乘就是 10²⁷,中间必须取模。

3 暴力:杨辉三角

brute.cpp标准答案:杨辉三角递推(和公式是完全不同的想法)
输入(stdin)
输出
点「运行 ▶」看结果
★ 这条递推为什么成立 —— 一句话

从 n 个东西里挑 k 个,盯住第 n 个东西

  • 挑它 ⇒ 剩下的从前 n−1 个里挑 k−1 个 ⇒ C(n−1, k−1)
  • 不挑它 ⇒ 从前 n−1 个里挑 k 个 ⇒ C(n−1, k)

两类不重不漏,加起来就是全部。∎

⚠ 注意它全程只有加法 —— 于是「取模」这件事在这里毫无难度(加完取一次就行)。 ★ 这一章所有的坑,都长在另一条路上(乘法和除法)。

4 动画一:三角形一行一行长出来

每一格 = 上一行的「左上 + 正上」
到第 8 行一共 36 次加法(= n(n+1)/2)
第 1 / 9 步
这一行 0 次加法★ 累计 0
1
★ ★ C(n,k) = C(n−1,k−1) + C(n−1,k):盯住第 n 个东西,挑它 / 不挑它。
第 0 行只有一个 1 —— C(0,0) = 1。
怎么看 —— 以及为什么它非换不可
  • 每一格 = 上一行的「左上 + 正上」,就是上面那条递推。
  • ★ 右边那个计数器:到第 n 行,加法次数正好是 n(n+1)/2

⚠ 而这正是它的死穴:

n加法次数要开多少格子
10³50 万10⁶
10⁴5 000 万10⁸
10⁶5 000 亿10¹²(开都开不出来)

不是慢,是根本装不下。 所以必须换成一行公式。

trace.cpp 是这个动画的文字版,check:viz 拿它和动画逐行比。

trace.cpp动画照着它画:每一行的数、这一行的加法次数、累计
输出
点「运行 ▶」看结果

5 ★ 关键一步:取模之下不能除

★★ 「除以 b」要换成「乘以 b 的逆元」

C(n,k) = n! / (k! · (n−k)!)。可取模之下不能直接除 —— (a / b) mod p(a mod p) / (b mod p) 根本不是一回事(6/3 = 2,可 6 mod 5 = 13 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) ≡ 1b⁻¹ ≡ b^(p−2)。 ⇒ 一次快速幂(第 42 章那五行)就够了。

⚠ 是 p−2,不是 p−1 —— 差一个数,b^(p−1) 算出来恒等于 1,整张表全废。

数学这一块四章,到这里正好收口: 第 40 章给了「存不存在」,第 41 章给了「p 是质数」这个前提,第 42 章给了「怎么算」。

6 正解,以及那个必须写明的前提

fast.cpp正解:阶乘 + 倒推逆元(预处理 O(n + log p),每次询问 O(1))
输入(stdin)
输出
点「运行 ▶」看结果
★ 那个倒推:把一个 log 整个摊掉

一个个求 (i!)⁻¹ 要 n 次快速幂(O(n log p))。其实只要一次

先求 (n!)⁻¹,然后 ((i−1)!)⁻¹ = (i!)⁻¹ · i      ← 从大往小倒着推

(因为 (i−1)! = i! / i,取逆元就是 i。)

invPow.cpp⚠ 不是 bug:每个逆元都单独快速幂 —— 答案逐字节相同,只是慢 23 倍
输入(stdin)
输出
点「运行 ▶」看结果

次数表n = 10⁶p = 10⁹+7,一次快速幂 45 次乘法):

做法次数是正解的几倍
杨辉三角(加法)500 000 500 000249 995 ×
阶乘 + 逐个快速幂求逆元46 000 04523.0 ×
✓ 阶乘 + 倒推逆元2 000 0451 ×

★ 那一句 ((i−1)!)⁻¹ = (i!)⁻¹ · i,把整整一个 log p 摊掉了。 (第 37 章自底向上建堆、第 38 章 O(n) 建树,都是同一种「把 log 摊掉」。)

⚠ 而三份代码的答案逐字节相同 —— 第 36 章那个「对拍看不见慢」的第八次现场。

count.cpp三种做法的次数(全是算出来的等式)
输出
点「运行 ▶」看结果
★★ 那个必须写明的前提:p 必须大于 n

「阶乘 + 逆元」里有一句话没写出来:k!(n−k)! 都得有逆元。 而逆元存在 ⟺ gcd(b, p) = 1 ——

只要 p ≤ nn! 里就含有 p 这个因子 ⇒ n! ≡ 0,而 0 没有逆元。

smallP.cpp★ 把「p ≤ n 时会怎样」跑出来给你看
输出
点「运行 ▶」看结果
pnk公式版杨辉三角版
1 000 003103120120
1 000 0033015117 055117 055
710301
7201005
13261302

⚠⚠ 注意它不报错、不崩溃 —— 只是安静地给你一串 0。 ★ 真要处理 p ≤ n,得换 Lucas 定理(把 n、k 写成 p 进制,逐位算 C 再乘起来)—— 这一章点到为止,但**「为什么必须换个办法」这件事得说清楚**。

★ 这也是第 33 章那条的又一次:同一段代码成不成立,取决于数据的取值范围。

7 ⚠ 顺带一个「同一个手滑,在两章里一死一活」的例子

sameInit.cpp⚠ 不是 bug:fac[0] 写成 1 而不是 1 % p —— 在这一章完全无害
输入(stdin)
输出
点「运行 ▶」看结果
★ 第 42 章那个致命的手滑,在这一章是 0 / 300

第 42 章里 res = 1 而不是 res = 1 % p 是个真 bug(p = 1 时答案该是 0)。 这一章同一个位置写 fac[0] = 1300 轮和正解逐字节相同 —— 因为题面把 p 的下界抬到了 10⁶1 % p 恒等于 1。

★★ 同一句代码危不危险,取决于数据的取值范围。 (第 33 章立的那条,这是第三次现场 —— 而这一次两个现场就隔着一章。)

8 ★ 对拍:六个错误版本

★ 清单:两个白送,四个卡在「一条具体的线」上
错误版本靠什么现形
wrongFermat 逆元用 p−1什么数据都行(整张逆元表全废)—— ★ 基线
wrongDir 逆元阶乘方向反同上,只有 n = k = 0 那组它蒙对
wrongFacP 阶乘忘了取模★ 要 n ≥ 2121! > 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 步会看到代价。

wrongFermat.cpp✗ 逆元用 b^(p−1):那算出来恒等于 1(基线)
输入(stdin)
输出
点「运行 ▶」看结果
wrongDir.cpp✗ 逆元阶乘从小往大推 —— 方向反了
输入(stdin)
输出
点「运行 ▶」看结果
wrongFacP.cpp✗ 阶乘那一行忘了 % p —— n ≥ 21 就现形
输入(stdin)
输出
点「运行 ▶」看结果
wrongOverflow.cpp✗ 三个数连乘、中间不取模 —— p > 2.1×10⁶ 才现形
输入(stdin)
输出
点「运行 ▶」看结果
wrongKn.cpp✗ k > n 时没返回 0 —— 顺手写的生成器造不出这种数据
输入(stdin)
输出
点「运行 ▶」看结果
wrongInvEnd.cpp✗ 逆元倒推少走一步 —— 只在 k 等于 0 或 n 时错
输入(stdin)
输出
点「运行 ▶」看结果
对拍器
★ 这个生成器的顺手档有三个 0 / 300 —— 而三条都不是「范围没覆盖」,是「覆盖到了那条线的错误一侧」。

300 轮实测(种子 1..300,最终档 8):

故意写错的地方被抓第几轮
wrongFermat(逆元用 p−1297 / 300第 2 轮
wrongDir(倒推方向反)297 / 300第 2 轮
wrongFacP(阶乘忘取模)296 / 300第 2 轮
wrongInvEnd(倒推少走一步)260 / 300第 2 轮
wrongOverflow(连乘不取模)259 / 300第 2 轮
wrongKnk > n226 / 300第 3 轮
invPow / sameInit(只是慢 / 完全等价)0 / 300

9 ★★ 生成器:三个 0,而三条都只差「一点点」

★★★ 顺手写法这一次的形状:不是范围没覆盖,是站错了那条线的哪一侧
档位相对档位 0 改了什么FermatDirFacPOvfKnInvEnd
0(顺手写法)p = 1000003、n ∈ [1,20]、k = rnd(0,n)300300000226
1k 独立取(于是能造出 k > n)29229100234128
2n 放大到 [1,60](跨过 21)30030029300149
3p 取到 [2.1×10⁶, 10⁹](跨过溢出线)30030002840236
4k 专门塞 0 或 n300300000278
5k 专门避开 0 和 n30030000062
6询问条数拉长300300000300

★★★ 那三个 0,每一个都只差「一点点」:

顺手写的是那条线在哪差在哪
n ∈ [1,20]n = 2121! > 9.22×10¹⁸1
p = 1000003p = 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 章那条「顺手写法」的第十四次

★★ 而「k 取哪儿」这个旋钮,两头都得造

对着看档位 4 和档位 5:

k 专门塞 0 / n → wrongInvEnd 278;k 专门避开 0 / n → 62。

⚠ 而「避开」正是很多人会做的事 —— 觉得 k = 0k = n 太平凡了,测不出什么。 ★ 可 wrongInvEnd 错的正好就是那几组

档位内容FermatDirFacPOvfKnInvEnd最弱支
71 + 2 + 3(三条线全跨过去)2902902902862666161
8(最终档)7 + ★ k 专门塞 0 / n297297296259226260226
9(对照)8 减去「k 独立取」30030029828602760
10(对照)8 减去「p 跨过溢出线」29829829102242500
11(对照)8 减去「n 跨过 21」30030002392242700

① ⚠⚠ 档位 7 是一个很典型的陷阱:三条线全跨过去了,看着很完备 —— 可 wrongInvEnd 反而掉到 61。 因为「k 独立取」让 k 落在 [0, 60] 里,恰好等于 0 或 n 的概率反而变小了

补齐了几个边界,可能把另一个边界稀释掉 —— 又一次「某一支占得太多也是坑」(第 31 章)。

② 三个对照档说明三处改动各自不可替代**:撤掉哪一处,对应那一列就回到 0。**

gen.cpp(十二个档位)六处改动全部可重跑,包括那个「k 专门避开 0 和 n」的反面教材
genBig.cpp大数据:n 和 q 压在两条不同的曲线上

10 ★★ 还第 17 章那笔账:调用次数正好排成杨辉三角

★ 第 17 章那笔账:调用次数排成杨辉三角
f(5,5) = 252 而 C(10,5) = 252 总调用 503
0
70
35
15
5
1
70
70
35
15
5
1
35
35
20
10
4
1
15
15
10
6
3
1
5
5
4
3
2
1
1
1
1
1
1
1
★★ 绿色那圈(递归的叶子)加起来 = 252 = C(10,5) = 252 ✓ 一个不多一个不少
★ 而 i, j ≥ 1 的每一格,调用次数都等于 C((n−i)+(m−j), n−i)(这一档全部对上 ✓)。
⚠ 把 n 调到 7 看看总调用次数 —— **这就是第 17 章说的「不记忆化要重算多少遍」**。
★★ 第 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),一个不多一个不少。

pascal17.cpp★ 两条等式当场跑出来对一遍
输出
点「运行 ▶」看结果

n = m = 6 时:叶子加起来 924 次 = C(12,6),总调用 1847 次。 把动画里的 n 拧到 7 看看那个总数 ——

这就是第 17 章说的「不记忆化要重算多少遍」,而这一章给了它一个精确的数。

11 自测

自测清单0 / 10
这一章我卡在哪(过一个月回来看,这几行比整章正文都值钱)
这一章记住三句话
  1. 取模之下不能除,只能乘逆元 —— 而逆元存在 ⟺ gcd(b,p) = 1(第 40 章), p 是质数时可以用费马小定理 b⁻¹ ≡ b^(p−2)(第 41、42 章)。 ⚠ 而这套公式有个必须写明的前提:p 必须大于 n
  2. 那句 ((i−1)!)⁻¹ = (i!)⁻¹ · i 把一个 log p 整个摊掉了 —— 预处理从 O(n log p) 降到 O(n + log p)(4600 万 → 200 万)。
  3. 三个 0 / 300,三条都只差一点点:n ≤ 20 差一个 21、p = 1000003 差两倍、 k = rnd(0,n) 差一个写法。 ★ 别只问「范围够不够大」,要问「有没有跨过那条线」。

写到这里,43 章全部完成。 回头看这一路,真正反复出现的不是某个算法,是三件事:

① 先跑再写 —— 教材里每一个数字都得是实测出来的; ② 对拍只能证伪,而且有四个盲区(看不见慢、看不见没漏报、碰不到最坏、抓不到某些溢出); ③ 生成器里那句「顺手」的写法,比算法本身更容易骗过你。