Z-function
Z-function (hàm Z) của xâu độ dài là mảng , trong đó là độ dài tiền tố chung dài nhất của và hậu tố . Chỉ một mảng này đủ để tìm mọi vị trí xuất hiện của một mẫu, tìm chu kỳ của xâu, đếm biên (border), so sánh một đoạn với tiền tố... Tính ngây thơ (so từng ký tự tại mỗi ) mất trên xâu như aaaa...a; thuật toán Z dùng lại thông tin cũ qua một "hộp Z" (Z-box) và chạy trong .
Ý tưởng / Trực giác
Định nghĩa
không có ý nghĩa tự nhiên (cả xâu khớp với chính nó); trang này quy ước , nhiều đề quy ước . Ví dụ:
i : 0 1 2 3 4 5 6 7
s : a b a c a b a b
z : 8 0 1 0 3 0 2 0
vì aba (bắt đầu tại ) khớp tiền tố aba, còn ký tự tiếp theo b khác c.
Hộp Z: dùng lại đoạn đã khớp
Khi duyệt từ trái sang, ta giữ một đoạn gọi là hộp Z: đoạn có lớn nhất đã tìm được mà (tức ). Hộp Z là "bản sao" của một tiền tố.
Xét mới:
- Trường hợp : nằm ngoài mọi đoạn đã biết, không có gì để dùng lại. So từng ký tự với như cách ngây thơ.
- Trường hợp : nằm trong hộp. Vì giống hệt , đoạn bắt đầu tại trong hộp giống đoạn bắt đầu tại trong tiền tố, ít nhất tới biên . Mà đã tính rồi, nên:
0 k l i r
s : [=====|=====……] [=====|=====……] |
\___________/ \___________/
tiền tố s[0..r-l-1] == hộp Z s[l..r-1]
Có hai khả năng:
- : đoạn khớp tại kết thúc trước khi chạm biên. Ký tự làm nó dừng, , cũng có bản sao nằm trong hộp: . Vậy chính xác; về mặt lý thuyết không cần so sánh ký tự nào (code bên dưới vẫn thử một phép so tại vị trí , thất bại ngay rồi dừng, và đó chính là phép thất bại duy nhất của trong lập luận đếm ở mục sau).
- : ta chỉ biết chắc (khớp tới biên). Từ trở đi là vùng chưa ai so, nên phải so tiếp từng ký tự bắt đầu tại vị trí .
Cuối mỗi bước, nếu thì hộp mới là .
Vì sao là : chỉ tăng
Đếm các phép so sánh trong vòng while:
- Mỗi phép so sánh thành công xảy ra ở vị trí (trường hợp thì bắt đầu từ ; trường hợp chạm biên thì bắt đầu đúng từ ). Sau bước đó, được cập nhật thành , nên mỗi lần khớp đẩy lên ít nhất . Vì và không bao giờ giảm, tổng số lần khớp .
- Mỗi có nhiều nhất một phép so sánh thất bại (vòng
whiledừng ngay khi gặp nó).
Tổng cộng không quá phép so sánh ký tự. Đây là cùng lập luận khấu hao với Manacher: biên phải của vùng đã biết chỉ tiến, không lùi.
Ví dụ chạy tay
abacabab, . Ban đầu .
i : 0 1 2 3 4 5 6 7
s : a b a c a b a b
(): so =a với =b: khác. . Hộp giữ nguyên.
(): a=a khớp, rồi =b với =c: khác. , hộp .
(): a với c: khác. .
(): a=a, b=b, a=a, rồi =c với =b: khác. , hộp mới .
i : 0 1 2 3 4 5 6 7
s : a b a c a b a b
[-----] [-----]
s[0..2] hộp Z s[4..6] = [4, 7): l=4, r=7
(): , , . Vì nên chính xác (vòng while chỉ làm một phép so sánh thất bại).
(): , , . Lúc này : đoạn khớp chạm biên, chỉ biết .
i : 0 1 2 3 4 5 6 7
s : a b a c a b a b
^ ^ |
k=2 i r=7 <- biết chắc s[6]=s[0], từ r trở đi phải so
So tiếp từ vị trí : =b với =b: khớp, . Hết xâu. Hộp mới , tiến từ lên .
(): , nên .
Kết quả . Tổng cộng có phép so sánh thành công và phép thất bại, dưới .
Cài đặt
C++
#include <bits/stdc++.h>
using namespace std;
// z[i] = length of the longest common prefix of s and s[i..]; z[0] = n by convention
vector<int> z_function(const string& s) {
int n = s.size();
vector<int> z(n, 0);
if (n == 0) return z;
z[0] = n;
int l = 0, r = 0; // Z-box [l, r): s[l..r-1] == s[0..r-l-1]
for (int i = 1; i < n; i++) {
if (i < r) z[i] = min(r - i, z[i - l]); // reuse the mirrored value
while (i + z[i] < n && s[z[i]] == s[i + z[i]]) z[i]++; // extend past r
if (i + z[i] > r) { l = i; r = i + z[i]; }
}
return z;
}
int main() {
string s;
cin >> s;
vector<int> z = z_function(s);
for (int x : z) cout << x << ' ';
cout << '\n';
}
Hàm chỉ dùng phép == trên phần tử nên đổi string thành vector<int> là chạy được với dãy số.
Python
def z_function(s):
n = len(s)
z = [0] * n
if n == 0:
return z
z[0] = n
l = r = 0 # Z-box [l, r)
for i in range(1, n):
if i < r:
z[i] = min(r - i, z[i - l])
while i + z[i] < n and s[z[i]] == s[i + z[i]]:
z[i] += 1
if i + z[i] > r:
l, r = i, i + z[i]
return z
print(z_function("abacabab")) # [8, 0, 1, 0, 3, 0, 2, 0]
Bản Python dùng được, nhưng vòng lặp thuần Python chậm hơn C++ nhiều lần; bài có và giới hạn thời gian chặt thì nên dùng C++.
Ứng dụng
Tìm mẫu: ghép
Để tìm mọi vị trí (độ dài ) xuất hiện trong , tính Z của xâu , với # là ký tự không có trong và . Vị trí trong (với ) ứng với vị trí trong , và xuất hiện tại đó khi và chỉ khi (thực ra ).
p = aba, t = abacababa
i : 0 1 2 3 4 5 6 7 8 9 10 11 12
S : a b a # a b a c a b a b a
z : - 0 1 0 3 0 1 0 3 0 3 0 1
^ ^ ^
z >= 3 tại i = 4, 8, 10 -> t chứa p tại 0, 4, 6
Vì sao cần ký tự phân cách:
- Chặn không vượt . Mọi đoạn khớp bắt đầu trong phải dừng trước
#của tiền tố, nên và đúng bằng độ dài tiền tố dài nhất của bắt đầu tại . Không có#thì có thể lớn hơn , mọi chỗ dùng giá trị này (không chỉ phép so ) phải tự cắtmin(z[i], m). Đây là lợi ích chính: dùng được trực tiếp làm lcp với (cho lcp/lcs, cho so sánh thứ tự). - Đoạn vắt qua ranh giới chỉ gây nhầm khi duyệt cả những nằm trong . Nếu chỉ xét các vị trí thuộc (như vòng lặp dưới đây), thì kể cả khi không có
#, điều kiện vẫn cho đúng các vị trí khớp, vì đoạn bắt đầu trong chỉ đọc tiếp trong . Nhầm lẫn chỉ xảy ra khi duyệt từ : với =aa, =ab, xâu ghép không phân cáchaaabcó nhưng nằm trong . Có#thì mọi trong đều có , nên duyệt từ cũng an toàn.
// 0-indexed starting positions of p in t
vector<int> find_occurrences(const string& p, const string& t) {
string s = p + '#' + t; // '#' must not appear in p or t
vector<int> z = z_function(s);
int m = p.size();
vector<int> pos;
for (int i = m + 1; i < (int)s.size(); i++)
if (z[i] >= m) pos.push_back(i - m - 1);
return pos;
}
Thời gian , bộ nhớ cho xâu ghép và mảng .
Chu kỳ và biên của xâu
() là chu kỳ của nếu với mọi hợp lệ. Điều này đúng khi và chỉ khi hậu tố trùng tiền tố cùng độ dài, tức
Tương tự, tiền tố độ dài là biên (vừa là tiền tố vừa là hậu tố) khi và chỉ khi . Chu kỳ và biên luôn đi thành cặp.
// all periods of s in increasing order
vector<int> all_periods(const string& s) {
int n = s.size();
vector<int> z = z_function(s);
vector<int> res;
for (int p = 1; p < n; p++)
if (p + z[p] == n) res.push_back(p);
res.push_back(n);
return res;
}
Ví dụ = abaababaab () có các chu kỳ . Chú ý: chu kỳ nhỏ nhất không nhất thiết chia hết . Xâu viết được thành () khi và chỉ khi và ; kiểm tra là đủ vì nếu có một chu kỳ , thì theo định lý Fine–Wilf (), cũng là chu kỳ, suy ra .
Đếm số xâu con phân biệt trong
Thêm lần lượt từng ký tự vào cuối xâu hiện tại . Các xâu con mới xuất hiện đều là hậu tố của . Đảo ngược thì các hậu tố đó thành tiền tố của . Một tiền tố của đã có từ trước khi và chỉ khi nó xuất hiện ở vị trí nào đó, tức độ dài . Vậy số xâu con mới là .
long long count_distinct_substrings(const string& s) {
long long total = 0;
string cur;
for (char c : s) {
cur += c;
string t(cur.rbegin(), cur.rend()); // new substrings = prefixes of t
vector<int> z = z_function(t);
int zmax = 0;
for (int i = 1; i < (int)t.size(); i++) zmax = max(zmax, z[i]);
total += (int)t.size() - zmax;
}
return total;
}
Ví dụ abab cho : thêm a được a; thêm b được b, ab; thêm a được ba, aba (còn a đã có, ); thêm b được bab, abab (còn b, ab đã có, ). Thời gian , đủ cho vài nghìn. Với lớn hơn, dùng mảng hậu tố hoặc suffix automaton. Cùng lập luận (đổi vai trò tiền tố/hậu tố) cho số xâu con phân biệt thay đổi thế nào khi thêm ký tự vào đầu, hoặc xóa một ký tự ở đầu hay cuối, mỗi thao tác .
So sánh một đoạn với tiền tố
chính là , nên với mọi ta trả lời trong :
- Đoạn bằng tiền tố độ dài khi và chỉ khi .
- Nếu không bằng, thứ tự từ điển của hai đoạn do ký tự đầu tiên khác nhau quyết định: so với .
Muốn so một đoạn của với tiền tố của thì dùng xâu ghép như trên. Muốn so hai đoạn bất kỳ (không phải tiền tố) thì Z không đủ; cần hash (tìm lcp bằng chặt nhị phân) hoặc mảng hậu tố + LCP.
Một mẹo hay dùng: tiền tố đối xứng. Tiền tố độ dài của là palindrome khi và chỉ khi nó trùng hậu tố độ dài của . Tính Z của ; tiền tố độ dài đối xứng khi và chỉ khi giá trị Z tại vị trí đạt .
Chuyển đổi giữa Z-function và hàm tiền tố
Hàm tiền tố là độ dài biên thực sự dài nhất của . Hai mảng mang cùng một lượng thông tin: cả hai đều mô tả tập các cặp (vị trí, độ dài) mà tiền tố xuất hiện, Z ghi theo điểm bắt đầu, ghi theo điểm kết thúc.
Z sang . Tiền tố độ dài xuất hiện kết thúc tại với mọi . Duyệt tăng dần; với mỗi đi từ xuống và gán nếu ô đó chưa có giá trị. Khi gặp ô đã có giá trị thì dừng: ô đó được đặt bởi một điểm bắt đầu sớm hơn (cho biên dài hơn), và các ô bên trái nó cũng vậy. Mỗi ô được gán đúng một lần nên tổng .
vector<int> z_to_pi(const vector<int>& z) {
int n = z.size();
vector<int> pi(n, 0);
for (int i = 1; i < n; i++)
for (int j = z[i] - 1; j >= 0; j--) {
if (pi[i + j] > 0) break; // already set by an earlier start (longer border)
pi[i + j] = j + 1;
}
return pi;
}
sang Z. Cách gọn nhất: dựng lại một dãy có đúng hàm tiền tố đó, rồi chạy Z trên dãy. Đặt nếu , ngược lại cho một ký hiệu mới chưa dùng (lấy luôn ). Với hợp lệ, dãy có hàm tiền tố bằng , và vì hai mảng cùng thông tin nên Z của chính là Z cần tìm.
vector<int> pi_to_z(const vector<int>& pi) {
int n = pi.size();
vector<int> t(n); // a sequence with the same prefix function
for (int i = 0; i < n; i++)
t[i] = (pi[i] > 0) ? t[pi[i] - 1] : i; // fresh symbol when there is no border
vector<int> z(n, 0);
if (n == 0) return z;
z[0] = n;
for (int i = 1, l = 0, r = 0; i < n; i++) {
if (i < r) z[i] = min(r - i, z[i - l]);
while (i + z[i] < n && t[z[i]] == t[i + z[i]]) z[i]++;
if (i + z[i] > r) { l = i; r = i + z[i]; }
}
return z;
}
Cả hai hàm đã được kiểm tra đối chiếu với cách tính trực tiếp trên hàng chục nghìn xâu ngẫu nhiên.
Khi nào chọn Z, KMP hay hash
| Tình huống | Nên dùng | Lý do |
|---|---|---|
| Đã có toàn bộ xâu, cần "khớp với tiền tố bao xa" tại mọi vị trí | Z | Đúng định nghĩa của , code ngắn, dễ nhớ |
| Tìm mẫu một lần, và cho sẵn | Z hoặc KMP | Cả hai , chọn cái quen tay |
| Xâu đến theo luồng (từng ký tự, không biết trước phần sau), hoặc cần automaton để quy hoạch động "không chứa mẫu" | KMP | chỉ phụ thuộc ; Z cần nhìn về phía phải |
| Duyệt chuỗi biên lồng nhau () | KMP | Hàm tiền tố cho sẵn cây biên |
| So sánh hai đoạn bất kỳ, lcp hai hậu tố bất kỳ, xâu 2 chiều, nhiều truy vấn | Hash | mỗi phép so, cho lcp; đổi lại có xác suất va chạm |
| Nhiều truy vấn lcp/thứ tự giữa các hậu tố, đếm xâu con phân biệt với lớn | Mảng hậu tố | Cấu trúc tổng quát hơn, đổi lại cài đặt dài hơn |
| Nhiều mẫu cùng lúc | Aho-Corasick | Z/KMP phải chạy lại cho từng mẫu |
Z và hàm tiền tố tất định (không có rủi ro va chạm như hash), nên khi bài chỉ cần so với tiền tố thì chúng an toàn hơn.
Độ phức tạp
| Thao tác | Thời gian | Bộ nhớ | Lý do |
|---|---|---|---|
| Tính mảng | lần khớp (mỗi lần đẩy tiến) cộng lần thất bại (một lần mỗi ) | ||
| Tìm mẫu trong | Một lần Z trên xâu ghép dài | ||
| Mọi chu kỳ / biên | Một lần Z, rồi kiểm tra cho mỗi | ||
| Đếm xâu con phân biệt | lần Z trên xâu dài tối đa | ||
| Z sang | Mỗi ô được gán một lần, mỗi dừng sau tối đa một lần gặp ô đã gán | ||
| sang Z | Dựng dãy rồi chạy Z |
⚠️ Lỗi thường gặp
- Quên
min(r - i, ...). Viếtz[i] = z[i - l]khi là sai: giá trị ở tiền tố có thể "lan" ra ngoài hộp, nơi hai bản sao không còn giống nhau. Với =aaaab: tại , hộp là , nhưng đáp án đúng . Triệu chứng: kết quả lớn hơn thực tế, thường chỉ lộ ra trên xâu có nhiều ký tự lặp. - Lẫn hộp nửa mở với đoạn đóng . Nhiều tài liệu dùng là vị trí cuối (đóng), khi đó công thức thành
min(r - i + 1, z[i - l])và điều kiện lài <= r. Trộn hai quy ước gây lệch một. Chọn một quy ước và giữ nguyên trong cả hàm. - Quy ước . Code ở đây đặt ; nhiều đề (và nhiều thư viện) đặt . Nếu lấy trên cả mảng mà quên bỏ thì luôn nhận được ; nếu đề yêu cầu in mà in thì sai ngay test đầu.
- Ký tự phân cách có mặt trong dữ liệu. Đề cho xâu gồm cả ký tự đặc biệt mà vẫn dùng
#thì đoạn khớp có thể đi xuyên qua ranh giới. Chọn ký tự chắc chắn không xuất hiện (ví dụ'\0'hay'$'khi đề chỉ có chữ cái), hoặc chuyển sangvector<int>và dùng phân cách . - Tưởng nhưng thực ra . Chỉ đúng tuyến tính khi dùng lại giá trị từ hộp Z. Nếu quên cập nhật hộp (
if (i + z[i] > r) { l = i; r = i + z[i]; }) hoặc bỏ nhánh dùng lạiz[i - l]thì vòngwhileso lại những ký tự đã so, và xâuaaa...avới sẽ quá thời gian. Ngược lại, đếm xâu con phân biệt bằng Z vốn là ; đừng dùng nó cho . - Quên đảo xâu khi đếm xâu con phân biệt. Xâu con mới khi thêm ký tự vào cuối là hậu tố, còn Z nói về tiền tố. Tính Z trên thay vì cho số đếm sai.
- Nhầm "có chu kỳ" với "là lũy thừa của một xâu". Mọi xâu đều có chu kỳ nhỏ nhất , nhưng chỉ viết được thành () khi và . Với
abaab, không chia hết nên xâu không phải lũy thừa. - Ghép xâu lặp lại cho nhiều mẫu. Với tới , xâu ghép và mảng
inttổng cộng khoảng MB, ổn. Nhưng ghép lại xâu trong vòng lặp nhiều truy vấn (mỗi mẫu một lần ghép với cả ) sẽ tốn thời gian; nhiều mẫu thì cân nhắc Aho-Corasick, hash hoặc mảng hậu tố.
Biến thể / Mở rộng
- Lcp của mỗi hậu tố của với . Từ ta có luôn mảng cho mọi , không chỉ các vị trí khớp trọn. Đảo cả hai xâu cho hậu tố chung dài nhất kết thúc tại mỗi vị trí. Ghép hai mảng này giải được bài "tìm mẫu cho phép sai đúng một ký tự": khớp tại với tối đa một chỗ sai khi .
- Đếm số lần xuất hiện của mọi tiền tố. Tiền tố độ dài xuất hiện tại khi . Đếm
cnt[z[i]]++rồi cộng dồn từ lớn xuống nhỏ để có số vị trí với cho mọi , tổng . - Chu kỳ vòng / xoay xâu. là một phép xoay của (cùng độ dài) khi và chỉ khi xuất hiện trong ; dùng tìm mẫu với .
- Dãy số thay cho xâu. Z chỉ cần phép so sánh bằng, nên áp dụng cho dãy số, hoặc cho dãy hiệu để tìm đoạn "cùng hình dạng" bất kể tịnh tiến.
- Thuật toán Main-Lorentz. Tìm mọi xâu con dạng (lặp liên tiếp) theo dạng nén (từng nhóm) trong bằng chia để trị, mỗi tầng dùng Z trên phần trái đảo ngược và phần phải. Số lần lặp dạng có thể tới (ví dụ
aaa...a), nên không thể liệt kê từng lần một trong ; thuật toán xuất mỗi nhóm lần lặp cùng tâm và cùng độ dài dưới dạng một khoảng vị trí, đủ để đếm tổng số lần lặp hoặc tìm lần lặp dài nhất. - Liên hệ. KMP cho cùng thông tin theo điểm kết thúc và làm được trực tuyến; hash xâu linh hoạt hơn với đoạn bất kỳ; mảng hậu tố giải các bài lcp tổng quát; Manacher dùng đúng ý tưởng "hộp + đối xứng + biên phải chỉ tiến" cho palindrome.
Bài tập luyện
Luyện kỹ thuật
- Khớp Xâu (strmatch) (Trung cấp) đếm số lần mẫu xuất hiện với : bài nhập môn cho kỹ thuật ghép và đếm .
- Tìm Chu Kỳ (periods) (Nâng cao) in mọi độ dài chu kỳ: áp dụng trực tiếp điều kiện .
- Tiền tố trùng Hậu tố (borderocc) (Kỳ cựu) tìm mọi biên (điều kiện ) rồi đếm số lần mỗi tiền tố đó xuất hiện bằng mảng đếm
cnt[z[i]]cộng dồn ngược. - Hàm Xâu (strfuncs) (Kỳ cựu) in cả Z-function và hàm tiền tố với ; chú ý quy ước của đề, và có thể lấy từ bằng phép chuyển đổi ở trên.
- Bậc đối xứng (paldeg) (Kỳ cựu) cần biết mọi tiền tố nào là palindrome: dùng Z trên (hoặc hash), sau đó bậc của tiền tố độ dài suy ra từ bậc của tiền tố độ dài .
Đề VOI
Trong các đề VOI hiện có trên hệ thống, chưa có bài nào mà lời giải chính thức dùng Z-function. Bài dưới đây chỉ dùng được Z cho một subtask, như một cách giải thay thế:
- Virus (voi17_virus) (Nâng cao) VOI 2017, chỉ subtask : đoạn cần tìm là xâu dạng . Gợi ý: cố định vị trí bắt đầu và tính Z của hậu tố ; mảng này cho biết nửa sau của một đoạn có lặp lại nửa đầu hay không với mọi độ dài cùng lúc, cho tổng cộng. Lời giải chính thức cho mọi dùng cửa sổ trượt trên mảng sai khác, không dùng Z.
Bình luận