☰
最大公约数与最小公倍数:从欧几里得到工程实践
2026/9/30 1:35:56 网站建设 项目流程

1. 从一道看似简单的算法题说起

先说一个我在面试中经常问的题目:请写出两个函数,一个求最大公约数(GCD),一个求最小公倍数(LCM)。听起来是小学生内容,但真的让候选人在白板上手写,能一次写对的人不到一半。有人只会暴力循环,有人记得欧几里得算法却说不清为什么递归边界是b == 0,还有人写最小公倍数时直接a * b / gcd(a, b),完全没意识到整数溢出的风险。

这类题目之所以经典,是因为它考察的不是"背没背过模板",而是三个底层能力:数学恒等式的理解能力、算法复杂度的分析能力、边界条件的处理能力。最大公约数有至少三种典型解法,最小公倍数有两条完全不同的实现路径,它们的背后对应着枚举思想、减治思想、公式推导思想,这些恰恰是更复杂算法(比如扩展欧几里得、同余方程、RSA 中的模逆元计算)的地基。

这篇文章我不打算只罗列代码,而是把每一种算法的原理、推导过程、时间复杂度、适用场景、以及我实际写代码时踩过的坑全部讲透。看完之后,你不仅能应付面试,还能明白"为什么递归终止条件是b == 0"这种别人总结好的结论是怎么来的。

2. 最大公约数:暴力枚举、欧几里得与更相减损术

2.1 暴力枚举法:最直白但最容易被忽略的解法

先看最没有技术含量的写法:从min(a, b)开始,逐个往下试,找到第一个能同时整除a和b的数,它就是最大公约数。

int gcdByEnum(int a, int b) { int g = min(a, b); while (g > 0) { if (a % g == 0 && b % g == 0) { return g; } g--; } return 1; }

复杂度是 O(min(a, b)),最坏情况下比如gcd(1, 100000),从 1 开始往上找其实很快,但如果求gcd(99991, 99989)这种两个大质数,就要循环将近十万次才能得到 1。

虽然这算法性能很差,但我强烈建议你不要小看它。在我的日常工作里,它有两个不可替代的作用。第一,当基准测试:用暴力枚举的结果去验证欧几里得等优化算法的正确性,尤其在写新代码或做代码审查时,拿暴力实现当"标准答案"跑随机数据比对,比人眼检查靠谱得多。第二,面试展示思维层次:面试官问算法题,最忌讳的是上来就写最优解,因为你跳过了思考过程。你先给出暴力解,再说"这个复杂度是 O(min(a,b)),在大数据量下不可行,我可以继续优化",这充分展示了你分析问题、逐步优化的工程思维。

另外,暴力枚举还有一个副作用:它天然处理了a或b为 1 的情况。任何一个数和 1 的最大公约数都是 1,min(a, b)也是 1,第一次循环就返回 1,逻辑上没有任何特殊分支需要处理。

2.2 欧几里得算法:两千年不变的经典

欧几里得算法就是我们常说的辗转相除法,核心公式是:

gcd(a, b) = gcd(b, a % b)

写成代码非常短:

// 递归版 int gcd(int a, int b) { return b == 0 ? a : gcd(b, a % b); } // 迭代版(推荐) int gcdIter(int a, int b) { while (b != 0) { int r = a % b; a = b; b = r; } return a; }

但要真正理解它,得先搞清楚一个关键问题:为什么取模之后公约数集合不变?

设a = b * q + r,其中r = a % b。如果存在一个数d能同时整除a和b,那么d也一定能整除r = a - b * q,因为a和b * q都能被d整除。反过来,如果d能同时整除b和r,那么d也一定能整除a = b * q + r。这就证明了(a, b)和(b, r)的公约数集合完全相同,所以它们的最大公约数也必然相等。

这样不断把问题规模缩小:(a, b) -> (b, a % b),余数r严格小于b,最终余数变成 0,此时gcd(x, 0) = x,递归结束。这里的边界理解很关键——很多人死记b == 0,但不理解为什么。从数学定义来说,gcd(x, 0) = |x|,因为任何数都能整除 0,而能整除x的最大数就是x本身。

时间复杂度上,欧几里得算法的收敛速度是指数级的,最坏情况是斐波那契数列相邻两项,比如gcd(144, 89)的取模序列长度等于斐波那契数增长到 n 的索引数,总体复杂度为 O(log min(a, b))。用具体数字感受一下:求gcd(1000000, 1),暴力枚举要循环一百万次,辗转相除法只做两次取模就得到结果:1000000 % 1 == 0,直接返回 1。差距就是这么大。

我个人的偏好是:生产代码里用迭代版,面试时先写递归版并主动解释递归深度。欧几里得的递归深度大约是对数级别,所以不容易栈溢出,但在一些嵌入式编译器上递归调用仍有栈开销,迭代版更稳妥。这一点在第五章会展开说。

2.3 更相减损术:减法思维与 Stein 算法的渊源

更相减损术的历史比欧几里得算法还早,中国古代数学著作《九章算术》里就有记载。它的核心公式是:

当 a > b 时,gcd(a, b) = gcd(a - b, b)

直觉上很容易理解:a和b的共同因子,一定也是a - b的因子,反过来也一样。证明思路和欧几里得算法几乎完全一致,只是把取模换成了减法。

int gcdBySubtraction(int a, int b) { while (a != b) { if (a > b) a -= b; else b -= a; } return a; }

这代码有一个隐藏 bug 风险:循环终止条件是a == b。当a、b都是正数时,这个条件是能正确结束的,但如果输入为 0 或负数就麻烦了。所以使用前必须做绝对值处理:a = abs(a); b = abs(b);,并且要单独处理a == 0 || b == 0的情况,否则a = 0时会进入死循环。

更相减损术的复杂度退化问题非常严重。最坏情况是gcd(1, 100000):100000 - 1减了九万九千九百九十九次,每次只把大数减少 1,整体复杂度 O(max(a, b))。虽然平均来说比暴力枚举略好,但本质上不是一个稳定性好的算法。

不过,更相减损术有一个重要价值:它是 Stein 算法的思想源头。Stein 算法在 1967 年提出,专门针对大整数场景,核心优化是用移位代替取模:

  • 如果a、b都是偶数,gcd(a, b) = 2 * gcd(a/2, b/2)
  • 如果a是偶数、b是奇数,gcd(a, b) = gcd(a/2, b)
  • 如果两者都是奇数,再用更相减损术

这样就把对大整数的除法运算全部换成了右移和减法运算。在处理几百位的大整数时(比如加密算法里的超大数),取模的开销极高,Stein 算法的优势非常明显。所以更相减损术不是没用,而是要配合其他优化手段使用,单纯"裸写"它在现代工程中几乎没有价值。

三种最大公约数算法的对比:

算法核心操作时间复杂度稳定性适用场景
暴力枚举取模O(min(a,b))最稳定,正确性显然测试基准、小数据
欧几里得算法取模O(log min(a,b))高效稳定绝大多数日常场景
更相减损术减法O(max(a,b))退化严重Stein 算法的基础

3. 最小公倍数的两种算法:公式推导与直接枚举

3.1 公式法:用最大公约数一步到位

最小公倍数和最大公约数之间有一个非常优美的恒等式:

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

所以求最小公倍数最常用的方法是:

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

为什么这个等式成立?用质因数分解来看最直观。假设:

  • a = p1^e1 * p2^e2 * ...
  • b = p1^f1 * p2^f2 * ...

最大公约数取每个质因子的较小指数,最小公倍数取较大指数。那么gcd * lcm中每个质因子的指数就是min(e, f) + max(e, f) = e + f,恰好等于a × b中每个质因子的指数之和。所以恒等式成立。

代码实现很短:

int lcm(int a, int b) { return a / gcd(a, b) * b; }

注意这里我特意写了a / gcd(a, b) * b而不是a * b / gcd(a, b)。这是我在实际开发中踩过的坑:如果先算a * b,再除以gcd,中间结果可能溢出。比如a = 100000, b = 99999,两者互质,a * b = 9999900000,已经超出 32 位整数的上限2147483647了,直接溢出变成负数。但如果先做除法,a / gcd(a, b) = 100000 / 1 = 100000,再乘以99999,结果虽然还是超过 32 位范围,但至少在除法这一步不会溢出。更稳妥的方案是直接全部用long long:

long long lcm(long long a, long long b) { return a / gcd(a, b) * b; }

现实中很多代码在 32 位整数范围内跑得好好的,一旦数据规模升级就出诡异问题,原因往往就是这种"先乘后除"的写法埋下的雷。

3.2 直接枚举法:适合小数据量场景的朴素思路

第二种求最小公倍数的方法是枚举。最朴素的思路是从 1 开始逐个往上找,第一个能同时被a和b整除的数就是公倍数。但更高效的枚举方式是从max(a, b)开始,每次累加max(a, b),这样可以保证枚举到的每个数都是较大数的倍数,只需要检查是否能被较小数整除即可:

int lcmByEnum(int a, int b) { int step = max(a, b); int l = step; while (l % a != 0 || l % b != 0) { l += step; } return l; }

你可能会问:为什么不每次加 1 检查?因为那样会做大量无效判断。从max(a, b)开始每次加max(a, b),枚举的次数最多是min(a, b)次,整体复杂度 O(min(a, b))。举个例子:求lcm(7, 13),从 13 开始加,13 不行,26 不行,39 不行……到 91 才行,一共枚举 7 次,恰好等于较小的那个数 7。而如果每次加 1,要枚举 91 次。差距一目了然。

那枚举法是不是就完全没用呢?也不是。在某些场景下它有独特价值:

  • 当a、b都很小(几十以内),枚举法写起来最直观,逻辑最简单,不容易出错。
  • 当你已经算出了最大公约数,但不想用除法(比如在某种受限环境下除法成本很高),枚举法可以作为替代。
  • 小学奥数教学场景里,枚举法能帮助学生理解公倍数的含义,建立数感。

但如果a、b都很大且互质,枚举法会退化到 O(min(a, b)),比如求lcm(99991, 99989),要从99991一直加到99991 * 99989,这个数字接近 100 亿,程序基本跑不完。所以工程上,公式法几乎总是优于枚举法,枚举法更多是教学和辅助验证的价值。

两种最小公倍数算法的对比:

算法依赖时间复杂度代码复杂度使用场景
公式法gcdO(log min(a,b))极低几乎所有工程场景
枚举法循环O(min(a,b)) 最坏低小数据、教学演示、无除法环境

4. 从两个数到多个数:算法推广与实战选型

4.1 多个数求最大公约数的迭代实现

实际开发中我们经常遇到的不只是两个数,而是一堆数同时求最大公约数,比如分数约分时需要计算分子、分母、公因数的最大公约数。好消息是,最大公约数运算满足结合律:

gcd(a, b, c) = gcd(gcd(a, b), c)

也就是说,先用任意两个数算出公约数,再拿这个结果和第三个数算,依次迭代下去即可:

int gcdArray(const vector<int>& nums) { if (nums.empty()) return 0; int g = nums[0]; for (int i = 1; i < nums.size(); i++) { g = gcd(g, nums[i]); } return g; }

这个实现的复杂度是 O(n log M),其中 n 是数组长度,M 是最大数值。g在迭代过程中严格不增,而且一旦g变成 1,就可以提前跳出循环——因为任何数和 1 的最大公约数都是 1,后面的所有计算都是无用功。这是一个非常实用的性能优化:

int gcdArray(const vector<int>& nums) { if (nums.empty()) return 0; int g = nums[0]; for (int i = 1; i < nums.size() && g != 1; i++) { g = gcd(g, nums[i]); } return g; }

这个优化在随机数据上尤其明显。数组里只要有两个互质的数,后续所有元素都不需要再计算了,直接返回 1。

4.2 多个数求最小公倍数的顺序计算

最小公倍数对多个数同样满足结合律:

lcm(a, b, c) = lcm(lcm(a, b), c)

对应的迭代实现:

long long lcmArray(const vector<int>& nums) { if (nums.empty()) return 0; long long l = nums[0]; for (int i = 1; i < nums.size(); i++) { l = lcm(l, nums[i]); } return l; }

我在这里要特别强调一个很多人容易犯的错误:三个数求最小公倍数,不能写成a * b * c / gcd(a, b) / gcd(b, c)。这个公式只在特定条件下成立,比如求lcm(2, 3, 5):按错误公式算,2 * 3 * 5 / 1 / 1 = 30,恰好等于正确答案 30。但换一组数就不行了,比如lcm(4, 6, 9):错误公式算出来是4 * 6 * 9 / 2 / 3 = 36,而正确答案是 36。咦,这个例子凑巧也对。我再换一组:lcm(4, 6, 10):错误公式4 * 6 * 10 / 2 / 2 = 60,正确答案是 60。好像还是对的?

实际上这个错误公式之所以看起来"经常对",是因为它本质上是把每个质因子的指数重复算了。真正暴露问题的情况是三个数中有两个数共享了最大公约数中未包含的因子时。我构造一个反例:lcm(8, 12, 18)。真确答案:8 的质因数分解是 2^3,12 是 2^2 × 3,18 是 2 × 3^2,所以 lcm 是 2^3 × 3^2 = 72。用错误公式:8 * 12 * 18 / gcd(8, 12) / gcd(12, 18) = 1728 / 4 / 6 = 72。结果还是对的?

好吧,让我仔细想一下。这个"错误公式"其实在某些形式下可以用更一般化的公式纠偏,但通常正确的做法就是两两迭代。直接演示一个必错场景:lcm(6, 10, 15)。正确答案:6=2×3,10=2×5,15=3×5,lcm=2×3×5=30。用"错误公式"6 * 10 * 15 / gcd(6,10) / gcd(10,15) = 900 / 2 / 5 = 90,90 ≠ 30,这就出错了。

所以结论很明确:对于多个数求最小公倍数,老老实实两两迭代,不要试图用一个复杂的组合公式一把梭。两两迭代不仅思路清晰,而且每一步都能保证中间结果是真实的最小公倍数,不会出现"局部正确全局错误"的隐藏 bug。

4.3 大整数场景下的溢出防护与选型建议

写算法题或业务代码时,一个隐藏的坑是中间结果的溢出。前面提到的a / gcd(a, b) * b已经规避了第一步的溢出风险,但累乘过程中依然可能溢出。比如lcmArray({1000000007, 1000000009, 999999937}),这三个数两两互质,按两两迭代算出来的中间结果会迅速膨胀到天文数字,即使是long long也扛不住。这种情况下就必须上高精度库或大数类型。

我整理了一个实战选型建议表:

数据规模推荐方案理由
32 位 int 范围内int gcd = gcdIter; lcm = a / gcd * b简单高效,注意用 long long 存放 lcm 结果
64 位 long long 范围内long long gcd+ 先除后乘规避乘法溢出,lcm 可能超界需自行判断
大整数(几百位)Stein 算法 + BigInteger 库避免大数取模的巨大开销
浮点数不适用gcd/lcm 定义在整数环上,浮点数请用别的方法

还有一点容易被忽略:数据规模不是唯一标准,计算频率也很重要。如果在一个嵌入式环境里每秒要调用几十万次 gcd,函数调用本身的开销都不可忽视,这时候建议用迭代版、内联函数、避免递归栈开销。我自己在做性能敏感模块时,甚至会把欧几里得算法手工展开成循环并加inline关键字。

5. 面试与工程中的加分细节:边界、溢出与表达

5.1 边界条件:0、负数、1 的处理标准

很多代码能跑通正常用例,却在边界条件上翻车。最大公约数和最小公倍数的边界条件有几个业界通用的约定:

关于 0:数学上gcd(0, 0)没有定义,但很多语言和库中约定返回 0。gcd(a, 0) = |a|,这是普遍接受的定义,所以欧几里得算法的递归边界b == 0返回a,天然与这个定义一致。而lcm(a, 0)呢?从公式a / gcd(a, 0) * 0 = a / |a| * 0 = ±0 = 0出发,几乎所有实现里 lcm 遇到 0 都返回 0。这个结果是否符合直觉另说,但至少和公式推导是一致的。

关于负数:最大公约数通常定义为正数。所以函数的正确做法是先取绝对值再计算:

int gcd(int a, int b) { a = abs(a); b = abs(b); while (b != 0) { int r = a % b; a = b; b = r; } return a; }

很多面试候选人栽在这个点上,他们写的代码在输入负数时返回负数,从数学定义讲这是错误的。

关于 1:任何数和 1 的最大公约数都是 1;任何数和 1 的最小公倍数是这个数本身。如果你的 gcd 实现是欧几里得算法,这些情况能自动正确处理,但如果用更相减损术就必须要小心a == b的终止条件与 0/1 的组合,所以更相减损术的实践中必须先做绝对值和零值检查。

5.2 递归改迭代的必要性

欧几里得算法的递归版只有一行,非常优雅。但工程上我更推荐迭代版,原因有两个。

第一,递归深度虽然是对数级别,但在特殊输入下依然可能出问题。比如求gcd(1, 2147483647),递归只进行两层就结束了,完全没问题。真正要注意的是在某些实现了循环优化的高级语言中,递归调用栈需要保存大量上下文(如 JS 的调用栈深度有限制)。虽然欧几里得很安全,但惯性地写递归是一种坏习惯,一旦扩展到其他递归算法就容易被坑。

第二,迭代版对编译器更友好。虽然现代编译器对尾递归有一定优化能力,但远不如直接写循环来得确定。C++ 标准并不强制编译器优化尾递归,所以在某些嵌入式或交叉编译环境下,递归版会产生比预期更多的栈开销。我一般这么劝身边的朋友:递归版用于表达思路,迭代版用于生产代码,两者都要会写,能在面试中边说思路边切换更好。

5.3 从这道题延伸出的思考方式

这道题最大的价值不在于记住三个算法,而在于它背后暴露出的思考范式。我自己带新人的时候,经常用这道题训练他们三个习惯:

第一,先问数据范围再写代码。候选人如果一开始就动手写暴力枚举,我往往会追问一句"如果a和b都是 10 亿呢?"会思考数据范围的人,会主动选择欧几里得算法,并在代码注释里写明复杂度。这个习惯在工作里尤其重要——写业务代码时,没人告诉你数据有多大,你必须自己判断。

第二,主动分析复杂度。能写出 gcd 的人很多,但能说出"欧几里得算法最坏情况是斐波那契数列相邻两项"的人很少。这个细节能体现你不仅会用,还深入理解过。

第三,关注数学恒等式背后的推导。a × b = gcd(a, b) × lcm(a, b)不是靠背的,而应该从质因数分解的角度自己推一遍。这种推导能力会在很多意想不到的地方派上用场——比如你以后遇到逆向求未知数的问题、同余方程的问题、甚至图像处理中的像素对齐问题,都会用到类似的因式分解和整数性质分析。

最后分享一个小技巧。我在做算法题或写库函数时,习惯把暴力枚举版和优化版同时写在测试代码里,然后用随机数做对拍验证。比如写了个欧几里得算法,就生成十万组随机数,分别用gcdByEnum和gcd算一遍,断言结果一致。这个方法能在几秒钟内帮你发现 99% 的实现错误,比任何代码审查都有效。做 gcd、lcm 这类看似简单的函数时,不要觉得没必要写测试——越简单的函数越容易被忽略,一旦出错,影响面反而更大。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询