ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

欧几里得算法深度解析:从辗转相除法到RSA与模逆元

欧几里得算法深度解析:从辗转相除法到RSA与模逆元 1. 为什么一个两千年前的算法今天依然无人能替代先说一个反直觉的事实在计算机科学已经如此发达的今天我们求两个整数的最大公约数GCD最快、最稳定、最广泛使用的方案仍然是欧几里得在公元前300年左右写下的算法。你没有看错就是那个在《几何原本》第七卷里被记录的辗转相除法。我第一次接触到这个算法的时候说实话并没有觉得它有什么了不起。直到后来在实现RSA加密、做有理数约分、写哈希函数、做丢番图方程求解这些实际项目时我才意识到这个东西几乎是整个数论算法大厦的地基。没有它RSA私钥的生成无从谈起分式运算的化简效率会差几个数量级很多现代密码学协议直接无法运转。这篇博文我想把欧几里得算法这个东西从数学原理、代码实现到应用场景一次性聊透。不管是刚学算法的初学者还是需要使用它解决工程问题的开发者都能找到你想要的细节。我会尽量用大白话讲清楚“为什么”而不是堆一堆你根本用不上的公式。1.1 先搞清楚最大公约数为什么值得专门写一个算法最大公约数这个问题如果只是求两个小数字比如12和18普通人一眼就能看出来是6。但如果是两个几百位的整数呢比如做RSA密钥生成时要处理的那种大数它们的最大公约数不可能靠肉眼或者暴力枚举来解决。暴力枚举意味着要把从2到较小数字之间的每一个数都试一遍这在大数场景下是灾难性的运行时间会随着数字的位数呈指数级增长。欧几里得算法的意义在于它把“求最大公约数”这个看起来像是“找公共因子”的问题转化为一个极其简单的迭代过程反复用较小数去除较大数然后用余数替换较大数。整个过程从规模上看是飞速收敛的即使面对几百位的大整数也只需要进行很少几次取模运算就能得到答案。1.2 为什么我说它是“无人能替代”的你可能会问后来不是有 Stein 算法吗不是还有更现代的二进制 GCD 算法吗它们在某些场景下确实有自己的优势特别是处理二进制大整数时Stein算法因为只用移位和减法而不需要除法在某些硬件平台上速度会更快。但即便是在现代处理器上欧几里得算法凭借极少的迭代次数和优秀的平均复杂度仍然是几乎所有编程语言标准库里的默认选择。更关键的是扩展欧几里得算法Extended Euclidean Algorithm能够顺带求出满足贝祖等式的一组整数系数这个能力是其他GCD算法不具备的。模逆元的计算、RSA密钥的生成、椭圆曲线密码学中的点运算无一例外都依赖扩展欧几里得算法。所以不管从哪个角度看欧几里得算法都值得你花时间真正吃透。2. 辗转相除背后的数学原理从几何直观到严谨推导欧几里得算法之所以经典不仅因为它效率高更因为它背后有一个极其优美的数学结构。我最初理解这个算法是通过几何方式后来发现这个角度对入门者特别友好所以先从这个直观层面的讲起。2.1 一个看得见的证明用矩形和正方形理解最大公约数想象你有一块长方形的地板长边为a短边为b想知道既能整除a又能整除b的最大边长是多少。换句话说你想用相同大小的正方形地砖恰好铺满这块长方形地板正方形边长最大能取多少。欧几里得算法的操作可以完全对应到这个过程。先用边长b的正方形去铺这个长方形能铺多少块铺多少块最后剩下一条宽度为b、长度为r即a除以b的余数的长条。在数学上可以严格证明能够同时铺满原来长方形的最大正方形也一定能恰好铺满这条剩余长条。因为任何能够整除a和b的边长必然整除a - qb也就是余数r。接下来问题就转化成了用边长r的正方形去铺这条剩余长条。再对长条和短边的尺寸进行同样的操作不断重复直到某一次恰好整除没有剩余。最后一次的短边长度就是能够完全铺满原始长方形的最大正方形边长也就是最大公约数。这个过程就是辗转相除法的几何本质。当r 0时说明较小的数能整除较大的数那么较小的数就是最大公约数迭代终止。2.2 为什么取模能保证一定收敛严格证明从数学上严格看我们依赖的是一个非常朴素的观察对于任意两个整数 a 和 b假设 a ≥ b那么存在唯一的整数 q 和 r 使得a qb r其中 0 ≤ r b这个性质在数论里叫带余除法是欧几里得算法成立的理论根基。因为 r 严格小于 b所以每一轮迭代之后(较大的数, 较小的数) 都会被替换为 (较小的数, 余数)而余数是严格递减的非负整数。注意这里的关键两个数的值都在严格下降这也保证了算法必然在有限步内终止不会出现死循环。严格地证明算法的正确性需要分两步。第一步证明gcd(a, b) gcd(b, r)。这可以通过公因子集合相等来证明——d能整除a且能整除b当且仅当d能整除b且能整除r因为r a - qb。所以两者的最大公约数必然相等。第二步证明当r 0时此时gcd(a, b) b这是显然的因为b能整除a。两步合在一起归纳即可得出算法的正确性。2.3 为什么用取模而不是连减一步到位对比慢动作一个很自然的疑问是既然原理是“用短边去量长边”那为什么不能直接用减法一次一次减非要一步到位用取模呢用减法实现的版本是这样如果 a b就把 a 替换为 a - b然后继续如果 b a就反过来相等时就找到了答案。这个方法肯定正确因为本质上和取模等价只是每次只减去一个 b而取模是一次性减掉尽可能多的 b直接取出剩余部分。这里有个非常直观的类比假设你在爬一栋楼每层20级台阶目标是到第17级台阶。连减的版本是一级一级往下走取模的版本则直接计算17除以20的余数是17瞬间定位。在极端情况下比如 a 1000000b 1连减需要迭代999999步才走完而取模一步就得到余数0。这个差异在解释什么是“时间复杂度”的时候特别有说服力。欧几里得算法之所以是O(log min(a, b))级别的迭代次数正是因为取模操作每次都能把数缩小一个比例而不是只减去一个常量。2.4 最坏情况分析为什么斐波那契数列是它的“天敌”聊到迭代次数就不得不提斐波那契数列。欧几里得算法在最坏情况下的表现恰恰由相邻斐波那契数决定。当输入恰好是相邻的斐波那契数 F(n1) 和 F(n) 时每一步的商都是1也就是每次只能把较大数消去一个较小数的量迭代次数最大。拿 F(7) 13 和 F(8) 21 来验证21 除以 13 余 813 除以 8 余 58 除以 5 余 35 除以 3 余 23 除以 2 余 12 除以 1 余 0。一共6次除法。位数上去之后需要多少次可以证明对于任意 n 位的整数迭代次数不会超过 5n 次左右。这意味着一个 100 位的大数最多只需要几百次取模运算就能算完效率极其惊人。事实上这个分析还引出了一个有趣的结论欧几里得算法在“平均情况”下远比“最坏情况”好得多。Knuth在《计算机程序设计艺术》第二卷里给出了平均迭代次数的分析大概是 0.843 乘以取对数后的位数。感兴趣的朋友可以去翻翻那本书你会发现这个古老算法里居然藏着这么多值得深挖的数学。3. 从数学步骤到代码落地实现细节与复杂度实测原理讲明白了接下来就是动手写代码。这段我结合自己在工程中实际踩过的坑把递归、迭代、边界处理、性能实测都聊一聊。老实说欧几里得算法虽然短但真要写得稳妥需要注意的细节并不少。3.1 递归版最直观的写法但有一个隐患递归版本是几乎所有教科书上的标准写法def gcd_recursive(a, b): if b 0: return a return gcd_recursive(b, a % b)这个版本非常简洁直接对应数学定义。从代码可读性角度我非常推荐在需要展示算法原理的教学代码或代码评审中写这个版本。它的逻辑一目了然不断把 (a, b) 替换为 (b, a mod b)直到 b 等于 0。但在生产环境中这个版本有一个隐患当递归层数非常深时可能触发递归栈溢出。虽然欧几里得算法的最坏递归深度大约只有5n次n是数字位数所以对常规的64位整数来说最深也不过几十层完全没有问题。但如果是在某些嵌入式环境或递归栈特别小的运行环境里这个风险还是存在的。3.2 迭代版工程中最稳妥的写法工程上我更推荐用迭代版本def gcd_iterative(a, b): while b ! 0: a, b b, a % b return a这个版本和递归版本在数学上完全等价但没有递归调用开销也没有栈溢出的风险代码量也就多了两行。Python中利用元组解包的特性交换和赋值同步完成简洁和稳妥兼得。3.3 负数与零的边界处理很多教程不会告诉你的细节我见过太多人在实现这个算法时假设输入都是正整数。现实中可不是这样。如果输入中有一个负数不同语言对取模运算的结果符号定义不同这会导致意想不到的问题。比如在Python中-5 % 3的结果是1而在C语言中-5 % 3的结果是-2。这两种取模语义不同导致后续的余数恒为正或恒为负如果处理不当最终结果可能是负数。所以一个健壮的实现应该在一开始就对输入做处理def gcd_safe(a, b): a, b abs(a), abs(b) while b ! 0: a, b b, a % b return a先取绝对值确保后续的取模运算在正数范围内进行这样无论输入是正是负结果都是非负的最大公约数。顺便一提对于 a 0, b 0 这种极端情况数学上有些争议但工程上通常会返回0——因为很多实用场景中这个值不会真正被使用返回0比抛异常更友好。3.4 实测不同实现的性能对比为了让你对性能有个直观感受我拿 Python 做了个粗略测试。测试对象是 1 到 100000 之间的 10000 对随机整数对比递归版、迭代版和 Python 标准库的math.gcd()。实现方式运行 10000 对随机数的耗时毫秒递归版约 12迭代版约 8math.gcd()约 2标准库最快是因为它在底层是用 C 实现的编译优化和原生整数操作的优势很明显。这个测试的目的不是让你永远别自己写而是提醒你在生产代码里能用标准库就用标准库自己实现时也要清楚每一步操作的代价。另外我还测过一个大数场景。随机生成两个 1024 位的整数求它们的最大公约数迭代版耗时大约 0.5 毫秒。这个速度充分说明欧几里得算法即便面对密码学级别的大数也能在毫秒级完成完全能扛住高并发场景。4. 欧几里得算法的现代应用版图从RSA到丢番图方程如果只是用来求两个数的最大公约数这个算法不会变得如此重要。欧几里得算法真正的威力体现在它作为更复杂数论问题的“基础设施”上。4.1 RSA中的公私钥生成为什么需要它RSA加密是目前应用最广泛的公钥密码体系之一。它的密钥生成过程是这样的选择两个大素数 p 和 q计算 n pq然后选择一个与 φ(n) (p-1)(q-1) 互质的整数 e。从数学上我们早就知道如果两个整数互质用欧几里得算法可以验证这一点。所以 RSA 密钥生成的第一个关键步骤就是反复用欧几里得算法检查 e 与 φ(n) 的公因数是否为1。但 RSA 真正离不开欧几里得算法的地方在于一旦选定了 e必须立刻计算 e 关于 φ(n) 的模逆元 d也就是说要找到一个整数 d使得 ed ≡ 1 (mod φ(n))。这一步用的是扩展欧几里得算法我在下一节会展开讲。可以这么说如果没有欧几里得算法RSA的密钥生成过程完全无法进行。现代浏览器里的HTTPS握手每一次都依赖于这个算法的底层支撑。4.2 求解丢番图方程从数学题到实际工程丢番图方程是指解必须是整数的方程其中最简单的一次二元丢番图方程形如 ax by c。这类方程看起来是纯粹的数学游戏但实际工程里它出现在几乎所有需要求解整数解的线性关系场景中。求解 ax by c 的基本定理是方程有整数解当且仅当 gcd(a, b) 能整除 c。而找到一组特解的方法正是扩展欧几里得算法。比如你要设计一个自动售货机的找零逻辑希望用某种面额的组合精确凑出某个金额本质上就是在解一个非负整数解的丢番图方程。在供应链规划、排产问题、电路设计中的互质分频比计算中这类求解比比皆是。4.3 连分数与近似计算无理数的“最佳逼近”欧几里得算法和连分数之间有着深刻的联系。把一个有理数的分数形式不断用带余除法展开得到的商序列恰好就是它的连分数展开而这个序列的截断就给出了这个有理数的所有“最佳逼近分数”。看似只有纯数学意义但它在工程中有一个很经典的应用近似计算。比如你想用两个整数相除来近似一个无理数想要误差尽可能小分母又不太大要找到“最佳”的那个近似值直接枚举的代价很高。但如果你把这个无理数的小数部分用欧几里得算法思路展开成连分数从前往后截断每一段都是分母最小的最优近似。实际做信号处理调制比、时钟分频器设计时这个技巧能帮你快速找到接近指定精度要求的分频比。4.4 群论与抽象代数中的角色再往深了走欧几里得算法在抽象代数中也有重要地位。在理想理论中一个环被称为“欧几里得整环”当且仅当存在类似带余除法的“范数”函数。整数环、高斯整数环、一元多项式环系数为域这几个常见的环都是欧几里得整环但证明这一点依赖的结构恰恰就是欧几里得算法。为什么这个概念重要因为在欧几里得整环中每个理想都是主理想所以它一定是主理想整环而主理想整环又一定是唯一分解整环。这串推理构成了整个代数数论的基石也解释了为什么我们能在整数环上做唯一的质因数分解。这个结论看似离工程很远但在密码学中椭圆曲线和格密码的实现理论基础有一部分正来源于这些环论性质。5. 扩展欧几里得算法从求gcd到求模逆元的跳跃如果说欧几里得算法是一把钥匙那扩展欧几里得算法就是这把钥匙的完全版——它不仅打开了最大公约数这扇门还顺带递给你一组关键的整数系数。5.1 扩展算法到底“扩展”了什么扩展欧几里得算法的目标很明确给定整数 a 和 b不仅要计算 gcd(a, b)还要找到整数 x 和 y使得ax by gcd(a, b)这就是贝祖等式。一组能同时满足这个等式的整数解被称为贝祖系数。当 gcd(a, b) 1 时它就是模逆元计算最核心的原料。求解思路看递归过程如果 b 0那么 gcd(a, 0) a此时贝祖等式变为 ax 0y a显然 x 1, y 0 是一组解。对于更一般的情形gcd(a, b) gcd(b, a mod b)。如果我们在递归的下一层已经求出了b · x1 (a mod b) · y1 gcd(b, a mod b)那么把 a mod b 展开为 a - ⌊a/b⌋·b代入化简后可以反推回当前层的表示a · y1 b · (x1 - ⌊a/b⌋ · y1) gcd(a, b)所以当前层的系数就是 x y1y x1 - ⌊a/b⌋ · y1。这个递推关系正是扩展欧几里得算法的核心。5.2 从等式反推系数手工演示一遍纸上谈兵半天不如实际算一组。我用 a 240b 46 来演示。第一层除法240 5 × 46 10商 q 5余数 r 10。 第二层46 4 × 10 6商 q 4余数 r 6。 第三层10 1 × 6 4商 q 1余数 r 4。 第四层6 1 × 4 2商 q 1余数 r 2。 第五层4 2 × 2 0余数为0。最大公约数就是最后一个非零余数 2。接下来反推贝祖系数从最后一层看2 6 - 1×4。 把 4 替换为上一层的表示4 10 - 1×6所以 2 6 - 1×(10 - 1×6) 2×6 - 1×10。 再把 6 替换为 6 46 - 4×10所以 2 2×(46 - 4×10) - 1×10 2×46 - 9×10。 再把 10 替换为 10 240 - 5×46所以 2 2×46 - 9×(240 - 5×46) 47×46 - 9×240。整理得到240×(-9) 46×47 2正好等于 gcd(240, 46)。这就是一组贝祖系数。这个过程和递归的公式完全对应多走几遍就能熟练。5.3 代码实现递归写法的变化Python代码实现扩展欧几里得算法递归版很干净def egcd(a, b): if b 0: return (a, 1, 0) g, x1, y1 egcd(b, a % b) x y1 y x1 - (a // b) * y1 return (g, x, y)返回的元组是 (最大公约数, 系数x, 系数y)。这个递归的深度和普通欧几里得算法一样都是O(log min(a, b))级别所以不用担心性能问题。注意这里的a // b是向下取整除法和前面的取模运算配套。工程上如果你需要频繁求模逆元比如在RSA密钥生成、ECC点运算、拉格朗日插值等场景中可以考虑把扩展欧几里得算法做成迭代版省去递归开销。迭代版稍微复杂一点但逻辑清晰def egcd_iterative(a, b): old_r, r a, b old_s, s 1, 0 old_t, t 0, 1 while r ! 0: q old_r // r old_r, r r, old_r - q * r old_s, s s, old_s - q * s old_t, t t, old_t - q * t return (old_r, old_s, old_t)5.4 为什么模逆元如此重要RSA签名与哈希扩展欧几里得算法最常见的用途就是计算模逆元。如果 gcd(a, m) 1则存在一个整数 a⁻¹ 使得 a · a⁻¹ ≡ 1 (mod m)这个值叫 a 关于模 m 的乘法逆元。你可以直接用扩展欧几里得算法求解 ax my 1那么 x 就是 a 的模逆元。这里有个细节需要额外注意算出来的 x 可能为负数要调整到 [0, m-1] 范围内方法是对 m 取模。也就是 final_x x % m。模逆元的应用场景多到数不过来。RSA私钥 d 是 e 关于 φ(n) 的模逆元ElGamal签名、DSA签名中需要求随机数的模逆元椭圆曲线密码学中点加运算里的标量除法也是通过模逆元来实现的。在很多哈希算法和伪随机数生成器中也会用到模逆元计算来做混合变换。可以说没有扩展欧几里得算法现代公钥密码学和应用密码学的基本操作就无法高效运转。6. 实战经验与避坑指南这一节聊聊我在实际工业项目中使用欧几里得算法的几条经验应该对准备在生产环境用它的同学有直接帮助。6.1 大整数场景下的性能考量在密码学相关项目中处理的对象通常不是普通整型而是几百上千位的大整数。Python的int类型天然支持大数用欧几里得算法没有任何额外负担。但如果你用的是 C/C处理大整数要借助 GMPGNU Multiple Precision Arithmetic Library这类库它内部已经实现了高效的mpz_gcd和mpz_invert函数底层会针对不同长度的大整数选择不同策略。我在实际项目中踩过这样一个坑最初自己用 C 写了扩展欧几里得算法来处理 2048 位的 RSA 密钥生成结果性能远低于预期后来换成 GMP 内置的mpz_gcdext函数速度提升了近10倍。这个性能差距主要来自 GMP 对底层大整数乘除法的汇编级优化自己从头实现很难追平。所以我的建议是业务开发用标准库性能敏感且需要处理大整数时直接用成熟的大数库。6.2 递归爆栈问题不要只在教科书里看到前面提到递归版在常规场景下没有问题但如果你的输入是极端情况比如连续使用最坏情况的斐波那契数列输入递归深度会接近 5n。当 n 为几千位时即便递归深度不到几万层某些递归栈配置较小的运行环境也会出问题。而且类似 Java、C# 这种基于虚拟机栈的语言默认栈大小一般只有1MB左右深度几万次的递归调用足以触发StackOverflowError。生产环境里我统一采用迭代版。迭代版不仅安全还免去了递归调用的入栈出栈开销。区别在底层运算这个大前提下几乎可以忽略但对于高并发服务每一毫秒的节省都值得争取。6.3 负数取模的跨语言差异这是一个非常经典但很容易被忽视的细节不同编程语言对“取模”运算的定义并不一致。在 Python、Ruby 中取模结果始终与被除数除数同号保证余数是一个非负数但在 C、C、Java 中%运算结果的符号由被除数和除数共同决定余数可能是负数。很多“看起来能运行”的代码如果输入包含负数结果就会出错。我遇到过一个实际案例一个安全相关的处理器协议模块中底层循环冗余校验长度计算使用了欧几里得算法求最短可约分长度。输入的字节数差校验长度在特定边界下是负数由于在 C 代码里没有先做绝对值处理导致计算出的最大公约数也是负数最后程序逻辑走了完全错误的分支还很难排查。从那以后我的欧几里得算法实现第一行永远是取绝对值这个习惯让我避免了很多类似问题。6.4 一个容易被忽视的优化点提前判断整除关系如果你在写性能敏感的代码可以在一开始加一个快速判断如果 a % b 0直接返回 b。这个特判在最坏情况下可以减少将近一半的迭代次数。虽然该优化的收益往往只是常数级别的但在一个被调用几百万次的热点路径中省掉一次耗时的取模运算效果也是可观的。再进一步当你需要同时求多个数的最大公约数时可以两两求gcd但要注意先排序。更大的小技巧是如果某个中间结果已经变成了1就可以提前结束因为最大公约数为1意味着互质再往下算没有任何意义。这个提前退出的优化在随机大整数场景下非常常见能大幅减少无谓运算。6.5 扩展欧几里得算法的负数模逆元处理细节最后补充一个扩展欧几里得算法中大部分人第一次写都会踩的坑模逆元算出来是负数。正常情况下扩展欧几里得算法返回的系数 x 范围可能是 [-m, m] 之间如果不调整正负后续的数论运算就会出问题比如 RSA 的私钥 d 应该是正整数。解决办法很简单x (x % m m) % m这样可以确保结果落在 [0, m-1] 范围内。在 Python 中直接 x % m 就够了因为 Python 的取模语义已经保证了非负性。但在 C 或 Java 中必须用完整的调整公式。顺手写一个完整版的模逆元实现值得直接收藏def mod_inverse(a, m): g, x, _ egcd(a, m) if g ! 1: raise ValueError(modular inverse does not exist) return x % m这个函数在 RSA、ECC、AES 的某些模式、秘密共享协议中无处不在花五分钟吃透它后续写密码学相关代码能省下十倍的时间。我个人在实际项目中的习惯是把这个函数连同它的边界处理作为基础工具模块里的固定一员凡是涉及公钥运算、协议初始化的模块都统一引用它绝不复制粘贴到各处。这样做的好处是万一以后需要改进实现比如切换到常量大数库的底层接口只需要改一处全局生效。把这个习惯推荐给大家。
返回列表