Baby-step Giant-step (Logarithm rời rạc)
Baby-step Giant-step (BSGS) là thuật toán giải phương trình logarithm rời rạc (discrete logarithm): cho , tìm số nguyên nhỏ nhất sao cho
Cách ngây thơ là thử lần lượt và tính cho tới khi gặp ; chu kỳ của dãy lũy thừa có thể dài cỡ nên độ phức tạp là — không khả thi khi . BSGS chia bài toán thành hai nửa và dùng bảng băm để chỉ duyệt giá trị, đưa độ phức tạp xuống còn .
Điều kiện cơ bản (phần đầu bài này): . Trường hợp tổng quát được xử lý ở mục Biến thể.
Ý tưởng / Trực giác
Mọi nghiệm trong khoảng đều có thể viết dưới dạng
Vì sao tách được như vậy? Đặt thì , nên khi chạy từ đến và chạy từ đến , biểu thức phủ hết mọi giá trị trong . Mà chu kỳ của luôn (định lý Euler: khi ), nên nếu có nghiệm thì chắc chắn có một nghiệm trong khoảng này.
Thay vào phương trình và chuyển vế để tách và về hai phía:
Đây là mấu chốt: vế phải chỉ phụ thuộc , vế trái chỉ phụ thuộc . Bài toán "tìm cặp làm hai vế bằng nhau" giống hệt bài toán meet in the middle (gặp nhau ở giữa):
- Baby steps (bước nhỏ): liệt kê trước tất cả vế phải với , lưu vào bảng băm
giá_trị -> j. Mỗi bước chỉ nhân thêm một lần nên gọi là "bước nhỏ". - Giant steps (bước lớn): với mỗi , tính rồi tra bảng. Mỗi bước nhảy một lượng (tương đương phép nhân ) nên gọi là "bước lớn".
Khi tra trúng một trong bảng, ta có , suy ra ngay . Vì duyệt tăng dần nên nghiệm tìm được đầu tiên cũng là nghiệm nhỏ nhất. Tổng cộng chỉ phép tính cho mỗi pha.
Ví dụ chạy tay
Giải , tức , , .
Chọn .
Pha Baby steps — tính và lưu bảng:
j | 13 * 3^j (mod 17) | ghi vào bảng
----+-------------------+--------------
0 | 13 | {13 -> 0}
1 | 13*3 = 39 = 5 | {5 -> 1}
2 | 5*3 = 15 | {15 -> 2}
3 | 15*3 = 45 = 11 | {11 -> 3}
4 | 11*3 = 33 = 16 | {16 -> 4}
Bảng băm: {13:0, 5:1, 15:2, 11:3, 16:4}.
Pha Giant steps — đầu tiên tính . Sau đó duyệt và tính , dò trong bảng:
i | 5^i (mod 17) | có trong bảng?
----+--------------+----------------------------
1 | 5 | CÓ -> j = 1 <== dừng!
x = i*B - j = 1*5 - 1 = 4
Kiểm tra: . Đúng. Vậy .
Lưu ý cách thuật toán "gặp nhau ở giữa": vế phải đã lập sẵn ở pha baby (), còn pha giant nhảy từng bước lớn và trúng ngay giá trị — tổng cộng chỉ tra vài ô thay vì thử hết một cách tuần tự.
Cài đặt
#include <bits/stdc++.h>
using namespace std;
// Lũy thừa nhanh: a^b mod m, O(log b)
long long power(long long a, long long b, long long m) {
long long res = 1 % m;
a %= m;
for (; b > 0; b >>= 1) {
if (b & 1) res = (__int128)res * a % m;
a = (__int128)a * a % m;
}
return res;
}
// Tìm x nhỏ nhất >= 0 với a^x ≡ b (mod m), yêu cầu gcd(a, m) = 1.
// Trả về -1 nếu vô nghiệm.
long long bsgs(long long a, long long b, long long m) {
a %= m; b %= m;
if (m == 1) return 0; // mọi thứ ≡ 0 (mod 1), x = 0 thoả
if (b == 1 % m) return 0; // a^0 = 1 ≡ b
long long B = (long long)ceil(sqrt((double)m));
// Baby steps: lưu b * a^j (mod m) -> j, với j = 0..B-1.
// Dùng map để khi trùng khoá vẫn giữ j NHỎ nhất (gán đè ngược thứ tự
// không cần thiết vì giant duyệt i tăng đã đảm bảo x nhỏ nhất).
unordered_map<long long, long long> table;
table.reserve(B * 2);
long long cur = b;
for (long long j = 0; j < B; j++) {
// chỉ ghi nếu chưa có để giữ j nhỏ nhất cho cùng một giá trị
if (!table.count(cur)) table[cur] = j;
cur = (__int128)cur * a % m;
}
// Giant steps: tìm (a^B)^i ≡ b * a^j (mod m), i = 1..B.
long long aB = power(a, B, m);
cur = aB; // bắt đầu từ i = 1
for (long long i = 1; i <= B; i++) {
auto it = table.find(cur);
if (it != table.end()) {
long long x = i * B - it->second;
if (x >= 0) return x; // i tăng dần => đây là x nhỏ nhất
}
cur = (__int128)cur * aB % m;
}
return -1; // vô nghiệm
}
Lưu ý dùng __int128 cho mọi phép nhân vì có thể tới , nên tích hai số cỡ vượt quá tầm của long long (): .
Độ phức tạp
| Pha | Thời gian | Bộ nhớ |
|---|---|---|
| Baby steps | cho bảng băm | |
| Giant steps | tra cứu | — |
| Tổng |
Lý giải: ta chọn , pha baby chạy đúng vòng (mỗi vòng một phép nhân một lần ghi bảng băm trung bình), pha giant cũng chạy tối đa vòng (mỗi vòng một phép nhân một lần tra bảng trung bình). Vì nên cả hai pha đều . Bộ nhớ do bảng băm chứa tối đa cặp khoá–giá trị, tức . Phép lũy thừa thêm nhưng bị nuốt trong tổng.
⚠️ Lỗi thường gặp
- Tràn số khi nhân (overflow). Với lớn cỡ , tích
cur * acó thể đạt , vượtlong long. Triệu chứng: kết quả sai ngẫu nhiên, đúng với test nhỏ nhưng sai test lớn. Cách tránh: ép(__int128)x * y % mở mọi phép nhân modulo, kể cả trongpower. - Quên chuẩn hoá và xử lý . Nếu không kiểm tra trường hợp (nghiệm ) thì thuật toán có thể bỏ sót hoặc trả nghiệm dương lớn hơn. Luôn
a %= m; b %= m;và xét trước. Đặc biệt khi thì mọi số đều , trả về . - Áp dụng khi . Hàm
bsgscơ bản giả định khả nghịch mod . Nếu (ví dụ ), nó có thể trả sai hoặc bỏ sót nghiệm nhỏ. Triệu chứng: sai trên test có không nguyên tố cùng nhau. Cách tránh: dùng bản mở rộngbsgs_extendedở mục dưới. - Modulo âm. Nếu đầu vào hoặc âm,
a % mtrong C++ cho kết quả âm, làm hỏng bảng băm. Cách tránh: chuẩn hoáa = ((a % m) + m) % m;trước khi chạy. - Bảng băm giữ sai khi trùng khoá. Nếu hai khác nhau cho cùng giá trị , ghi đè khoá khiến nghiệm không còn nhỏ nhất. Cách tránh: chỉ ghi khi khoá chưa tồn tại (
if (!table.count(cur))) để giữ nhỏ nhất, kết hợp duyệt tăng dần. - Chọn sai biên. Lấy (làm tròn xuống) có thể khiến , bỏ sót nghiệm ở cuối chu kỳ. Luôn dùng và cho chạy tới .
Biến thể / Mở rộng
Trường hợp . Rút gọn dần phương trình: đặt , nếu thì (sau khi đã loại nghiệm nhỏ) vô nghiệm; ngược lại chia cả lẫn cho và tích luỹ thừa số , lặp tới khi rồi gọi bsgs.
// a^x ≡ b (mod m), gcd(a,m) có thể != 1
long long bsgs_extended(long long a, long long b, long long m) {
a %= m; b %= m;
long long n = 0, t = 1; // t = (a/g) tích luỹ, n = số bước đã rút
for (long long g = __gcd(a, m); g != 1; g = __gcd(a, m)) {
if (b == t) return n; // nghiệm nhỏ x = n
if (b % g != 0) return -1; // không chia hết => vô nghiệm
b /= g; m /= g;
t = (__int128)t * (a / g) % m;
n++;
if (m == 1) return n; // mọi số ≡ 0 (mod 1)
}
if (t == b % m) return n;
// Giờ gcd(a,m)=1: giải a^(x-n) ≡ b * t^{-1} (mod m), nghịch đảo bằng Fermat/Euler
// Khi m nguyên tố: t^{-1} = t^{m-2}. Tổng quát dùng nghịch đảo Euler.
long long inv_t = power(t, m - 2, m); // chỉ đúng khi m nguyên tố
long long res = bsgs(a, (__int128)b * inv_t % m, m);
return res == -1 ? -1 : res + n;
}
Lưu ý: nghịch đảo t^{m-2} chỉ đúng khi nguyên tố. Với tổng quát sau khi rút, cần nghịch đảo bằng thuật toán Euclid mở rộng vì lúc đó luôn có .
Ứng dụng — log rời rạc trong . Với nguyên tố và là căn nguyên thuỷ (primitive root), mọi viết được ; BSGS tìm . Đây là nền tảng của nhiều bài mật mã và tổ hợp số học.
Tìm chu kỳ / bậc nhân (multiplicative order). Bậc của mod là số nhỏ nhất với ; có thể tìm bằng BSGS với (loại nghiệm ).
Bài tập luyện
CTOJ hiện chưa có bài thuần BSGS/logarithm rời rạc trong pool công khai; các bài dưới đây rèn luyện đúng bộ công cụ mà BSGS được xây dựng trên đó: lũy thừa modular nhanh, bậc nhân (multiplicative order) và cấu trúc chu kỳ của ánh xạ nhân modulo. Sắp dễ → khó:
- Đường đi trong đồ thị I (graphpath1) — (Advanced) Đếm số đường đi đúng cạnh với : luyện lũy thừa nhanh (trên ma trận) — chính là phép và lặp làm lõi của BSGS.
- Đường đi trong đồ thị II (graphpath2) — (Advanced) Biến thể min-plus của bài trên với : củng cố kỹ thuật nhân lặp theo lũy thừa của số mũ lớn, tư duy "chia nhỏ số mũ" giống Baby/Giant step.
- Số học mô-đun (modarith) — (Advanced) Đếm hàm thoả : buộc phải phân tích cấu trúc chu kỳ và bậc nhân của mod — đúng tư duy log rời rạc và độ dài chu kỳ lũy thừa.
- Cấu Hình Kỳ Diệu (gymnasts) — (Expert) Đếm cấu hình tuần hoàn với , dựa trên và chu kỳ chia hết: rèn lập luận chu kỳ/ước số nguyên tố cùng nhau và lũy thừa modular trên dải — đúng quy mô mà BSGS nhắm tới.