Nhập môn

Thuật toán Euclid tìm ước chung lớn nhất (GCD) và bội chung nhỏ nhất (LCM): C++, Python

Thuật toán Euclid là cách tìm ước chung lớn nhất (ƯCLN, viết tắt là GCD) của hai số nguyên a và b bằng cách liên tục thay cặp (a, b) bằng (b, a % b) cho tới khi số dư bằng 0. Thay vì thử chia lần lượt mất O(min(a, b)) bước, thuật toán Euclid chỉ cần tối đa vài chục phép chia lấy dư trong thời gian O(log(min(a, b))), ngay cả khi hai số đầu vào lớn tới 10¹⁸.

Ý chính
  • Công thức cốt lõi: gcd(a, b) = gcd(b, a % b) với điểm dừng gcd(a, 0) = a.
  • Độ phức tạp: O(log(min(a, b))) vì cứ sau mỗi 2 bước chia lấy dư, giá trị số chia giảm đi ít nhất một nửa.
  • Tính BCNN không tràn số: Luôn viết lcm(a, b) = (a / gcd(a, b)) * b (chia trước, nhân sau) để tránh tràn kiểu long long 64-bit trong C++.
  • Thuật toán Euclid mở rộng: Tìm đồng thời cặp số nguyên (x, y) thỏa mãn đẳng thức Bézout a * x + b * y = gcd(a, b).

Ước chung lớn nhất (GCD) và Bội chung nhỏ nhất (LCM) là gì?

Với hai số nguyên không âm a và b (không đồng thời bằng 0):

  • Ước chung lớn nhất — ƯCLN (gcd(a, b)): Là số nguyên dương lớn nhất mà cả a và b đều chia hết. Ví dụ: các ước chung của 48 và 18 là {1, 2, 3, 6}, nên gcd(48, 18) = 6. Quy ước gcd(a, 0) = gcd(0, a) = a.
  • Bội chung nhỏ nhất — BCNN (lcm(a, b)): Là số nguyên dương nhỏ nhất chia hết cho cả a và b. Ví dụ: lcm(48, 18) = 144.

Nếu duyệt ngược d từ min(a, b) về 1 để tìm số đầu tiên mà cả a và b cùng chia hết, bạn sẽ mất O(min(a, b)) phép chia. Khi a, b ≈ 10¹², vòng lặp này phải chạy hàng nghìn tỷ bước và bị quá thời gian (TLE). Thuật toán Euclid rút ngắn công việc đó xuống chỉ còn vài chục bước.

Ý tưởng trực quan qua bài toán lát gạch hình chữ nhật

Vì sao gcd(a, b) luôn bằng gcd(b, a % b)? Hãy hình dung một căn phòng hình chữ nhật kích thước 48 × 18 mét và bạn muốn lát kín sàn bằng các viên gạch hình vuông cỡ d × d sao cho cạnh d là lớn nhất có thể:

  • Để lát kín cạnh ngắn 18 và cạnh dài 48, cạnh viên gạch d phải là ước chung của 48 và 18. Vậy cạnh viên gạch vuông lớn nhất chính là gcd(48, 18).
  • Từ hình chữ nhật 48 × 18, ta cắt ra tối đa các hình vuông lớn cỡ 18 × 18. Vì 48 = 18 × 2 + 12, ta cắt được 2 hình vuông 18 × 18 và còn dư một hình chữ nhật nhỏ kích thước 12 × 18 (tức b × (a % b)).
  • Nhận xét: Mọi viên gạch vuông d × d lát kín cạnh 18 thì đương nhiên lát kín hai hình vuông 18 × 18 vừa cắt ra. Do đó, viên gạch d × d lát kín căn phòng 48 × 18 khi và chỉ khi nó lát kín phần còn dư 18 × 12.
Hình chữ nhật 48 × 18 = (18 + 18 + 12) × 18 18 × 18 Bước 1 (ô 1) 18 × 18 Bước 1 (ô 2) 12 × 12 Bước 2 2 ô 6×6 1) 48 = 18×2 + 12 → gcd(18, 12) 2) 18 = 12×1 + 6 → gcd(12, 6) 3) 12 = 6×2 + 0 → ƯCLN = 6 Viên gạch vuông 6 × 6 lát kín phần dư 12 × 6 nên cũng lát kín toàn bộ hình 48 × 18
Lát gạch hình chữ nhật 48 × 18 18 × 18 Bước 1 18 × 18 Bước 1 12 × 12 Bước 2 2 ô 6×6 Bước 1: 48 = 18×2 + 12 → gcd(18, 12) Bước 2: 18 = 12×1 + 6 → gcd(12, 6) Bước 3: 12 = 6×2 + 0 → ƯCLN = 6 Cạnh gạch vuông lớn nhất lát kín là 6
Hình 1. Lát gạch hình chữ nhật 48 × 18: cắt 2 ô 18 × 18 còn dư 18 × 12; cắt tiếp 1 ô 12 × 12 còn dư 12 × 6; cuối cùng vừa khít 2 ô vuông 6 × 6.

Lặp lại quy tắc cắt hình vuông theo cạnh ngắn hơn cho tới khi không còn phần dư:

Bước Hình chữ nhật (a, b) Phép chia a = b × q + r Hình vuông cắt ra Phần còn dư (b, a % b)
1 (48, 18) 48 = 18 × 2 + 12 2 ô vuông 18 × 18 (18, 12)
2 (18, 12) 18 = 12 × 1 + 6 1 ô vuông 12 × 12 (12, 6)
3 (12, 6) 12 = 6 × 2 + 0 2 ô vuông 6 × 6 (6, 0) — Dừng, kết quả = 6

Về mặt đại số, đặt a = b × q + r (với r = a % b). Nếu d là ước chung của a và b thì r = a - b × q cũng chia hết cho d; ngược lại nếu d là ước chung của b và r thì a = b × q + r cũng chia hết cho d. Tập ước chung của (a, b) và (b, r) trùng nhau nên gcd(a, b) = gcd(b, a % b).

Cách tính BCNN (LCM) chống tràn số 64-bit

Giữa ƯCLN và BCNN của hai số nguyên dương a, b luôn có đẳng thức a × b = gcd(a, b) × lcm(a, b). Tuy nhiên, viết lcm = (a * b) / gcd(a, b) rất dễ gây tràn số trong C++:

  • Với a = 10¹² và b = 1,5 × 10¹², ta có gcd(a, b) = 5 × 10¹¹ và lcm(a, b) = 3 × 10¹² (nằm gọn trong kiểu long long).
  • Nhưng nếu nhân a * b = 1,5 × 10²⁴ trước, tích trung gian vượt quá giới hạn 2⁶³ - 1 ≈ 9,22 × 10¹⁸ của long long và bị tràn số!

Vì a luôn chia hết cho gcd(a, b), quy tắc bắt buộc khi viết hàm lcm là chia trước, nhân sau: lcm(a, b) = (a / gcd(a, b)) * b

Code tìm ƯCLN, BCNN và hàm có sẵn trong C++17, Python

Ngoài việc tự viết vòng lặp while (b != 0), bạn có thể gọi hàm chuẩn có sẵn:

  • C++17 trở lên: std::gcd(a, b) và std::lcm(a, b) trong thư viện <numeric>.
  • Python 3: math.gcd(a, b) và math.lcm(a, b).
#include <iostream>
#include <numeric>

using namespace std;

// 1. Thuật toán Euclid tìm ƯCLN bằng vòng lặp trong O(log(min(a, b)))
long long gcd_euclid(long long a, long long b) {
    while (b != 0) {
        long long r = a % b;
        a = b;
        b = r;
    }
    return a;
}

// 2. Tính BCNN an toàn: chia trước nhân sau để tránh tràn số 64-bit
long long lcm_an_toan(long long a, long long b) {
    if (a == 0 || b == 0) return 0;
    return (a / gcd_euclid(a, b)) * b;
}

int main() {
    cout << gcd_euclid(48, 18) << " " << lcm_an_toan(48, 18) << "\n";

    // Với a = 10^12, b = 1.5 * 10^12, tích a * b = 1.5 * 10^24 vượt quá long long,
    // nhưng chia trước nhân sau vẫn tính đúng lcm = 3 * 10^12
    long long a = 1000000000000LL, b = 1500000000000LL;
    cout << gcd_euclid(a, b) << " " << lcm_an_toan(a, b) << "\n";

    // Đối chiếu với hàm có sẵn std::gcd và std::lcm trong <numeric> (từ C++17)
    bool khop = (std::gcd(a, b) == gcd_euclid(a, b)) && (std::lcm(a, b) == lcm_an_toan(a, b));
    cout << khop << "\n";
    return 0;
}

Chương trình in ra 6 144, 500000000000 3000000000000 (không tràn số ở 10¹²) và 1 (khớp với thư viện chuẩn).

Học cùng giảng viên

Chủ đề này nằm trong Lecture 06: Number Theory I (Lý thuyết số I) của Khóa Orange. Xem đề cương

Vì sao thuật toán Euclid có độ phức tạp O(log(min(a, b)))?

Phép chia lấy dư r = a % b không giảm cố định một nửa ở từng bước đơn lẻ như tìm kiếm nhị phân, nhưng hãy xét 2 bước liên tiếp từ (a, b) sang (b, r) với a ≥ b > 0:

  • Nếu b <= a / 2: Ngay sau 1 bước, số chia mới b đã nhỏ hơn hoặc bằng a / 2.
  • Nếu b > a / 2: Thương nguyên a / b bằng 1, nên phần dư r = a % b = a - b < a / 2.

Vậy cứ sau 2 bước chia lấy dư, giá trị các số giảm đi ít nhất một nửa, nên số phép chia tối đa không quá 2 × log₂(min(a, b)):

Giới hạn a, b Duyệt ước O(min(a, b)) Thuật toán Euclid O(log(min(a, b)))
10³ Tối đa 1.000 bước Tối đa ~15 bước
10⁹ 10⁹ bước (Quá thời gian) Tối đa ~45 bước
10¹⁸ Không thể chạy nổi Tối đa ~90 bước

Trường hợp chạy nhiều bước nhất là khi a, b là hai số Fibonacci liên tiếp (do thương luôn bằng 1), nhưng ngay cả khi a, b ≤ 10¹⁸ cũng chỉ tốn tối đa 87 phép chia.

Thuật toán Euclid mở rộng (Extended Euclidean Algorithm)

Theo định lý Bézout, với mọi cặp số nguyên a, b, luôn tồn tại cặp số nguyên (x, y) sao cho a × x + b × y = gcd(a, b). Thuật toán Euclid mở rộng tìm đồng thời cả g = gcd(a, b) lẫn (x, y):

  1. Điểm dừng (b == 0): gcd(a, 0) = a, phương trình a × x + 0 × y = a có nghiệm x = 1, y = 0.
  2. Bước quy nạp: Giả sử gọi đệ quy cho (b, a % b) trả về (x₁, y₁) thỏa mãn b × x₁ + (a % b) × y₁ = g. Thay a % b = a - (a / b) × b vào và nhóm theo a, b: a × y₁ + b × (x₁ - (a / b) × y₁) = g Suy ra công thức cập nhật ngược từ dưới lên: x = y₁ và y = x₁ - (a / b) × y₁.

Bảng chạy từng bước của ext_gcd(30, 12) cho phương trình 30x + 12y = 6:

Tầng đệ quy (a, b) Thương a // b Nghiệm con (x₁, y₁) Công thức tính (x, y) Kiểm tra a*x + b*y
Đáy (6, 0) — Điểm dừng x = 1, y = 0 6×1 + 0×0 = 6
Tầng (12, 6) 2 (1, 0) x = 0, y = 1 - 2×0 = 1 12×0 + 6×1 = 6
Đỉnh (30, 12) 2 (0, 1) x = 1, y = 0 - 2×1 = -2 30×1 + 12×(-2) = 6

Ứng dụng tìm nghịch đảo modulo tổng quát: Để tìm x sao cho (a × x) % m == 1, ta giải a × x + m × y = 1 bằng Euclid mở rộng (khi gcd(a, m) == 1). Khác với định lý Fermat nhỏ dùng lũy thừa nhị phân chỉ chạy được khi m nguyên tố, Euclid mở rộng áp dụng được cho mọi số m bất kỳ nguyên tố cùng nhau với a.

#include <iostream>
#include <tuple>

using namespace std;

// Thuật toán Euclid mở rộng: tìm (g, x, y) sao cho a * x + b * y = g = gcd(a, b)
tuple<long long, long long, long long> ext_gcd(long long a, long long b) {
    if (b == 0) {
        return {a, 1, 0};
    }
    auto [g, x1, y1] = ext_gcd(b, a % b);
    long long x = y1;
    long long y = x1 - (a / b) * y1;
    return {g, x, y};
}

int main() {
    auto [g1, x1, y1] = ext_gcd(30, 12);
    // 30 * 1 + 12 * (-2) = 6
    cout << g1 << " " << x1 << " " << y1 << "\n";

    auto [g2, x2, y2] = ext_gcd(17, 100);
    // 17 * (-47) + 100 * 8 = 1 -> nghịch đảo modulo 100 của 17 là (-47 % 100 + 100) % 100 = 53
    long long nghich_dao = (x2 % 100 + 100) % 100;
    cout << g2 << " " << x2 << " " << y2 << " " << nghich_dao << "\n";
    return 0;
}

Chương trình in ra 6 1 -2 (30×1 + 12×(-2) = 6) và 1 -47 8 53 (nghịch đảo của 17 theo modulo 100 là 53, vì 17 × 53 = 901 ≡ 1 (mod 100)).

Lỗi hay gặp

Cẩn thận
  • Tràn số khi tính (a * b) / gcd(a, b): Luôn chia trước nhân sau: (a / gcd(a, b)) * b.
  • Truyền số âm vào hàm gcd tự viết trong C++: Phép % với số âm trong C++ có thể ra kết quả âm. Hãy lấy std::abs hoặc dùng std::gcd của C++17.
  • Quên chuẩn hóa nghiệm âm khi tìm nghịch đảo modulo: Nghiệm x của Euclid mở rộng có thể âm (-47); trong C++ cần viết (x % m + m) % m để đưa về [0, m - 1].

Luyện ngay trên Big-O Coding

Thực hành ngay thuật toán Euclid tìm ƯCLN, BCNN qua các bài tập dưới đây trên Big-O Coding:

Câu hỏi thường gặp

Nếu truyền a < b vào hàm gcd(a, b) thì có cần đổi chỗ hai số trước không?

Không cần. Khi a < b (như gcd(18, 48)), ngay ở bước đầu tiên 18 % 48 = 18, nên cặp (18, 48) tự động đổi chỗ thành (48, 18).

Làm sao tìm ƯCLN và BCNN của một mảng gồm n số?

Khởi tạo g = a[0], sau đó duyệt i từ 1 đến n - 1 và cập nhật g = gcd(g, a[i]) (tương tự l = lcm(l, a[i]) cho BCNN).

Hai số nguyên tố cùng nhau (coprime) là gì?

Là hai số nguyên có ước chung lớn nhất bằng 1 (gcd(a, b) == 1), ví dụ 8 và 15 đều là hợp số nhưng nguyên tố cùng nhau vì gcd(8, 15) = 1.

Muốn học bài bản cùng giảng viên?

Khóa Orange: làm chủ thuật toán nâng cao như Graph, DP tối ưu, Segment Tree, HLD để giải bài Hard. 16 buổi online, C++/Java/Python, học thử buổi 1 miễn phí.

Xem Khóa Orange

Nguồn tham khảo

Chia sẻ: Facebook LinkedIn
Nhắn Zalo tư vấnZalo Gọi 0937 401 483