演算法圖鑑
Math & Number Theory · 01 / 04

GCD & LCM最大公因數

歐幾里得輾轉相除與擴展歐幾里得

用在:分數化簡、比例、週期同步

時間複雜度O(log n)
空間複雜度O(1)
難度入門
前置知識Recursion

01為什麼需要它

音訊重取樣:44.1 kHz 轉成 48 kHz

CD 音樂每秒 44100 個取樣,影片規格要 48000。重取樣器的做法是先插值放大 L 倍、再抽取縮小 M 倍,L/M 必須等於 48000/44100。直接拿 L = 48000,多相濾波器就得切成 48000 個相位。

為什麼用它比例要先約分:gcd(48000, 44100) = 300,所以 L/M = 160/147,濾波器只要 160 個相位。輾轉相除四次除法就得到 300,完全不需要把兩個數做質因數分解。

120Hz 螢幕為什麼能順暢播 24 fps 電影和 30 fps 影片

電影每秒 24 格,網路影片常是 30 格。60Hz 螢幕上 24 fps 的每一格只能輪流停 2 次、3 次更新,播放速度忽快忽慢,看起來會微微頓挫。要挑一個更新率,讓兩種格率都能整除。

為什麼用它要找的是 24 和 30 的最小公倍數:lcm(24, 30) = 24 ÷ gcd(24, 30) × 30 = 24 ÷ 6 × 30 = 120。在 120Hz 下,24 fps 每格剛好停 5 次更新,30 fps 剛好 4 次。排程裡「幾個週期什麼時候再次對齊」都是同一個算式。

產生 RSA 私鑰

教科書範例:p = 61、q = 53,φ(n) = 60 × 52 = 3120,公鑰指數 e = 17。私鑰 d 必須滿足 17 × d 除以 3120 餘 1。真正的金鑰 φ(n) 有 2048 位元,從 1 開始逐一試 d 永遠試不完。

為什麼用它3120 不是質數,不能直接套費馬小定理求反元素。擴展歐幾里得在算 gcd(17, 3120) = 1 的同時,求出 17x + 3120y = 1 的整數解,x 取模 3120 就是 d = 2753。步數只和較小那個數的位數有關,實務上 e = 65537,就算 φ(n) 有 2048 位元也只要二十次上下的除法。

看到這些關鍵字就想到它:最大公因數、最小公倍數、約分、通分、比例、週期何時重合、整除、ax + by = c 有沒有整數解、模數不是質數時的反元素、陣列所有數的公因數。

02核心概念

輾轉相除法(歐幾里得演算法)只靠一個等式:gcd(a, b) = gcd(b, a mod b)。理由是 a mod b = a − q·b:任何同時整除 a 和 b 的數,一定也整除 a − q·b;反過來,同時整除 b 和餘數 r 的數,也整除 a = q·b + r。兩組數的公因數集合完全相同,最大的那個當然也相同。每做一次,數字就變小,直到餘數為 0,而 gcd(g, 0) = g。整個過程從不需要知道 a、b 的質因數,這是它比「分解質因數再取共同部分」快得多的原因。

為什麼快:看連續兩步 (a, b) → (b, r) → (r, r′)。若 b ≤ a/2,則 r < b ≤ a/2;若 b > a/2,商只能是 1,r = a − b < a/2。所以每兩步第一個數至少減半,除法次數是 O(log min(a, b))。最壞情況是相鄰的費氏數,每次商都是 1、縮得最慢,例如 gcd(89, 55) 要 9 次除法。迴圈版只用兩個變數,空間 O(1);遞迴版要 O(log) 的呼叫堆疊。最小公倍數靠 gcd(a, b) × lcm(a, b) = a × b 得到,寫成 a / gcd × b。多個數就兩兩折疊,總共 O(n log M),M 是最大值;gcd 一旦變成 1 就可以提前停。

擴展歐幾里得在求 gcd 的同時找出整數 x、y 使 ax + by = gcd(a, b),這組解一定存在(Bézout 等式)。遞迴寫法:假設已經求出 b·x′ + (a mod b)·y′ = g,把 a mod b = a − ⌊a/b⌋·b 代進去整理,得到 x = y′y = x′ − ⌊a/b⌋·y′。兩個直接的推論:ax + by = c 有整數解若且唯若 g 整除 c;當 gcd(a, m) = 1 時,ax + my = 1 兩邊取模 m 得到 ax ≡ 1,x 就是 a 的模反元素。Modular Arithmetic 那篇用費馬小定理加快速冪求反元素,但那要求模數是質數;擴展歐幾里得只要求互質。

常見的坑:用減法版 gcd(a − b, b) 代替取餘數,gcd(10⁹, 1) 要減十億次;C++ 的 % 結果跟著被除數的正負號,負數要先取絕對值(C++17 的 std::gcd 已經處理好);lcm 寫成 a * b / gcd 會在除之前溢位,而且 a、b 有 0 時會除以 0;擴展歐幾里得算出的 x 常是負的,當反元素用之前要 (x % m + m) % m。題目裡看到分數,通常存成約分後的 (分子, 分母) 並把負號固定放在分子,就不會有浮點誤差,也能直接拿來當雜湊表的 key。

03演算法步驟

  1. 1先取絕對值。約定 gcd(a, 0) = a,所以 gcd(0, 0) = 0
  2. 2while b != 0a, b = b, a % b。迴圈結束時 a 就是最大公因數。a < b 也不必先交換,第一輪會自動換過來。
  3. 3最小公倍數:任一數為 0 就回傳 0,否則回傳 a // gcd(a, b) * b,先除再乘。多個數就從左到右折疊 gcd 或 lcm。
  4. 4擴展版:b == 0 時回傳 (a, 1, 0);否則遞迴求出 (g, x′, y′),回傳 (g, y′, x′ − (a // b)·y′)。迭代版則讓每一列維持 r = a·s + b·t,r、s、t 都用「上上列 − q × 上一列」往下算。
  5. 5應用:解 ax + by = c 先檢查 c % g == 0,有解時把 x、y 乘上 c / g。求 a 模 m 的反元素時確認 g == 1,答案是 x % m(拉回 0 到 m − 1)。

04互動示範

用 252 和 105,分成兩段。前半段是輾轉相除:左邊逐行寫出「被除數 = 商 × 除數 + 餘數」,黃色是每行的餘數,下一行它就變成除數;右邊的表是餘數序列 r 和商 q。藍色是目前這一步,三次除法後餘數變 0,上一個餘數 21 標成綠色,接著順便算出 lcm = 1260。後半段是擴展歐幾里得:表格多出 s、t 兩欄,每一列都滿足 r = 252·s + 105·t。留意 s、t 和 r 走的是同一條「上上列減掉 q 倍的上一列」規則,最後綠色那一列給出 x = −2、y = 5。

開始gcd(252, 105)
輾轉相除
還沒開始除
餘數序列
rq
0252
1105
步驟 0/10求 gcd(252, 105)。核心觀察:gcd(a, b) = gcd(b, a mod b),因為 a 和 b 的公因數一定也整除 a mod b。反覆做,直到餘數變 0。

05程式碼

gcd、lcm、擴展歐幾里得,以及用它求模反元素。Python 的擴展版用遞迴寫,和上面「代回去整理係數」的推導一一對應;C++ 用迭代寫,就是示範裡的 r、s、t 表格,而且不佔呼叫堆疊。兩邊最後都示範怎麼對多個數折疊,以及標準庫內建的版本。

from functools import reduce


def gcd(a, b):
    """輾轉相除法:gcd(a, b) = gcd(b, a mod b)。O(log min(a, b))"""
    a, b = abs(a), abs(b)
    while b:
        a, b = b, a % b                  # 大的換成餘數,直到餘數為 0
    return a


def lcm(a, b):
    """最小公倍數:先除再乘,中間值不會超過答案"""
    if a == 0 or b == 0:
        return 0
    return abs(a // gcd(a, b) * b)


def ext_gcd(a, b):
    """擴展歐幾里得(a, b >= 0):回傳 (g, x, y),滿足 a*x + b*y == g"""
    if b == 0:
        return a, 1, 0                   # a*1 + 0*0 == a
    g, x1, y1 = ext_gcd(b, a % b)        # b*x1 + (a % b)*y1 == g
    # 代入 a % b == a - (a // b)*b,整理成 a 和 b 的係數
    return g, y1, x1 - (a // b) * y1


def mod_inverse(a, m):
    """a 在模 m 下的反元素;m 不必是質數,只要 gcd(a, m) == 1"""
    g, x, _ = ext_gcd(a % m, m)
    if g != 1:
        return None                      # 不互質就沒有反元素
    return x % m                         # x 可能是負的,拉回 [0, m)


if __name__ == "__main__":
    print(gcd(48000, 44100))             # 300(44.1 kHz → 48 kHz 的比例是 160/147)
    print(lcm(24, 30))                   # 120
    print(ext_gcd(252, 105))             # (21, -2, 5):252·(−2) + 105·5 = 21
    print(mod_inverse(17, 3120))         # 2753(RSA 範例的私鑰 d)
    print(mod_inverse(6, 9))             # None(gcd(6, 9) = 3)
    print(reduce(gcd, [84, 126, 210]))   # 42:多個數就兩兩折疊
    print(reduce(lcm, [6, 8, 15]))       # 120
    # 內建:math.gcd,Python 3.9 起還有 math.lcm,兩者都接受多個參數

06練習題

  • LeetCode 1979Find Greatest Common Divisor of ArrayEasy
  • LeetCode 1071Greatest Common Divisor of Strings(字串版的輾轉相除)Easy
  • LeetCode 914X of a Kind in a Deck of Cards(所有出現次數的 gcd)Easy
  • LeetCode 592Fraction Addition and Subtraction(通分再約分)Medium
  • LeetCode 365Water and Jug Problem(Bézout:gcd 整除目標才量得出來)Medium
  • LeetCode 878Nth Magical Number(lcm 加二分答案)Hard