Rabin-Karp — rolling hash cho đa mẫu
So khớp bằng hash: tính hash cửa sổ trượt trong O(1) nhờ rolling hash. Mạnh khi tìm nhiều pattern cùng lúc; rủi ro hash collision.
TL;DR: Rabin-Karp thay việc so từng ký tự bằng so hash của cửa sổ trượt. Rolling hash cập nhật trong O(1) khi trượt một ký tự: bỏ ký tự trái, thêm ký tự phải. Khi hash khớp, mới so ký tự để xác nhận (tránh "spurious hit" — hash trùng nhưng chuỗi khác). Trung bình O(n+m); worst-case O(n·m) khi nhiều collision. Ưu thế lớn nhất: tìm k pattern cùng độ dài chỉ cần một lần quét text — hash k pattern vào Set, mỗi cửa sổ kiểm tra O(1) thay vì O(k·m).
KMP ở bài trước đảm bảo O(n+m) tất định bằng cách không bao giờ lùi con trỏ text. Rabin-Karp đặt câu hỏi khác hơn: "Thay vì so từng ký tự, tại sao không so một con số đại diện cho cả cửa sổ?" Nếu hash của cửa sổ khác hash của pattern, chắc chắn không khớp — bỏ qua mà không so ký tự nào. Nếu hash bằng nhau, xác suất cao là khớp thật, chỉ cần verify lại.
1. Ý tưởng: so hash thay vì so ký tự
Giả sử ta có hàm hash(s) trả về một số nguyên dương cho mỗi chuỗi s. Khi trượt cửa sổ dài m qua text:
- Tính
hp = hash(P)— hash của pattern, tính một lần. - Tính
hw = hash(T[0..m-1])— hash của cửa sổ đầu tiên. - Với mỗi bước trượt: nếu
hw ≠ hp→ bỏ qua (không so ký tự). Nếuhw = hp→ so ký tự để xác nhận. - Cập nhật
hwcho cửa sổ kế tiếp — đây là chỗ rolling hash phát huy.
Vấn đề: nếu mỗi bước tính lại hash(T[i..i+m-1]) từ đầu thì mất O(m), tổng O(n·m) — không khác naive. Cần cập nhật hash trong O(1) khi trượt 1 ký tự.
2. Rolling hash — cập nhật O(1)
Polynomial hash với base b và modulus q (q nguyên tố lớn):
hash(s[0..m-1]) = (s[0]·b^(m-1) + s[1]·b^(m-2) + ... + s[m-1]·b^0) mod q
Khi trượt từ cửa sổ T[i..i+m-1] sang T[i+1..i+m]:
- Bỏ ký tự trái: trừ
T[i]·b^(m-1) - Thêm ký tự phải: thêm
T[i+m]·b^0 = T[i+m] - Nhân toàn bộ với
bđể dịch chỉ số lên 1
Công thức rolling:
h_new = (h_old - T[i]·b^(m-1)) · b + T[i+m] (mod q)
Precompute b^(m-1) mod q một lần trước. Mỗi bước trượt: 1 phép trừ + 1 phép nhân + 1 phép cộng + 2 phép mod → O(1).
function rollingHashSearch(T, P):
n <- T.length
m <- P.length
b <- 31 -- base (số nguyên tố nhỏ, thường = kích thước bảng chữ cái)
q <- 1_000_000_007 -- modulus nguyên tố lớn
bm <- b^(m-1) mod q -- precompute lũy thừa cao nhất
hp <- 0 -- hash của pattern
hw <- 0 -- hash của cửa sổ đầu
-- tính hash ban đầu
for j from 0 to m - 1:
hp <- (hp * b + ord(P[j])) mod q
hw <- (hw * b + ord(T[j])) mod q
-- trượt cửa sổ
for i from 0 to n - m:
if hw = hp: -- hash khớp
if T[i..i+m-1] = P: -- verify ký tự (tránh spurious hit)
báo tìm thấy tại i
if i < n - m: -- cập nhật rolling hash
hw <- (hw - (ord(T[i]) * bm) mod q + q) * b + ord(T[i+m])
hw <- hw mod q
return
// Time: O(n+m) trung bình Space: O(1) ngoài kết quả
2.1 Trace nhỏ: T = "ABCAB", P = "CAB" (b=31, q=101)
Để minh hoạ, tính hash đơn giản hơn: hash(s) = (ord(s[0])·100 + ord(s[1])·10 + ord(s[2])) mod 101 (giả sử A=1, B=2, C=3).
| Cửa sổ | Chuỗi | Hash thô | Hash mod 101 | = hp = 312 mod 101 = 9? |
|---|---|---|---|---|
| T[0..2] | "ABC" | 1·100+2·10+3 = 123 | 123 mod 101 = 22 | ≠ |
| T[1..3] | "BCA" | 2·100+3·10+1 = 231 | 231 mod 101 = 29 | ≠ |
| T[2..4] | "CAB" | 3·100+1·10+2 = 312 | 312 mod 101 = 9 | hp = 9 ✓ → verify → khớp! |
flowchart LR
INIT(["Tính hp, hw ban đầu"]) --> LOOP{"Còn cửa sổ?"}
LOOP -->|"có"| CMP{"hw = hp?"}
CMP -->|"không"| ROLL["rolling hash O(1)"]
ROLL --> LOOP
CMP -->|"có"| VERIFY["so ký tự T[i..i+m-1] = P?"]
VERIFY -->|"khớp thật"| FOUND(["báo vị trí i"])
VERIFY -->|"spurious hit"| ROLL
FOUND --> ROLL
LOOP -->|"hết"| DONE(["kết thúc"])3. Spurious hit — hash collision và tại sao PHẢI verify
Spurious hit (va chạm giả) xảy ra khi hw = hp nhưng T[i..i+m-1] ≠ P. Hash function không phải injection — nhiều chuỗi có thể cùng hash value. Ví dụ với q = 101: chuỗi "abc" và "xyz" có thể cùng hash nếu (ord('a')·b² + ...) mod 101 = (ord('x')·b² + ...) mod 101.
Vì thế bước verify (so ký tự khi hash khớp) là bắt buộc — không thể bỏ vì sợ "chậm".
Ảnh hưởng lên độ phức tạp:
- Nếu số spurious hit ít (thực tế với q lớn, xác suất mỗi vị trí là 1/q ≈ 10^-9): tổng thời gian verify ≈ O(n/q · m) ≈ O(1) → tổng O(n+m).
- Worst-case: mọi cửa sổ đều là spurious hit → verify O(m) cho từng trong O(n) cửa sổ → O(n·m).
Worst-case này xảy ra khi chọn q quá nhỏ hoặc chuỗi đặc biệt. Trong thực tế, chọn q nguyên tố lớn (10^9+7) làm xác suất collision rất thấp.
Base b thường chọn bằng kích thước bảng chữ cái (26 cho lowercase, 128 cho ASCII). Modulus q chọn nguyên tố lớn như 10^9+7 hay 10^9+9. Dùng hai cặp (b,q) song song (double hashing) giảm xác suất collision xuống còn ~1/q² ≈ 10^-18 — đủ an toàn cho mọi ứng dụng thực tế.
4. Ưu thế đa mẫu — điểm mạnh độc đáo của Rabin-Karp
Đây là lý do chính Rabin-Karp vẫn được dùng dù KMP đảm bảo worst-case tốt hơn.
Bài toán: Cho k pattern P1, P2, ..., Pk cùng độ dài m và một text T dài n. Tìm tất cả vị trí xuất hiện của bất kỳ pattern nào.
KMP/naive cho đa mẫu: phải chạy k lần qua text → O(k·n + k·m).
Rabin-Karp cho đa mẫu:
- Tính hash của k pattern, bỏ vào một
Set H— O(k·m). - Quét text một lần: với mỗi cửa sổ, kiểm tra
hw ∈ Htrong O(1). - Chỉ verify ký tự khi có hash match → O(1) trung bình mỗi cửa sổ.
- Tổng: O(k·m + n) — không phụ thuộc k trong bước quét text.
function rabinKarpMulti(T, patterns): -- tất cả pattern cùng độ dài m
m <- patterns[0].length
b <- 31
q <- 1_000_000_007
bm <- b^(m-1) mod q
-- bước 1: hash tất cả pattern vào Set
patternHashes <- Set rỗng
for each P in patterns:
hp <- 0
for j from 0 to m - 1:
hp <- (hp * b + ord(P[j])) mod q
patternHashes.add(hp)
-- bước 2: quét text một lần
hw <- 0
for j from 0 to m - 1: -- hash cửa sổ đầu
hw <- (hw * b + ord(T[j])) mod q
results <- danh sách rỗng
for i from 0 to n - m:
if hw ∈ patternHashes: -- kiểm tra O(1) với Set/hash-table
for each P in patterns: -- verify pattern nào khớp thật
if T[i..i+m-1] = P:
thêm (i, P) vào results
if i < n - m:
hw <- ((hw - (ord(T[i]) * bm) mod q + q) * b + ord(T[i+m])) mod q
return results
// Time: O(k·m + n) trung bình Space: O(k)
So sánh ba hướng tiếp cận đa mẫu:
| Thuật toán | Preprocessing | Quét text | Tổng | Ghi chú |
|---|---|---|---|---|
| Naive × k | — | O(k·n·m) | O(k·n·m) | Kém nhất |
| KMP × k | O(k·m) | O(k·n) | O(k·(n+m)) | Tuyến tính theo k |
| Rabin-Karp | O(k·m) | O(n) | O(k·m + n) | Quét text độc lập với k |
| Aho-Corasick | O(k·m) | O(n + số kết quả) | O(k·m + n) | Tất định, không collision |
Rabin-Karp và Aho-Corasick có cùng asymptotic, nhưng Rabin-Karp đơn giản hơn nhiều để cài đặt. Aho-Corasick đảm bảo worst-case còn Rabin-Karp chỉ đảm bảo trung bình.
5. So sánh Rabin-Karp và KMP
| Tiêu chí | KMP | Rabin-Karp |
|---|---|---|
| Worst-case 1 pattern | O(n+m) tất định | O(n·m) khi nhiều collision |
| Average-case | O(n+m) | O(n+m) |
| Đa mẫu cùng độ dài | O(k·(n+m)) | O(k·m + n) |
| Cài đặt | Trung bình (failure function) | Đơn giản (hash + số học modular) |
| Con trỏ text lùi? | Không bao giờ | Không (trượt tuần tự) |
| Rủi ro | Không có | Hash collision |
KMP là lựa chọn tốt khi cần đảm bảo tuyệt đối. Rabin-Karp là lựa chọn thực dụng khi tìm đa mẫu hoặc cần triển khai nhanh.
6. Pitfall
Pitfall 1 — Bỏ bước verify khi hash khớp
-- SAI: tin vào hash, không verify
if hw = hp:
báo tìm thấy tại i -- có thể là spurious hit!
-- ĐÚNG: luôn verify bằng so ký tự
if hw = hp:
if T[i..i+m-1] = P:
báo tìm thấy tại i
Bỏ verify dẫn đến báo vị trí sai (false positive). Với q nhỏ hoặc dữ liệu đặc biệt, tỉ lệ false positive có thể rất cao. Rabin-Karp là thuật toán xác suất đúng (không báo false negative), không phải tất định — luôn phải verify.
Pitfall 2 — Quên + q trước phép mod khi trừ
-- SAI: kết quả có thể âm khi hw < T[i]*bm mod q
hw <- ((hw - ord(T[i]) * bm) * b + ord(T[i+m])) mod q
-- ĐÚNG: cộng q để đảm bảo không âm trước mod
hw <- ((hw - (ord(T[i]) * bm) mod q + q) * b + ord(T[i+m])) mod q
Trong nhiều ngôn ngữ, phép mod với số âm trả về giá trị âm (ví dụ: -3 mod 5 = -3 trong Python). Cộng thêm q trước khi mod đảm bảo kết quả luôn không âm.
Pitfall 3 — Dùng q quá nhỏ dẫn đến collision nhiều
-- SAI: q nhỏ → nhiều collision → verify thường xuyên → thực ra O(n·m)
q <- 101 -- chỉ 101 giá trị hash khác nhau!
-- ĐÚNG: q nguyên tố lớn
q <- 1_000_000_007 -- ~10^9 giá trị, xác suất collision ~10^-9/cửa sổ
Với q=101 và n=10^6 cửa sổ, trung bình ~10^4 spurious hit — verify 10^4 lần × O(m) = tốn kém. Với q=10^9+7, chỉ ~10^-3 spurious hit trung bình cho cả quá trình.
7. Liên hệ các bài khác
- KMP — failure function & matching O(n+m): Tiếp cận tất định so với xác suất của Rabin-Karp. KMP không có rủi ro collision nhưng khó mở rộng cho đa mẫu; Rabin-Karp mở rộng tự nhiên qua hash set.
- Naive string matching: Cả hai đều trượt cửa sổ qua text; điểm khác biệt là Rabin-Karp dùng hash để bỏ qua nhanh các vị trí không khớp thay vì so ký tự từng bước.
- Aho-Corasick: Giải cùng bài toán đa mẫu nhưng theo hướng automaton — xây trie + failure link, tất định O(n + số kết quả). Khi k pattern lớn và cần đảm bảo worst-case, Aho-Corasick thay thế Rabin-Karp.
- Hash function — nền tảng: Polynomial hash (Rabin fingerprint) là trường hợp đặc biệt của hash function tổng quát — nên đọc để hiểu tại sao chọn base và modulus nguyên tố.
📚 Deep Dive
Bài báo gốc:
- Rabin, M.O. (1981) — "Fingerprinting by Random Polynomials", Center for Research in Computing Technology, Harvard University. Karp và Rabin áp dụng vào string matching năm 1987: "Efficient Randomized Pattern-Matching Algorithms", IBM Journal of Research and Development 31(2).
Ứng dụng ngoài string matching:
- rsync / bsdiff: Rabin fingerprinting được dùng để tìm các đoạn dữ liệu giống nhau giữa hai phiên bản file (content-defined chunking) — cơ sở của rsync và bsdiff. Cửa sổ trượt hash xác định ranh giới chunk không phụ thuộc offset tuyệt đối.
- Plagiarism detection (Winnowing): Thuật toán Winnowing (Schleimer et al. 2003, dùng trong MOSS) lấy rolling hash của k-gram, chọn minimum hash trong mỗi cửa sổ làm "fingerprint" — về bản chất là Rabin-Karp tổng quát hoá.
- Near-duplicate detection: Kiểm tra hai tài liệu có gần giống nhau bằng cách so tập rolling hash (MinHash + Locality Sensitive Hashing).
Sách:
- Introduction to Algorithms (CLRS), §32.2 "The Rabin-Karp algorithm" — phân tích chi tiết expected runtime và điều kiện worst-case.
- Competitive Programmer's Handbook (Laaksonen), Chapter 26 "String Algorithms" — double hashing để giảm collision.
Tóm tắt
- Rabin-Karp so hash của cửa sổ trượt thay vì so từng ký tự — chỉ verify ký tự khi hash khớp.
- Rolling hash cập nhật O(1) khi trượt 1 ký tự:
h_new = (h_old - T[i]·b^(m-1))·b + T[i+m] (mod q). - Spurious hit (hash trùng, chuỗi khác) là không thể tránh hoàn toàn — bước verify bằng so ký tự là bắt buộc.
- Trung bình O(n+m); worst-case O(n·m) khi nhiều collision — chọn q nguyên tố lớn để giảm thiểu.
- Ưu thế đa mẫu: k pattern cùng độ dài m → hash vào Set, quét text một lần O(n) — tổng O(k·m + n), độc lập k trong bước quét.
- Double hashing (hai cặp b, q) giảm xác suất collision xuống ~1/q² — đủ an toàn cho ứng dụng thực tế.
Tự kiểm tra
Q1Tại sao phải có bước verify so ký tự sau khi hash khớp? Không thể tin hash hoàn toàn sao?▸
Hash function không phải injection — nhiều chuỗi khác nhau có thể cho cùng hash value (gọi là collision). Với modulus q = 10^9+7, có 10^9+7 giá trị hash khả dĩ, nhưng số chuỗi dài m là vô hạn — theo nguyên lý pigeonhole luôn có collision.
Rabin-Karp là thuật toán xác suất: nó không bao giờ bỏ sót vị trí khớp thật (no false negative), nhưng có thể báo nhầm vị trí không khớp (false positive = spurious hit). Bước verify loại bỏ false positive — không thể bỏ mà không làm sai thuật toán.
Q2Giải thích công thức rolling hash: h_new = (h_old - T[i]·b^(m-1))·b + T[i+m]. Mỗi phép toán làm gì?▸
Khai triển: hash của cửa sổ T[i..i+m-1] là T[i]·b^(m-1) + T[i+1]·b^(m-2) + ... + T[i+m-1]·b^0.
Trừ T[i]·b^(m-1): loại bỏ ký tự trái khỏi tổng — chỉ còn T[i+1]·b^(m-2) + ... + T[i+m-1]·b^0. Nhân b: dịch mọi số mũ lên 1 — biến T[i+1]·b^(m-2) thành T[i+1]·b^(m-1). Cộng T[i+m]: thêm ký tự phải mới vào vị trí số mũ 0. Kết quả là hash của T[i+1..i+m] — đúng cửa sổ kế tiếp.
Q3Rabin-Karp tìm k pattern cùng độ dài m trong text dài n đạt O(k·m + n) trung bình, không phải O(k·(n+m)). Vì sao?▸
Bước preprocessing: tính hash của k pattern — O(k·m). Bước quét text: mỗi cửa sổ chỉ tính rolling hash O(1) rồi kiểm tra hw ∈ patternHashes trong O(1) (hash set lookup). Quét n cửa sổ → O(n) tổng.
Khác biệt với KMP × k: KMP phải chạy matching riêng cho từng pattern — k lần quét text O(n) = O(k·n). Rabin-Karp quét text đúng một lần bất kể k, nên k không nhân vào O(n). Chi phí preprocessing tuyến tính theo k·m là không tránh được (phải đọc từng ký tự của mỗi pattern ít nhất một lần).
Q4Khi nào worst-case O(n·m) của Rabin-Karp thực sự xảy ra? Cho ví dụ cụ thể.▸
Worst-case xảy ra khi gần như mọi cửa sổ đều là spurious hit — hash khớp nhưng chuỗi khác, phải verify O(m) liên tục.
Ví dụ: chọn q = 2 (chỉ hai giá trị hash: 0 và 1). Text T = "aaa...a", pattern P = "aaa...ab". Mọi cửa sổ của T đều có hash 0 (giả sử hash chẵn = 0), pattern cũng hash 0 — mọi cửa sổ đều trigger verify, mỗi verify O(m). Tổng O(n·m). Khắc phục: chọn q nguyên tố lớn (10^9+7) để xác suất collision mỗi cửa sổ chỉ ~10^-9.
Q5So sánh Rabin-Karp và KMP cho bài toán tìm 1 pattern: khi nào chọn cái nào?▸
Chọn KMP khi cần đảm bảo worst-case tuyệt đối O(n+m) (hệ thống thời gian thực, dữ liệu nhị phân/DNA có thể trigger collision nhiều, hay môi trường cần deterministic behavior). KMP không có rủi ro gì về correctness.
Chọn Rabin-Karp khi cần cài đặt nhanh, bảng chữ cái lớn (collision ít), hoặc sắp mở rộng sang đa mẫu. Rolling hash đơn giản hơn failure function nhiều. Với q lớn (10^9+7), rủi ro thực tế gần bằng 0.
Q6Vì sao phải cộng thêm q trước khi mod trong bước cập nhật rolling hash? Không cộng thì sao?▸
Phép trừ hw - T[i]·bm mod q có thể cho kết quả âm — vì T[i]·bm mod q có thể lớn hơn hw. Trong toán học modular, kết quả âm vẫn hợp lệ nhưng nhiều ngôn ngữ lập trình trả về mod âm (C: phụ thuộc implementation; Java: đúng với Math.floorMod nhưng sai với % khi số âm).
Cộng thêm q trước khi mod đảm bảo giá trị luôn không âm: (hw - x + q) mod q cho cùng kết quả toán học nhưng không âm. Đây là pattern chuẩn trong số học modular khi có phép trừ.
Q7rsync dùng rolling hash để tìm đoạn dữ liệu giống nhau giữa hai phiên bản file. Điểm nào của Rabin-Karp làm cho nó phù hợp với bài toán này?▸
rsync cần tìm các đoạn (chunk) giống nhau giữa file cũ và file mới, kể cả khi chunk bị dịch chuyển sang vị trí offset khác — không cần biết vị trí trước. Rolling hash cho phép quét toàn bộ file mới liên tục và kiểm tra hash của từng cửa sổ có nằm trong tập hash của các chunk file cũ không (đúng bài toán đa mẫu của Rabin-Karp).
Quan trọng hơn: rolling hash là content-defined — ranh giới chunk phụ thuộc nội dung, không phụ thuộc offset. Khi thêm một ký tự vào đầu file, chunk boundary tự điều chỉnh thay vì shift toàn bộ — giảm lượng data phải truyền qua mạng.
Bài tiếp theo: Z-function — z-array và ứng dụng
Bài này có giúp bạn hiểu bản chất không?
Hỏi đáp về bài này
Chưa có câu hỏi
Có gì chưa rõ trong bài? Đặt câu hỏi đầu tiên — câu trả lời từ cộng đồng giúp bạn (và người sau).
Đặt câu hỏi đầu tiên