
1. 从一道经典面试题说起你真的懂公约数与公倍数吗几年前我面试一位刚毕业的程序员随口问了一句“求两个数的最大公约数你会几种方法”对方愣了一下说“辗转相除法啊课本上就教了这个。”我接着问“那如果不用除法只用减法和比较呢如果数字特别大比如两个上百位的整数呢或者我需要同时求出一堆数的最大公约数和最小公倍数怎么设计算法效率最高”这几个问题下来对方明显有些卡壳了。这件事让我感触很深。最大公约数Greatest Common Divisor, GCD和最小公倍数Least Common Multiple, LCM这两个概念从小学开始接触到大学编程再到实际开发、密码学甚至算法优化无处不在。很多人对它们的认知却停留在“用一个公式算出来”的层面至于背后的原理、不同场景下的最优解法、以及那些能显著提升效率的“骚操作”知之甚少。今天我们就抛开教科书式的简单罗列从一个一线开发者的视角系统性地拆解GCD和LCM的多种求法。我不会只告诉你“怎么做”更重要的是讲清楚“为什么这么做”以及在什么情况下该选择哪种方法。你会发现这个看似基础的问题背后藏着算法思想、数学优化和工程实践的巧妙结合。无论你是正在准备技术面试还是想在项目中写出更高效的代码这篇文章都能给你带来实实在在的收获。2. 概念重温与核心关系一切算法的起点在深入各种“花样”求法之前我们必须把地基打牢。清晰、准确的概念理解是选择和应用不同算法的前提。2.1 最大公约数连接两个数的“最大纽带”最大公约数顾名思义就是能同时整除两个或多个整数的最大正整数。比如数字12和18它们的公约数有1、2、3、6其中最大的就是6所以GCD(12, 18) 6。这个概念有几个关键点需要厘清定义域我们通常讨论的是正整数。虽然概念可以扩展到零和负数例如规定GCD(a,0)|a|GCD(a,b)GCD(|a|,|b|)但在绝大多数编程和算法场景中我们默认处理的是正整数。互质如果两个数的最大公约数是1比如8和9那么它们被称为“互质”或“互素”。这个性质在数论和密码学如RSA算法中至关重要。几何意义想象你有一块长12厘米、宽18厘米的矩形瓷砖你想把它切割成大小相同的最大正方形小块且没有剩余。那么这个小正方形的边长就是12和18的最大公约数——6厘米。2.2 最小公倍数容纳两个数的“最小容器”最小公倍数是指能被两个或多个整数整除的最小正整数。同样以12和18为例它们的公倍数有36、72、108…其中最小的就是36所以LCM(12, 18) 36。它的核心要点在于无穷性任何两个数的公倍数有无限多个但最小公倍数是唯一确定的。实际意义生活中常见的“相遇问题”就是最小公倍数的应用。比如甲每12天休息一次乙每18天休息一次他们下次同时休息需要多少天答案就是LCM(12, 18) 36天。2.3 那个你必须烂熟于心的核心公式GCD和LCM之间存在着一个极其优美且实用的关系这是连接所有算法的桥梁对于任意两个正整数 a 和 b有a * b GCD(a, b) * LCM(a, b)这个公式太重要了我们可以从中推导出求LCM的捷径LCM(a, b) a * b / GCD(a, b)相互验证当你通过某种方法求出GCD后可以用这个公式快速算出LCM反之亦可用于验证结果的正確性。注意在实际编程中直接使用a * b / gcd(a, b)来计算LCM存在整数溢出的风险。例如当a和b都是接近32位整数上限的大数时a * b的结果可能会溢出。更安全的做法是先计算a / gcd(a, b)再乘以b前提是确保除法是整除。理解了这个核心公式我们的目标就变得清晰了只要高效地求出GCDLCM自然手到擒来。因此下文我们将把主要精力放在GCD的各种求法上。3. 经典求法深度剖析从朴素到高效掌握了理论基础我们进入实战环节。我将按照从易到难、从慢到快的顺序逐一拆解四种经典的GCD求法并分析它们的适用场景。3.1 穷举法最直观的“暴力破解”这是每个人最初能想到的方法既然要找最大的公约数那我就从较小的那个数开始一个一个往下试直到找到第一个能同时整除两个数的数。算法步骤比较两个数a和b找到其中较小的那个记为min。从min开始循环递减到1。对每一个循环变量i判断i是否能同时整除a和b即a % i 0 b % i 0。第一个满足条件的i就是最大公约数。代码示例Pythondef gcd_naive(a, b): min_val min(a, b) for i in range(min_val, 0, -1): # 从大到小遍历 if a % i 0 and b % i 0: return i return 1 # 所有正整数都至少有一个公约数1为什么它效率低它的时间复杂度是O(min(a, b))。当两个数很大且相差悬殊时例如求GCD(1000000, 1)它需要循环近100万次而实际上答案就是1。这是一种非常低效的做法。实操心得虽然穷举法在实际开发中几乎不会被使用但它在教学和验证阶段有独特价值。当你实现了一个更复杂的算法如辗转相除法后可以用穷举法对小数值进行暴力验证确保复杂算法的正确性。这是一种简单有效的“单元测试”思路。3.2 更相减损术古老东方的智慧这是出自《九章算术》的算法其原理基于一个直观的观察两个数的最大公约数与它们的差和较小数的最大公约数相同。即GCD(a, b) GCD(a-b, b)假设a b。算法步骤如果a b那么最大公约数就是a。如果a b则令a a - b。如果b a则令b b - a。重复步骤1-3直到两者相等。代码示例def gcd_subtraction(a, b): while a ! b: if a b: a a - b else: b b - a return a为什么它可能比穷举法好但也有问题它避免了穷举法的盲目遍历。对于像GCD(1000000, 1)这样的 case更相减损术会快速收敛1000000-1然后999999-1...虽然也要做很多次减法但比循环100万次判断取模要快。 但是它的最坏情况时间复杂度依然很高。考虑GCD(10000, 1)和GCD(10000, 9999)前者需要约10000次减法。后者更糟糕10000-99991然后变成GCD(9999,1)又需要约9999次减法。几乎退化成了线性时间。注意事项更相减损术在两者相差不大时性能尚可。但当两数相差极大时尤其是一个是另一个的倍数性能会急剧下降。因此它通常不作为通用解法而是作为理解辗转相除法的前导知识。3.3 辗转相除法效率与优雅的典范这是目前最著名、应用最广泛的算法也叫欧几里得算法。它是对“更相减损术”的极致优化。其核心原理是GCD(a, b) GCD(b, a % b)其中%是取模运算。为什么这个原理成立我们可以这样理解假设a除以b的商是q余数是r即a b * q r。那么任何能同时整除a和b的数也一定能整除r因为r a - b*q反之任何能同时整除b和r的数也一定能整除a。因此a和b的公约数集合与b和r的公约数集合完全相同那么它们的最大公约数也必然相同。算法步骤递归版本最清晰如果b 0则返回a作为结果。否则计算a除以b的余数r。将问题转化为求GCD(b, r)并重复此过程。代码示例递归def gcd_euclid_recursive(a, b): if b 0: return a return gcd_euclid_recursive(b, a % b)代码示例迭代递归虽然简洁但存在函数调用开销和栈深度限制。迭代版本是工程中的首选。def gcd_euclid_iterative(a, b): while b ! 0: a, b b, a % b # 并行赋值巧妙完成状态更新 return a为什么它如此高效关键在于取模运算%能极大地缩小问题的规模。可以证明在辗转相除法中每经过两次迭代两个数中较大的那个至少会减半。这使得它的时间复杂度是O(log(min(a, b)))。对于天文数字般的大整数它也能在极少的步骤内求出结果。实操心得与避坑指南处理负数和大数标准的%运算符在不同语言中对负数的处理可能不同Python的%结果始终是非负的符合数学定义很安全但C/C/Java中-5 % 2可能是-1。安全的做法是在计算前先取绝对值a, b abs(a), abs(b)。迭代优于递归对于可能非常大的输入使用迭代版本可以避免递归深度过大导致的栈溢出错误。利用语言内置函数现代编程语言的标准库如Python的math.gcd()C17的std::gcd通常已经实现了高度优化的辗转相除法直接调用是最佳实践。但在面试或需要理解原理时你必须能手写。3.4 Stein算法针对计算机特性的优化辗转相除法已经很快了但它依赖于耗时的取模运算%。在计算机底层取模运算通常是通过除法实现的而除法是CPU指令集中比较慢的操作。Stein算法又称二进制GCD算法的巧妙之处在于它完全避免了除法和取模只使用位移, 和减法这些操作在硬件层面速度极快。它的原理基于以下几个数学观察若a和b都是偶数则GCD(a, b) 2 * GCD(a/2, b/2)。若a是偶数b是奇数则GCD(a, b) GCD(a/2, b)。因为2不是奇数的约数若a和b都是奇数则GCD(a, b) GCD(|a-b|, min(a, b))。此时|a-b|必然是偶数又可以转化为情况2。算法步骤如果a b返回a。如果a 0返回b如果b 0返回a。如果a和b都是偶数返回2 * GCD(a1, b1)。如果a是偶数b是奇数返回GCD(a1, b)。如果a是奇数b是偶数返回GCD(a, b1)。如果a和b都是奇数且a b返回GCD(a-b, b)否则返回GCD(b-a, a)。代码示例def gcd_stein(a, b): if a b: return a if a 0: return b if b 0: return a # 判断奇偶性使用位与操作 a 1 if (a 1) 0: # a是偶数 if (b 1) 0: # b也是偶数 return gcd_stein(a 1, b 1) 1 # 情况1 else: return gcd_stein(a 1, b) # 情况2 else: # a是奇数 if (b 1) 0: # b是偶数 return gcd_stein(a, b 1) # 情况3 else: # a, b都是奇数 if a b: return gcd_stein(a - b, b) # 情况4 else: return gcd_stein(b - a, a) # 情况4为什么它在某些场景下更快对于特别大的整数尤其是当它们在二进制表示下有很多尾随0即能被2的多次幂整除时Stein算法通过右移操作能迅速减小数值规模。它用廉价的位移和减法替代了昂贵的取模运算在硬件优化层面具有优势。不过对于普通大小的整数现代CPU的除法指令已经足够快辗转相除法的简洁性使其依然是通用场景下的首选。4. 进阶场景与工程实践掌握了单个GCD的求法在实际项目中我们往往会遇到更复杂的需求。下面这些场景才是真正考验你对这个问题理解深度的地方。4.1 求多个数的最大公约数与最小公倍数现实问题很少只涉及两个数。例如你要设计一个周期性任务调度系统有三个任务分别每4、6、8秒执行一次你想知道它们第一次同时执行的时刻就需要求LCM(4, 6, 8)。核心思路化归为两两求解。多个数的GCDGCD(a, b, c) GCD(GCD(a, b), c)。你可以先求出前两个数的GCD再用这个结果和第三个数求GCD以此类推。多个数的LCMLCM(a, b, c) LCM(LCM(a, b), c)。原理同上。代码示例求多个数的LCMimport math from functools import reduce def lcm_of_list(numbers): 计算一个整数列表的最小公倍数 def lcm(a, b): return a // math.gcd(a, b) * b # 先除后乘防止溢出 return reduce(lcm, numbers) # 使用reduce函数进行累积计算 # 示例求4, 6, 8的最小公倍数 print(lcm_of_list([4, 6, 8])) # 输出24注意事项计算多个数的LCM时中间结果增长非常快整数溢出是首要考虑的问题。务必使用a // gcd * b这种先除后乘的顺序。Python的整数是任意精度的所以不用担心但在C/Java等语言中使用long long类型并注意运算顺序至关重要。4.2 大整数运算与算法选择当数字大到超出普通整数类型如64位的范围时我们就进入了“大整数”的领域。许多编程语言如Python, Java的BigInteger有内置的大整数支持。在这种情况下算法选择尤为重要辗转相除法依然是可靠的选择。大整数库的取模运算虽然比小整数慢但算法复杂度O(log n)的优势依然存在。Stein算法其优势可能更加明显。因为大整数的除法/取模操作代价极高而位移和减法相对廉价。许多高性能的大整数库如GMP在求GCD时会综合使用辗转相除法和Stein算法的思想进行优化。专用库对于极端性能要求的场景如密码学直接使用像GMPGNU Multiple Precision Arithmetic Library这样的专业数学库是唯一的选择它们实现了经过极致优化的算法。实操心得在Python中你可以放心地用math.gcd()处理大整数它的底层实现已经足够优化。如果你自己实现对于非常大的数可以尝试实现一个混合策略先用Stein算法处理掉所有的因子2通过统计右移次数当两个数都变成奇数后再切换回辗转相除法。这常常能带来不错的性能提升。4.3 在分数运算与化简中的应用GCD的一个直接应用就是分数的化简。分数a/b的最简形式是(a/gcd(a, b)) / (b/gcd(a, b))。示例化简分数 24/36计算 GCD(24, 36) 12。分子分母同除以1224 ÷ 12 2 36 ÷ 12 3。最简分数为 2/3。在实现一个分数类Fraction时构造函数的首要步骤就是在存储分子分母后立即用它们的GCD进行化简这样可以保证分数始终以最简形式存在便于后续的相等比较和运算。4.4 在时间与周期调度问题中的建模这是LCM的典型应用场景。除了开头提到的休息日问题还有交通信号灯三个方向的红绿灯周期分别是60秒、90秒、120秒它们同时变绿后需要多少秒再次同时变绿答案是 LCM(60, 90, 120) 360秒。定时备份系统需要每天、每周、每月备份一次数据如何找到下一个同时执行三种备份的日期这需要计算天、周、月周期的最小公倍数当然月的周期需要具体定义。解决这类问题的关键是将现实世界的“周期”抽象为正整数然后求LCM。一个常见的陷阱是忽略单位的统一比如一个周期是“每30分钟”另一个是“每2小时”必须先统一为相同单位分钟或小时再计算。5. 常见问题与性能优化陷阱在实际编码和面试中围绕GCD和LCM的“坑”不少。下面我总结几个最常见的问题和优化技巧。5.1 递归与迭代的抉择我们前面展示了递归和迭代两种实现。如何选择递归代码极其简洁数学表达清晰是描述算法逻辑的完美方式。但是对于输入规模不确定的情况递归存在栈溢出风险Python默认递归深度约1000层。虽然对于GCD由于收敛极快几乎不会达到这个深度但这是一个不好的习惯。迭代没有栈溢出风险性能通常略优于递归避免了函数调用开销是工程实现的绝对首选。结论理解递归原理编写迭代代码。5.2 整数溢出一个隐蔽的杀手这是计算LCM时最容易出错的地方。回顾公式LCM(a, b) a * b / GCD(a, b)。错误做法return a * b / gcd(a, b)正确做法return a / gcd(a, b) * b两者的区别在于运算顺序。假设a和b都是很大的32位整数例如接近21亿它们的乘积a * b会轻易超过32位整数的表示范围约42亿导致溢出结果错误。而先做除法a / gcd(a, b)可以保证这个除法是整除并且结果会变小再乘以b溢出的风险就大大降低了。在C/Java等语言中即使使用64位的long long对于极端大的数也可能溢出。此时可以考虑使用大整数类型或者在计算过程中使用浮点数进行估算会损失精度。5.3 处理零和负数一个健壮的GCD函数必须能处理边界情况。GCD(a, 0)根据定义任何非零整数与0的最大公约数是其绝对值。所以GCD(a, 0) |a|。在辗转相除法中如果b为0循环或递归会直接返回a恰好符合这个定义。负数最大公约数在数学上定义为正整数。因此GCD(-12, 18)应该等于GCD(12, 18) 6。安全的做法是在函数入口处对参数取绝对值a, b abs(a), abs(b)。一个健壮的迭代版GCD实现如下def robust_gcd(a, b): a, b abs(a), abs(b) # 处理负数 while b: a, b b, a % b return a # 当b为0时a即为结果也正确处理了GCD(a,0)|a|5.4 算法复杂度与实测对比我们来直观感受一下不同算法的效率差异。我写了一个简单的测试使用Python的timeit计算GCD(123456789, 987654321)十万次穷举法慢到无法忍受对于大数基本不可用。更相减损术由于两数相差不大性能尚可但明显慢于后两者。辗转相除法迭代速度极快是通用场景下的标杆。Stein算法递归在这个具体案例上可能与辗转相除法相差无几或略慢因为递归调用有开销。但对于具有大量二进制尾随0的数字其优势会显现。性能选择指南通用场景中小整数毫不犹豫使用语言内置的math.gcd或自己写的迭代版辗转相除法。大整数运算且追求极致性能考虑使用Stein算法或研究专业数学库如GMP的实现。教学或验证可以使用穷举法或更相减损术来辅助理解。多个数计算记住化归为两两求解的模式并使用reduce等高阶函数保持代码简洁。最后我个人最深刻的体会是基础算法就像木匠的工具箱里的凿子和刨子看起来简单但你对它们的理解深度和运用熟练度直接决定了你能做出什么样的作品。把GCD和LCM这个问题吃透你收获的不仅仅是几种解法更是化归思想将多个数问题化为两个数问题、算法优化思维从更相减损到辗转相除的演进和边界处理意识溢出、零值、负数的锻炼。下次再有人问你这个问题你完全可以微笑着反问“你需要的是通用的解法还是针对特定二进制模式的优化解法呢”