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

GCD、LCM 与欧几里得算法

辗转相除为什么对、为什么快,这一章只讲这两件事。★ 关键一步是一行证明:gcd(a,b) = gcd(b, a mod b) —— 它证的是「两对数的公约数集合完全相同」。

需要先学:第 1 章 递归入门:函数怎么调用自己例题:求 n 个数的 gcd 与 lcm建议用时:120 分钟
新的一块:数学。而它和前面最大的不同,在「风险在哪」

第 35~39 章那一块(数据结构)的难点在结构:写错一句下推,答案就悄悄错了。 数学这一块的算法短得多 —— 这一章的正解只有 5 行 —— 可风险原封不动地转移到了别处:

取模、溢出、负数、以及 0 和 1 这两个边界。

★ 所以这一章的六个错误版本里,有三个是溢出,而且它们要的数据一个比一个苛刻。 ⚠ 而最值得记的一件事在生成器上:这一章的「顺手写法」不是给数据加了一条题目没有的性质, 是随机数据自己把题目做没了 —— 第 11 步会拿一个算得出来的常数说清这件事。

1一句话问题

给定 n 个正整数 a[1..n](n ≤ 10⁵,1 ≤ a_i ≤ 10⁹):

  • 第一行输出 gcd(a_1, …, a_n);
  • 第二行输出 lcm(a_1, …, a_n);★ 如果它超过 10¹⁸ 就输出 -1;
  • ★ 第三行输出前缀 gcd:g_1 g_2 … g_n,其中 g_i = gcd(a_1, …, a_i)。
★ 后两问都是故意加的,各有各的用处

① 第二问那个 -1: 一道纯粹的「求 lcm」在 n = 10⁵ 时答案会大到没法写。 加了上限之后,「什么时候该判溢出、判在哪一句」就成了题目的一部分 —— 而这正是数学这一块最容易翻车的地方。 ⚠ 第 10 步会看到:三个和溢出有关的错误版本,要求的数据规模一个比一个高。

② 第三问那个前缀 gcd: 它是「题面多问一句」的第六次(第 35~39 章连着五次)。 这一次多问的东西很朴素 —— 它逼你把中间过程也算对,而不是只交一个最终答案。 第 11 步会看到,它正好卡住一个只错在中间过程上的 bug。

⚠ 而它还有一个副作用,是写完之后才发现的:这一行让「随机数据把题目做没了」这件事变得看得见 —— 随机数据上这一行几乎恒等于 a₁ 1 1 1 …。

2手算一遍:默认那 8 个数

★ 这 8 个数是特意排出来的:六个错误版本全都会在它上面现形
8
12 18 30 5 60 7 1000000007 907696939

第一行 gcd = 1        (12,18 → 6;6,30 → 6;6,5 → 1,之后就一直是 1)
第二行 lcm = -1       (lcm 到 1260 × 1000000007 = 1.26×10¹², 再乘 9 亿 → 超 10¹⁸)
第三行 前缀 gcd = 12 6 6 1 1 1 1 1

三处特意安排:

  • 前三个数带公因子(12、18、30 → 6),第四个数 5 一来 gcd 就掉到 1 —— ★ 这样第三行才有内容可看,而不是从第二项开始就一路 1;
  • gcd(30,5) = 5 而 gcd(12,18,30,5) = 1 ——「前缀 gcd」和「相邻两个的 gcd」在这里分道扬镳;
  • 最后两个是九位数,而且和前面互质 —— lcm 一步跨过 10¹⁸。

⚠ 前六个数完全可以手算;后两个是给溢出准备的,得靠代码。这一点得说明白, 别假装那两个数也是手算出来的。

3三个暴力:而它们慢的原因「各不相同」

第一个是标准答案:按定义算 —— 把每个数分解质因数,gcd 取指数的 min,lcm 取 max。

brute.cpp标准答案:分解质因数(和欧几里得是完全不同的思路)
// 标准答案 —— **按定义算**:把每个数分解质因数,gcd 取指数的 min,lcm 取 max。
//
// 为什么它存在:这是和欧几里得**完全不同的一条思路**(第 9、15 章那条规矩:
// 标准答案最好换个想法写,同一个想法写两遍只能验出打字错误)。
// · 欧几里得:不管这些数长什么样,只反复取模;
// · 这一份:**真的把质因数摆出来** —— gcd 是「每个质因子各取最少的那份」,
// lcm 是「各取最多的那份」。定义就长这样。
//
// ★ 顺带把一条重要的等式摆在了明处:**gcd(a,b) × lcm(a,b) = a × b**
// (每个质因子的 min + max = 两个指数之和)。正文里那句「先除后乘」就是从这来的。
//
// ⚠ 它慢在哪:试除要到 √a(10⁹ 时是 31623 步),所以是 O(n√A) ——
// 比欧几里得的 O(n log A) 慢得多,可比「从 min 往下枚举」(slow.cpp,O(min(a,b)))快得多。
// ⇒ 这一章一共有**三种**求 gcd 的办法,慢的方式各不相同,第 4、5 步会摆成一张表。
#include <bits/stdc++.h>
using namespace std;
const long long LIM = 1000000000000000000LL; // 10¹⁸:题面说超过它就输出 -1
/** 试除分解:返回 {质因子, 指数} */
static map<long long, int> factor(long long x) {
map<long long, int> f;
for (long long p = 2; p * p <= x; p++)
while (x % p == 0) { f[p]++; x /= p; }
if (x > 1) f[x]++; // ⚠ 剩下的那个也是质因子(可能很大)
return f;
}
/** 把 {质因子, 指数} 乘回一个数;超过 LIM 就报告溢出 */
static bool build(const map<long long, int>& f, long long& out) {
long long v = 1;
for (auto& [p, e] : f)
for (int k = 0; k < e; k++) {
if (v > LIM / p) return false; // ⚠ 乘之前先判
v *= p;
}
out = v;
return true;
}
int main() {
ios::sync_with_stdio(false);
cin.tie(nullptr);
int n;
if (!(cin >> n)) return 0;
vector<long long> a(n);
for (auto& x : a) cin >> x;
vector<map<long long, int>> fs(n);
for (int i = 0; i < n; i++) fs[i] = factor(a[i]);
// gcd:每个质因子取**最少**的那份(只有大家都有的质因子才留得下)
map<long long, int> gf = fs[0];
for (int i = 1; i < n; i++) {
map<long long, int> nf;
for (auto& [p, e] : gf) {
auto it = fs[i].find(p);
if (it != fs[i].end()) nf[p] = min(e, it->second);
}
gf = nf;
}
long long g = 1;
build(gf, g);
// lcm:每个质因子取**最多**的那份
map<long long, int> lf;
for (int i = 0; i < n; i++)
for (auto& [p, e] : fs[i]) lf[p] = max(lf[p], e);
long long l = 0;
bool ok = build(lf, l);
printf("%lld\n", g);
printf("%lld\n", ok ? l : -1LL);
// ★ 第三行:前缀 gcd —— 一边扫一边把「取 min」做下去
map<long long, int> pf = fs[0];
for (int i = 0; i < n; i++) {
if (i) {
map<long long, int> nf;
for (auto& [p, e] : pf) {
auto it = fs[i].find(p);
if (it != fs[i].end()) nf[p] = min(e, it->second);
}
pf = nf;
}
long long cur = 1;
build(pf, cur);
printf("%lld%c", cur, i == n - 1 ? '\n' : ' ');
}
return 0;
}
点一下即可编辑
输入(stdin)
输出
点「运行 ▶」看结果
★ 顺带把一条等式摆在明处

每个质因子的 min + max = 两个指数之和,于是

gcd(a,b) × lcm(a,b) = a × b

—— 这就是代码里那句 lcm = a / gcd(a,b) * b 的来历。 ⚠ 而为什么要先除后乘,第 7 步再说,那是这一章一半的坑。

第二个暴力最老实:从 min(a,b) 往下试,第一个同时整除两个数的就是答案。

slow.cpp⚠ 不是 bug:答案和正解逐字节相同,只是要试到 min(a,b)
// ⚠ 这不是错误版本 —— 答案和正解**逐字节相同**,只是求 gcd 的办法最老实:
// **从 min(a,b) 往下一个个试,第一个同时整除两个数的就是最大公约数。**
//
// 为什么它存在:这是这一章耗时表里**最慢**的那一支,而且它慢得很好懂 ——
// gcd(a,b) 要转 **min(a,b) − gcd(a,b) + 1** 圈。a、b 都取到 10⁹ 而又互质的话,
// 它要转整整十亿圈。
//
// ★ 这一章一共三种求 gcd 的办法,而它们慢的**原因各不相同**(正文第 5 步那张表):
// · slow(枚举约数):慢在**min(a,b) 有多大** —— 只看规模;
// · sub (更相减损):慢在**a 和 b 差得有多远** —— 看的是两个数的比例;
// · brute(分解质因数):慢在**√a** —— 又是另一条曲线。
// ⇒ 想让哪一支现形,要拧的旋钮根本不是同一个(第 36 章那条的又一次)。
//
// ⚠ 所以正文里那张耗时表**必须给出不止一个形状的数据**,只报一列谁都能被冤枉。
#include <bits/stdc++.h>
using namespace std;
const long long LIM = 1000000000000000000LL;
/** 枚举法求 gcd:从 min(a,b) 往下找第一个同时整除的 */
long long gcdSlow(long long a, long long b) {
if (a == 0) return b;
if (b == 0) return a;
for (long long d = min(a, b); d >= 1; d--)
if (a % d == 0 && b % d == 0) return d;
return 1;
}
int main() {
ios::sync_with_stdio(false);
cin.tie(nullptr);
int n;
if (!(cin >> n)) return 0;
vector<long long> a(n);
for (auto& x : a) cin >> x;
long long g = a[0];
for (int i = 1; i < n; i++) g = gcdSlow(g, a[i]);
long long l = a[0];
bool over = false;
for (int i = 1; i < n && !over; i++) {
long long t = l / gcdSlow(l, a[i]);
if (t > LIM / a[i]) over = true;
else l = t * a[i];
}
printf("%lld\n", g);
printf("%lld\n", over ? -1LL : l);
long long cur = a[0];
for (int i = 0; i < n; i++) {
if (i) cur = gcdSlow(cur, a[i]);
printf("%lld%c", cur, i == n - 1 ? '\n' : ' ');
}
return 0;
}
点一下即可编辑
输入(stdin)
输出
点「运行 ▶」看结果

第三个是更相减损术(《九章算术》里那个):gcd(a,b) = gcd(a−b, b)。

sub.cpp⚠ 也不是 bug:只是把「取模」换成了「相减」
// ⚠ 这不是错误版本 —— 它的答案和正解**逐字节相同**,只是把「取模」换成了「相减」。
//
// 更相减损术(《九章算术》里那个):**gcd(a,b) = gcd(a−b, b)**(a > b)。
// 证明和取模版一模一样(公约数集合不变),而且取模本来就是「连着减很多次」的缩写。
//
// ★ 于是它成了这一章的「第四个盲区」现场(第 36 章立的):
// **只影响复杂度、不影响答案的写法,对拍原理上一个字都看不见。**
// gcd(10⁹, 1) 这种输入,取模版一步到头,减法版要减 **10⁹ 次**。
// ⇒ 尺子只能换成「数圈数」:count.cpp 数的就是这个。
//
// ⚠ 而且这一章的两份「慢」是**两种不同的慢**,正文里要分开讲:
// · brute(枚举约数):慢在**要试到 min(a,b)**;
// · sub(相减):慢在**a 和 b 差得越远越慢**(一次只减掉一个 b)。
// 前者只看规模,后者看的是**两个数的比例** —— 生成器要拧的旋钮根本不是同一个。
#include <bits/stdc++.h>
using namespace std;
const long long LIM = 1000000000000000000LL;
/** 更相减损术:大的减小的,减到两个数相等 */
long long gcdSub(long long a, long long b) {
if (a == 0) return b;
if (b == 0) return a;
while (a != b) {
if (a > b) a -= b;
else b -= a;
}
return a;
}
int main() {
ios::sync_with_stdio(false);
cin.tie(nullptr);
int n;
if (!(cin >> n)) return 0;
vector<long long> a(n);
for (auto& x : a) cin >> x;
long long g = a[0];
for (int i = 1; i < n; i++) g = gcdSub(g, a[i]);
long long l = a[0];
bool over = false;
for (int i = 1; i < n && !over; i++) {
long long t = l / gcdSub(l, a[i]);
if (t > LIM / a[i]) over = true;
else l = t * a[i];
}
printf("%lld\n", g);
printf("%lld\n", over ? -1LL : l);
long long cur = a[0];
for (int i = 0; i < n; i++) {
if (i) cur = gcdSub(cur, a[i]);
printf("%lld%c", cur, i == n - 1 ? '\n' : ' ');
}
return 0;
}
点一下即可编辑
输入(stdin)
输出
点「运行 ▶」看结果
★★ 先把话说在前面:这三份的答案和正解「逐字节相同」

check:viz 里 300 轮钉着这件事。也就是说 ——

这一章接下来要讲的所有差距,对拍原理上一个字都看不见。 (第 36 章立的「随机对拍第四个盲区」,第 37、38、39 章各一次现场,这是第五次。)

★ 而这一章还把这个盲区推宽了一点:前四次至少有两份是「同一个算法的不同写法」, 这一次四份代码的想法两两不同 —— 分解质因数 / 取模 / 相减 / 枚举约数。

4实测慢:三种慢法,各有各的绝境

genBig.cpp 的旋钮不是规模,是这两个数长什么样:

genBig.cpp(五个档位)0 随机大数 / 1 一大一小 / 2 相邻斐波那契 / 3 两个大质数 / 4 两个一样的数

本机实测(n = 2,命令写在下面,读者可以自己复现):

g++ -O2 -o genBig genBig.cpp && g++ -O2 -o fast fast.cpp && g++ -O2 -o sub sub.cpp && g++ -O2 -o slow slow.cpp
./genBig 2 3 > big3.txt      # 0 随机 / 1 一大一小 / 2 斐波那契 / 3 两个大质数 / 4 两个一样
time ./slow < big3.txt
这两个数 ✓ fast 取模 brute 分解质因数 sub 相减 slow 枚举约数
随机两个大数 0.00 秒 0.00 秒 0.00 秒 0.60 秒
★ 一大一小(10⁹ 和 1) 0.00 秒 0.00 秒 1.68 秒 0.00 秒
★ 相邻的两个斐波那契数 0.00 秒 0.00 秒 0.00 秒 7.40 秒
★ 两个大质数 0.00 秒 0.00 秒 0.02 秒 10.13 秒
⚠ 两个一样的数 0.00 秒 0.00 秒 0.00 秒 0.00 秒
⚠ 这张表要横着读,不是竖着读

每一档的输家都不一样,而且换一档就换一个人。

  • 「一大一小」是相减法的绝境:第一步就要减十亿次;
  • 「两个大质数」是枚举约数的绝境:一路试到 min(a,b) 才发现 gcd 是 1;
  • 「两个一样的数」谁都不慢 —— 三种做法同时一步到头。

★ 只报一列,无论报哪一列,都会把某个做法冤枉成「其实也还行」。 这是第 35 章那条(数据的形状不对,暴力会假装自己不慢)在这一章的样子。

⚠ 而 fast 和 brute 那两列全程都是 0.00 —— 秒表在它们身上完全失灵。 (第 36~39 章连着四次,这是第五次。)所以下一步还得换尺子。

5慢在哪:换尺子,数「转了多少圈」

★★ 这一章的尺子有件新鲜事:它不真的去跑,是把圈数算出来的

秒表失灵就数次数(第 21 章以来的老规矩)。可这一章有个新麻烦: 相减法在 gcd(10⁹, 1) 上要减十亿次,真跑一次要按秒算,三百轮就按分钟算。

好在这两个数都有闭式,一行就能算出来:

做法 转的圈数 怎么算出来的
fast 取模 取模次数 跑一遍就知道
sub 相减 每一步商的和 a = q·b + r 那一步,相减法要减 q 次
slow 枚举 min(a,b) − gcd(a,b) + 1 就是那个 for 循环的长度
brute 试除 约 √a 分解一个数要试到 √a

★ 这是这本书第一次用「算」的方式给出计数器,理由很实在:跑不动。 而它和「跑出来」是同一个数 —— 前两行是定义,后两行是循环边界。

count.cpp四种做法并排,数转的圈数(sub / slow 那两列是算出来的)
// ★ 这一章的尺子:四种求 gcd 的办法,各自要转多少圈。
//
// 为什么它存在:
// fast(取模)/ sub(相减)/ slow(枚举约数)/ brute(分解质因数)
// —— 四份代码的**答案逐字节相同**,对拍在它们身上一个字都看不见
// (第 36 章立的第四个盲区,这是第五次现场)。★ 次数可复现,秒数不可复现。
//
// ⚠⚠ **而这一章的尺子有一件前几章没有过的事:它不真的去跑那些慢的做法,是把圈数「算」出来的。**
// 因为 sub 在 gcd(10⁹, 1) 上要减十亿次、slow 要试十亿次 —— 真跑要按分钟算。
// 可这两个数都有闭式,一行就能算:
//
// · fast(取模):转的圈数 = 取模次数,直接跑一遍就知道;
// · sub (相减):**减法次数 = 欧几里得每一步商的和**(a = q·b + r 那一步要减 q 次);
// · slow(枚举):从 min(a,b) 一路试到 gcd,**次数 = min(a,b) − gcd(a,b) + 1**;
// · brute(试除分解):分解 a 要试到 √a,**次数 ≈ √a**(这里按实际试除次数数)。
//
// ★ 「算出来」和「跑出来」在这里是同一个数(前两行是定义,后两行是循环边界)——
// 这是这本书第一次用**算**的方式给出计数器,理由是**跑不动**。
//
// ★★ 而这张表要说的话是:**三种慢法,慢的原因各不相同。**
// · slow 慢在 **min(a,b) 有多大**(只看规模);
// · sub 慢在 **a 和 b 差得有多远**(看比例:gcd(10⁹,1) 是它的绝境,gcd(10⁹,10⁹−1) 反而只要两步多);
// · brute 慢在 **√a**。
// ⇒ 想让哪一支现形,要拧的旋钮根本不是同一个(第 36 章那条的又一次)。
//
// 用法:./count < 数据 打印那张表
// ./count csv < 数据 同一批数字,给脚本和 check:viz 读
#include <bits/stdc++.h>
using namespace std;
// 按显示宽度补空格:ASCII 算 1 格,汉字算 2 格
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), ' ');
}
/** 取模版的圈数,顺便把「商的和」(=相减版的减法次数)也带出来 */
static long long gcdSteps(long long a, long long b, long long& modCnt, long long& subCnt) {
while (b) {
modCnt++;
subCnt += a / b; // ★ 这一步取模 = 减了 a/b 次
long long t = a % b;
a = b;
b = t;
}
return a;
}
/** 试除分解 x 要试多少次(和 brute.cpp 里那个循环一字对应) */
static long long factorSteps(long long x) {
long long c = 0;
for (long long p = 2; p * p <= x; p++) {
c++;
while (x % p == 0) x /= p;
}
return c;
}
int main(int argc, char** argv) {
ios::sync_with_stdio(false);
cin.tie(nullptr);
const string mode = (argc > 1) ? argv[1] : "";
int n;
if (!(cin >> n)) return 0;
vector<long long> a(n);
for (auto& x : a) cin >> x;
long long modCnt = 0, subCnt = 0, slowCnt = 0, facCnt = 0;
long long g = a[0];
for (int i = 1; i < n; i++) {
long long lo = min(g, a[i]);
long long ng = gcdSteps(g, a[i], modCnt, subCnt);
slowCnt += lo - ng + 1; // ★ 枚举版:从 min 一路试到 gcd
g = ng;
}
for (int i = 0; i < n; i++) facCnt += factorSteps(a[i]);
if (mode == "csv") {
printf("fast,%lld\nsub,%lld\nslow,%lld\nbrute,%lld\ngcd,%lld\n",
modCnt, subCnt, slowCnt, facCnt, g);
return 0;
}
printf("n = %d,全体 gcd = %lld\n\n", n, g);
printf("%s %s %s\n", padDisp("做法", 10).c_str(), padDisp("慢在哪", 30).c_str(),
padDisp("转的圈数", 16).c_str());
struct Row { const char* name; const char* why; long long v; };
Row rows[4] = {
{"✓ fast", "取模:每两步规模至少减半", modCnt},
{"sub", "相减:a 和 b 差得越远越慢", subCnt},
{"slow", "枚举约数:要试到 min(a,b)", slowCnt},
{"brute", "分解质因数:要试到 √a", facCnt},
};
for (auto& r : rows)
printf("%s %s %s\n", padDisp(r.name, 10).c_str(), padDisp(r.why, 30).c_str(),
padDisp(to_string(r.v), 16).c_str());
printf("\n★ 四种做法的答案**完全一样** —— 这张表里的差别,对拍一个字都看不见。\n");
printf("⚠ sub / slow 这两列是**算**出来的,不是跑出来的(跑一次要按分钟算)——\n");
printf(" 减法次数 = 欧几里得每一步商的和;枚举次数 = min(a,b) − gcd + 1。\n");
return 0;
}
点一下即可编辑
输入(stdin)
输出
点「运行 ▶」看结果

实测(./genBig 2 档位,全部写进了 check:viz):

这两个数 ✓ fast 取模 sub 相减 slow 枚举 brute 试除
随机两个大数 17 86 35 380 259 10 830
★ 一大一小 1 999 999 937 1 31 621
★ 相邻斐波那契 43 ← 最坏 44 701 408 733 527
★ 两个大质数 7 22 727 280 999 999 893 63 242
⚠ 两个一样的数 1 1 1 63 242 ← 只有它还慢

★★ 这张表里藏着这一章最漂亮的一句话,在斐波那契那一行:

取模 43 步,相减 44 步 —— 几乎一样。 因为斐波那契那一档每一步的商都是 1,而「取模」本来就是「一口气减 q 次」的缩写。 ⇒ 斐波那契之所以是取模法的最坏情况,恰恰因为那时候取模退化成了相减。

⚠ 最后一行也值得单看:brute(分解质因数)只看这个数本身有多大,不看两个数的关系 —— 所以别人都一步到头的时候,它还得老老实实试 63 242 次。

于是问题变得很具体了:

枚举约数要试 min(a,b) 次,相减法要减「商的和」那么多次。 那么 —— 有没有办法一步就把 a 缩到很小?

6★ 关键一步:gcd(a, b) = gcd(b, a mod b)

★★ 一行证明,而它证的不是「最大值相等」

设 a = k·b + r(r = a mod b)。

  • 若 d | b 且 d | r,那么 d | (k·b + r) = a ⇒ d 是 (a,b) 的公约数;
  • 若 d | a 且 d | b,那么 d | (a − k·b) = r ⇒ d 是 (b,r) 的公约数。

⇒ 两对数的公约数集合完全相同,那么其中最大的那个当然也相同。∎

★ 这一步值钱的地方在于它换了个对象去证: 直接去比「两个最大值谁大谁小」是证不动的, 把问题换成集合相等,一行就完事了。

第 19 章的交换论证(别盯着最优解,去看「把它变成我的解」的过程)、 第 34 章的切割性质(别盯着最小生成树,去看一条切割边)—— 都是同一种手法:证不动的时候,换一个更大的对象去证。

★ 为什么它快:每两步规模至少减半

设 a ≥ b,看 a mod b:

  • 若 b ≤ a/2,那么 a mod b < b ≤ a/2;
  • 若 b > a/2,那么 a mod b = a − b < a/2。

两种情况都得到 a mod b < a/2 ⇒ 两步之内第一个数至少减半 ⇒ O(log a)。

⚠ 但这只是个上界。真正的最坏情况长什么样、随机数据能不能碰到它,第 9 步专门量。

7正解,以及这一章一半的坑

fast.cpp正解:辗转相除(算法本身只有 5 行)
// 正解 —— 欧几里得算法(辗转相除)
//
// ============ ★ 关键一步:一行就能证完 ============
//
// **gcd(a, b) = gcd(b, a mod b)**(b ≠ 0)
//
// 证明只要看**公约数的集合**,而不是 gcd 那个最大值本身:
// 设 a = k·b + r(r = a mod b)。
// · 若 d | b 且 d | r,则 d | (k·b + r) = a ⇒ d 是 (a,b) 的公约数;
// · 若 d | a 且 d | b,则 d | (a − k·b) = r ⇒ d 是 (b,r) 的公约数。
// ⇒ **两对数的公约数集合完全相同**,那么其中最大的那个当然也相同。∎
//
// ★ 这一步值钱的地方在于「证的是集合相等」:
// 直接去比两个 max 谁大谁小是证不动的,把问题换成「集合」就一行完事。
// (第 19 章交换论证、第 34 章切割性质,都是同一种手法:**别盯着最优解,去看一个更大的对象**。)
//
// ============ 为什么它快 ============
//
// 每**两步**规模至少减半:看 a mod b(设 a ≥ b)——
// · 若 b ≤ a/2,则 a mod b < b ≤ a/2;
// · 若 b > a/2,则 a mod b = a − b < a/2。
// 两种情况都得到 **a mod b < a/2**,所以两步之内第一个数至少减半 ⇒ **O(log)**。
//
// ⚠ 但这只是个上界。真正的最坏情况是**相邻的斐波那契数**(Lamé 定理),
// 而随机数据**永远碰不到它** —— steps.cpp 把这两列并排量出来了。
//
// ============ ⚠ 三个边界,一个都不能想当然 ============
//
// · gcd(a, 0) = a ——「0 被所有数整除」,所以循环条件是 `while (b)`;
// · lcm 必须**先除后乘**:`a / gcd(a,b) * b`。写成 `a * b / gcd` 中间就爆 long long;
// · 求 n 个数的 lcm 时**一超过上限就得停**,否则后面那些乘法照样会溢出。
//
// 复杂度 O(n log A)。
#include <bits/stdc++.h>
using namespace std;
const long long LIM = 1000000000000000000LL; // 10¹⁸:题面说超过它就输出 -1
/** 辗转相除 —— 循环写法,b 变成 0 就到头了 */
long long gcd2(long long a, long long b) {
while (b) { // ⚠ 是 while (b),不是 while (a)
long long t = a % b;
a = b;
b = t;
}
return a; // ★ gcd(a, 0) = a
}
int main() {
ios::sync_with_stdio(false);
cin.tie(nullptr);
int n;
if (!(cin >> n)) return 0;
vector<long long> a(n);
for (auto& x : a) cin >> x;
long long g = a[0];
for (int i = 1; i < n; i++) g = gcd2(g, a[i]);
long long l = a[0];
bool over = false;
for (int i = 1; i < n && !over; i++) {
long long t = l / gcd2(l, a[i]); // ⚠ 先除后乘
if (t > LIM / a[i]) over = true; // 乘之前先判会不会超
else l = t * a[i];
}
printf("%lld\n", g);
printf("%lld\n", over ? -1LL : l);
// ★ 第三行:前缀 gcd —— g_i = gcd(a_1 … a_i)
long long cur = a[0];
for (int i = 0; i < n; i++) {
if (i) cur = gcd2(cur, a[i]);
printf("%lld%c", cur, i == n - 1 ? '\n' : ' ');
}
return 0;
}
点一下即可编辑
输入(stdin)
输出
点「运行 ▶」看结果
⚠ 算法只有 5 行,可下面这三条一条都不能少

① 循环条件是 while (b),不是 while (b > 1)。 gcd(a, 0) = a(0 被所有数整除),所以到头的标志是 b 变成 0。 写成 b > 1 会在「这一对互质」的时候返回一个错的数 —— 而两个随机数互质的概率是 60.79%。

② lcm 必须先除后乘:a / gcd(a,b) * b。 写成 a * b / gcd(a,b),中间那一乘就已经爆了 long long。 ★ a 和 b 都到 10⁹ 时 a*b 是 10¹⁸ —— 还没爆; 可 a 是已经累出来的 lcm(能到 10¹⁸ 附近),再乘一个 10⁹ 就是 10²⁷。

③ 溢出判断要写在乘之前:

long long t = l / gcd2(l, a[i]);
if (t > LIM / a[i]) over = true;    // ✓ 乘之前先判
else l = t * a[i];

写成「先乘完再判 l > LIM」是站错了位置 —— 那时候 l 早就绕回去了。 ⚠ 这个 bug 最阴的地方是:逻辑本身完全正确,只是晚了一句。

8动画一:每一帧就是一句 a = q × b + r

每一帧就是一句 a = q × b + r
全程:取模 15 次 / 相减 1907697027 次
第 1 / 24 步
★ 取模次数 0⚠ 相减法要减 0 次当前 g = 12
g = 12
★ 结论:gcd(a₁…aᵢ) = gcd( gcd(a₁…aᵢ₋₁), aᵢ ) —— 所以只要会算两个数的 gcd 就够了。
起点:g = a₁ = 12。接下来把后面的数一个个并进来。
怎么看这个动画
  • 上面那根长条是 a,下面按 q 段切开的是「q 个 b」,尾巴上剩下的那一小截就是余数 r。 一眼就能看出「取模 = 一口气减掉 q 个 b」。
  • ★★ 右边两个计数器要一起看:取模次数每步 +1,相减次数每步 +q。
  • 切到「★ 一大一小」那一档:第一步的 q 就是一亿 —— 相减法当场崩掉。
  • 切到「★ 相邻的两个斐波那契数」:每一步的 q 都是 1,两个计数器齐头并进 —— ★ 这就是上一步那句话的画面版。

⚠ trace.cpp 是这个动画的文字版,check:viz 拿它和动画逐步比 (a / 商 / b / 余数 / 两个累计计数),不只比最终答案。

trace.cpp动画照着它画:每一步的 a = q×b + r,以及两个计数器
// 动画的文字版 —— 欧几里得的每一步:a = q·b + r,然后 (a,b) ← (b,r)
//
// 为什么它存在:动画是用 TypeScript 重写一遍算法画出来的,
// 万一两边不一致,学生看到的画面就是在骗人(第 27 章那次就是这么被抓住的)。
// `check:viz` 拿它和动画**逐步**比:每一步的 a / b / 商 / 余数 / 累计取模次数 / 累计减法次数。
//
// ⚠ 每一步都同时报两个计数:
// · 取模次数(这一步 +1);
// · **相减法要减的次数(这一步 +q)** —— ★ 取模就是「一口气减 q 次」的缩写,
// 所以商 q 一大,相减法就当场崩掉(gcd(10⁹, 1) 的第一步商就是 10⁹)。
// ★★ 反过来也成立,而且它正是这一章最漂亮的一句话:
// **斐波那契之所以是取模法的最坏情况,恰恰因为那时候每一步的商都是 1 —— 取模退化成了相减。**
//
// 用法:./trace < 数据(第一行 n,第二行 n 个数)—— 逐对做 gcd
#include <bits/stdc++.h>
using namespace std;
int main() {
ios::sync_with_stdio(false);
cin.tie(nullptr);
int n;
if (!(cin >> n)) return 0;
vector<long long> a(n);
for (auto& x : a) cin >> x;
long long g = a[0];
long long mods = 0, subs = 0;
printf("起点 g = %lld\n", g);
for (int i = 1; i < n; i++) {
long long x = g, y = a[i];
printf("并入 a[%d] = %lld\n", i + 1, a[i]);
while (y) {
long long q = x / y, r = x % y;
mods++; subs += q;
printf(" 步 %lld %lld = %lld × %lld + %lld | 取模累计 %lld 相减累计 %lld\n",
mods, x, q, y, r, mods, subs);
x = y; y = r;
}
g = x;
printf(" 得 g = %lld\n", g);
}
printf("收尾 gcd = %lld 取模次数 %lld 相减次数 %lld 倍数 %.2f\n",
g, mods, subs, mods ? (double)subs / (double)mods : 0.0);
return 0;
}
点一下即可编辑
输入(stdin)
输出
点「运行 ▶」看结果

9★ 上界证出来了,随机数据却永远碰不到它

★★ 第四次现场(第 33、37、38 章各一次),而这次最坏情况有名字

steps.cpp 不带随机:1 ≤ a, b ≤ n 全枚举,逐位可复现。

steps.cpp★ 全枚举 + 斐波那契那一列,不带随机
// ★ 这张表不带随机:1..N 全枚举,逐位可复现 —— 量的是「欧几里得要转几圈」。
//
// 为什么它存在(第 33、37、38 章那条,这是第四次):
// **上界证出来了,不等于随机数据碰得到它。**
// `gcd(a,b)` 的步数上界是 O(log),可随机取一对 (a,b) 平均只转几圈,
// 而最坏情况(Lamé 定理:**相邻的两个斐波那契数**)要转的圈数是它的好几倍。
// ⇒ **要让一条界现形,就得自己造那个输入。**
//
// ★★ 而这一章的平均值同样是**能算出来**的(Heilbronn / Dixon):
// 随机取 1 ≤ a, b ≤ N,欧几里得的平均步数 → **(12 ln2 / π²) · ln N ≈ 0.8428 · ln N**。
// 下面那一列实测和它一路贴着走 —— 又一次「证出来的常数 = 实测出来的常数」。
//
// ★ 最坏情况为什么是斐波那契:
// 要让步数多,就要让每一步「减得尽量少」—— 也就是 a = 1·b + r 且 r 尽量大。
// 倒着看:从 (1,1) 出发,每一步把 (b, a) 变成 (a+b, b),得到的正是斐波那契数列。
// 于是 **gcd(F(k+1), F(k)) 要转 k−1 圈**,而 F(k) ≈ φ^k / √5 ⇒ 步数 ≈ log_φ(a) ≈ 1.44 log₂ a。
//
// 用法:./steps [N] 默认 3000(全枚举 1 ≤ a, b ≤ N 的所有对)
#include <bits/stdc++.h>
using namespace std;
static int steps(long long a, long long b) { // 转了几圈(取模几次)
int c = 0;
while (b) { long long t = a % b; a = b; b = t; c++; }
return c;
}
int main(int argc, char** argv) {
const int N = (argc > 1) ? atoi(argv[1]) : 3000;
printf("① 全枚举 1 ≤ a, b ≤ n 的所有数对,欧几里得的平均步数 vs 最坏步数\n\n");
printf(" n 平均步数 0.8428·ln n 最坏步数 1.4404·log2 n 最坏那一对\n");
for (int n = 100; n <= N; n *= 3) {
long long tot = 0, cnt = 0;
int worst = 0, wa = 0, wb = 0;
for (int a = 1; a <= n; a++)
for (int b = 1; b <= n; b++) {
int s = steps(a, b);
tot += s; cnt++;
if (s > worst) { worst = s; wa = a; wb = b; }
}
printf("%9d %12.3f %12.3f %12d %14.3f (%d, %d)\n",
n, (double)tot / (double)cnt, 0.8428 * log((double)n),
worst, 1.4404 * log2((double)n), wa, wb);
}
printf("\n② ★ 最坏那一对,永远是相邻的两个斐波那契数(自己造,随机碰不到)\n\n");
printf(" k F(k) F(k+1) 步数\n");
long long f1 = 1, f2 = 1;
for (int k = 2; k <= 40; k++) {
long long f3 = f1 + f2;
f1 = f2; f2 = f3;
if (k % 5 == 0)
printf("%9d %11lld %11lld %8d\n", k, f1, f2, steps(f2, f1));
}
printf("\n③ ⚠ 同样大小的数,随机一对要几步?(拿上面那些 F(k+1) 当上限随机取)\n\n");
printf(" 数的大小 随机 1000 对的平均步数 斐波那契那一对\n");
mt19937_64 rng(20260814u);
f1 = 1; f2 = 1;
for (int k = 2; k <= 40; k++) {
long long f3 = f1 + f2;
f1 = f2; f2 = f3;
if (k % 10 != 0) continue;
long long tot = 0;
for (int t = 0; t < 1000; t++) {
long long a = (long long)(rng() % (unsigned long long)f2) + 1;
long long b = (long long)(rng() % (unsigned long long)f2) + 1;
tot += steps(a, b);
}
printf("%14lld %26.3f %18d\n", f2, tot / 1000.0, steps(f2, f1));
}
printf("\n★ 结论:随机数据上的步数只有最坏的一半上下,而且它**永远碰不到**那个最坏。\n");
return 0;
}
点一下即可编辑
输出
点「运行 ▶」看结果
n 平均步数(全枚举) 0.8428 · ln n 最坏步数 ★ 最坏那一对
100 3.983 3.881 10 (55, 89)
300 4.888 4.807 12 (144, 233)
900 5.804 5.733 14 (377, 610)
2 700 6.727 6.659 17 (1597, 2584)

★★ 两件事都值得停下来看:

① 最坏那一对,永远是相邻的两个斐波那契数(55/89、144/233、377/610、1597/2584)—— 这就是 Lamé 定理。道理也直白:要让步数多,就要每一步「减得尽量少」, 也就是 a = 1·b + r 且 r 尽量大;倒着推回去,(b, a) → (a+b, b),长出来的正是斐波那契数列。 于是 gcd(F(k+1), F(k)) 正好要转 k−1 步(表里每一行都钉了这条)。

② 平均步数是能算出来的(Heilbronn / Dixon):(12 ln2 / π²)·ln n ≈ 0.8428 · ln n —— 而实测那一列和它一路贴着走,四行差都不到 0.11。

★ 「证出来的常数」和「实测出来的常数」是同一个数 —— 第 37、38、39 章那条规矩,这是第四次。

⚠ 而最要紧的是第三张表:同样大小的两个数,随机取一对 vs 斐波那契那一对:

数的大小 随机 1000 对的平均步数 ★ 斐波那契那一对
89 3.904 9
10 946 7.948 19
1 346 269 11.901 29
165 580 141 15.801 39

★★ 随机数据只跑在最坏的 40% 上,而且它永远碰不到那个最坏。 要让 O(log) 这条界现形,得自己造那个输入。

10★ 对拍:六个错误版本,各自靠什么现形

★ 这一章的清单分成三组,而甲乙两组要的东西「正好相反」
错误版本 靠什么现形
甲 wrongOne while (b > 1) 某一对数互质(随机数据 60.8% 就撞上)—— ★ 基线
甲 wrongBreak g==1 就 break 整个循环 全体 gcd 中途变 1,而且后面还有数
乙 wrongPrefix 前缀 gcd 写成相邻 gcd ★ 要 gcd(a_{i−1},a_i) ≠ gcd(a_1..a_i) ⇒ 数据得带公因子
丙 wrongNoLim 根本不判溢出 答案超过 10¹⁸ 就行 —— ★ 三个溢出 bug 里门槛最低的
丙 wrongLate 判断写在乘完之后 ★★ 要撞破 long long(≈9.2×10¹⁸),而且绕回去之后要落在 10¹⁸ 以内
丙 wrongLcm 先乘后除 同上

★ 「值域拧满」在这一章不是开关,是一把有三个刻度的旋钮。 ⚠ 而甲组要「gcd 会变成 1」、乙组要「有公因子」—— 这两组是打架的,第 11 步会看到代价。

wrongOne.cpp✗ while (b > 1):到 1 就停了,可返回的还是 a(基线)
// ✗ 错误版本:循环条件写成 `while (b > 1)`(「b 都到 1 了,肯定就是 1,可以停」)
//
// ✗ 停是可以停,**可返回值写错了**:这时候答案是 1,它却把 a 返回了出去。
//
// 靠什么现形:某一次 gcd 的中间步骤里出现 `b == 1` 而 `a > 1` ——
// 也就是**那一对数互质**。随机数据上这件事的概率是 6/π² ≈ 60.8%,所以它非常好抓。
// ★ 它在这一章里的角色和第 38 章的 wrongDir 一样:**基线**。
#include <bits/stdc++.h>
using namespace std;
const long long LIM = 1000000000000000000LL; // 10¹⁸:题面说超过它就输出 -1
/** 辗转相除 —— 循环写法,b 变成 0 就到头了 */
long long gcd2(long long a, long long b) {
while (b > 1) { // ✗ 到 1 就停了,可返回的还是 a
long long t = a % b;
a = b;
b = t;
}
return a; // ★ gcd(a, 0) = a
}
int main() {
ios::sync_with_stdio(false);
cin.tie(nullptr);
int n;
if (!(cin >> n)) return 0;
vector<long long> a(n);
for (auto& x : a) cin >> x;
long long g = a[0];
for (int i = 1; i < n; i++) g = gcd2(g, a[i]);
long long l = a[0];
bool over = false;
for (int i = 1; i < n && !over; i++) {
long long t = l / gcd2(l, a[i]); // ⚠ 先除后乘
if (t > LIM / a[i]) over = true; // 乘之前先判会不会超
else l = t * a[i];
}
printf("%lld\n", g);
printf("%lld\n", over ? -1LL : l);
// ★ 第三行:前缀 gcd —— g_i = gcd(a_1 … a_i)
long long cur = a[0];
for (int i = 0; i < n; i++) {
if (i) cur = gcd2(cur, a[i]);
printf("%lld%c", cur, i == n - 1 ? '\n' : ' ');
}
return 0;
}
点一下即可编辑
输入(stdin)
输出
点「运行 ▶」看结果
wrongBreak.cpp✗ 「gcd 已经是 1 了,后面不用算了」—— 把整个循环 break 掉
// ✗ 错误版本:「gcd 已经是 1 了,后面不用算了」—— 把整个循环 break 掉
//
// `if (g == 1) break;` 这句话对**第一行**(全体 gcd)来说完全正确:
// gcd 一旦变成 1 就再也变不回去。
// ✗ 可它顺手把**后面那些数也跳过了**,于是这些数根本没参与 lcm 和前缀 gcd。
//
// 靠什么现形:全体 gcd 中途变成 1,**而且后面还有数**。
// ★ 这个 bug 的形状值得记:**一个只对「其中一问」成立的剪枝,被顺手用在了整个循环上。**
// (第 35 章那条「同一个顺手写法在同一道题的两问里效果相反」的近亲。)
#include <bits/stdc++.h>
using namespace std;
const long long LIM = 1000000000000000000LL; // 10¹⁸:题面说超过它就输出 -1
/** 辗转相除 —— 循环写法,b 变成 0 就到头了 */
long long gcd2(long long a, long long b) {
while (b) { // ⚠ 是 while (b),不是 while (a)
long long t = a % b;
a = b;
b = t;
}
return a; // ★ gcd(a, 0) = a
}
int main() {
ios::sync_with_stdio(false);
cin.tie(nullptr);
int n;
if (!(cin >> n)) return 0;
vector<long long> a(n);
for (auto& x : a) cin >> x;
long long g = a[0];
int stop = n;
for (int i = 1; i < n; i++) {
g = gcd2(g, a[i]);
if (g == 1) { stop = i + 1; break; } // ✗ 后面那些数被整个跳过了
}
long long l = a[0];
bool over = false;
for (int i = 1; i < stop && !over; i++) {
long long t = l / gcd2(l, a[i]); // ⚠ 先除后乘
if (t > LIM / a[i]) over = true; // 乘之前先判会不会超
else l = t * a[i];
}
printf("%lld\n", g);
printf("%lld\n", over ? -1LL : l);
// ★ 第三行:前缀 gcd —— g_i = gcd(a_1 … a_i)
long long cur = a[0];
for (int i = 0; i < n; i++) {
if (i && i < stop) cur = gcd2(cur, a[i]);
printf("%lld%c", cur, i == n - 1 ? '\n' : ' ');
}
return 0;
}
点一下即可编辑
输入(stdin)
输出
点「运行 ▶」看结果
wrongPrefix.cpp✗ 前缀 gcd 写成了相邻两个数的 gcd
// ✗ 错误版本:第三行的前缀 gcd 写成了**相邻两个数的 gcd**
//
// g_i 应该是 gcd(a_1 … a_i),这里写成了 gcd(a_{i−1}, a_i)。
//
// 靠什么现形:★★ 要 **gcd(a_{i−1}, a_i) ≠ gcd(a_1 … a_i)** ——
// 而随机数据上这两个数**几乎总是同时等于 1**(见 coprime.cpp:
// 两个随机数互质的概率是 6/π² ≈ 60.8%,n 个数的 gcd 是 1 的概率是 1/ζ(n),n = 5 时就 96%)。
// ⇒ **必须让数据带公因子**,否则这道题整个退化成「输出 1」,这个 bug 一轮都抓不到。
#include <bits/stdc++.h>
using namespace std;
const long long LIM = 1000000000000000000LL; // 10¹⁸:题面说超过它就输出 -1
/** 辗转相除 —— 循环写法,b 变成 0 就到头了 */
long long gcd2(long long a, long long b) {
while (b) { // ⚠ 是 while (b),不是 while (a)
long long t = a % b;
a = b;
b = t;
}
return a; // ★ gcd(a, 0) = a
}
int main() {
ios::sync_with_stdio(false);
cin.tie(nullptr);
int n;
if (!(cin >> n)) return 0;
vector<long long> a(n);
for (auto& x : a) cin >> x;
long long g = a[0];
for (int i = 1; i < n; i++) g = gcd2(g, a[i]);
long long l = a[0];
bool over = false;
for (int i = 1; i < n && !over; i++) {
long long t = l / gcd2(l, a[i]); // ⚠ 先除后乘
if (t > LIM / a[i]) over = true; // 乘之前先判会不会超
else l = t * a[i];
}
printf("%lld\n", g);
printf("%lld\n", over ? -1LL : l);
// ★ 第三行:前缀 gcd —— g_i = gcd(a_1 … a_i)
for (int i = 0; i < n; i++) {
long long cur = i ? gcd2(a[i - 1], a[i]) : a[0]; // ✗ 只和前一个比,不是和前缀比
printf("%lld%c", cur, i == n - 1 ? '\n' : ' ');
}
return 0;
}
点一下即可编辑
输入(stdin)
输出
点「运行 ▶」看结果
wrongNoLim.cpp✗ 根本不判溢出,一路乘下去
// ✗ 错误版本:**根本不判溢出**,一路乘下去
//
// 靠什么现形:只要**答案超过 10¹⁸** 就现形 —— 三个溢出 bug 里**门槛最低**的一个
// (另外两个要撞破 long long 才算数)。
// ⚠ 所以它是这一组的「基线」:抓得住它,一点都不能说明值域拧够了。
#include <bits/stdc++.h>
using namespace std;
const long long LIM = 1000000000000000000LL; // 10¹⁸:题面说超过它就输出 -1
/** 辗转相除 —— 循环写法,b 变成 0 就到头了 */
long long gcd2(long long a, long long b) {
while (b) { // ⚠ 是 while (b),不是 while (a)
long long t = a % b;
a = b;
b = t;
}
return a; // ★ gcd(a, 0) = a
}
int main() {
ios::sync_with_stdio(false);
cin.tie(nullptr);
int n;
if (!(cin >> n)) return 0;
vector<long long> a(n);
for (auto& x : a) cin >> x;
long long g = a[0];
for (int i = 1; i < n; i++) g = gcd2(g, a[i]);
long long l = a[0];
bool over = false; // ✗ 它永远是 false
for (int i = 1; i < n; i++)
l = l / gcd2(l, a[i]) * a[i]; // ✗ 一路乘,超没超都不管
printf("%lld\n", g);
printf("%lld\n", over ? -1LL : l);
// ★ 第三行:前缀 gcd —— g_i = gcd(a_1 … a_i)
long long cur = a[0];
for (int i = 0; i < n; i++) {
if (i) cur = gcd2(cur, a[i]);
printf("%lld%c", cur, i == n - 1 ? '\n' : ' ');
}
return 0;
}
点一下即可编辑
输入(stdin)
输出
点「运行 ▶」看结果
wrongLate.cpp✗ 溢出判断写在乘完之后 —— 逻辑对,位置错
// ✗ 错误版本:溢出判断**写在乘完之后**
//
// long long t = l / gcd(l, a[i]);
// l = t * a[i]; ← ✗ 先乘
// if (l > LIM) over = true; ← ✗ 再判:可这时候 l 早就绕回去了
//
// 靠什么现形:★★ 和 wrongLcm 一样,要 `t × a[i]` 撞破 **long long** ——
// 只要没撞破,「乘完再判」和「乘前判」给的答案**一模一样**。
// ★ 这是这一章最阴的一个:判断逻辑本身写对了,**只是站错了位置**。
#include <bits/stdc++.h>
using namespace std;
const long long LIM = 1000000000000000000LL; // 10¹⁸:题面说超过它就输出 -1
/** 辗转相除 —— 循环写法,b 变成 0 就到头了 */
long long gcd2(long long a, long long b) {
while (b) { // ⚠ 是 while (b),不是 while (a)
long long t = a % b;
a = b;
b = t;
}
return a; // ★ gcd(a, 0) = a
}
int main() {
ios::sync_with_stdio(false);
cin.tie(nullptr);
int n;
if (!(cin >> n)) return 0;
vector<long long> a(n);
for (auto& x : a) cin >> x;
long long g = a[0];
for (int i = 1; i < n; i++) g = gcd2(g, a[i]);
long long l = a[0];
bool over = false;
for (int i = 1; i < n && !over; i++) {
long long t = l / gcd2(l, a[i]);
l = t * a[i]; // ✗ 先乘
if (l > LIM) over = true; // ✗ 再判 —— 已经晚了
}
printf("%lld\n", g);
printf("%lld\n", over ? -1LL : l);
// ★ 第三行:前缀 gcd —— g_i = gcd(a_1 … a_i)
long long cur = a[0];
for (int i = 0; i < n; i++) {
if (i) cur = gcd2(cur, a[i]);
printf("%lld%c", cur, i == n - 1 ? '\n' : ' ');
}
return 0;
}
点一下即可编辑
输入(stdin)
输出
点「运行 ▶」看结果
wrongLcm.cpp✗ lcm 先乘后除
// ✗ 错误版本:lcm **先乘后除**(`l * a[i] / g` 而不是 `l / g * a[i]`)
//
// 靠什么现形:★ 要 `l × a[i]` 撞破 **long long**(约 9.2×10¹⁸)——
// 注意这比题面那个 10¹⁸ 的上限**还要大一截**:光是「答案超过 10¹⁸」还抓不到它。
// ⇒ 要的是**值域拧满 + 数之间别有太大的公因子**(有公因子 lcm 就涨不快)。
// ⚠ 这一章一共三个和溢出有关的 bug,它们要的条件**一个比一个苛刻**,正文里有一张表。
#include <bits/stdc++.h>
using namespace std;
const long long LIM = 1000000000000000000LL; // 10¹⁸:题面说超过它就输出 -1
/** 辗转相除 —— 循环写法,b 变成 0 就到头了 */
long long gcd2(long long a, long long b) {
while (b) { // ⚠ 是 while (b),不是 while (a)
long long t = a % b;
a = b;
b = t;
}
return a; // ★ gcd(a, 0) = a
}
int main() {
ios::sync_with_stdio(false);
cin.tie(nullptr);
int n;
if (!(cin >> n)) return 0;
vector<long long> a(n);
for (auto& x : a) cin >> x;
long long g = a[0];
for (int i = 1; i < n; i++) g = gcd2(g, a[i]);
long long l = a[0];
bool over = false;
for (int i = 1; i < n && !over; i++) {
long long p = l * a[i]; // ✗ 先乘:这一句就已经溢出了
long long t = p / gcd2(l, a[i]);
if (t > LIM) over = true;
else l = t;
}
printf("%lld\n", g);
printf("%lld\n", over ? -1LL : l);
// ★ 第三行:前缀 gcd —— g_i = gcd(a_1 … a_i)
long long cur = a[0];
for (int i = 0; i < n; i++) {
if (i) cur = gcd2(cur, a[i]);
printf("%lld%c", cur, i == n - 1 ? '\n' : ' ');
}
return 0;
}
点一下即可编辑
输入(stdin)
输出
点「运行 ▶」看结果
★★ 默认那组数据上,三个溢出 bug 给出的是「同一个」错误答案
第一行 第二行 第三行
正解 1 -1 12 6 6 1 1 1 1 1
wrongOne ✗ 2 -1 ✗ 12 6 6 **5 5 2 2 2**
wrongBreak 1 ✗ 180 12 6 6 1 1 1 1 1
wrongPrefix 1 -1 ✗ 12 6 6 **5 5** 1 1 1
wrongNoLim 1 ✗ 18575894801788 12 6 6 1 1 1 1 1
wrongLate 1 ✗ 18575894801788 12 6 6 1 1 1 1 1
wrongLcm 1 ✗ 18575894801788 12 6 6 1 1 1 1 1

★ 后三个一模一样 —— 光看这一组数据,你分不清是哪一个 bug。

⚠ 这件事本身要写进正文:「对拍抓到了」和「我知道错在哪」是两回事。 三个 bug 都会让那一乘绕回去,绕回去之后的那个数当然一样。

★ 而 wrongBreak 的那个 180 是最好玩的:if (g == 1) break; 这句话对第一问完全正确 (gcd 一旦是 1 就再也变不回去),它只是顺手把后面那些数也跳过了 —— 于是 lcm 只算了前四个数。

★ 一个只对「其中一问」成立的剪枝,被顺手用在了整个循环上。

对拍器
★ 这个生成器有八个旋钮,而全场最大的功臣是一个「反直觉」的:把 n 压短。下面两张表把每一处的账都摆出来。
// 正解 —— 欧几里得算法(辗转相除)
//
// ============ ★ 关键一步:一行就能证完 ============
//
// **gcd(a, b) = gcd(b, a mod b)**(b ≠ 0)
//
// 证明只要看**公约数的集合**,而不是 gcd 那个最大值本身:
// 设 a = k·b + r(r = a mod b)。
// · 若 d | b 且 d | r,则 d | (k·b + r) = a ⇒ d 是 (a,b) 的公约数;
// · 若 d | a 且 d | b,则 d | (a − k·b) = r ⇒ d 是 (b,r) 的公约数。
// ⇒ **两对数的公约数集合完全相同**,那么其中最大的那个当然也相同。∎
//
// ★ 这一步值钱的地方在于「证的是集合相等」:
// 直接去比两个 max 谁大谁小是证不动的,把问题换成「集合」就一行完事。
// (第 19 章交换论证、第 34 章切割性质,都是同一种手法:**别盯着最优解,去看一个更大的对象**。)
//
// ============ 为什么它快 ============
//
// 每**两步**规模至少减半:看 a mod b(设 a ≥ b)——
// · 若 b ≤ a/2,则 a mod b < b ≤ a/2;
// · 若 b > a/2,则 a mod b = a − b < a/2。
// 两种情况都得到 **a mod b < a/2**,所以两步之内第一个数至少减半 ⇒ **O(log)**。
//
// ⚠ 但这只是个上界。真正的最坏情况是**相邻的斐波那契数**(Lamé 定理),
// 而随机数据**永远碰不到它** —— steps.cpp 把这两列并排量出来了。
//
// ============ ⚠ 三个边界,一个都不能想当然 ============
//
// · gcd(a, 0) = a ——「0 被所有数整除」,所以循环条件是 `while (b)`;
// · lcm 必须**先除后乘**:`a / gcd(a,b) * b`。写成 `a * b / gcd` 中间就爆 long long;
// · 求 n 个数的 lcm 时**一超过上限就得停**,否则后面那些乘法照样会溢出。
//
// 复杂度 O(n log A)。
#include <bits/stdc++.h>
using namespace std;
const long long LIM = 1000000000000000000LL; // 10¹⁸:题面说超过它就输出 -1
/** 辗转相除 —— 循环写法,b 变成 0 就到头了 */
long long gcd2(long long a, long long b) {
while (b) { // ⚠ 是 while (b),不是 while (a)
long long t = a % b;
a = b;
b = t;
}
return a; // ★ gcd(a, 0) = a
}
int main() {
ios::sync_with_stdio(false);
cin.tie(nullptr);
int n;
if (!(cin >> n)) return 0;
vector<long long> a(n);
for (auto& x : a) cin >> x;
long long g = a[0];
for (int i = 1; i < n; i++) g = gcd2(g, a[i]);
long long l = a[0];
bool over = false;
for (int i = 1; i < n && !over; i++) {
long long t = l / gcd2(l, a[i]); // ⚠ 先除后乘
if (t > LIM / a[i]) over = true; // 乘之前先判会不会超
else l = t * a[i];
}
printf("%lld\n", g);
printf("%lld\n", over ? -1LL : l);
// ★ 第三行:前缀 gcd —— g_i = gcd(a_1 … a_i)
long long cur = a[0];
for (int i = 0; i < n; i++) {
if (i) cur = gcd2(cur, a[i]);
printf("%lld%c", cur, i == n - 1 ? '\n' : ' ');
}
return 0;
}
点一下即可编辑

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

故意写错的地方 被抓 第几轮
wrongNoLim(不判溢出) 300 / 300 第 1 轮
wrongOne(while (b > 1)) 268 / 300 第 1 轮
wrongBreak(提前 break) 184 / 300 第 2 轮
wrongLcm(先乘后除) 142 / 300 第 2 轮
wrongPrefix(相邻 gcd) 131 / 300 第 1 轮
wrongLate(判断晚了一句) 117 / 300 第 2 轮
sub / slow / brute(只是快慢不同) ★ 0 / 300 —

11★★ 生成器:随机数据把这道题自己做没了,而那个概率算得出来

★★★ 先说这一章最该记住的一件事

生成器里最顺手的那句话是 a_i = rnd(1, 10⁹)。它有一个后果 —— 而这个后果是能算出来的:

coprime.cpp★ 量一量:随机 n 个数的 gcd 是 1 的概率
// ★★ 这份代码是用来说清「为什么随机数据会把这道题做没了」的。
//
// 生成器里最顺手的那句 `a_i = rnd(1, 10⁹)` 有一个后果,而且这个后果**是能算出来的**:
//
// ① 随机两个数**互质**的概率 = **6/π² ≈ 60.79%**
// (证明的骨架:两个数同时被质数 p 整除的概率是 1/p²,
// 各个质数互相独立 ⇒ 互质的概率 = Π(1 − 1/p²) = 1/ζ(2) = 6/π²。)
// ② 随机 n 个数的 gcd 是 1 的概率 = **1/ζ(n)**
// (同一个式子:Π(1 − 1/pⁿ) = 1/ζ(n)。n = 3 时 83.2%、n = 5 时 96.4%、n = 8 时 99.6%。)
//
// ⇒ **「输出这 n 个数的 gcd」这一问,在随机数据上几乎恒等于「输出 1」。**
// 题目整个退化了 —— 而这不是哪个参数取了极端值造成的,是**随机本身**造成的。
// ★ 第 24 章问过「这个量取到极端时,题目会退化成哪道更简单的题」,
// 这一章的答案是:**不用取极端,随机就够了。**
//
// ⚠ 但这**不等于**「所以要把数据都造成有公因子的」——
// gen.cpp 的档位 1 就是这么干的,结果把另外两个 bug 打成了 12 / 4(它们要的正是 gcd 变 1)。
// ★ 两组需求打架的时候,得**两边都留一点**,而不是把一边推到底。
//
// 用法:./coprime [抽多少组] 默认 200000
#include <bits/stdc++.h>
using namespace std;
static long long g2(long long a, long long b) {
while (b) { long long t = a % b; a = b; b = t; }
return a;
}
/** ζ(s) 的粗略求和(s ≥ 2 时收敛很快,取到 10⁶ 项误差远小于 0.001%) */
static double zeta(int s) {
double v = 0;
for (int k = 1; k <= 1000000; k++) v += pow((double)k, -(double)s);
return v;
}
int main(int argc, char** argv) {
const long long T = (argc > 1) ? atoll(argv[1]) : 200000LL;
const long long HI = 1000000000LL;
mt19937_64 rng(20260814u);
auto rnd = [&]() { return (long long)(rng() % (unsigned long long)HI) + 1; };
printf("每一行抽 %lld 组,a_i 在 [1, 10⁹] 里独立均匀取\n\n", T);
printf(" n 个数 实测 gcd = 1 的比例 1/ζ(n)(算出来的)\n");
for (int n = 2; n <= 8; n++) {
long long hit = 0;
for (long long t = 0; t < T; t++) {
long long g = rnd();
for (int i = 1; i < n; i++) g = g2(g, rnd());
if (g == 1) hit++;
}
printf("%8d %22.4f%% %22.4f%%\n",
n, 100.0 * (double)hit / (double)T, 100.0 / zeta(n));
}
printf("\n★ n = 2 那一行就是「两个随机数互质的概率 = 6/π²」——\n");
printf(" 6/π² = %.4f%%,而实测那一列和它一路贴着走。\n", 600.0 / (M_PI * M_PI));
printf("⚠ 于是「输出 gcd」这一问在随机数据上几乎恒等于「输出 1」:题目自己退化了。\n");
return 0;
}
点一下即可编辑
输出
点「运行 ▶」看结果
n 个数 实测 gcd = 1 的比例 1/ζ(n)(算出来的)
2 60.63% 60.79% ← 这就是 6/π²
3 83.12% 83.19%
4 92.42% 92.39%
5 96.48% 96.44%
8 99.58% 99.59%

推导只有一句:两个数同时被质数 p 整除的概率是 1/p²,各个质数互相独立 ⇒ 互质的概率 = Π(1 − 1/p²) = 1/ζ(2) = 6/π²;n 个数就把 2 换成 n。

★★★ 于是「输出这 n 个数的 gcd」这一问,在随机数据上几乎恒等于「输出 1」—— 题目自己退化了。 第 24 章问过「这个量取到极端时,题目会退化成哪道更简单的题」, 这一章的答案是:不用取极端,随机就够了。

⚠ 这是「顺手写法」的第十二次,可它的形状是新的: 前十一次都是「我替题目做了一个它没规定的主」(1 号点当根、边权全写 1、初始数组全 0……), 这一次我什么都没多做 —— 是随机本身把题目做没了。

★★ 于是我去补公因子。⚠ 实测把两个 bug 打崩了
档位 相对档位 0 改了什么 One Break Prefix NoLim Late Lcm 最弱支
0(顺手写法) n ∈ [2,8]、a_i 在 [1,10⁹] 里独立均匀 263 147 158 244 56 84 56
1 ⚠ 全部乘一个公因子 g ∈ [1,30] ★ 12 ★ 4 155 244 37 124 4
2 一半的数乘公因子 221 89 195 244 43 83 43
3 值域压到 [1,50] 265 204 157 ★ 0 ★ 0 ★ 0 0
4 n 拉长到 [10,20] 300 174 297 300 ★ 0 5 0
5 混入 1 285 187 116 221 50 84 50
6 值域贴着上限取 266 148 161 244 50 82 50
7 相邻成倍数关系 240 131 200 203 36 74 36
8 ★★ n 压短到 [3,5] 263 174 128 300 ★ 116 154 ★ 116

① ⚠ 档位 1 是这一章的反面教材。 我按上面那条「随机数据把题目做没了」去补公因子,一口气全加上 —— 结果 wrongOne 从 263 掉到 12、wrongBreak 从 147 掉到 4。 道理很直白:甲组那两个 bug 要的正是「gcd 会变成 1」,我把这条路堵死了。

★ 第 31 章那条「某一支永远走不到是坑,某一支占得太多也是坑」的又一次现场 —— 而这次是我照着上一条教训做,做过了头。 ⇒ 改成「只给一半的数乘公因子」(档位 2),Prefix 涨到 195,甲组也活了下来。

② ⚠⚠ 而全场最大的功臣,是一个和直觉相反的旋钮:把 n 压短。 最弱支 56 → 116,翻了一倍还多,而且 wrongNoLim 直接满分。 为什么?wrongLate 只在溢出那一步的乘积撞破 long long、而且绕回去之后落在 10¹⁸ 以内时才现形; n 一大,它绕回去之后后面还有好几次乘法,迟早会有一次让 l > 10¹⁸ 成立 —— 于是它蒙对了 -1。

★★ 数据越多越容易抓 bug,这句话在这里是反的。

③ ⚠ 对照着看档位 4(n 拉长)就更清楚了:它把 wrongOne 顶到 300、wrongPrefix 顶到 297, 看着是全表最漂亮的一档 —— 可它同时把两个溢出 bug 打成 0 和 5。

★★ 同一个旋钮的两头,正好是两组 bug 的天堂和坟墓。 (第 38 章「n 是不是 2 的幂」那次的第二次现场,这次的旋钮是 n 本身。)

★ 定档,以及一处「明知有用也没留」的改动
档位 内容 One Break Prefix NoLim Late Lcm 最弱支
9(最终档) 8 + 6(n 压短 + 值域贴上限) 268 184 131 300 117 142 ✓ 117
10(对照) 9 + 一半的数乘公因子 206 105 162 300 95 179 95
11(对照) 9 + 混入 1 289 202 89 222 96 118 89
12(诊断) 9,但公因子改成全部都乘 ★ 13 ★ 10 146 300 110 226 10

① 「混入 1」是负分,撤回(117 → 89)。第 32、34、35、37、39 章那条调优不可加的又一次。

② ⚠ 而档位 10 那笔账要老实写:它是有用的,可我没留。 加上「一半的数乘公因子」之后:wrongPrefix 131 → 162, 而且 300 轮里 gcd ≠ 1 的从 32 轮涨到 94 轮(边界覆盖实打实变好了)。 代价是最弱支从 117 掉到 95。

★★ 这和第 38 章那次判据相同、结论相反,值得并排记一次: · 第 38 章为了边界覆盖留下了一个抓获率略差的旋钮(最弱支 222 → 207,掉 7%); · 这一章同样是为了边界覆盖,可代价是 117 → 95(掉 19%)—— 太贵,不留。 ⇒ 判据从来只有一条(让最弱的那一支尽量强),变的是代价有多大。 ⚠ 但那一支不能没有,所以它作为可重跑的档位 10 留在生成器里 —— 读者随时能跑到「数据带公因子」的那一支。

gen.cpp(十三个档位)八处改动全部可重跑,包括那个「照着上一条教训做过头」的反面教材

12动画二:四种做法的圈数 —— 而它们的答案完全相同

四种做法转的圈数(★ 而它们的答案完全相同)
这组数据的 gcd = 1
✓ 取模:每两步规模至少减半
17
相减:a 和 b 差得越远越慢
86
枚举约数:要试到 min(a,b)
35,380,259
分解质因数:要试到 √a
10,830
⚠ 条的长度按 对数 画(不然十亿那一根会把别的挤没);右边的数字才是真的圈数。
⚠ `sub` 和 `slow` 这两列是**算**出来的,不是跑出来的 —— 真跑要按分钟算 (减法次数 = 每一步商的和;枚举次数 = min(a,b) − gcd + 1)。口径和 count.cpp 一字不差。
★ 换几档数据看,每一档的输家都不一样
  • 一大一小 → 相减法崩(十亿次);
  • 两个大质数 → 枚举约数崩(要试到 min);
  • 两个一样的数 → 前三种同时一步到头,只有分解质因数还得跑 √a。

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

★ 这就是「对拍看不见慢」的第五次现场,而且这次四份代码的想法两两不同。

13选讲:扩展欧几里得(第 42 章会用到)

★ 一行代换,从欧几里得那一行直接翻译过来

递归回来时已经有 b·x' + (a mod b)·y' = g,而 a mod b = a − ⌊a/b⌋·b,代进去:

b·x' + (a − ⌊a/b⌋·b)·y' = a·y' + b·(x' − ⌊a/b⌋·y') = g

⇒ x = y’,y = x’ − ⌊a/b⌋ · y’。边界:b = 0 时 (x, y) = (1, 0)。∎

★ 它顺带证明了裴蜀定理:a·x + b·y 能取到的最小正整数正好是 gcd(a,b) —— 于是「a·x + b·y = c 有整数解 ⟺ gcd(a,b) | c」。第 42 章求逆元时会直接用它。

exgcd.cpp★ 不是「打印一下」:两万组随机数据当场硬验 a·x + b·y == gcd
// 选讲 —— 扩展欧几里得:在算 gcd 的同时,把 **a·x + b·y = gcd(a,b)** 的那一对 (x, y) 也带出来。
//
// ★ 关键一步只有一行代换,而且它是**从欧几里得那一行直接翻译过来的**:
//
// 设递归回来时已经有 b·x' + (a mod b)·y' = g,
// 而 a mod b = a − ⌊a/b⌋·b,代进去:
// b·x' + (a − ⌊a/b⌋·b)·y' = g
// = a·y' + b·(x' − ⌊a/b⌋·y') = g
// ⇒ **x = y',y = x' − ⌊a/b⌋ · y'**。∎
//
// 边界:b = 0 时 gcd = a,取 (x, y) = (1, 0)。
//
// ★ 它证明了一件很值钱的事(裴蜀定理):
// **a·x + b·y 能取到的最小正整数,正好是 gcd(a,b)** —— 于是
// 「a·x + b·y = c 有整数解 ⟺ gcd(a,b) | c」。第 42 章求逆元时会直接用它。
//
// ⚠ 这份代码不是「打印一下就算了」:它对每一对 (a,b) **当场验算** a·x + b·y == g,
// 而且用 __int128 验(a、b 到 10⁹ 时 a·x 可能超 long long)。
//
// 用法:./exgcd 跑一批固定的例子 + 随机 20000 组自验
// ./exgcd a b 只算一对
#include <bits/stdc++.h>
using namespace std;
/** 返回 gcd(a,b),并把满足 a·x + b·y = gcd 的一对 (x,y) 写回去 */
long long exgcd(long long a, long long b, long long& x, long long& y) {
if (b == 0) { x = 1; y = 0; return a; }
long long x1, y1;
long long g = exgcd(b, a % b, x1, y1);
x = y1; // ★ 就是上面那一行代换
y = x1 - (a / b) * y1;
return g;
}
int main(int argc, char** argv) {
if (argc > 2) {
long long a = atoll(argv[1]), b = atoll(argv[2]), x, y;
long long g = exgcd(a, b, x, y);
printf("gcd(%lld, %lld) = %lld,x = %lld,y = %lld\n", a, b, g, x, y);
printf("验算:%lld × %lld + %lld × %lld = %lld %s\n",
a, x, b, y, g,
(__int128)a * x + (__int128)b * y == g ? "✓" : "✗");
return 0;
}
printf(" a b gcd x y 验算\n");
long long ex[][2] = {{12, 18}, {35, 15}, {1, 1}, {7, 1}, {1000000007, 998244353},
{252, 198}, {121393, 75025}};
for (auto& e : ex) {
long long x, y, g = exgcd(e[0], e[1], x, y);
printf("%10lld %10lld %10lld %11lld %11lld %s\n", e[0], e[1], g, x, y,
(__int128)e[0] * x + (__int128)e[1] * y == g ? "✓" : "✗");
}
// ★ 随机自验:20000 组,每一组都硬验 a·x + b·y == gcd
mt19937_64 rng(20260814u);
int bad = 0;
for (int t = 0; t < 20000; t++) {
long long a = (long long)(rng() % 1000000000ULL) + 1;
long long b = (long long)(rng() % 1000000000ULL) + 1;
long long x, y, g = exgcd(a, b, x, y);
if ((__int128)a * x + (__int128)b * y != g) bad++;
}
printf("\n★ 随机 20000 组硬验 a·x + b·y == gcd(a,b):%s\n",
bad == 0 ? "全部通过 ✓" : "有不通过的 ✗");
printf("⚠ 验算必须用 __int128:a、b 到 10⁹ 时 a·x 会超 long long(x 可以到 10⁹ 量级)。\n");
return 0;
}
点一下即可编辑
输出
点「运行 ▶」看结果

⚠ 两个细节:

  • 验算必须用 __int128:a、b 到 10⁹ 时 a·x 会超 long long(x 本身能到 10⁹ 量级)。
  • 看那一行 gcd(121393, 75025) = 1,x = 28657,y = −46368 —— ★ 全是斐波那契数。 最坏输入的系数也长在同一个数列上,不是巧合。

14自测

自测清单0 / 11
配套练习
  • 洛谷 P1029 [NOIP 2001 普及组] 最大公约数和最小公倍数问题解析 → —— 设 P = x₀·a、Q = x₀·b,则 a·b = y₀/x₀ 且 gcd(a,b) = 1,枚举因子即可。⚠ 先判 y₀ % x₀ 是不是 0 —— 这是本章第 7 步那半章坑的同类
  • 洛谷 P1888 三角函数解析 → —— 五行题:★ 要的是「最短边 / 斜边」(较小锐角对着最短边,而 sin = 对边/斜边),再用 gcd 约分。⚠⚠ 这里原来写的是「最小两边之比」—— 那是 tan 不是 sin,2026-09-01 照样例 3 5 4 → 3/5 订正(详见解析页)
  • 洛谷 P1372 又是毕业季I解析 → —— ★ 答案是 n/k,但要说得出为什么 —— 提示:取 d、2d、…、kd,需要 kd ≤ n。练的是「看出它是 gcd」这一步,不是 gcd 本身
  • 洛谷 P2651 添加括号III解析 → —— ★ 除法表达式加括号能否变成整数:a₂ 必在分母、a₁ 必在分子,其余都能挪到分子。约分之后判整除 —— gcd 在这里是化简工具,不是答案
  • 洛谷 P2152 [SDOI2009] SuperGCD解析 → —— ★★ 高精度 gcd。取模在高精度下太贵,得换成「更相减损 + 提取因子 2」的二进制 gcd。本章第 9 步那条「每两步至少减半」的界,在这里换了一种形式再出现一次
这一章我卡在哪(过一个月回来看,这几行比整章正文都值钱)
这一章记住三句话
  1. gcd(a,b) = gcd(b, a mod b) —— 证的是两对数的公约数集合相同,不是最大值相同。 证不动的时候换一个更大的对象去证(第 19、34 章同款)。
  2. 上界是 O(log),最坏是相邻的斐波那契数,而随机数据只跑在最坏的 40% 上。 ★ 而斐波那契之所以最坏,恰恰因为那时候每一步的商都是 1,取模退化成了相减。
  3. 随机数据会把这道题自己做没了(n 个数 gcd = 1 的概率是 1/ζ(n))—— ⚠ 可照着这条去补公因子,补过头就把另外两个 bug 打崩了(263 → 12、147 → 4)。 ★ 而全场最大的功臣是个反直觉的旋钮:把 n 压短(最弱支 56 → 116)。

⚠ 下一章(质数)会把「试除」这件事从这里接过去: 这一章分解一个数要试到 √a,下一章要问的是一次把 1..n 全部筛出来要多少步。