计算最大公约数的欧几里得算法(Euclidean algorithm for computing the greatest common divisor)
给定两个非负整数 a a a 和 b b b ,我们必须找到它们的 GCD (最大公约数)。它通常表示为 gcd ( a , b ) \gcd(a, b) gcd(a,b) 。数学上定义为:
gcd ( a , b ) = max k = 1 … ∞ : k ∣ a ∧ k ∣ b k . \gcd(a, b) = \max_ {k = 1 \dots \infty ~ : ~ k \mid a ~ \wedge k ~ \mid b} k. gcd(a,b)=k=1…∞ : k∣a ∧k ∣bmaxk.
(这里的符号 “|” 表示可分性,即“ k ∣ a k|a k∣a ” 意为 “ k k k 可以分解出 a a a ”)
当其中一个数为零而另一个非零时,根据定义,它们的最大公约数是第二个数。当两个数都为零时,它们的最大公约数是未定义的(可以是任意大的数),但我们可以将其定义为零。这给了我们一个简单的规则:如果一个数字为零,则最大公约数是另一个数字。
下面讨论的欧几里得算法将在 O ( log min ( a , b ) ) O(\log \min(a, b)) O(logmin(a,b)) 的时间内找到两个数 a a a 和 b b b 的最大公约数。
该算法首先在欧几里得的“元素”(大约公元前 300 年)中被描述,但该算法可能有更早的起源。
算法
最初,欧几里得算法的公式如下:从较大的数字中减去较小的数字,直到其中一个数字为零。事实上,如果 g g g 除以 a a a 和 b b b ,它也除以 a − b a-b a−b 。另一方面,如果 g g g 除以 a − b a-b a−b 和 b b b ,那么它也除以 a = b + ( a − b ) a=b+(a-b) a=b+(a−b) ,这意味着 { a , b } \{a, b\} {a,b} 和 { b , a − b } \{b,a-b\} {b,a−b} 的公约数集是重合的。
请注意,在 b b b 至少被减去 ⌊ a b ⌋ \left\lfloor\frac{a}{b}\right\rfloor ⌊ba⌋ 次之前, a a a 仍然是较大的数字。因此,为了加快速度, a − b a-b a−b 被替换为 a − ⌊ a b ⌋ b = a m o d b a-\left\lfloor\frac{a}{b}\right\rfloor b = a \bmod b a−⌊ba⌋b=amodb 。然后以极其简单的方式制定算法:
gcd ( a , b ) = { a , b = 0 gcd ( b , a m o d b ) , 其他情况 \gcd(a, b) = \begin{cases}a,&b = 0 \\ \gcd(b, a \bmod b),&\text{其他情况}\end{cases} gcd(a,b)={a,gcd(b,amodb),b=0其他情况
实现
int gcd (int a, int b) {
if (b == 0)
return a;
else
return gcd (b, a % b);
}
使用 C++ 中的三元运算符,我们可以将其编写为单行。
int gcd (int a, int b) {
return b ? gcd (b, a % b) : a;
}
最后,这是一个非递归实现:
int gcd (int a, int b) {
while (b) {
a %= b;
swap(a, b);
}
return a;
}
请注意,从 C++17 开始,在 C++ 中gcd
作为标准函数实现。
时间复杂度
该算法的运行时间由拉梅定理估计,该定理在欧几里得算法和斐波那契数列之间建立了惊人的联系:
如果 a > b ≥ 1 a > b \geq 1 a>b≥1 且 b < F n b < F_n b<Fn 对于一些 n n n ,欧几里得算法最多执行 n − 2 n-2 n−2 次递归调用。
此外,可以证明该定理的上界是最优的。 当 a = F n a = F_n a=Fn 且 b = F n − 1 b = F_{n-1} b=Fn−1 , g c d ( a , b ) gcd(a, b) gcd(a,b)将正好执行 n − 2 n-2 n−2 次递归调用。换句话说,连续的斐波那契数是欧几里得算法的最坏输入情况。
鉴于斐波那契数呈指数增长,我们得到欧几里得算法复杂度为 O ( log min ( a , b ) ) O(\log \min(a, b)) O(logmin(a,b)) 。
另一种估计复杂度的方法是注意到 a ≥ b a \geq b a≥b 情况下的 a m o d b a \bmod b amodb 至少比 a a a 小 2 2 2 倍,因此在算法的每次迭代中,较大的数字至少减少一半。
最小公倍数
计算最小公倍数(通常表示为 LCM )可以简化为使用以下简单公式计算 GCD :
lcm ( a , b ) = a ⋅ b gcd ( a , b ) \text{lcm}(a, b) = \frac{a \cdot b}{\gcd(a, b)} lcm(a,b)=gcd(a,b)a⋅b
因此,可以使用具有相同时间复杂度的欧几里得算法计算 LCM :
这里给出了一种实现,它通过先用 GCD 除以 a a a ,巧妙地避免了整数溢出。
int lcm (int a, int b) {
return a / gcd(a, b) * b;
}
二进制 GCD
Binary GCD 算法是对普通 Eulidean 算法的优化。
正常算法的缓慢部分是模运算。模运算,尽管我们将它们视为 O ( 1 ) O(1) O(1) , 但它其实比简单的操作(如加法、减法或按位操作)慢得多。所以最好避免这些。
事实证明,您可以设计一种避免模运算的快速 GCD 算法。它基于几个属性:
- 如果两个数字都是偶数,那么我们可以将两者都分解并计算剩余数字的 GCD: gcd ( 2 a , 2 b ) = 2 gcd ( a , b ) \gcd(2a, 2b) = 2 \gcd(a, b) gcd(2a,2b)=2gcd(a,b) 。
- 如果其中一个数字是偶数而另一个是奇数,那么我们可以从偶数中删除因子 2: gcd ( 2 a , b ) = gcd ( a , b ) \gcd(2a, b) = \gcd(a, b) gcd(2a,b)=gcd(a,b) , b b b 为奇数。
- 如果两个数字都是奇数,那么减去另一个数字中的一个数字不会改变 GCD: gcd ( a , b ) = gcd ( b , a − b ) \gcd(a, b) = \gcd(b, a-b) gcd(a,b)=gcd(b,a−b) 。
仅使用这些属性,以及来自 GCC 的一些快速按位函数,我们可以实现一个快速版本:
int gcd(int a, int b) {
if (!a || !b)
return a | b;
unsigned shift = __builtin_ctz(a | b);
a >>= __builtin_ctz(a);
do {
b >>= __builtin_ctz(b);
if (a > b)
swap(a, b);
b -= a;
} while (b);
return a << shift;
}
请注意,这种优化通常不是必需的,并且大多数编程语言在其标准库中已经具有 GCD 功能。例如 C++17在 numeric
头文件中有 std::gcd
这样的功能。