[算法][数论] 最大公约数(GCD)与欧几里得算法
算法 数论 GCD
Lastmod: 2020-10-31 周六 18:48:34

最大公约数(GCD)与欧几里得算法

最大公约数(Greatest Common Divisor,GCD)

两个数最大公约数顾名思义,两个数的所有公约数中最大的。

若正整数 $a,b$ 的质因数分解为

$$a = \prod_{i} p_i ^ {e_{a,i}}$$ $$b = \prod_{i} p_i ^ {e_{b,i}}$$

则其最大公约数为

$$b = \prod_{i} p_i ^ {\min(e_{a,i}, e_{b,i})}$$

两个数 $a$ 与 $b$ 的最大公约数通常记作

$$gcd(a,b)$$

或直接记作

$$(a,b)$$

假定 $a \ge b>0$ 显然两个数的最大公约数不会超过较小的数字 $b$,

于是我们得到了一种枚举的方式得到最大公约数,只要枚举 $b$ 到 $1$ 之间的所有值即可得到 $a$,$b$ 的最大公约数。但这显然太慢了,考虑 $(a,b) = 1$ 的情况我们要花费 $O(\min(a,b))$ 的时间这是完全无法接受的。

辗转相减

设 $(a,b) = g$,

如果 $a = b$ 则 $a = b = g$,

此外的情况,假定 $a > b$,

则我们发现 $g|a$ 又 $g|b$ 则 $g|(a-b)$

也就是说 $(b,a-b) \ge g$

假设 $(b,a-b) = c > g$,

则 $c|(a-b), c|b$ 从而 $c|(a-b+b)$ 则 $c|a$。

注意到此时 $c$ 是 $a$ 与 $b$ 的公因子且大于 $g$ 这与 $(a,b) = g$ 矛盾,因此,

$$(b,a-b) = c = g = (a,b)$$

我们知道 $(a,b) \ge 1$,而算法每运行一次,总会使得两个数的总值严格下降,直到 $a=b$。

int gcd(int a,int b)
{
    if(a==b) return a;
    if(a<b) swap(a,b);
    return gcd(a-b,b);
}

我们已经得到了一个“非常好”的递归算法。但求 $(2,10^{18})$ 不就爆炸了吗。

欧几里得算法 辗转相除

此时我们注意到,我们总是把一个较大的数减到小于等于较小的数为止,然后才会换减数。

这个过程像极了爱情除法。我们用整数除法把较大的数 $a$ 除以较小的数 $b$,如果能整除说明较小的数$b = (a,b)$,否则我们会从 $a$ 里减掉最多次数的 $b$,即从 $a$ 中减掉 $\left\lfloor \frac{a}{b} \right\rfloor $ 次 $b$,得到

$$a' = a - \left\lfloor \frac{a}{b} \right\rfloor \cdot b$$

而这个结果相当于 $a$ 对 $b$ 取模(取余)。

int gcd(int a,int b)
{
    if(a<b) swap(a,b);
    if(a%b==0) return b;
    return gcd(a%b,b);
}

辗转相除的复杂度

设 $a \ge b$, 若 b|a,则已经得出答案 $T(a,b) = O(1)$

若 $b \nmid a$ 则 $a \neq b$,$a' = a \ \mathrm{mod} \ b$。由 $a > b$ 则 $a-a' > b$ 而 $a' < b$

从而得出结论 $a' < \frac{a}{2}$

也就是说每次取模操作必然使得较大的数字减半则

$$T(a,b) = \log(a) + \log(b) = \log(ab)$$

也就是说我们已经得到了对数级别的优秀算法。

但是这个界其实不是紧界,实际上我们可以通过构造序列证明一个更紧的界。

这里我们考虑让迭代次数尽可能多,这显然就是让 $a$ $b$ 为斐波那契数列的相邻两项嘛

考虑 $a, b(a \ge b)$ 一共需要 $n$ 次取模操作才能得到 $(a,b)$。

令 $A_{n+2} = a, A_{n+1} = b$

则 $A_{i} = A_{i+2} \ \mathrm{mod} \ A_{i+1} ( 0 \le i \le n)$

此时 $A_1 = (a,b) \ge 1$,$A_0 = 0$

考虑斐波那契数列 $F_0 = 0, F_1 = 1, F_i = F_{i-1} + F_{i-2} (i \ge2)$

$$F_0 \le A_0, F_1 \le A_1$$

假设 $\forall 0 \le i \le k, F_i \le A_i$ 成立则

$$F_{k+1} = F_{k}+F_{k-1} \le A_k + A_{k-1} \le A_{k+1}$$

于是对于 $b = A_{n+1} \ge F_{n+1}$

换句话说如果较小的数 $b < F_{n+1}$ 则需要的次数将不会超过 $n$ 次。(这里可以采用反证法,考虑存在一个 $b<F_{n+1}$ 且需要次数大于 $m>n$ 的情况,此时我们知道对于 $m$ 次操作而言,$A_{m+1} \ge F_{m+1}$ 而这与假设 $b=A_{m+1} < F_{m+1}$ 相违背)

我们知道对于 $F_{n+1}=N$,$n$ 是 $O(\log(N))$ 的因此对于辗转相除而言复杂度是 $O(\log(\min(a,b)))$ 的。

关于 % 运算

首先我们可以发现,当我们运行

return gcd(a%b,b)

时,其实我们已经知道第一个参数不会大等第二个参数了,因此我们可以考虑将这个语句写成

return gcd(b,a%b)

来尽量减少 swap 操作。

其实

if(a<b) swap(a,b)

这个语句是不必要的,因为如果如果大小关系是反的,经过一次我们修改过的取模操作,那么就会得到正确的大小关系。

注意到如果已经得到 $b|a$ 的一组输入了,其实我们这时知道了 $(a,b) = b$ 但是每次递归中检查

if(a%b==0) return b;

这里取模再判断的操作是一个非常昂贵的操作。如果我们对于这样的 $a,b$ 如果再多递归一次的话,我们会发现我们总会得到一组 $(b,0)$ 这样的输入,换句话说,我们可以考虑将循环终止条件改为判断

if(b==0) return a;

至此我们可以写出一个简短而优美的版本

int gcd(int a,int b)
{
    return b ? gcd(b,a%b) : a;
}

非递归

虽然我们知道对于 gcd 而言递归深度不会很深,但是递归依旧存在。此时我们可以考虑进一步优化这个递归为迭代形式。

int gcd(int a,int b)
{
    int t;
    while(b)
    {
        t=b; 
        b=a%b; 
        a=t;
    }
    return a;
}
Prev: [模板] 对拍程序
Next: [算法][数论] 扩展欧几里得算法