阶段 8 · 数学 · 第 42 章普及组 J

快速幂与取模

★ 关键一步是 a^b = (a^(b/2))² —— 接第 12 章的分治,一句话把 b 次乘法压到 log b 次。而这一章另一半的内容在「取模」上。

需要先学:第 12 章 分治进阶例题:多组 a^b mod p建议用时:110 分钟
算法五行就写完了 —— 而这一章七成的力气花在别处

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 组

★ 这 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 次

brute.cpp标准答案:连乘 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;
}
点一下即可编辑
输入(stdin)
输出
点「运行 ▶」看结果
⚠⚠ 这一章的对拍有一个前面十五章都没碰到过的硬限制

标准答案是「连乘 b 次」,所以对拍的数据里 b 必须压得很小(这里压在 10⁴ 以内)。 可题面允许 b 到 10¹⁸ ——

★★ 那一半的取值范围,对拍根本够不着。

⇒ 补救办法在第 10 步:不比答案,比性质(a^(b₁+b₂) ≡ a^b₁ · a^b₂)。 这是第 31 章「答案不唯一时写验证器」那一招的另一种用法: ★ 这一次答案是唯一的,够不着的是「规模」。

4慢在哪:次数是能精确算出来的

★★ 这一章的次数不是「大约 log」,是三个等号
count.cpp三种做法的次数(全部是算出来的,不是跑出来的)
// ★ 这一章的尺子:数**乘法(或加法)执行了多少次**
//
// 为什么它存在:连乘 / 快速幂 / 快速幂+龟速乘 三份代码的**答案逐字节相同**,
// 对拍在它们身上一个字都看不见(第 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 的位数。 这就是上面那句「两条曲线的旋钮不是同一个」的实测版。

genBig.cpp(三个档位)两个旋钮:b 有多大、p 有多大 —— 它们管的不是同一件事

5⚠ 三件和算法无关的事,先说清楚

★ ① 乘法溢出:不是「p 大」,是「p 大过一条具体的线」

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 的时候,把乘法也拆成二进制。
mul.cpp⚠ 不是 bug:龟速乘版,答案逐字节相同,只是每次乘法变成 log p 次加法
// ⚠ 这不是错误版本 —— 它把 `__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;
}
点一下即可编辑
输入(stdin)
输出
点「运行 ▶」看结果

★ 注意它和快速幂是同一个想法用了两遍: 快速幂把指数拆成二进制,龟速乘把乘数拆成二进制。

★ ② 负数取模:C++ 的 % 会给负余数

-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 时根本不够。)

★★ ③ 初值不是 1,是 1 % p

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) 次「乘进答案」+ 位数次「平方」。

fast.cpp正解:快速幂(算法本身五行,另外三行全是取模的坑)
// 正解 —— 快速幂(反复平方法)
//
// ============ ★ 关键一步: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;
}
点一下即可编辑
输入(stdin)
输出
点「运行 ▶」看结果

7动画一:b 的二进制一位位消失

b 的二进制从低位往高位扫:位是 1 就乘进答案,然后 a 自平方
连乘要 13 次 / 快速幂 7 次
第 1 / 6 步
★ 乘进答案 0 次平方 0 次popcount(b) = 3,位数 = 4
b =
1
1
0
1
res = 1
a  = 3
★ ★ 初值是 1 % p,不是 1 —— p = 1 时答案是 0。
起点:res = 1 mod 1000000007 = 1,a = 3,b = 13(二进制 1101)。
怎么看这个动画
  • 上面那排格子是 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 拿它和动画逐步比。

trace.cpp动画照着它画:每一步的位、res、a,以及两个计数器
// 动画的文字版 —— 快速幂的每一步: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

★★ 最后两条要的是「两个边界同时出现」—— 这是前十五章都没碰到过的形状。

wrongLoop.cpp✗ while (b > 1):最后一位没乘进去(基线)
// ✗ 错误版本:循环条件写成 `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;
}
点一下即可编辑
输入(stdin)
输出
点「运行 ▶」看结果
wrongOrder.cpp✗ 先平方、再判这一位 —— 整整错一位
// ✗ 错误版本:**先平方、再判这一位**(两句话调了个个儿)
//
// ```
// 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;
}
点一下即可编辑
输入(stdin)
输出
点「运行 ▶」看结果
wrongNeg.cpp✗ 没归一化负数 —— 答案跑到 [0,p) 外面去了
// ✗ 错误版本:**没把负数归一化**(少了那句 `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;
}
点一下即可编辑
输入(stdin)
输出
点「运行 ▶」看结果
wrongOverflow.cpp✗ 乘法没用 __int128 —— 只在 p > 3.04×10⁹ 时现形
// ✗ 错误版本:乘法**没用 __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;
}
点一下即可编辑
输入(stdin)
输出
点「运行 ▶」看结果
wrongOne.cpp✗ 初值写成 1 —— 只在 p = 1 且 b = 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;
}
点一下即可编辑
输入(stdin)
输出
点「运行 ▶」看结果
wrongZero.cpp✗ 「底数是 0 就返回 0」—— 顺手加的一句,把 0⁰ = 1 也吞了
// ✗ 错误版本:把 `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;
}
点一下即可编辑
输入(stdin)
输出
点「运行 ▶」看结果
对拍器
★ 这个生成器的顺手档,六个 bug 里有四个是 0 / 300 —— 全书最惨的一次开局。而救活它们的办法,有一个是前面十五章都没用过的:把两个边界配着塞。
// 正解 —— 快速幂(反复平方法)
//
// ============ ★ 关键一步: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,以及一个前面没用过的招

★★★ 顺手档:六个 bug 里四个 0 / 300 —— 全书最惨的一次开局
档位 相对档位 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,掉多少抓获率都划算。

gen.cpp(十四个档位)七处改动全部可重跑,包括那个「单独补一个边界不够」的诊断档

10★★ 对拍够不着的那一半:比性质,不比答案

★ 标准答案跑不到 b = 10¹⁸,那一半只能换个验法
verify.cpp★ 验证器:b 到 10¹⁸、p 到 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 的二进制有 60 位、 其中 24 个 1; p 的二进制有 30 位
连乘 b 次
1,000,000,000,000,000,000
快速幂 + 龟速乘(加法次数)
2,520
✓ 快速幂 + __int128(乘法次数)
84
★★ 快速幂的次数 = popcount(b) + 位数 = 24 + 60 = 84
⚠ 条的长度按 对数 画(不然 10¹⁸ 那一根会把别的挤没);右边的数字才是真的次数。
★ 换 p 试试:**上面第一根和最后一根一动不动** —— 快速幂的乘法次数只和 b 有关;只有龟速乘那一行跟着 p 走。
★ 这个动画有一个专门要你动手的地方

把 b 固定、只换 p:

第一根(连乘)和最后一根(快速幂)一动不动,只有中间那根(龟速乘)跟着变。

因为快速幂的乘法次数只和 b 有关 —— popcount(b) + 位数,和模数一点关系都没有; 而龟速乘把每一次乘法拆成 ⌊log₂p⌋+1 次加法,它才是跟着 p 走的那一个。

⚠ 而这三根条对应的三份代码,答案逐字节相同(check:viz 里 300 轮钉着)——

第 36 章那个「对拍看不见慢」的第七次现场。

12自测

自测清单0 / 11
配套练习
  • 洛谷 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 位」,而那五行照样成立
这一章我卡在哪(过一个月回来看,这几行比整章正文都值钱)
这一章记住三句话
  1. a^b = (a^(b/2))² —— 把 b 看成二进制,乘法次数正好是 popcount(b) + 位数。 ★ 而这个次数只和 b 有关,和模数一点关系都没有。
  2. 算法五行,坑有三处:乘法溢出(p > 3.04×10⁹ 就要 __int128)、 负数取模(% 会给负余数)、初值 1 % p(p = 1 时答案是 0)。 ★ 第 40 章那句在这一章最彻底:算法越短,风险越往边界上转移。
  3. 有些 bug 要「两个边界同时出现」 —— p = 1 且 b = 0、a ≡ 0 且 b = 0。 ⚠ 各自独立地塞进去,同时发生的概率只有 3%;必须配着造(最弱支 46 → 111)。

⚠ 下一章(组合数)会把这一章的快速幂直接拿去用:求乘法逆元。 而杨辉三角那条递推,会和第 17 章那个「记忆化搜索的调用次数正好是杨辉三角」呼应收尾。