Bài 14: Hash Xâu & Z-Algorithm¶
Tác giả: FPTOJ Team
Nội dung tham khảo từ: VNOI Wiki - Hash, Z-function
1. Hash Xâu¶
Bản chất vấn đề¶
Cho xâu \(S\) độ dài \(N\), cần so sánh nhanh hai xâu con bất kỳ hoặc tìm tất cả vị trí xuất hiện của mẫu \(P\) (độ dài \(M\)) trong \(S\).
So sánh trực tiếp từng ký tự mất \(O(NM)\) — quá chậm khi \(N, M\) lớn.
Tư duy cốt lõi¶
Polynomial Hash chuyển mỗi xâu thành một số nguyên:
Với \(B\) là cơ số (thường dùng 31 hoặc 131) và \(M\) là số nguyên tố lớn (thường \(10^9 + 7\)).
Hash tiền tố cho phép tính hash của bất kỳ đoạn con \(S[l..r]\) trong \(O(1)\):
trong đó \(H[i]\) là hash của \(i\) ký tự đầu tiên.
Phân tích tính đúng đắn¶
Hash tiền tố hoạt động như thế nào?
Tương tự prefix sum, \(H[i]\) lưu hash của \(i\) ký tự đầu. Khi cần hash đoạn \(S[l..r]\), ta "trừ đi" phần tiền tố \(S[0..l-1]\) sau khi nhân lên để cân bậc.
Ví dụ với \(S = \texttt{"abcab"}\), \(B = 31\), tính hash đoạn \([2, 4] = \texttt{"cab"}\):
| \(i\) | 0 | 1 | 2 | 3 | 4 | 5 |
|---|---|---|---|---|---|---|
| \(H[i]\) | \(0\) | \(H(\texttt{"a"})\) | \(H(\texttt{"ab"})\) | \(H(\texttt{"abc"})\) | \(H(\texttt{"abca"})\) | \(H(\texttt{"abcab"})\) |
Phần \(H[2] \cdot B^3\) chính là hash của \(\texttt{"ab"}\) được "dịch lên" bậc cao để khi trừ đi sẽ loại bỏ đúng phần đóng góp của \(S[0..1]\) trong \(H[5]\).
Tại sao có thể collision?
Với modulo \(M\), chỉ có tối đa \(M\) giá trị hash khác nhau. Theo Birthday Paradox, khi có \(n\) xâu, xác suất ít nhất một cặp trùng hash là:
| Số xâu (\(n\)) | \(M = 10^9+7\) | \(M = 10^9+9\) | \(2\) hash |
|---|---|---|---|
| \(10^3\) | \(\sim 0.00005\%\) | \(\sim 0.00005\%\) | \(\sim 0\%\) |
| \(10^5\) | \(\sim 0.5\%\) | \(\sim 0.5\%\) | \(\sim 0\%\) |
| \(10^6\) | \(\sim 39\%\) | \(\sim 39\%\) | \(\sim 5 \times 10^{-11}\%\) |
| \(10^7\) | \(\sim 99.99\%\) | \(\sim 99.99\%\) | \(\sim 5 \times 10^{-6}\%\) |
Với \(1\) hash, chỉ cần \(\sim 44{,}721\) xâu (\(\approx \sqrt{2 \times 10^9}\)) là xác suất collision đã đạt \(\sim 50\%\).
Giải pháp: Double Hash — dùng \(2\) modulo khác nhau. Chỉ khi cả hai hash đều khớp mới kết luận xâu trùng. Không gian giá trị khi đó là \(10^9 \times 10^9 = 10^{18}\), xác suất collision gần bằng \(0\).
Đánh giá độ phức tạp¶
- Thời gian: \(O(N + M)\) — tính trước hash tiền tố và lũy thừa \(B\) trong \(O(N)\), mỗi đoạn con kiểm tra \(O(1)\)
- Không gian: \(O(N)\) — mảng hash tiền tố và lũy thừa
- Double Hash: thời gian và không gian gấp đôi, vẫn \(O(N + M)\)
2. Z-Algorithm¶
Bản chất vấn đề¶
Tính mảng \(Z\) cho xâu \(S\) độ dài \(N\), trong đó \(Z[i]\) là độ dài đoạn con dài nhất bắt đầu tại vị trí \(i\) trùng với tiền tố của \(S\).
Khi kết hợp với ký tự phân tách, Z-function giải bài toán tìm xâu mẫu trong \(O(N + M)\).
Tư duy cốt lõi¶
Cài đặt trực tiếp (dễ hiểu, \(O(N^2)\) worst-case):
Tối ưu hóa: Duy trì vùng \([l, r]\) là vùng khớp xa nhất bên phải đã tìm được. Nếu \(i \leq r\), ta có thể reuse kết quả đã tính.
Phân tích tính đúng đắn¶
Minh họa tính mảng Z với \(S = \texttt{"aabaaab"}\) (độ dài \(7\)):
| \(i\) | 0 | 1 | 2 | 3 | 4 | 5 | 6 |
|---|---|---|---|---|---|---|---|
| \(S[i]\) | a |
a |
b |
a |
a |
a |
b |
| \(Z[i]\) | — | ? | ? | ? | ? | ? | ? |
Khởi tạo: \(l = 0,\ r = 0\). Ta bỏ qua \(Z[0]\) (không sử dụng).
Bước 1: \(i = 1\)
\(i = 1\) nằm ngoài vùng \([l, r] = [0, 0]\) → so sánh từ đầu.
So sánh: \(S[0] = \texttt{'a'}\) vs \(S[1] = \texttt{'a'}\) → khớp, \(S[1] = \texttt{'a'}\) vs \(S[2] = \texttt{'b'}\) → không khớp.
\(Z[1] = 1\). Cập nhật: \(l = 1,\ r = 1\).
| \(i\) | 0 | 1 | 2 | 3 | 4 | 5 | 6 |
|---|---|---|---|---|---|---|---|
| \(Z[i]\) | — | \(1\) | ? | ? | ? | ? | ? |
Bước 2: \(i = 2\)
\(i = 2\) nằm ngoài vùng \([l, r] = [1, 1]\) → so sánh từ đầu.
So sánh: \(S[0] = \texttt{'a'}\) vs \(S[2] = \texttt{'b'}\) → không khớp.
\(Z[2] = 0\). Không cập nhật \(l, r\).
| \(i\) | 0 | 1 | 2 | 3 | 4 | 5 | 6 |
|---|---|---|---|---|---|---|---|
| \(Z[i]\) | — | \(1\) | \(0\) | ? | ? | ? | ? |
Bước 3: \(i = 3\)
\(i = 3\) nằm ngoài vùng \([l, r] = [1, 1]\) → so sánh từ đầu.
So sánh: \(S[0] = \texttt{'a'}\) vs \(S[3] = \texttt{'a'}\) → khớp, \(S[1] = \texttt{'a'}\) vs \(S[4] = \texttt{'a'}\) → khớp, \(S[2] = \texttt{'b'}\) vs \(S[5] = \texttt{'a'}\) → không khớp.
\(Z[3] = 2\). Cập nhật: \(l = 3,\ r = 4\).
| \(i\) | 0 | 1 | 2 | 3 | 4 | 5 | 6 |
|---|---|---|---|---|---|---|---|
| \(Z[i]\) | — | \(1\) | \(0\) | \(2\) | ? | ? | ? |
Bước 4: \(i = 4\)
\(i = 4\) nằm trong vùng \([l, r] = [3, 4]\) → reuse.
\(Z[i - l] = Z[4 - 3] = Z[1] = 1\). Đặt \(Z[4] = \min(r - i + 1,\ Z[i - l]) = \min(1, 1) = 1\).
So sánh tiếp: \(S[1] = \texttt{'a'}\) vs \(S[5] = \texttt{'a'}\) → khớp, \(S[2] = \texttt{'b'}\) vs \(S[6] = \texttt{'b'}\) → khớp.
\(Z[4] = 3\). Cập nhật: \(l = 4,\ r = 6\).
| \(i\) | 0 | 1 | 2 | 3 | 4 | 5 | 6 |
|---|---|---|---|---|---|---|---|
| \(Z[i]\) | — | \(1\) | \(0\) | \(2\) | \(3\) | ? | ? |
Bước 5: \(i = 5\)
\(i = 5\) nằm trong vùng \([l, r] = [4, 6]\) → reuse.
\(Z[i - l] = Z[5 - 4] = Z[1] = 1\). Đặt \(Z[5] = \min(6 - 5 + 1,\ 1) = \min(2, 1) = 1\).
So sánh tiếp: \(S[1] = \texttt{'a'}\) vs \(S[6] = \texttt{'b'}\) → không khớp.
\(Z[5] = 1\). Không cập nhật \(l, r\).
| \(i\) | 0 | 1 | 2 | 3 | 4 | 5 | 6 |
|---|---|---|---|---|---|---|---|
| \(Z[i]\) | — | \(1\) | \(0\) | \(2\) | \(3\) | \(1\) | ? |
Bước 6: \(i = 6\)
\(i = 6\) nằm trong vùng \([l, r] = [4, 6]\) → reuse.
\(Z[i - l] = Z[6 - 4] = Z[2] = 0\). Đặt \(Z[6] = \min(6 - 6 + 1,\ 0) = \min(1, 0) = 0\).
So sánh trực tiếp: \(S[0] = \texttt{'a'}\) vs \(S[6] = \texttt{'b'}\) → không khớp.
\(Z[6] = 0\).
Kết quả:
| \(i\) | 0 | 1 | 2 | 3 | 4 | 5 | 6 |
|---|---|---|---|---|---|---|---|
| \(S[i]\) | a |
a |
b |
a |
a |
a |
b |
| \(Z[i]\) | — | \(1\) | \(0\) | \(2\) | \(3\) | \(1\) | \(0\) |
Tại sao reuse đúng?
Vì \(S[l..r]\) đã khớp với \(S[0..r-l]\), và \(i\) nằm trong \([l, r]\), nên:
Do đó \(Z[i] \geq Z[i-l]\), nhưng không vượt quá \(r - i + 1\) (phần đã biết khớp).
Đánh giá độ phức tạp¶
- Thời gian: \(O(N)\) cho Z-function, \(O(N + M)\) cho tìm kiếm mẫu. Mỗi lần so sánh ký tự, \(r\) tăng ít nhất \(1\), và \(r\) chỉ tăng, nên tổng số phép so sánh tối đa \(2N\).
- Không gian: \(O(N + M)\) — mảng \(Z\) trên xâu kết hợp.
Tìm kiếm xâu mẫu bằng Z-Algorithm:
Nối xâu thành \(P + \texttt{"\$"} + T\), ký tự \(\texttt{"\$"}\) không xuất hiện trong \(P\) hay \(T\). Tính mảng \(Z\) trên xâu kết hợp. Nếu \(Z[i] = |P|\) thì \(P\) xuất hiện trong \(T\) tại vị trí \(i - |P| - 1\).
3. So sánh các thuật toán tìm xâu mẫu¶
| Thuật toán | Độ phức tạp | Ưu điểm | Nhược điểm |
|---|---|---|---|
| Brute-force | \(O(NM)\) | Đơn giản nhất | Chậm với dữ liệu lớn |
| KMP | \(O(N + M)\) | Ổn định, không dùng modulo | Khó hiểu hơn |
| Hash (Rabin-Karp) | \(O(N + M)\) | Linh hoạt, dễ code | Có thể collision |
| Z-Algorithm | \(O(N + M)\) | Không cần hash, không lo collision | Cần ký tự phân tách |
4. Lưu ý và bẫy hay gặp¶
Hash collision — Sai kết quả¶
Quên \(+\) MOD khi trừ¶
Chọn BASE không tốt¶
Quên modulo khi nhân¶
Z-Algorithm: Quên ký tự phân tách¶
Hash: Ký tự 'a' có giá trị 0 hay 1?¶
Bài tập luyện tập¶
Cơ bản¶
| Bài | Nền tảng | Độ khó | Chủ đề |
|---|---|---|---|
| CSES - String Matching | CSES | ⭐⭐ | Tìm xâu bằng Hash/KMP |
| CSES - Finding Borders | CSES | ⭐⭐ | Prefix function / Z-function |
| SPOJ - NHAY | SPOJ | ⭐⭐ | KMP/Hash |
| VNOJ - SUBSTR | VNOJ | ⭐⭐ | Hash/KMP |
| VNOJ - NKTEXT | VNOJ | ⭐⭐ | Hash xâu |
Trung bình¶
| Bài | Nền tảng | Độ khó | Chủ đề |
|---|---|---|---|
| CSES - Counting Patterns | CSES | ⭐⭐⭐ | Hash + Set/Map |
| CSES - Pattern Positions | CSES | ⭐⭐⭐ | Hash + Binary Search |
| CF - MUH and Cube Walls | CF | ⭐⭐⭐ | KMP nâng cao |
| VNOJ - PALINY | VNOJ | ⭐⭐⭐ | Palindrome + Hash |
| CF - Good Substrings | CF | ⭐⭐⭐ | Hash + Set đếm substring |
| CF - Password | CF | ⭐⭐⭐ | Z-function + Hash |
Nâng cao¶
| Bài | Nền tảng | Độ khó | Chủ đề |
|---|---|---|---|
| CF - Palindrome Degree | CF | ⭐⭐⭐⭐ | Hash palindrome |
| CF - String Compression | CF | ⭐⭐⭐⭐ | Hash + KMP |
| VNOJ - QUERYSTR | VNOJ | ⭐⭐⭐⭐ | Hash + Queries |
| CF - Occurrences | CF | ⭐⭐⭐⭐ | Hash + DP |
| SPOJ - ADAPHONE | SPOJ | ⭐⭐⭐⭐ | Hash nâng cao |
Bài viết liên quan¶
Tài liệu tham khảo¶
- VNOI Wiki - Hash
- VNOI Wiki - Z-function
- CP-Algorithms - String Hashing
- CP-Algorithms - Z-function
- GeeksforGeeks - Rabin-Karp Algorithm
- YouTube - String Hashing (Errichto)
Bài tiếp theo: Deque & Sliding Window →