Z-function — z-array và ứng dụng
Z-array[i] = độ dài tiền tố chung dài nhất của chuỗi với hậu tố bắt đầu tại i. Tính O(n) bằng z-box; ứng dụng matching và đếm lặp.
TL;DR: Z-function tính mảng z[i] — độ dài đoạn bắt đầu tại i trùng khớp với tiền tố của chuỗi. Thuật toán O(n) dùng z-box [l, r] để tái dùng kết quả đã tính: nếu i nằm trong box hiện tại, lấy z[i-l] làm điểm khởi đầu thay vì so từ đầu. Ứng dụng nổi bật: ghép P + '#' + T rồi tìm vị trí z[i] = |P| để phát hiện mọi lần khớp trong O(n+m); tìm chu kỳ ngắn nhất của chuỗi. Z-function và failure function của KMP là hai mặt của cùng ý tưởng prefix — và có thể chuyển đổi cho nhau.
Ở bài trước bạn thấy Rabin-Karp dùng rolling hash để bỏ qua so sánh ký tự. Bài này quay lại hướng "prefix thông minh" như KMP, nhưng thay vì hỏi "hậu tố nào trùng tiền tố?" (lps), Z-function hỏi trực tiếp hơn: "đứng tại vị trí i, đoạn dài bao nhiêu bắt đầu từ đây trùng khớp với phần đầu chuỗi?"
1. Định nghĩa z-array
Cho chuỗi S độ dài n. Z-array là mảng z[0..n-1] với:
z[i]= độ dài của đoạn chung dài nhất giữaS(tính từ đầu) vàS[i..](hậu tố bắt đầu tạii).
Nói cách khác: z[i] là số ký tự lớn nhất sao cho S[0..z[i]-1] = S[i..i+z[i]-1].
Quy ước: z[0] thường bỏ qua hoặc đặt bằng 0 (tránh tầm thường — toàn bộ chuỗi luôn trùng chính nó).
Ví dụ S = "AABAAB" (tiền tố là "AAB..."):
| i | S[i..] | Trùng tiền tố bao nhiêu? | z[i] |
|---|---|---|---|
| 0 | AABAAB | (quy ước bỏ qua) | 0 |
| 1 | ABAAB | A (1: A=A, rồi B≠A) | 1 |
| 2 | BAAB | (không: B≠A) | 0 |
| 3 | AAB | AAB (3: AAB=AAB, hết hậu tố) | 3 |
| 4 | AB | A (1: A=A, rồi B≠A) | 1 |
| 5 | B | (không: B≠A) | 0 |
Vậy z = [0, 1, 0, 3, 1, 0].
Nhận xét trực giác: z[i] > 0 nghĩa là "có một đoạn bắt đầu tại i mà hình dạng giống hệt phần đầu chuỗi" — chính là gợi ý để tìm pattern matching.
2. Tính O(n) bằng z-box
2.1 Ý tưởng z-box
Z-box [l, r] là đoạn [l, r-1] xa nhất bên phải mà ta đã biết là trùng với tiền tố S[0..r-l-1]. Tức là: S[l..r-1] = S[0..r-l-1] và r là lớn nhất tại thời điểm đang xét.
Khi tính z[i]:
- Nếu
i >= r: không có thông tin, so sánh thủ công từ đầu, cập nhật[l, r]nếurmới lớn hơn. - Nếu
i < r(nằm trong box): biếtS[i..r-1] = S[i-l..r-l-1]. Gọik = i - l, ta đã cóz[k].- Nếu
z[k] < r - i: đoạn trùng nằm gọn trong box →z[i] = z[k](không cần so thêm). - Nếu
z[k] >= r - i: đoạn trùng chạm hoặc vượt biênr→z[i]ít nhất làr - i, nhưng có thể dài hơn → so tiếp từS[r]vàS[r-l], cập nhật[l, r].
- Nếu
Nhờ hai trường hợp này, mỗi ký tự được so sánh tối đa một lần khi mở rộng box → tổng O(n).
2.2 Pseudocode
function computeZ(S):
n <- S.length
z <- mảng n phần tử, z[0] = 0
l <- 0
r <- 0 -- z-box hiện tại [l, r)
for i from 1 to n-1:
if i < r: -- i nằm trong box
k <- i - l
if z[k] < r - i: -- trùng gọn trong box
z[i] <- z[k]
continue -- không cần so thêm
else: -- chạm biên, cần mở rộng
z[i] <- r - i -- ít nhất r-i ký tự trùng
else:
z[i] <- 0 -- ngoài box, bắt đầu từ 0
-- mở rộng thủ công từ z[i] ký tự đã biết
while i + z[i] < n and S[z[i]] = S[i + z[i]]:
z[i] <- z[i] + 1
-- cập nhật box nếu đạt xa hơn bên phải
if i + z[i] > r:
l <- i
r <- i + z[i]
return z
// Time: O(n) Space: O(n)
2.3 Trace S = "AABAAB"
Khởi đầu: l=0, r=0, z=[0,0,0,0,0,0]. Ký hiệu [l,r) — biên phải r là exclusive.
| i | l,r trước | Xử lý | z[i] | l,r sau |
|---|---|---|---|---|
| 1 | 0,0 | i >= r → ngoài box, so thủ công: S[0]=A khớp S[1]=A; rồi S[1]=A lệch S[2]=B | 1 | 1,2 |
| 2 | 1,2 | i=2 >= r=2 → ngoài box: S[0]=A lệch S[2]=B ngay | 0 | 1,2 |
| 3 | 1,2 | i=3 >= r=2 → ngoài box: A=A, A=A, B=B, hết hậu tố → mở rộng được 3 | 3 | 3,6 |
| 4 | 3,6 | i=4 < r=6 → trong box, k=i-l=1, z[1]=1; r-i=2; vì z[k]=1 < 2 → lấy luôn z[4]=z[1]=1, không so thêm | 1 | 3,6 |
| 5 | 3,6 | i=5 < r=6 → trong box, k=2, z[2]=0; r-i=1; vì 0 < 1 → z[5]=z[2]=0 | 0 | 3,6 |
Kết quả z = [0, 1, 0, 3, 1, 0] — khớp bảng định nghĩa ở mục 1.
Để ý hai bước cuối: tại i=4 và i=5, con trỏ nằm trong box [3,6) mới mở (do z[3]=3). Ta tái dùng ngay z[k] mà không so sánh ký tự nào — đây chính là chỗ z-box tiết kiệm so với so từ đầu.
Khi trace bằng tay, hãy viết chuỗi S và gạch chân đoạn [l,r) sau mỗi bước. Đoạn gạch chân luôn khớp với tiền tố cùng độ dài — đó chính là thông tin ta tái dùng khi i < r.
3. Ứng dụng: pattern matching O(n+m)
3.1 Ý tưởng ghép chuỗi
Để tìm pattern P trong text T, ghép chúng thành chuỗi mới:
S = P + '#' + T
Ký tự '#' (separator) không xuất hiện trong cả P lẫn T — đảm bảo z-value tại vị trí trong T không vượt quá |P|.
Sau khi tính z cho S:
- Với mọi vị trí
itrong phầnTcủaS(tứci >= |P| + 1), nếuz[i] = |P|thìPxuất hiện tại vị tríi - |P| - 1trongT.
3.2 Pseudocode
function ZSearch(T, P):
m <- P.length
S <- P + '#' + T -- nối chuỗi với separator
z <- computeZ(S)
matches <- danh sách rỗng
for i from m+1 to S.length-1:
if z[i] = m: -- khớp hoàn toàn pattern
matches.append(i - m - 1) -- vị trí trong T
return matches
// Time: O(n+m) Space: O(n+m)
Ví dụ: P = "AB", T = "ABABC", S = "AB#ABABC".
z của S:
z[0]=0(quy ước)z[1]=0(B≠A)z[2]=0(#≠A)z[3]=2(AB=AB) → khớp tạiT[0]z[4]=0(B≠A)z[5]=2(AB=AB) → khớp tạiT[2]z[6]=0(B≠A)z[7]=0(C≠A)
Pattern "AB" xuất hiện tại vị trí 0 và 2 trong T — đúng.
flowchart LR
subgraph S["S = P + '#' + T"]
P1["P[0]"] --- P2["P[1]"] --- SEP["'#'"] --- T0["T[0]"] --- T1["T[1]"] --- T2["T[2]"] --- T3["T[3]"] --- T4["T[4]"]
end
T0 -->|"z = dài P"| MATCH1["Khớp tại T[0]"]
T2 -->|"z = dài P"| MATCH2["Khớp tại T[2]"]3.3 Tại sao separator cần thiết?
Không có #, khi tính z[|P|] (ngay đầu phần T), box có thể mở rộng vào trong P → z[i] có thể vượt |P| và vị trí báo là "khớp" dù text chỉ khớp một phần P. Separator cắt đứt khả năng đó: z[i] tại phần T không bao giờ vượt |P| vì sẽ gặp # trước.
4. Ứng dụng: tìm chu kỳ ngắn nhất
Chuỗi S có chu kỳ (period) p nếu S[i] = S[i - p] với mọi i >= p — tức dịch chuỗi sang phải p bước thì phần chồng lấn trùng khớp. Lưu ý: định nghĩa này cho phép bản lặp cuối dở dang (ví dụ "ABCAB" có chu kỳ 3 vì S[3]=S[0], S[4]=S[1], dù "AB" cuối chưa đủ một "ABC"). Chu kỳ ngắn nhất liên quan trực tiếp đến z-array:
plà một chu kỳ củaSkhi và chỉ khiz[p] >= n - p— đoạn từpđến cuối (dàin-p) trùng với tiền tố.
Vậy chu kỳ ngắn nhất là p nhỏ nhất thoả z[p] >= n - p.
z[p] >= n - p cho ta chu kỳ (cho phép đuôi dở). Nếu cần chuỗi lặp nguyên một khối (như "ABCABC" = "ABC" lặp đúng 2 lần, không dư), phải thêm điều kiện n mod p = 0. "ABCAB" có chu kỳ 3 nhưng KHÔNG lặp nguyên (5 không chia hết cho 3).
function shortestPeriod(S):
n <- S.length
z <- computeZ(S)
for p from 1 to n-1:
if z[p] >= n - p: -- đoạn từ p đến cuối trùng tiền tố
return p -- p là chu kỳ ngắn nhất
return n -- chuỗi không lặp — chu kỳ = chính nó
// Time: O(n) Space: O(n)
Ví dụ: S = "ABCABC", n=6. Tính z:
| i | z[i] |
|---|---|
| 1 | 0 |
| 2 | 0 |
| 3 | 3 |
| 4 | 0 |
| 5 | 0 |
z[3]=3 >= 6-3=3 → p=3 là chu kỳ → "ABC" lặp 2 lần. Đúng.
flowchart LR
S0["A"] --- S1["B"] --- S2["C"] --- S3["A"] --- S4["B"] --- S5["C"]
S3 -.->|"z[3]=3, khớp tiền tố"| S0
S3 -.->|"p=3 là chu kỳ"| PER["Chu kỳ ngắn nhất = 3"]5. So sánh Z-function và Failure Function (KMP)
Hai cấu trúc giải cùng lớp bài nhưng nhìn từ góc khác:
| Tiêu chí | Z-function z[i] | Failure function lps[i] |
|---|---|---|
| Câu hỏi | Đoạn từ i dài bao nhiêu trùng tiền tố? | Tiền tố của P[0..i] dài bao nhiêu cũng là hậu tố? |
| Xây | O(n) bằng z-box | O(m) bằng chính KMP trên pattern |
| Matching | Ghép P+'#'+T, tìm `z[i]= | P |
| Tính trực giác | Dễ hiểu hơn — so trực tiếp với tiền tố | Khó hơn — "tiền tố trùng hậu tố" |
| Tính tương đương | Chuyển đổi được cho nhau qua biến đổi tuyến tính (xem Gusfield Ch.1) — không phải công thức z-1 đơn giản | — |
Hai cấu trúc đều O(n) và đều giải được bài matching, period, lặp. Chọn cái nào là sở thích cá nhân — Z-function thường dễ code hơn trong thi đấu vì logic z-box tuyến tính và không có edge case "j về 0".
6. Pitfall
Pitfall 1 — Quên separator hoặc dùng separator xuất hiện trong input
-- SAI: separator '/' có thể xuất hiện trong path string
S <- P + "/" + T
-- DUNG: dùng ký tự KHÔNG xuất hiện trong cả P lẫn T
-- Với DNA (A/C/G/T): dùng '$'
-- Với số nguyên: dùng -1 (nếu lưu mảng số)
-- Với chữ cái thường: dùng '#' hoặc '$'
Nếu separator xuất hiện trong T, z[i] tại vị trí đó có thể bằng |P| dù không thực sự khớp — báo false positive.
Pitfall 2 — Nhầm biên box inclusive: dùng i <= r thay vì i < r
Box được định nghĩa nửa mở [l, r) — r là exclusive (ký tự tại r chưa chắc khớp). Nếu kiểm tra "i trong box" bằng i <= r, thì khi i = r ta sẽ tái dùng z[i-l] cho một vị trí thực ra nằm ngoài vùng đã biết → kết quả sai.
-- SAI: i = r van bi coi la "trong box"
if i <= r: -- BUG: r exclusive, i=r la NGOAI box
k <- i - l
z[i] <- min(z[k], r - i) -- r-i = 0 -> z[i]=0 sai khi le ra phai so thu cong
-- DUNG: r exclusive nen dung i < r
if i < r:
k <- i - l
if z[k] < r - i: z[i] <- z[k]
else: z[i] <- r - i -- roi mo rong thu cong tu day
Điều kiện cập nhật box if i + z[i] > r (dùng >) thì đúng — chỉ dời box khi vươn xa hơn biên phải hiện tại. Lỗi nằm ở phép kiểm tra "i có trong box không", phải là i < r vì r exclusive.
Pitfall 3 — Dùng z-array để matching nhưng tính z trên T alone thay vì P+'#'+T
-- SAI: tính z trên T, tìm vị trí z[i] = |P|
z_T <- computeZ(T)
-- Không liên quan! z_T[i] so T[i..] với T[0..], không liên quan P
-- DUNG: LUÔN nối P + separator + T
S <- P + '#' + T
z <- computeZ(S)
-- Tìm z[i] = |P| với i >= |P|+1
Z-array tính trên T đơn thuần đo "đoạn từ i trùng bao nhiêu với tiền tố của T" — hoàn toàn khác bài toán tìm P trong T.
7. Liên hệ các bài khác
- KMP — failure function: Z-function và failure function là hai mặt của ý tưởng "tiền tố thần kỳ". KMP tái dùng thông tin theo chiều "dịch j", Z-function tái dùng theo chiều "z-box trượt phải". Hai cấu trúc có thể chuyển đổi qua lại — hiểu cả hai giúp phân tích bài từ hai góc.
- Rabin-Karp: Hướng hoàn toàn khác (hash thay vì prefix structure). Rabin-Karp mạnh hơn cho bài đa mẫu khi cần tìm nhiều pattern cùng lúc (multiset hash); Z-function sạch hơn cho bài đơn pattern.
- Aho-Corasick: Nếu Z-function giải đơn pattern bằng ghép chuỗi, Aho-Corasick giải tập pattern bằng automaton trên trie. Failure link của Aho-Corasick tổng quát hoá
lpscủa KMP lên cây — không có analogue trực tiếp với z-array. - Naive matching: Z-function là nâng cấp trực tiếp — cùng bài toán "tìm P trong T" nhưng O(n+m) thay vì O(n·m), nhờ z-box tránh so sánh thừa.
📚 Deep Dive
Nguồn gốc: Z-function được đề cập rõ ràng trong Gusfield (1997) — Algorithms on Strings, Trees, and Sequences (Cambridge), chương 1, như một cách tiếp cận thay thế cho KMP. Gusfield gọi nó là "Z-algorithm" và chứng minh tính tương đương với KMP qua biến đổi tuyến tính.
Sách:
- Competitive Programmer's Handbook (Laaksonen) — mục "Z-algorithm": trình bày ngắn gọn với code template; rất phổ biến trong competitive programming vì logic đơn giản hơn KMP.
- Algorithms on Strings, Trees, and Sequences (Gusfield) — chứng minh đầy đủ và so sánh sâu với failure function, suffix array, suffix tree.
Ứng dụng thực tế:
- Tìm chu kỳ chuỗi trong bài toán nén (Lempel-Ziv): xác định đoạn lặp ngắn nhất để mã hoá.
- Kiểm tra chuỗi xoay vòng:
Slà xoay vòng củaTkhi và chỉ khiSxuất hiện trongT+T. - Bài toán "border" chuỗi (border = tiền tố cũng là hậu tố): z-array cho thấy trực tiếp tại
z[i] = n - i.
Tóm tắt
z[i]= số ký tự bắt đầu từitrùng với tiền tố củaS— định nghĩa trực quan, dễ hiểu hơnlps.- Z-box
[l, r)lưu đoạn xa nhất bên phải đã biết trùng tiền tố; tái dùngz[i-l]khii < r→ O(n) tổng thể (mỗi ký tự mở rộng box tối đa một lần). - Matching: ghép
P + '#' + T, tìmz[i] = |P|vớii >= |P|+1— separator ngăn box vượt biên pattern. - Chu kỳ ngắn nhất: tìm
pnhỏ nhất sao choz[p] >= n - p. - Z-function và failure function (KMP) tương đương về sức mạnh — Z-function thường dễ code hơn trong thi đấu.
- Pitfall thường gặp: separator sai, nhầm điều kiện box exclusive/inclusive, tính z trên T đơn thuần thay vì
P+'#'+T.
Tự kiểm tra
Q1Sự khác biệt cốt lõi giữa z[i] và lps[i] (KMP) là gì? Trong trường hợp nào bạn chọn Z-function thay vì KMP?▸
z[i] hỏi: "đoạn từ vị trí i dài bao nhiêu trùng với tiền tố của toàn chuỗi?" — nhìn thẳng từ bên ngoài vào. lps[i] hỏi: "trong P[0..i], tiền tố thực sự dài nhất nào cũng là hậu tố?" — nhìn vào cấu trúc nội tại của đoạn con.
Cả hai trả lời cùng lớp câu hỏi và đều O(n), nhưng Z-function thường được chọn trong thi đấu lập trình vì: (1) logic z-box tuyến tính dễ code không có edge case "j về 0"; (2) ứng dụng matching qua ghép chuỗi rất rõ ràng — không cần hai con trỏ song song. KMP được chọn khi cần streaming (không biết trước độ dài text) vì không cần lưu toàn bộ P+"#"+T.
Q2Tại sao z-box đảm bảo O(n)? Biến nào đóng vai trò 'tín dụng' trong phân tích amortized?▸
Biến r (biên phải của box) chỉ tăng, không bao giờ giảm. Mỗi bước mở rộng thủ công (vòng while) đều làm r tăng thêm ít nhất 1. Tổng số lần tăng của r bị chặn bởi n.
Trong phân tích amortized: gán "tín dụng" cho mỗi lần tăng r. Tổng tín dụng = O(n). Bước "tái dùng z[k]" không tốn tín dụng vì không mở rộng box. Bước "so thủ công" dùng tín dụng (tăng r). Tổng bước so thủ công toàn thuật toán = O(n) → tổng O(n).
Q3Tại sao separator '#' phải không xuất hiện trong cả P lẫn T? Điều gì xảy ra nếu '#' xuất hiện trong T?▸
Khi tính z trên S = P+"#"+T, mục tiêu là: z[i] ở phần T chỉ đạt tối đa |P| — vì gặp # ở vị trí |P| của S, so sánh sẽ thất bại ngay. Nhờ đó z[i] = |P| là điều kiện đủ để khẳng định khớp hoàn toàn.
Nếu # xuất hiện trong T: giả sử T[j]="#". Khi tính z[i] tại vị trí tương ứng trong S, so sánh S[|P|]="#" với T[j]="#" sẽ thành công — box mở rộng vào P. z[i] có thể vượt |P| dù không khớp P, dẫn đến false positive.
Q4Cho S = "ABCABCABC". Giá trị z[3] và z[6] là bao nhiêu? Từ đó suy ra chu kỳ ngắn nhất là gì?▸
S = "ABCABCABC", n=9.
z[3]: so S[3..]="ABCABC" với S[0..]="ABCABCABC" → A=A, B=B, C=C, A=A, B=B, C=C rồi hết S[3..] → z[3]=6.
z[6]: so S[6..]="ABC" với tiền tố → A=A, B=B, C=C → z[6]=3.
Chu kỳ: kiểm tra p=3: z[3]=6 >= 9-3=6 → đúng → chu kỳ ngắn nhất là 3 (chuỗi ABC lặp 3 lần).
Q5Trong trường hợp i < r (i nằm trong box), tại sao ta không luôn dùng z[i-l] làm kết quả cuối cùng mà phải kiểm tra thêm điều kiện z[k] < r-i?▸
Khi i < r, ta biết S[i..r-1] = S[i-l..r-l-1]. Gọi k = i - l.
Nếu z[k] < r-i: đoạn trùng của S[k..] kết thúc trước biên r-l của box. Điều này có nghĩa S[k+z[k]] != S[z[k]]. Vì S[i..] = S[k..] (trong đoạn box), S[i+z[k]] != S[z[k]] cũng vậy → không mở rộng được. Kết luận: z[i] = z[k] chính xác.
Nếu z[k] >= r-i: đoạn trùng của S[k..] chạm hoặc vượt biên box. Ta chỉ đảm bảo r-i ký tự từ i khớp — những ký tự sau r chưa biết (nằm ngoài box). Phải so tiếp thủ công từ S[r].
Q6Z-function có thể dùng để kiểm tra S có phải xoay vòng của T không (S is rotation of T). Mô tả cách làm.▸
Chuỗi S là xoay vòng của T khi và chỉ khi |S| = |T| và S xuất hiện trong T+T.
Cách làm: kiểm tra |S| = |T| trước. Nếu bằng, tạo U = S + "#" + T + T, tính z trên U. Tìm vị trí i >= |S|+1 sao cho z[i] = |S|. Nếu tìm được → S là xoay vòng của T.
Ví dụ: S="BCA", T="ABC". U="BCA#ABCABC". z[4]=0 (A≠B), z[5]=3 (BCA=BCA) → tìm thấy → S là xoay vòng của T. Thời gian O(n).
Q7Nếu z[i] = n - i với n là độ dài chuỗi S, điều đó có nghĩa gì về S[i..]?▸
z[i] = n - i nghĩa là toàn bộ hậu tố S[i..] (từ i đến cuối, độ dài n-i) trùng khớp hoàn toàn với tiền tố đầu tiên S[0..n-i-1].
Đây chính xác là định nghĩa border: một chuỗi là border của S nếu nó vừa là tiền tố vừa là hậu tố thực sự của S. Vậy S[0..n-i-1] là một border của S.
Hệ quả: có thể dùng z-array để liệt kê mọi border — đơn giản hơn dùng lps (KMP) vì z-array cho thấy trực tiếp vị trí hậu tố nào khớp tiền tố, không cần giải ngược chuỗi lps.
Bài tiếp theo: Aho-Corasick — automaton so khớp đa mẫu
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