Thuật toán Ứng dụng — DP, String, Big Data & hơn nữa
14/38
Bài 14 / 38~20 phútPattern matching & StringMiễn phí lượt xem

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:

  1. Tính hp = hash(P) — hash của pattern, tính một lần.
  2. Tính hw = hash(T[0..m-1]) — hash của cửa sổ đầu tiên.
  3. Với mỗi bước trượt: nếu hw ≠ hp → bỏ qua (không so ký tự). Nếu hw = hp → so ký tự để xác nhận.
  4. Cập nhật hw cho 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).

Plain Text
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ỗiHash thôHash mod 101= hp = 312 mod 101 = 9?
T[0..2]"ABC"1·100+2·10+3 = 123123 mod 101 = 22
T[1..3]"BCA"2·100+3·10+1 = 231231 mod 101 = 29
T[2..4]"CAB"3·100+1·10+2 = 312312 mod 101 = 9hp = 9 ✓ → verify → khớp!

Bốn bước biến hash 123 của cửa sổ ABC thành hash 231 của cửa sổ BCA: bỏ ký tự trái, nhân cơ số, thêm ký tự phải

Công thức đọc một lần thì hợp lý, nhưng vì sao nó là O(1) chứ không phải O(m) chỉ thấy khi chạy số thật. Ký tự C nằm giữa cửa sổ không bị đụng tới lần nào, dù nó có mặt trong cả hai cửa sổ — đó chính là chỗ tiết kiệm.

Thử ngẫmnếu cửa sổ dài m tăng từ 10 lên 10.000 ký tự, công thức rolling hash ở trên có chậm đi theo m không?

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""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.

Chọn base và modulus

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:

  1. Tính hash của k pattern, bỏ vào một Set H — O(k·m).
  2. Quét text một lần: với mỗi cửa sổ, kiểm tra hw ∈ H trong O(1).
  3. Chỉ verify ký tự khi có hash match → O(1) trung bình mỗi cửa sổ.
  4. Tổng: O(k·m + n) — không phụ thuộc k trong bước quét text.
Plain 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ánPreprocessingQuét textTổngGhi chú
Naive × kO(k·n·m)O(k·n·m)Kém nhất
KMP × kO(k·m)O(k·n)O(k·(n+m))Tuyến tính theo k
Rabin-KarpO(k·m)O(n)O(k·m + n)Quét text độc lập với k
Aho-CorasickO(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.

Thử ngẫmhệ thống chống spam của bạn cần chặn 50.000 từ khoá cấm cùng lúc trong mỗi tin nhắn. Bảng so sánh ở trên gợi ý thuật toán nào, và vì sao k không nhân vào chi phí quét?

5. So sánh Rabin-Karp và KMP

Tiêu chíKMPRabin-Karp
Worst-case 1 patternO(n+m) tất địnhO(n·m) khi nhiều collision
Average-caseO(n+m)O(n+m)
Đa mẫu cùng độ dàiO(k·(n+m))O(k·m + n)
Cài đặtTrung 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 roKhô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.

Thử ngẫmmột dịch vụ tài chính cấm dùng thuật toán "có thể sai xác suất" cho việc phát hiện gian lận. Rabin-Karp có đáp ứng được yêu cầu đó không, kể cả sau bước verify?

6. Pitfall

Pitfall 1 — Bỏ bước verify khi hash khớp

Plain Text
-- 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!
Plain Text
-- ĐÚ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ừ

Plain Text
-- 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
Plain Text
-- ĐÚ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

Plain Text
-- 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!
Plain Text
-- ĐÚ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

📚 Deep Dive — Rabin fingerprint & ứng dụng thực tế

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

Tự kiểm tra
0/7 câu đã trả lời
  1. Q1
    Tạ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?
  2. Q2
    Giả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ì?
  3. Q3
    Rabin-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?
  4. Q4
    Khi nào worst-case O(n·m) của Rabin-Karp thực sự xảy ra? Cho ví dụ cụ thể.
  5. Q5
    So sánh Rabin-Karp và KMP cho bài toán tìm 1 pattern: khi nào chọn cái nào?
  6. Q6
    Vì 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?
  7. Q7
    rsync 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?

Bài tiếp theo: Z-function — z-array và ứng dụng

Bài này đáng gửi cho bạn học cùng?

Copy link đã gắn nguồn — dán group, chat, hoặc LinkedIn.

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

Đặt 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

Bài tiếp theo

Z-function — z-array và ứng dụng