Thuật toán Ứng dụng — DP, String, Big Data & hơn nữa/Rabin-Karp — rolling hash cho đa mẫu
14/66
Bài 14 / 66~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).

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

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.

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

📚 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
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?

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.

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ì?

Khai triển: hash của cửa sổ T[i..i+m-1]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.

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?

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

Q4
Khi 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.

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?

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.

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?

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

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?

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

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