a^b mod p 的正解只有五行。可这一章要讲的东西里,算法只占三成:
| 要讲的 | 占多少 |
|---|---|
a^b = (a^(b/2))²(第 6 步) |
三成 —— 接第 12 章的分治,一句话的事 |
乘法溢出(p 到 10¹⁸ 时 a*b 装不下) |
★ 三成 |
负数取模(C++ 的 % 会给负余数) |
★ 两成 |
p = 1 和 b = 0 这两个边界 |
★★ 两成 |
★ 第 40 章立过一条:算法越短,风险越往「取模、溢出、负数、0 和 1」上转移。 这一章是那句话最彻底的一次现场 —— 六个错误版本里,四个和算法本身无关。
⚠ 而生成器那一节会看到一个前面十五章都没出现过的形状: 有两个 bug 要「两个边界同时出现」 —— 靠各自独立的概率相乘,等一辈子也等不来。
1一句话问题
给定 q 组询问(
q ≤ 10⁵),每组三个整数a、b、p:
−10¹⁸ ≤ a ≤ 10¹⁸,0 ≤ b ≤ 10¹⁸,1 ≤ p ≤ 10¹⁸;- 输出
a^b mod p,答案取在[0, p)里;约定0⁰ = 1。★ 最后再输出一行:有多少组的答案是 0。
| 范围 | 为什么这么写 | 卡住哪个 bug |
|---|---|---|
a 可以是负数 |
C++ 的 % 对负数给负余数 |
wrongNeg |
p 可以到 10¹⁸ |
res * a 就是 10³⁶ —— long long 装不下 |
wrongOverflow |
p 可以等于 1 |
这时候任何数都 ≡ 0,连 a⁰ 也是 0 |
wrongOne |
b 可以等于 0 且约定 0⁰ = 1 |
「底数是 0 就返回 0」这句顺手的剪枝会吞掉它 | wrongZero |
★ 而末行那句「有多少组的答案是 0」是「题面多问一句」的第八次 ——
这次它兜的是答案恰好为 0 的那些组(p = 1 和 a ≡ 0),
也就是最容易被一句「顺手的特判」搞错的那一片。
2手算一遍:默认那 8 组
2 10 1000000007 → 1024
-3 5 1000000007 → 999999764 ★ 负底数 + 奇次幂:(-3)⁵ = -243 ≡ p-243
5 0 1000000007 → 1 a⁰ = 1
0 0 7 → 1 ★ 题面约定 0⁰ = 1
123456789 1000 1000000007 → 620139939
7 13 1 → 0 ★ p = 1:任何数都 ≡ 0
999999937 12 999999999999999989 → 860406115710957185 ★ p ≈ 10¹⁸:乘法必须 __int128
5 0 1 → 0 ★★ p = 1 且 b = 0 —— 这一组只有它抓得到 wrongOne
末行 → 2 (答案是 0 的有两组)⚠ 第二行值得单独看:(-3)⁵ = -243,而 C++ 里 -243 % (10⁹+7) 给的是 −243,
不是 999999764。少写一句 if (a < 0) a += p;,答案就是个负数。
⚠⚠ 而最后一组是专门为 wrongOne 加的:
p = 1 那一行(第 6 组)它其实是对的 —— 因为 b = 13 ≥ 1,循环里 a = 7 % 1 = 0,
res 乘着乘着就变 0 了。只有 p = 1 且 b = 0 同时成立,它才现形。
★ 这件事第 9 步会变成整整一档生成器。
3暴力:老老实实乘 b 次
// 标准答案 —— 老老实实乘 b 次//// 为什么它存在:// ① 它是对拍里的标准答案。想法和快速幂**完全不同**:一个是「按定义连乘」,// 一个是「把指数拆成二进制」。(第 9、15 章那条规矩。)// ② ★ 它同时是这一章的反面教材:`b` 到 10¹⁸ 时它要乘十亿亿次 —— 跑到天荒地老。//// ⚠⚠ **于是这一章的对拍有一个前面十几章都没碰到过的限制:// 标准答案只能在「b 很小」的数据上跑。**// ⇒ 生成器必须把 b 压在 10⁵ 以内,可题面允许 b 到 10¹⁸ ——// **那一半的取值范围,对拍根本够不着。**// ★ 补救办法见 verify.cpp:不比答案,**比性质**(a^(b₁+b₂) ≡ a^b₁ · a^b₂)。// 这是第 31 章「答案不唯一时写验证器」那一招的**另一种用法**:// **这次答案是唯一的,够不着的是「规模」。**//// ⚠ 取模的三件事它一件都不能少(和正解一模一样,否则对拍对的是两个错的东西):// 负数先归一化、乘法用 __int128、初值写 `1 % p`。
#include <bits/stdc++.h>using namespace std;
using ll = long long;using i128 = __int128;
/** a^b mod p,按定义连乘 b 次 */ll powerBrute(ll a, ll b, ll p) { ll res = 1 % p; // ★ p = 1 时是 0 a %= p; if (a < 0) a += p; for (ll i = 0; i < b; i++) res = (i128)res * a % p; return res;}
int main() { ios::sync_with_stdio(false); cin.tie(nullptr);
int q; if (!(cin >> q)) return 0;
string out; int zeros = 0; for (int i = 0; i < q; i++) { ll a, b, p; cin >> a >> b >> p; ll v = powerBrute(a, b, p); if (v == 0) zeros++; out += to_string(v); out += '\n'; } out += to_string(zeros); out += '\n';
cout << out; return 0;}点「运行 ▶」看结果
标准答案是「连乘 b 次」,所以对拍的数据里 b 必须压得很小(这里压在 10⁴ 以内)。 可题面允许 b 到 10¹⁸ ——
★★ 那一半的取值范围,对拍根本够不着。
⇒ 补救办法在第 10 步:不比答案,比性质(a^(b₁+b₂) ≡ a^b₁ · a^b₂)。
这是第 31 章「答案不唯一时写验证器」那一招的另一种用法:
★ 这一次答案是唯一的,够不着的是「规模」。
4慢在哪:次数是能精确算出来的
// ★ 这一章的尺子:数**乘法(或加法)执行了多少次**//// 为什么它存在:连乘 / 快速幂 / 快速幂+龟速乘 三份代码的**答案逐字节相同**,// 对拍在它们身上一个字都看不见(第 36 章那个第四盲区,这是第七次现场)。//// ★★ 而这一章的次数是**能精确算出来**的,不是「大约 log」:// · 连乘:**正好 b 次**乘法;// · 快速幂:**popcount(b) 次**「乘进答案」+ **⌊log₂b⌋+1 次**平方// (和第 38 章树状数组那个「步数恰好等于 popcount」是同一种数法);// · 龟速乘版:把上面每一次乘法都换成 **⌊log₂p⌋+1 次加法**。//// ⚠ 于是有一件事要说清楚:**快速幂的乘法次数只和 b 有关,和 p 一点关系都没有;// 可龟速乘版的开销和 p 成 log 关系** —— 两条曲线的旋钮不是同一个。//// 用法:./count b p 打印那张表// ./count b 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 b = (argc > 1) ? atoll(argv[1]) : 1000000; const ll p = (argc > 2) ? atoll(argv[2]) : 1000000007LL; const string mode = (argc > 3) ? argv[3] : "";
// 快速幂:乘进答案的次数 = popcount(b),平方的次数 = 二进制位数 ll pc = 0, bits = 0; for (ll t = b; t; t >>= 1) { pc += (t & 1); bits++; } const ll fastMul = pc + bits; // 总乘法次数 const ll bruteMul = b; // 连乘:正好 b 次 ll pbits = 0; for (ll t = p; t; t >>= 1) pbits++; const ll mulAdd = fastMul * pbits; // 龟速乘版:每次乘法换成 log p 次加法
if (mode == "csv") { printf("brute,%lld\nfast,%lld\nfastPop,%lld\nfastBits,%lld\nmulAdd,%lld\npbits,%lld\n", bruteMul, fastMul, pc, bits, mulAdd, pbits); return 0; }
printf("b = %lld(二进制 %lld 位,其中 %lld 个 1),p = %lld(二进制 %lld 位)\n\n", b, bits, pc, p, pbits); printf("%s %s %s\n", padDisp("做法", 26).c_str(), padDisp("次数", 22).c_str(), padDisp("是快速幂的几倍", 18).c_str()); struct Row { const char* name; ll v; }; Row rows[3] = { {"连乘 b 次", bruteMul}, {"快速幂 + 龟速乘(加法次数)", mulAdd}, {"✓ 快速幂 + __int128(乘法)", fastMul}, }; for (auto& r : rows) printf("%s %s %s\n", padDisp(r.name, 26).c_str(), padDisp(to_string(r.v), 22).c_str(), padDisp(fastMul ? to_string((double)r.v / (double)fastMul).substr(0, 8) : "-", 18).c_str());
printf("\n★★ 快速幂的乘法次数 = popcount(b) + 位数 = %lld + %lld = %lld —— 这是个**等号**。\n", pc, bits, fastMul); printf("⚠ 而它**和 p 一点关系都没有**;龟速乘那一行才和 p 成 log 关系(%lld 位)。\n", pbits); return 0;}点「运行 ▶」看结果
b = 10¹⁸(二进制 60 位,其中 24 个 1)、p ≈ 10¹⁸(二进制 60 位):
| 做法 | 次数 | 是快速幂的几倍 |
|---|---|---|
| 连乘 b 次 | 1 000 000 000 000 000 000 | 1.19 × 10¹⁶ |
| 快速幂 + 龟速乘(加法次数) | 5 040 | 60 × |
✓ 快速幂 + __int128(乘法次数) |
84 | 1 × |
★★ 那个 84 是这么来的,而且是个等号:
乘法次数 = popcount(b)(乘进答案的次数)+ 二进制位数(平方的次数)= 24 + 60 = 84。 (和第 38 章树状数组那个「步数恰好等于 popcount(r)」是同一种数法。)
⚠ 而这张表里有一件很容易被忽略的事:
快速幂的乘法次数只和 b 有关,和 p 一点关系都没有;
只有龟速乘那一行跟着 p 走(每次乘法换成 ⌊log₂p⌋+1 次加法)。
★ 两条曲线的旋钮根本不是同一个 —— 下一步那张耗时表会把这件事量出来。
本机实测(q = 10⁵ 组询问):
g++ -O2 -o genBig genBig.cpp && g++ -O2 -o fast fast.cpp && g++ -O2 -o mul mul.cpp && g++ -O2 -o brute brute.cpp
./genBig 100000 1 > big1.txt # 0: b≈10¹⁸ p=10⁹+7 / 1: b≈10¹⁸ p≈10¹⁸ / 2: b≤10⁴
time ./fast < big1.txt > /dev/null
| 数据 | ✓ fast(__int128) |
mul(龟速乘) |
brute(连乘) |
|---|---|---|---|
| b ≈ 10¹⁸,p = 10⁹+7 | 0.05 秒 | 1.46 秒 | 跑不动 |
| b ≈ 10¹⁸,p ≈ 10¹⁸ | 0.05 秒 | 2.91 秒 | 跑不动 |
| b ≤ 10⁴,p = 10⁹+7 | 0.02 秒 | 0.31 秒 | 2.66 秒 |
★★ 第一行到第二行:
fast一动不动(0.05),mul翻了一倍(1.46 → 2.91) —— 因为 p 从 30 位变成 60 位,而龟速乘的开销正比于 p 的位数。 这就是上面那句「两条曲线的旋钮不是同一个」的实测版。
5⚠ 三件和算法无关的事,先说清楚
res 和 a 都在 [0, p) 里,所以 res * a 最大是 (p−1)²。
long long 的上限是 2⁶³ − 1 ≈ 9.22 × 10¹⁸,于是安全线是
p ≤ √(2⁶³) ≈ 3.04 × 10⁹
★★ 而 OI 里最顺手的模数 10⁹+7 正好在这条线以下((10⁹)² = 10¹⁸ < 9.2×10¹⁸)——
所以拿 10⁹+7 造数据,wrongOverflow 永远抓不到。
⚠ 第 38、40 章那条:先估一估多大的数据才溢出,再决定拧到哪一格。 这一章的「那一格」精确到了小数点后两位。
两种兜法:
__int128(GCC 扩展,OI 里可以用)—— 正解用的就是它;- 龟速乘:没有
__int128的时候,把乘法也拆成二进制。
// ⚠ 这不是错误版本 —— 它把 `__int128` 换成了**龟速乘**,答案逐字节相同。//// ============ ★ 龟速乘:把乘法也拆成二进制 ============//// `a * b % p` 在 p 到 10¹⁸ 时会爆 long long。没有 __int128 的时候怎么办?// **用和快速幂一模一样的手法,把乘法拆成加法**://// a·b = a·(b 的二进制) = Σ a·2^k(b 的第 k 位是 1 时)//// 而 `a·2^k % p` 可以一步步翻倍着算,**每一步都在 [0, p) 里,永远不会溢出**。//// ★ 于是「快速幂」和「龟速乘」是**同一个想法用了两遍**:// · 快速幂把**指数**拆成二进制,把 b 次乘法压到 log b 次;// · 龟速乘把**乘数**拆成二进制,把一次乘法拆成 log b 次加法。// ⚠ 代价是:龟速乘让每一次乘法都变成 O(log p) —— 整个快速幂就成了 **O(log b × log p)**。// 所以名字里那个「龟速」是认真的:正文第 5 步那张表量了它到底慢多少。//// ⚠ 而这一章的对拍看不见这个差别:**它和正解答案完全相同**// (第 36 章那个第四盲区,这是第七次现场)。
#include <bits/stdc++.h>using namespace std;
using ll = long long;
/** 龟速乘:a * b % p,全程不溢出(每一步都在 [0, p) 里) */ll mulmod(ll a, ll b, ll p) { ll res = 0; a %= p; while (b) { if (b & 1) res = (res + a) % p; // ⚠ res + a < 2p ≤ 2×10¹⁸,long long 兜得住 a = (a + a) % p; b >>= 1; } return res;}
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 = mulmod(res, a, p); a = mulmod(a, a, p); b >>= 1; } return res;}
int main() { ios::sync_with_stdio(false); cin.tie(nullptr);
int q; if (!(cin >> q)) return 0;
string out; int zeros = 0; for (int i = 0; i < q; i++) { ll a, b, p; cin >> a >> b >> p; ll v = power(a, b, p); if (v == 0) zeros++; out += to_string(v); out += '\n'; } out += to_string(zeros); out += '\n';
cout << out; return 0;}点「运行 ▶」看结果
★ 注意它和快速幂是同一个想法用了两遍: 快速幂把指数拆成二进制,龟速乘把乘数拆成二进制。
-7 % 3 == -1(不是 2)。C++ 规定 % 的结果和被除数同号。
⇒ 所以读进来第一件事就是
a %= p;
if (a < 0) a += p; // 或者写成 a = ((a % p) + p) % p⚠ 两步都不能少:先 % p 是为了让它落进 (−p, p),再 + p 才能落进 [0, p)。
(只写 a += p 在 a = −10¹⁸、p = 7 时根本不够。)
a⁰ = 1 没错,可题面要的是 a^b mod p —— 而 p = 1 时任何数都 ≡ 0。
ll res = 1 % p; // ★ 不是 1★ 这一句是这一章最容易漏、也最容易被小数据蒙混过去的一行:
p = 1 且 b ≥ 1 时它其实错不了(循环里 a 会变成 0,res 跟着变 0)——
只有 p = 1 且 b = 0 同时成立,它才现形。
6★ 关键一步:a^b = (a^(b/2))²
递归的说法(接第 12 章的分治):
- b 是偶数:
a^b = (a^(b/2))² - b 是奇数:
a^b = a · (a^((b−1)/2))²
⇒ 每一步把指数砍一半,所以只要 ⌊log₂b⌋ + 1 步。
迭代的说法(更好记,也是代码里那五行)—— 把 b 看成二进制:
b = 13 = 1101₂ ⇒ a¹³ = a⁸ · a⁴ · a¹于是「从低位往高位扫 b,位是 1 就把当前的 a^(2^k) 乘进答案」:
ll res = 1 % p;
while (b) {
if (b & 1) res = (i128)res * a % p;
a = (i128)a * a % p;
b >>= 1;
}★ 这两种说法的次数是同一个:popcount(b) 次「乘进答案」+ 位数次「平方」。
// 正解 —— 快速幂(反复平方法)//// ============ ★ 关键一步:a^b = (a^(b/2))² ============//// b 是偶数:a^b = (a^(b/2))²// b 是奇数:a^b = a · (a^((b−1)/2))²//// ⇒ **每一步把指数砍一半**,所以只要 ⌊log₂b⌋ + 1 步。这就是第 12 章那个分治的形状:// 把一个大问题换成**同形状的、规模减半的**小问题。//// 写成迭代版更好记 —— **把 b 看成二进制**:// b = 13 = 1101₂ ⇒ a¹³ = a⁸ · a⁴ · a¹// 于是「从低位往高位扫 b,位是 1 就把当前的 a^(2^k) 乘进答案」://// res = 1; while (b) { if (b & 1) res = res * a % p; a = a * a % p; b >>= 1; }//// ★ 乘法次数 = **popcount(b) + ⌊log₂b⌋**(乘进答案的次数 + 平方的次数)——// 和第 38 章树状数组那个「步数恰好等于 popcount」是同一种数法。//// ============ ⚠⚠ 这一章一半的内容在「取模」上,而不是在「快速幂」上 ============//// **① 乘法溢出。** `p` 可以到 10¹⁸,那么 `res * a` 就是 10³⁶ —— **long long 装不下**。// 这里用 `__int128` 兜住(GCC 扩展,OI 里可以用)。// ⚠ 没有 __int128 的时候怎么办?见 mul.cpp(龟速乘:把乘法也拆成二进制)。//// **② 负数取模。** C++ 的 `%` 对负数给的是**负余数**(`-7 % 3 == -1`),// 而题面要的答案在 `[0, p)` 里。所以读进来第一件事就是// `a = ((a % p) + p) % p` —— **两次取模,一次都不能少**。//// **③ b = 0 和 p = 1 这两个边界。**// `a^0 = 1`,可如果 `p = 1`,答案是 **0**(任何数模 1 都是 0)。// 所以初值不能直接写 `res = 1`,得写 `res = 1 % p`。// ★ 这一句是这一章最容易漏、也最容易被小数据蒙混过去的一行。//// 复杂度:每组询问 O(log b)。
#include <bits/stdc++.h>using namespace std;
using ll = long long;using i128 = __int128;
/** a^b mod p,答案落在 [0, p) */ll power(ll a, ll b, ll p) { ll res = 1 % p; // ★ 不是 1!p = 1 时答案是 0 a %= p; if (a < 0) a += p; // ⚠ 负数取模:C++ 的 % 会给负余数 while (b) { if (b & 1) res = (i128)res * a % p; // ⚠ 用 __int128 兜住 10³⁶ a = (i128)a * a % p; b >>= 1; } return res;}
int main() { ios::sync_with_stdio(false); cin.tie(nullptr);
int q; if (!(cin >> q)) return 0;
string out; int zeros = 0; for (int i = 0; i < q; i++) { ll a, b, p; cin >> a >> b >> p; ll v = power(a, b, p); if (v == 0) zeros++; out += to_string(v); out += '\n'; } out += to_string(zeros); // ★ 末行:有多少组的答案是 0 out += '\n';
cout << out; return 0;}点「运行 ▶」看结果
7动画一:b 的二进制一位位消失
- 上面那排格子是 b 的二进制(高位在左),处理过的位会灰掉 —— 这就是「每一步把指数砍一半」的画面版。
- 这一位是 1 就把当前的
a(也就是a^(2^k))乘进res;不管是 0 是 1,a都要自平方。 - ★ 右边两个计数器:乘进答案的次数 = popcount(b),平方的次数 = 位数 —— 都是等号。
- 切到「★ b = 255(二进制全是 1)」和「★ b = 256(只有一个 1)」对比一下: 同样是 8~9 位,乘进答案的次数差了 8 倍。
- ⚠ 再切到「p = 1」那一档:从第 0 帧起
res就是 0 —— 初值1 % p那一句的画面版。
⚠ trace.cpp 是这个动画的文字版,check:viz 拿它和动画逐步比。
// 动画的文字版 —— 快速幂的每一步:b 的哪一位、乘不乘进答案、当前的底数是几//// 为什么它存在:动画是用 TypeScript 重写一遍算法画出来的,// 万一两边不一致,学生看到的画面就是在骗人(第 27 章那次就是这么被抓住的)。// `check:viz` 拿它和动画**逐步**比:每一步的位、res、a、以及两个累计计数。//// ★ 盯住两件事:// ① **乘进答案的次数 = popcount(b)**,平方的次数 = b 的二进制位数 —— 都是等号;// ② 那一行「b 的二进制」从右往左一位位消失 —— 这就是「每一步把指数砍一半」。//// 用法:./trace a b p 默认 3 13 1000000007
#include <bits/stdc++.h>using namespace std;
using ll = long long;using i128 = __int128;
int main(int argc, char** argv) { ll a = (argc > 1) ? atoll(argv[1]) : 3; ll b = (argc > 2) ? atoll(argv[2]) : 13; ll p = (argc > 3) ? atoll(argv[3]) : 1000000007LL;
string bin; for (ll t = b; t; t >>= 1) bin += char('0' + (t & 1)); reverse(bin.begin(), bin.end()); if (bin.empty()) bin = "0";
printf("起点 a = %lld b = %lld(二进制 %s) p = %lld\n", a, b, bin.c_str(), p);
ll res = 1 % p; a %= p; if (a < 0) a += p; printf("归一 res = 1 %% p = %lld a = %lld\n", res, a);
ll mulRes = 0, sqCnt = 0, step = 0; while (b) { step++; int bit = (int)(b & 1); if (bit) { res = (ll)((i128)res * a % p); mulRes++; } ll na = (ll)((i128)a * a % p); sqCnt++; printf("步 %lld 位=%d %s res=%lld a=%lld -> %lld 乘进答案 %lld 次 平方 %lld 次\n", step, bit, bit ? "乘进答案" : "跳过 ", res, a, na, mulRes, sqCnt); a = na; b >>= 1; } printf("收尾 答案 %lld 乘进答案 %lld 次 平方 %lld 次 合计 %lld 次乘法\n", res, mulRes, sqCnt, mulRes + sqCnt); printf("★ 乘进答案的次数 = popcount(b),平方的次数 = b 的二进制位数 —— 都是等号。\n"); return 0;}点「运行 ▶」看结果
8★ 对拍:六个错误版本
| 错误版本 | 靠什么现形 |
|---|---|
wrongLoop while (b > 1) |
只要 b ≥ 1 —— ★ 基线 |
wrongOrder 先平方再判位 |
同上,几乎白送 |
wrongNeg 不归一化负数 |
★ 要 a < 0 + 指数是奇数(两件事同时) |
wrongOverflow 乘法没用 __int128 |
★ 要 p > 3.04 × 10⁹ |
wrongOne 初值写成 1 |
★★ 要 p = 1 且 b = 0 |
wrongZero 把 0⁰ 当成 0 |
★★ 要 a ≡ 0 且 b = 0 |
★★ 最后两条要的是「两个边界同时出现」—— 这是前十五章都没碰到过的形状。
// ✗ 错误版本:循环条件写成 `while (b > 1)`//// 于是最后一位(b == 1 那一次)**没有被乘进答案**,结果整整少乘一个 a。//// 靠什么现形:几乎什么数据都行 —— **只要 b ≥ 1**。★ 它是这一章的基线。// ⚠ 但注意 b = 0 时它是对的(循环本来就不进),所以「只造 b = 0」的数据抓不到它。#include <bits/stdc++.h>using namespace std;
using ll = long long;using i128 = __int128;
/** a^b mod p,答案落在 [0, p) */ll power(ll a, ll b, ll p) { ll res = 1 % p; // ★ 不是 1!p = 1 时答案是 0 a %= p; if (a < 0) a += p; // ⚠ 负数取模:C++ 的 % 会给负余数 while (b > 1) { // ✗ 最后一位没乘进去 if (b & 1) res = (i128)res * a % p; // ⚠ 用 __int128 兜住 10³⁶ a = (i128)a * a % p; b >>= 1; } return res;}
int main() { ios::sync_with_stdio(false); cin.tie(nullptr);
int q; if (!(cin >> q)) return 0;
string out; int zeros = 0; for (int i = 0; i < q; i++) { ll a, b, p; cin >> a >> b >> p; ll v = power(a, b, p); if (v == 0) zeros++; out += to_string(v); out += '\n'; } out += to_string(zeros); // ★ 末行:有多少组的答案是 0 out += '\n';
cout << out; return 0;}点「运行 ▶」看结果
// ✗ 错误版本:**先平方、再判这一位**(两句话调了个个儿)//// ```// a = a * a % p; ← ✗ 先把 a 平方了// if (b & 1) res = res * a % p; ← ✗ 于是乘进答案的是 a^(2^(k+1)),整整错一位// ```//// 靠什么现形:**b 的二进制里至少有一个 1**,也就是 b ≥ 1 —— 同样几乎白送。// ★ 它和 wrongLoop 是一对:**同样是「错一位」,一个错在最后一位,一个错在每一位。**#include <bits/stdc++.h>using namespace std;
using ll = long long;using i128 = __int128;
/** a^b mod p,答案落在 [0, p) */ll power(ll a, ll b, ll p) { ll res = 1 % p; // ★ 不是 1!p = 1 时答案是 0 a %= p; if (a < 0) a += p; // ⚠ 负数取模:C++ 的 % 会给负余数 while (b) { a = (i128)a * a % p; // ✗ 先平方了 if (b & 1) res = (i128)res * a % p; // ✗ 于是乘进去的是错一位的那个 b >>= 1; } return res;}
int main() { ios::sync_with_stdio(false); cin.tie(nullptr);
int q; if (!(cin >> q)) return 0;
string out; int zeros = 0; for (int i = 0; i < q; i++) { ll a, b, p; cin >> a >> b >> p; ll v = power(a, b, p); if (v == 0) zeros++; out += to_string(v); out += '\n'; } out += to_string(zeros); // ★ 末行:有多少组的答案是 0 out += '\n';
cout << out; return 0;}点「运行 ▶」看结果
// ✗ 错误版本:**没把负数归一化**(少了那句 `if (a < 0) a += p;`)//// C++ 的 `%` 对负数给的是**负余数**:`-7 % 3 == -1`(不是 2)。// 于是 a 是负数时,答案会变成一个负数 —— 而题面要的是 `[0, p)` 里的数。//// 靠什么现形:★ 询问里必须有 **a < 0**,而且**指数是奇数**// (偶次幂会把负号平方掉,答案照样对)。// ⚠ 两个条件要同时满足 —— 这是「两件事都得发生」的又一次(第 36 章那条)。#include <bits/stdc++.h>using namespace std;
using ll = long long;using i128 = __int128;
/** a^b mod p,答案落在 [0, p) */ll power(ll a, ll b, ll p) { ll res = 1 % p; // ★ 不是 1!p = 1 时答案是 0 a %= p; // ✗ 少了 if (a < 0) a += p; while (b) { if (b & 1) res = (i128)res * a % p; // ⚠ 用 __int128 兜住 10³⁶ a = (i128)a * a % p; b >>= 1; } return res;}
int main() { ios::sync_with_stdio(false); cin.tie(nullptr);
int q; if (!(cin >> q)) return 0;
string out; int zeros = 0; for (int i = 0; i < q; i++) { ll a, b, p; cin >> a >> b >> p; ll v = power(a, b, p); if (v == 0) zeros++; out += to_string(v); out += '\n'; } out += to_string(zeros); // ★ 末行:有多少组的答案是 0 out += '\n';
cout << out; return 0;}点「运行 ▶」看结果
// ✗ 错误版本:乘法**没用 __int128**,直接 `res * a % p`//// 靠什么现形:★ 要 `res × a` 撞破 long long(≈9.2×10¹⁸)——// 也就是 **p 得足够大**(p 到 3×10⁹ 以上时两个接近 p 的数一乘就爆了)。// ⚠ 而「顺手」写的生成器多半把 p 取在 10⁹ 附近(「模数嘛,10⁹+7」)——// **那正好是它安全的上限**:`(10⁹)² = 10¹⁸ < 9.2×10¹⁸`。// ★ 所以这个 bug 要的不是「p 大」,是 **p 大过某一条具体的线**(约 3.04×10⁹ = √(2⁶³))。// ⇒ 第 38、40 章那条:**先估一估多大的数据才溢出,再决定拧到哪一格。**#include <bits/stdc++.h>using namespace std;
using ll = long long;using i128 = __int128;
/** a^b mod p,答案落在 [0, p) */ll power(ll a, ll b, ll p) { ll res = 1 % p; // ★ 不是 1!p = 1 时答案是 0 a %= p; if (a < 0) a += p; // ⚠ 负数取模:C++ 的 % 会给负余数 while (b) { if (b & 1) res = res * a % p; // ✗ 没兜住:p 一大这里就溢出 a = a * a % p; // ✗ 同上 b >>= 1; } return res;}
int main() { ios::sync_with_stdio(false); cin.tie(nullptr);
int q; if (!(cin >> q)) return 0;
string out; int zeros = 0; for (int i = 0; i < q; i++) { ll a, b, p; cin >> a >> b >> p; ll v = power(a, b, p); if (v == 0) zeros++; out += to_string(v); out += '\n'; } out += to_string(zeros); // ★ 末行:有多少组的答案是 0 out += '\n';
cout << out; return 0;}点「运行 ▶」看结果
// ✗ 错误版本:初值写成 `res = 1`,而不是 `res = 1 % p`//// `a^0 = 1` 没错,可题面要的是 **`a^b mod p`** —— 而 `p = 1` 时任何数都 ≡ 0。// 于是它在 `p = 1` 时输出 1,正解输出 0。//// 靠什么现形:★★ 询问里必须出现 **p = 1**。// ⚠ 而「顺手」写的生成器多半是 `p = rnd(2, 10⁹)` 或者干脆写死 `10⁹+7` ——// **p = 1 这个边界随机撞不到**(`1 ≤ p` 是题面写死的)。// ★ 第 41 章那条的同款:**有些 bug 要的是「某一个特定的数」,只能专门塞。**// ⚠ 顺带:b = 0 时它同样错(res 一次都没被乘过,直接返回 1)——// 所以这个 bug 有两条腿,可两条腿要的是同一个东西:**p = 1**。#include <bits/stdc++.h>using namespace std;
using ll = long long;using i128 = __int128;
/** a^b mod p,答案落在 [0, p) */ll power(ll a, ll b, ll p) { ll res = 1; // ✗ 忘了模 p:p = 1 时答案该是 0 a %= p; if (a < 0) a += p; // ⚠ 负数取模:C++ 的 % 会给负余数 while (b) { if (b & 1) res = (i128)res * a % p; // ⚠ 用 __int128 兜住 10³⁶ a = (i128)a * a % p; b >>= 1; } return res;}
int main() { ios::sync_with_stdio(false); cin.tie(nullptr);
int q; if (!(cin >> q)) return 0;
string out; int zeros = 0; for (int i = 0; i < q; i++) { ll a, b, p; cin >> a >> b >> p; ll v = power(a, b, p); if (v == 0) zeros++; out += to_string(v); out += '\n'; } out += to_string(zeros); // ★ 末行:有多少组的答案是 0 out += '\n';
cout << out; return 0;}点「运行 ▶」看结果
// ✗ 错误版本:把 `0⁰` 当成了 0//// 题面约定 **`0⁰ = 1`**(然后再模 p)。这份代码顺手加了一句「底数是 0 就直接返回 0」——// 看着挺合理,可它把 `b = 0` 那一支也一起吞了。//// 靠什么现形:★★ 询问里必须同时出现 **a ≡ 0 (mod p) 且 b = 0**。// ⚠ 随机数据里 b = 0 的概率是 1/(b 的取值个数),a ≡ 0 的概率是 1/p ——// **两个都靠运气的话,这辈子都撞不上。**// ⇒ 生成器必须**同时**专门塞 b = 0 和 a = 0。★ 第 41 章那条「专门塞某个数」的加强版:// 这次要**两个特定的数一起出现**。#include <bits/stdc++.h>using namespace std;
using ll = long long;using i128 = __int128;
/** a^b mod p,答案落在 [0, p) */ll power(ll a, ll b, ll p) { ll res = 1 % p; a %= p; if (a < 0) a += p; if (a == 0) return 0; // ✗ 顺手加的一句:把 0⁰ = 1 也吞掉了 // ⚠ 负数取模:C++ 的 % 会给负余数 while (b) { if (b & 1) res = (i128)res * a % p; // ⚠ 用 __int128 兜住 10³⁶ a = (i128)a * a % p; b >>= 1; } return res;}
int main() { ios::sync_with_stdio(false); cin.tie(nullptr);
int q; if (!(cin >> q)) return 0;
string out; int zeros = 0; for (int i = 0; i < q; i++) { ll a, b, p; cin >> a >> b >> p; ll v = power(a, b, p); if (v == 0) zeros++; out += to_string(v); out += '\n'; } out += to_string(zeros); // ★ 末行:有多少组的答案是 0 out += '\n';
cout << out; return 0;}点「运行 ▶」看结果
// 正解 —— 快速幂(反复平方法)//// ============ ★ 关键一步:a^b = (a^(b/2))² ============//// b 是偶数:a^b = (a^(b/2))²// b 是奇数:a^b = a · (a^((b−1)/2))²//// ⇒ **每一步把指数砍一半**,所以只要 ⌊log₂b⌋ + 1 步。这就是第 12 章那个分治的形状:// 把一个大问题换成**同形状的、规模减半的**小问题。//// 写成迭代版更好记 —— **把 b 看成二进制**:// b = 13 = 1101₂ ⇒ a¹³ = a⁸ · a⁴ · a¹// 于是「从低位往高位扫 b,位是 1 就把当前的 a^(2^k) 乘进答案」://// res = 1; while (b) { if (b & 1) res = res * a % p; a = a * a % p; b >>= 1; }//// ★ 乘法次数 = **popcount(b) + ⌊log₂b⌋**(乘进答案的次数 + 平方的次数)——// 和第 38 章树状数组那个「步数恰好等于 popcount」是同一种数法。//// ============ ⚠⚠ 这一章一半的内容在「取模」上,而不是在「快速幂」上 ============//// **① 乘法溢出。** `p` 可以到 10¹⁸,那么 `res * a` 就是 10³⁶ —— **long long 装不下**。// 这里用 `__int128` 兜住(GCC 扩展,OI 里可以用)。// ⚠ 没有 __int128 的时候怎么办?见 mul.cpp(龟速乘:把乘法也拆成二进制)。//// **② 负数取模。** C++ 的 `%` 对负数给的是**负余数**(`-7 % 3 == -1`),// 而题面要的答案在 `[0, p)` 里。所以读进来第一件事就是// `a = ((a % p) + p) % p` —— **两次取模,一次都不能少**。//// **③ b = 0 和 p = 1 这两个边界。**// `a^0 = 1`,可如果 `p = 1`,答案是 **0**(任何数模 1 都是 0)。// 所以初值不能直接写 `res = 1`,得写 `res = 1 % p`。// ★ 这一句是这一章最容易漏、也最容易被小数据蒙混过去的一行。//// 复杂度:每组询问 O(log b)。
#include <bits/stdc++.h>using namespace std;
using ll = long long;using i128 = __int128;
/** a^b mod p,答案落在 [0, p) */ll power(ll a, ll b, ll p) { ll res = 1 % p; // ★ 不是 1!p = 1 时答案是 0 a %= p; if (a < 0) a += p; // ⚠ 负数取模:C++ 的 % 会给负余数 while (b) { if (b & 1) res = (i128)res * a % p; // ⚠ 用 __int128 兜住 10³⁶ a = (i128)a * a % p; b >>= 1; } return res;}
int main() { ios::sync_with_stdio(false); cin.tie(nullptr);
int q; if (!(cin >> q)) return 0;
string out; int zeros = 0; for (int i = 0; i < q; i++) { ll a, b, p; cin >> a >> b >> p; ll v = power(a, b, 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,最终档 12):
| 故意写错的地方 | 被抓 | 第几轮 |
|---|---|---|
wrongLoop(while (b > 1)) |
278 / 300 | 第 1 轮 |
wrongOrder(先平方再判位) |
278 / 300 | 第 1 轮 |
wrongOverflow(没用 __int128) |
278 / 300 | 第 1 轮 |
wrongOne(初值写成 1) |
185 / 300 | 第 1 轮 |
wrongNeg(不归一化负数) |
125 / 300 | 第 1 轮 |
wrongZero(0⁰ 当成 0) |
111 / 300 | 第 8 轮 |
mul(只是慢) |
★ 0 / 300 | — |
9★★ 生成器:四个 0,以及一个前面没用过的招
| 档位 | 相对档位 0 改了什么 | Loop | Order | Neg | Ovf | One | Zero |
|---|---|---|---|---|---|---|---|
| 0(顺手写法) | q ∈ [3,8]、a ∈ [1,10⁹]、b ∈ [1,10⁴]、p = 10⁹+7 | 300 | 300 | ★ 0 | ★ 0 | ★ 0 | ★ 0 |
| 1 | ★ a 允许取负数 | 300 | 300 | 244 | 0 | 0 | 0 |
| 2 | ★ b 允许取 0 | 300 | 300 | 0 | 0 | 0 | 0 |
| 3 | ★ a 允许取 0 | 300 | 300 | 0 | 0 | 0 | 0 |
| 4 | ★ p 随机取到 10¹⁸ | 300 | 300 | 0 | ★ 300 | 0 | 0 |
| 5 | ★ p 专门塞 1 | 299 | 299 | 0 | 0 | ⚠ 0 | 0 |
| 6 | p 取小([1,50]) | 275 | 290 | 0 | 0 | 0 | 0 |
① 那四个 0 各有各的原因,而且每一个都指着「顺手写法」的一条:
a顺手取正 ⇒wrongNeg见不到光;b顺手从 1 起、a顺手不取 0 ⇒wrongZero见不到光;- ★★
p顺手写 10⁹+7(OI 里的条件反射)⇒wrongOverflow正好安全落地 —— 它要的不是「p 大」,是 p 大过 3.04×10⁹ 那条线,而 10⁹+7 就在线下。
② ⚠⚠ 而档位 5 是这一章最该盯的一行:专门塞了 p = 1,wrongOne 还是 0 / 300。
因为它要的是 p = 1 且 b = 0 同时成立 —— 而这一档里 b 还是从 1 起的。
★ 单独补一个边界不够,得把两个边界配起来。
| 档位 | 内容 | Loop | Order | Neg | Ovf | One | Zero | 最弱支 |
|---|---|---|---|---|---|---|---|---|
| 9 | 1+2+3+4+5(五个边界全放开) | 287 | 287 | 136 | 287 | 66 | 46 | 46 |
| 12(最终档) | 9 + ★★ 成对造边界(25% 的询问直接造成 b = 0,再配上 a ≡ 0 或 p = 1) |
278 | 278 | 125 | 278 | ★ 185 | ★ 111 | ✓ 111 |
| 13(对照) | 12 减去「成对造边界」(=档位 9) | 287 | 287 | 136 | 287 | 66 | 46 | 46 |
| 10(对照) | 9 减去「p 塞 1」 | 299 | 299 | 181 | 299 | ★ 0 | 31 | 0 |
| 11(对照) | 9 减去「p 到 10¹⁸」 | 295 | 295 | 157 | ★ 0 | 62 | 24 | 0 |
① 「成对造」把最弱支从 46 抬到 111(One 66 → 185、Zero 46 → 111)。
道理很简单,算一下就明白:把两个边界各自独立地塞进去,
b = 0 的概率是 20%、a ≡ 0 的概率是 15% ——
同时发生只有 3%,一组询问平均 5.5 条,也就是每轮 15% 左右。
而「成对造」直接把这两件事绑在一起发生。
★★★ 有些 bug 要的不是「某一个特定的值」(第 41 章那条), 而是「两个特定的值同时出现」—— 靠各自独立的概率相乘,等一辈子也等不来。
② 两个对照档说明另外两处也不可替代:
撤掉「p 塞 1」→ wrongOne 回到 0;撤掉「p 到 10¹⁸」→ wrongOverflow 回到 0。
③ ⚠ 而「成对造」是有代价的:Loop / Order / Neg / Ovf 四列各掉 10 上下 (287 → 278、136 → 125……),因为四分之一的询问被「b = 0」占掉了,那些询问测不到别的。
★ 又是第 30 章那条:把 0 变成非 0,掉多少抓获率都划算。
10★★ 对拍够不着的那一半:比性质,不比答案
// ★★ 验证器 —— 对拍够不着的那一半(b 到 10¹⁸),只能**比性质,不比答案**。//// 为什么需要它:这一章的标准答案是「连乘 b 次」,所以对拍的数据里 b 必须压在 10⁴ 以内。// **可题面允许 b 到 10¹⁸ —— 那一半的取值范围,对拍根本跑不到。**//// ⇒ 换个办法:不比「答案对不对」,比「答案满不满足它该满足的性质」。// 这是第 31 章「答案不唯一时写验证器」那一招的**另一种用法**:// ★ **这次答案是唯一的,够不着的是「规模」。**//// 查三条性质(全部用 __int128 兜住乘法):// ① **指数可加**:a^(b₁+b₂) ≡ a^b₁ · a^b₂ (mod p)// ② **底数可乘**:(a₁·a₂)^b ≡ a₁^b · a₂^b (mod p)// ③ **费马小定理**(p 是质数且 a 不是 p 的倍数时):a^(p−1) ≡ 1 (mod p)//// ⚠ 验证器的盲区必须写清楚(第 31 章那条):// **这三条性质,一份「永远输出 0」的程序是过不了的**(①②会挂),// 可它们**证明不了答案一定对** —— 比如一份「把结果统一乘 2」的程序能过 ①(不能过 ③)。// ⇒ **验证器是够不着时的替代品,不是对拍的升级版。**//// 用法:./verify [轮数] 默认 20000
#include <bits/stdc++.h>using namespace std;
using ll = long long;using i128 = __int128;
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;}
static bool isPrime(ll x) { if (x < 2) return false; for (ll i = 2; i * i <= x; i++) if (x % i == 0) return false; return true;}
int main(int argc, char** argv) { const int T = (argc > 1) ? atoi(argv[1]) : 20000; mt19937_64 rng(20260814u); auto rnd = [&](ll lo, ll hi) { return (ll)(rng() % (unsigned long long)(hi - lo + 1)) + lo; };
int bad1 = 0, bad2 = 0, bad3 = 0; ll maxB = 0, maxP = 0;
for (int t = 0; t < T; t++) { ll p = rnd(1, 1000000000000000000LL); ll a = rnd(-1000000000000000000LL, 1000000000000000000LL); ll b1 = rnd(0, 500000000000000000LL), b2 = rnd(0, 500000000000000000LL); maxB = max(maxB, b1 + b2); maxP = max(maxP, p);
// ① 指数可加 if (power(a, b1 + b2, p) != (ll)((i128)power(a, b1, p) * power(a, b2, p) % p)) bad1++;
// ② 底数可乘 ll a2 = rnd(-1000000000LL, 1000000000LL); ll prod = (ll)(((i128)(a % p) * (a2 % p)) % p); if (prod < 0) prod += p; if (power(prod, b1, p) != (ll)((i128)power(a, b1, p) * power(a2, b1, p) % p)) bad2++; }
// ③ 费马小定理:拿一批真质数来验 const ll PRIMES[] = {2, 3, 97, 1000003, 1000000007, 998244353, 2147483647}; for (ll p : PRIMES) { if (!isPrime(p)) { bad3++; continue; } for (int t = 0; t < 2000; t++) { ll a = rnd(1, 1000000000000000000LL); if (a % p == 0) continue; if (power(a, p - 1, p) != 1 % p) bad3++; } }
printf("随机 %d 组,b 最大到 %lld,p 最大到 %lld\n\n", T, maxB, maxP); printf("① 指数可加 a^(b1+b2) ≡ a^b1 · a^b2 :%s(%d 处不符)\n", bad1 ? "✗" : "✓ 全部通过", bad1); printf("② 底数可乘 (a1·a2)^b ≡ a1^b · a2^b :%s(%d 处不符)\n", bad2 ? "✗" : "✓ 全部通过", bad2); printf("③ 费马小定理 a^(p−1) ≡ 1(p 质数) :%s(%d 处不符)\n", bad3 ? "✗" : "✓ 全部通过", bad3); printf("\n⚠ 但这三条**证明不了答案一定对** —— 它们只是对拍够不着时的替代品。\n"); return 0;}点「运行 ▶」看结果
查的是三条答案该满足的性质(全部用 __int128 兜住乘法):
| 性质 | 式子 |
|---|---|
| ① 指数可加 | a^(b₁+b₂) ≡ a^b₁ · a^b₂ (mod p) |
| ② 底数可乘 | (a₁·a₂)^b ≡ a₁^b · a₂^b (mod p) |
| ③ 费马小定理(p 质数、a 不是 p 的倍数) | a^(p−1) ≡ 1 (mod p) |
两万组随机数据、b 跑到 10¹⁸ 量级(对拍那一侧只到 10⁴,差了十四个数量级)——三条全过。
⚠⚠ 但验证器的盲区必须写清楚(第 31 章那条):
这三条性质证明不了答案一定对。 比如一份「把结果统一乘 2」的程序能过 ①(不能过 ③); 而一份「永远输出 0」的程序 ①② 都能过(
0 ≡ 0 · 0)——只有 ③ 抓得住它。★ 验证器是「够不着」时的替代品,不是对拍的升级版。
★ 而这次「够不着」的原因和第 31 章不一样,值得并排记一次:
- 第 31 章:答案不唯一,没法逐字节比;
- 这一章:答案是唯一的,可标准答案跑不到那个规模。
11动画二:三种做法的次数 —— 换 p 试试
把 b 固定、只换 p:
第一根(连乘)和最后一根(快速幂)一动不动,只有中间那根(龟速乘)跟着变。
因为快速幂的乘法次数只和 b 有关 —— popcount(b) + 位数,和模数一点关系都没有;
而龟速乘把每一次乘法拆成 ⌊log₂p⌋+1 次加法,它才是跟着 p 走的那一个。
⚠ 而这三根条对应的三份代码,答案逐字节相同(check:viz 里 300 轮钉着)——
第 36 章那个「对拍看不见慢」的第七次现场。
12自测
- 洛谷 P1226 【模板】快速幂||取余运算解析 → —— ★ 第 12 章分治时见过它一次,那时它只是「分治的一个例子」;这一章它是主角。默写五行。⚠ 而这一章列的六个错法,代进这道题的范围之后有三个根本发生不了(含初值那条:它的 p ≥ 2)—— 解析页把每一条都算了一遍
- 洛谷 P1965 [NOIP 2013 提高组] 转圈游戏解析 → —— 每轮位移 m,k 轮之后落在 (x + m·10^k) mod n —— 一次快速幂。练的是「把题目翻译成一个幂」这一步
- 洛谷 P1082 [NOIP 2012 提高组] 同余方程解析 → —— ★ 求 ax ≡ 1 (mod b) 的最小正整数解,也就是逆元。b 不保证是质数,所以要用 exgcd(第 40 章第 13 步那份)。这道题正好卡在第 40 和第 43 章中间,先做它,第 43 章会顺很多
- 洛谷 P3390 【模板】矩阵快速幂解析 → —— ★ 把「乘」换成矩阵乘,五行骨架一个字不用改 —— 这才是快速幂真正的威力:它要的只是结合律。k ≤ 10¹²,正好用上本章第 4 步「次数只和 b 有关」那条
- 洛谷 P1045 [NOIP 2003 普及组] 麦森数解析 → —— ★★ 求 2^P−1 的位数和末 500 位。位数用对数算(不是快速幂),末 500 位用高精度快速幂 —— 同一道题里「取模」被换成了「只留末 500 位」,而那五行照样成立
a^b = (a^(b/2))²—— 把 b 看成二进制,乘法次数正好是 popcount(b) + 位数。 ★ 而这个次数只和 b 有关,和模数一点关系都没有。- 算法五行,坑有三处:乘法溢出(p > 3.04×10⁹ 就要
__int128)、 负数取模(%会给负余数)、初值1 % p(p = 1 时答案是 0)。 ★ 第 40 章那句在这一章最彻底:算法越短,风险越往边界上转移。 - 有些 bug 要「两个边界同时出现」 ——
p = 1且b = 0、a ≡ 0且b = 0。 ⚠ 各自独立地塞进去,同时发生的概率只有 3%;必须配着造(最弱支 46 → 111)。
⚠ 下一章(组合数)会把这一章的快速幂直接拿去用:求乘法逆元。 而杨辉三角那条递推,会和第 17 章那个「记忆化搜索的调用次数正好是杨辉三角」呼应收尾。