阶段 8 · 数学 · 第 40 章

GCD、LCM 与欧几里得算法

★ 关键一步是一行证明 —— gcd(a,b) = gcd(b, a mod b),而它证的不是「最大值相等」,是**两对数的公约数集合完全相同**。⚠ 这一章的生成器踩到了一件新事:随机数据会**把这道题自己做没了**,而且那个概率算得出来。

例题:求 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
  • ★ 第三行输出前缀 gcdg_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) = 5gcd(12,18,30,5) = 1 ——「前缀 gcd」和「相邻两个的 gcd」在这里分道扬镳;
  • 最后两个是九位数,而且和前面互质 —— lcm 一步跨过 10¹⁸。

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

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

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

brute.cpp标准答案:分解质因数(和欧几里得是完全不同的思路)
输入(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)
输入(stdin)
输出
点「运行 ▶」看结果

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

sub.cpp⚠ 也不是 bug:只是把「取模」换成了「相减」
输入(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 章那条(数据的形状不对,暴力会假装自己不慢)在这一章的样子。

⚠ 而 fastbrute 那两列全程都是 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 那两列是算出来的)
输入(stdin)
输出
点「运行 ▶」看结果

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

这两个数fast 取模sub 相减slow 枚举brute 试除
随机两个大数178635 380 25910 830
★ 一大一小1999 999 937131 621
★ 相邻斐波那契43 ← 最坏44701 408 733527
★ 两个大质数722 727 280999 999 89363 242
⚠ 两个一样的数11163 242 ← 只有它还慢

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

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

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

于是问题变得很具体了:

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

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

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

a = k·b + rr = a mod b)。

  • d | bd | r,那么 d | (k·b + r) = ad 是 (a,b) 的公约数
  • d | ad | b,那么 d | (a − k·b) = rd 是 (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 行)
输入(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。 ★ ab 都到 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,以及两个计数器
输入(stdin)
输出
点「运行 ▶」看结果

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

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

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

steps.cpp★ 全枚举 + 斐波那契那一列,不带随机
输出
点「运行 ▶」看结果
n平均步数(全枚举)0.8428 · ln n最坏步数★ 最坏那一对
1003.9833.88110(55, 89)
3004.8884.80712(144, 233)
9005.8045.73314(377, 610)
2 7006.7276.65917(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 对的平均步数★ 斐波那契那一对
893.9049
10 9467.94819
1 346 26911.90129
165 580 14115.80139

★★ 随机数据只跑在最坏的 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(基线)
输入(stdin)
输出
点「运行 ▶」看结果
wrongBreak.cpp✗ 「gcd 已经是 1 了,后面不用算了」—— 把整个循环 break 掉
输入(stdin)
输出
点「运行 ▶」看结果
wrongPrefix.cpp✗ 前缀 gcd 写成了相邻两个数的 gcd
输入(stdin)
输出
点「运行 ▶」看结果
wrongNoLim.cpp✗ 根本不判溢出,一路乘下去
输入(stdin)
输出
点「运行 ▶」看结果
wrongLate.cpp✗ 溢出判断写在乘完之后 —— 逻辑对,位置错
输入(stdin)
输出
点「运行 ▶」看结果
wrongLcm.cpp✗ lcm 先乘后除
输入(stdin)
输出
点「运行 ▶」看结果
★★ 默认那组数据上,三个溢出 bug 给出的是**同一个**错误答案
第一行第二行第三行
正解1-112 6 6 1 1 1 1 1
wrongOne2-112 6 6 **5 5 2 2 2**
wrongBreak118012 6 6 1 1 1 1 1
wrongPrefix1-112 6 6 **5 5** 1 1 1
wrongNoLim11857589480178812 6 6 1 1 1 1 1
wrongLate11857589480178812 6 6 1 1 1 1 1
wrongLcm11857589480178812 6 6 1 1 1 1 1

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

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

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

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

对拍器
★ 这个生成器有八个旋钮,而全场最大的功臣是一个**反直觉**的:把 n 压短。下面两张表把每一处的账都摆出来。

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

故意写错的地方被抓第几轮
wrongNoLim(不判溢出)300 / 300第 1 轮
wrongOnewhile (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 的概率
输出
点「运行 ▶」看结果
n 个数实测 gcd = 1 的比例1/ζ(n)(算出来的)
260.63%60.79% ← 这就是 6/π²
383.12%83.19%
492.42%92.39%
596.48%96.44%
899.58%99.59%

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

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

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

★★ 于是我去补公因子。⚠ 实测把两个 bug 打崩了
档位相对档位 0 改了什么OneBreakPrefixNoLimLateLcm最弱支
0(顺手写法)n ∈ [2,8]、a_i 在 [1,10⁹] 里独立均匀263147158244568456
1全部乘一个公因子 g ∈ [1,30]124155244371244
2一半的数乘公因子22189195244438343
3值域压到 [1,50]2652041570000
4n 拉长到 [10,20]300174297300050
5混入 1285187116221508450
6值域贴着上限取266148161244508250
7相邻成倍数关系240131200203367436
8★★ n 压短到 [3,5]263174128300116154116

① ⚠ 档位 1 是这一章的反面教材。 我按上面那条「随机数据把题目做没了」去补公因子,一口气全加上 —— 结果 wrongOne 从 263 掉到 12wrongBreak 从 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 本身。)

★ 定档,以及一处「明知有用也没留」的改动
档位内容OneBreakPrefixNoLimLateLcm最弱支
9(最终档)8 + 6(n 压短 + 值域贴上限)268184131300117142117
10(对照)9 + 一半的数乘公因子2061051623009517995
11(对照)9 + 混入 1289202892229611889
12(诊断)9,但公因子改成全部都乘131014630011022610

① 「混入 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
输出
点「运行 ▶」看结果

⚠ 两个细节:

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

14 自测

自测清单0 / 10
这一章我卡在哪(过一个月回来看,这几行比整章正文都值钱)
这一章记住三句话
  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 全部筛出来要多少步