第 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
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。
// 标准答案 —— **按定义算**:把每个数分解质因数,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;}点「运行 ▶」看结果
每个质因子的 min + max = 两个指数之和,于是
gcd(a,b) × lcm(a,b) = a × b
—— 这就是代码里那句 lcm = a / gcd(a,b) * b 的来历。
⚠ 而为什么要先除后乘,第 7 步再说,那是这一章一半的坑。
第二个暴力最老实:从 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;}点「运行 ▶」看结果
第三个是更相减损术(《九章算术》里那个):gcd(a,b) = gcd(a−b, b)。
// ⚠ 这不是错误版本 —— 它的答案和正解**逐字节相同**,只是把「取模」换成了「相减」。//// 更相减损术(《九章算术》里那个):**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;}点「运行 ▶」看结果
check:viz 里 300 轮钉着这件事。也就是说 ——
这一章接下来要讲的所有差距,对拍原理上一个字都看不见。 (第 36 章立的「随机对拍第四个盲区」,第 37、38、39 章各一次现场,这是第五次。)
★ 而这一章还把这个盲区推宽了一点:前四次至少有两份是「同一个算法的不同写法」, 这一次四份代码的想法两两不同 —— 分解质因数 / 取模 / 相减 / 枚举约数。
4实测慢:三种慢法,各有各的绝境
genBig.cpp 的旋钮不是规模,是这两个数长什么样:
本机实测(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 |
★ 这是这本书第一次用「算」的方式给出计数器,理由很实在:跑不动。 而它和「跑出来」是同一个数 —— 前两行是定义,后两行是循环边界。
// ★ 这一章的尺子:四种求 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;}点「运行 ▶」看结果
实测(./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正解,以及这一章一半的坑
// 正解 —— 欧几里得算法(辗转相除)//// ============ ★ 关键一步:一行就能证完 ============//// **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;}点「运行 ▶」看结果
① 循环条件是 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 段切开的是「q 个 b」,尾巴上剩下的那一小截就是余数 r。 一眼就能看出「取模 = 一口气减掉 q 个 b」。
- ★★ 右边两个计数器要一起看:取模次数每步 +1,相减次数每步 +q。
- 切到「★ 一大一小」那一档:第一步的 q 就是一亿 —— 相减法当场崩掉。
- 切到「★ 相邻的两个斐波那契数」:每一步的 q 都是 1,两个计数器齐头并进 —— ★ 这就是上一步那句话的画面版。
⚠ trace.cpp 是这个动画的文字版,check:viz 拿它和动画逐步比
(a / 商 / b / 余数 / 两个累计计数),不只比最终答案。
// 动画的文字版 —— 欧几里得的每一步: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;}点「运行 ▶」看结果
9★ 上界证出来了,随机数据却永远碰不到它
steps.cpp 不带随机:1 ≤ a, b ≤ n 全枚举,逐位可复现。
// ★ 这张表不带随机: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 步会看到代价。
// ✗ 错误版本:循环条件写成 `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;}点「运行 ▶」看结果
// ✗ 错误版本:「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;}点「运行 ▶」看结果
// ✗ 错误版本:第三行的前缀 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;}点「运行 ▶」看结果
// ✗ 错误版本:**根本不判溢出**,一路乘下去//// 靠什么现形:只要**答案超过 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;}点「运行 ▶」看结果
// ✗ 错误版本:溢出判断**写在乘完之后**//// 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;}点「运行 ▶」看结果
// ✗ 错误版本: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;}点「运行 ▶」看结果
| 第一行 | 第二行 | 第三行 | |
|---|---|---|---|
| 正解 | 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 只算了前四个数。
★ 一个只对「其中一问」成立的剪枝,被顺手用在了整个循环上。
// 正解 —— 欧几里得算法(辗转相除)//// ============ ★ 关键一步:一行就能证完 ============//// **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⁹)。它有一个后果 —— 而这个后果是能算出来的:
// ★★ 这份代码是用来说清「为什么随机数据会把这道题做没了」的。//// 生成器里最顺手的那句 `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……), 这一次我什么都没多做 —— 是随机本身把题目做没了。
| 档位 | 相对档位 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 留在生成器里 —— 读者随时能跑到「数据带公因子」的那一支。
12动画二:四种做法的圈数 —— 而它们的答案完全相同
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 章求逆元时会直接用它。
// 选讲 —— 扩展欧几里得:在算 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自测
- 洛谷 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 步那条「每两步至少减半」的界,在这里换了一种形式再出现一次
gcd(a,b) = gcd(b, a mod b)—— 证的是两对数的公约数集合相同,不是最大值相同。 证不动的时候换一个更大的对象去证(第 19、34 章同款)。- 上界是 O(log),最坏是相邻的斐波那契数,而随机数据只跑在最坏的 40% 上。 ★ 而斐波那契之所以最坏,恰恰因为那时候每一步的商都是 1,取模退化成了相减。
- 随机数据会把这道题自己做没了(n 个数 gcd = 1 的概率是 1/ζ(n))—— ⚠ 可照着这条去补公因子,补过头就把另外两个 bug 打崩了(263 → 12、147 → 4)。 ★ 而全场最大的功臣是个反直觉的旋钮:把 n 压短(最弱支 56 → 116)。
⚠ 下一章(质数)会把「试除」这件事从这里接过去: 这一章分解一个数要试到 √a,下一章要问的是一次把 1..n 全部筛出来要多少步。