Chương 4·Bài 48

Z Function

Gõ một từ vào ô tìm kiếm, trình soạn thảo tô mọi chỗ nó xuất hiện. Một cách làm việc đó: ở mỗi vị trí của văn bản, hỏi đoạn bắt đầu tại đây khớp với đầu từ cần tìm được bao xa; khớp đủ cả từ thì đó là một lần xuất hiện.

Hàm Z trả lời câu hỏi “khớp với đầu xâu bao xa” cho mọi vị trí cùng lúc: z[i]z[i] là độ dài đoạn dài nhất bắt đầu tại ii trùng với một tiền tố của ss, quy ước z[0]=nz[0] = n. Cả mảng tính trong O(n)O(n) nhờ một “hộp” nhớ đoạn khớp xa nhất đã thấy, và làm được hầu hết việc của hàm tiền tố KMP (bài 21): tìm mẫu, liệt kê viền, tìm chu kỳ, đếm số lần xuất hiện của mỗi tiền tố.

Hàm Z và hộp

Duyệt ii từ 1 tới n−1n - 1, giữ hộp [l,r)[l, r): đoạn khớp với tiền tố có đầu phải rr xa nhất đã tìm được, tức s[l..r)=s[0..r−l)s[l..r) = s[0..r-l). Nếu i<ri < r, vị trí ii nằm trong hộp nên đoạn bắt đầu tại ii giống đoạn bắt đầu tại i−li - l của tiền tố, tới hết hộp: lấy sẵn z[i]=min⁡(r−i, z[i−l])z[i] = \min(r - i,\ z[i - l]). Rồi so tiếp từng ký tự từ i+z[i]i + z[i] ra ngoài. Nếu đoạn mới vượt quá rr thì dời hộp thành [i,i+z[i])[i, i + z[i]).

Khung xanh là hộp [l, r); ô cam là đoạn khớp với tiền tố bắt đầu từ i, ô xanh lá là tiền tố tương ứng.

Mỗi phép so sánh thành công làm rr tăng 1 (vì so ngoài hộp), còn mỗi ii có nhiều nhất một phép so sánh hỏng. rr không vượt nn, nên tổng số phép so sánh là O(n)O(n). Bỏ hộp đi, so lại từ đầu ở mỗi ii, thì trên xâu ngẫu nhiên vẫn nhanh vì mỗi lần chỉ khớp vài ký tự; nhưng xâu toàn một chữ lại cho O(n2)O(n^2):

Mảng z của xâu 100 nghìn chữ a, trung vị 5 lần chạy, i7-10850H, g++ -O2.so lại từ đầu ở mỗi i2007 ms · gấp 4015 lầndùng hộp [l, r)0,5 ms
Mảng z của xâu 100 nghìn chữ a, so lại từ đầu ở mỗi i và dùng hộp, đo trên i7-10850H, g++ -O2.

Hộp nhanh gấp hàng nghìn lần ở đúng loại test mà người ra đề hay cài.

C++
void zfunc(string &s, ll z[]) {
    ll n = s.size();
    z[0] = n;
    for (ll i = 1, l = 0, r = 0; i < n; i++) {
        z[i] = 0;
        if (i < r) z[i] = min(r - i, z[i - l]);
        while (i + z[i] < n && s[z[i]] == s[i + z[i]]) z[i]++;
        if (i + z[i] > r) l = i, r = i + z[i];
    }
}

Hỏi nhanh

Hộp hiện tại là [l,r)=[4,10)[l, r) = [4, 10) và z[2]=7z[2] = 7. Khi tính z[6]z[6], giá trị ban đầu trước khi so tiếp là bao nhiêu?

Ví dụDấu Vết Tiền Tố

Đề. Xâu SS dài tới 5⋅1055 \cdot 10^5. In mảng zz với z[0]=nz[0] = n.

Lời giải. Đúng hàm zfunc ở trên; z[0] = n theo quy ước của đề.

Code đầy đủ
#include <bits/stdc++.h>
using namespace std;
#define ll long long

const ll N = 5e5 + 5;
ll z[N];
string s;

void zfunc(string &s, ll z[]) {
    ll n = s.size();
    z[0] = n;
    for (ll i = 1, l = 0, r = 0; i < n; i++) {
        z[i] = 0;
        if (i < r) z[i] = min(r - i, z[i - l]);
        while (i + z[i] < n && s[z[i]] == s[i + z[i]]) z[i]++;
        if (i + z[i] > r) l = i, r = i + z[i];
    }
}

signed main() {
    ios::sync_with_stdio(false);
    cin.tie(0);

    cin >> s;
    zfunc(s, z);
    for (ll i = 0; i < (ll)s.size(); i++) cout << z[i] << ' ';
    cout << '\n';
}
abacaba

O(n)O(n).

Tìm mẫu

Muốn tìm mẫu PP trong văn bản TT, tính hàm Z của P+#+TP + \# + T với # không có trong hai xâu. Ở vị trí thuộc phần TT, zz bị chặn bởi ∣P∣|P| vì không đoạn nào vượt qua # được; z=∣P∣z = |P| đúng khi PP xuất hiện ở đó. Cả bài O(∣P∣+∣T∣)O(|P| + |T|), bộ nhớ cho xâu ghép.

Ví dụCác Vị Trí Khớp

Đề. Văn bản AA và mẫu BB, mỗi xâu dài tới 10610^6. In mọi vị trí (đánh số từ 1) mà BB bắt đầu trong AA, kể cả chồng lấn; không có thì in 0.

Lời giải. Ghép mẫu, một ký tự ngăn cách và văn bản: s=B+#+As = B + \# + A. # không có trong hai xâu, nên ở mọi vị trí thuộc phần AA, giá trị zz không vượt ∣B∣|B|; z[i]≥∣B∣z[i] \ge |B| nghĩa là BB khớp trọn tại đó. Hàm zfunc như ví dụ trước.

Bước 1: quét phần văn bản. Vị trí ii của xâu ghép ứng với vị trí i−∣B∣i - |B| của AA khi đánh số từ 1.

C++
for (ll i = m + 1; i < (ll)s.size(); i++)
    if (z[i] >= m) {
        cout << i - m << ' ';
        any = 1;
    }

Xâu ghép dài tới 2⋅106+12 \cdot 10^6 + 1, mảng z khai báo đủ.

Code đầy đủ
#include <bits/stdc++.h>
using namespace std;
#define ll long long

const ll N = 2e6 + 5;
ll z[N];
string a, b;

void zfunc(string &s, ll z[]) {
    ll n = s.size();
    z[0] = n;
    for (ll i = 1, l = 0, r = 0; i < n; i++) {
        z[i] = 0;
        if (i < r) z[i] = min(r - i, z[i - l]);
        while (i + z[i] < n && s[z[i]] == s[i + z[i]]) z[i]++;
        if (i + z[i] > r) l = i, r = i + z[i];
    }
}

signed main() {
    ios::sync_with_stdio(false);
    cin.tie(0);

    cin >> a >> b;
    string s = b + "#" + a;
    zfunc(s, z);
    ll m = b.size();
    bool any = 0;
    for (ll i = m + 1; i < (ll)s.size(); i++)
        if (z[i] >= m) {
            cout << i - m << ' ';
            any = 1;
        }
    if (!any) cout << 0;
    cout << '\n';
}
bananana
ana

O(∣A∣+∣B∣)O(|A| + |B|).

Viền và chu kỳ

Hậu tố bắt đầu tại ii là một viền (vừa là tiền tố, vừa là hậu tố) khi và chỉ khi nó khớp với tiền tố tới hết xâu: z[i]=n−iz[i] = n - i. Viền đó dài n−in - i. Một số p<np < n là chu kỳ của xâu khi sj=sj−ps_j = s_{j-p} với mọi j≥pj \ge p, tức hậu tố từ pp trùng tiền tố cùng độ dài: lại là z[p]=n−pz[p] = n - p. Vậy chu kỳ pp và viền dài n−pn - p là một (như bài 21), và nn luôn là chu kỳ. Liệt kê tất cả chỉ cần một vòng qua mảng zz, ví dụ in mọi chu kỳ theo thứ tự tăng:

C++
for (ll p = 1; p < n; p++)
    if (z[p] == n - p) cout << p << ' ';
cout << n << '\n';

Hỏi nhanh

Xâu abababa có z[2]=5z[2] = 5. Điều đó cho biết gì?

Đếm lần xuất hiện

Tiền tố dài LL xuất hiện tại vị trí ii khi và chỉ khi z[i]≥Lz[i] \ge L. Vậy số lần xuất hiện của mọi tiền tố tính một lượt: đếm cnt[v]cnt[v] là số vị trí có z[i]=vz[i] = v, rồi cộng dồn từ vv lớn xuống: số vị trí có z≥Lz \ge L. Kết hợp với điều kiện viền (z[n−L]=Lz[n - L] = L) cho các bài “tiền tố vừa là hậu tố, xuất hiện bao nhiêu lần”, “viền dài nhất còn xuất hiện ở giữa”.

Ví dụBiên Và Số Lần Xuất Hiện

Đề. Xâu dài tới 10510^5. Với mọi độ dài LL mà tiền tố dài LL cũng là hậu tố (kể cả cả xâu), in LL và số lần tiền tố đó xuất hiện, theo LL tăng dần.

Lời giải. Tiền tố dài LL xuất hiện tại ii khi và chỉ khi z[i]≥Lz[i] \ge L. Vậy số lần xuất hiện là số vị trí có z≥Lz \ge L.

Bước 1: đếm theo giá trị zz. cnt[v] là số vị trí có z=vz = v, kể cả z[0]=nz[0] = n. Cộng dồn từ vv lớn xuống thì cnt[L] là số vị trí có z≥Lz \ge L.

C++
for (ll i = 0; i < n; i++) cnt[z[i]]++;
for (ll v = n - 1; v >= 0; v--) cnt[v] += cnt[v + 1];

Bước 2: chọn các viền. Tiền tố dài LL cũng là hậu tố khi z[n - L] == L; cả xâu (L=nL = n) luôn thỏa và xuất hiện 1 lần.

C++
for (ll L = 1; L <= n; L++)
    if (L == n || z[n - L] == L) res.push_back({L, cnt[L]});
Code đầy đủ
#include <bits/stdc++.h>
using namespace std;
#define ll long long

const ll N = 1e5 + 5;
ll z[N], cnt[N];
string s;

void zfunc(string &s, ll z[]) {
    ll n = s.size();
    z[0] = n;
    for (ll i = 1, l = 0, r = 0; i < n; i++) {
        z[i] = 0;
        if (i < r) z[i] = min(r - i, z[i - l]);
        while (i + z[i] < n && s[z[i]] == s[i + z[i]]) z[i]++;
        if (i + z[i] > r) l = i, r = i + z[i];
    }
}

signed main() {
    ios::sync_with_stdio(false);
    cin.tie(0);

    cin >> s;
    ll n = s.size();
    zfunc(s, z);
    for (ll i = 0; i < n; i++) cnt[z[i]]++;
    for (ll v = n - 1; v >= 0; v--) cnt[v] += cnt[v + 1];
    vector<pair<ll, ll>> res;
    for (ll L = 1; L <= n; L++)
        if (L == n || z[n - L] == L) res.push_back({L, cnt[L]});
    cout << res.size() << '\n';
    for (pair<ll, ll> p : res) cout << p.first << ' ' << p.second << '\n';
}
ABACABA

O(n)O(n).

Ví dụMật Khẩu Ba Lần

Đề. Xâu dài tới 10610^6. Tìm xâu dài nhất vừa là tiền tố, vừa là hậu tố, và còn xuất hiện ở một vị trí bắt đầu khác; không có thì in Just a legend.

Lời giải. Viền dài L=n−iL = n - i ứng với z[i]=n−iz[i] = n - i. Lần xuất hiện ở giữa phải bắt đầu tại một jj với 1≤j<i1 \le j < i và z[j]≥Lz[j] \ge L; bắt đầu từ ii trở đi thì không còn đủ LL ký tự trước cuối xâu (trừ chính viền). Duyệt ii tăng dần, giữ mx là max⁡z[j]\max z[j] trên các j<ij < i. ii tăng thì LL giảm, nên viền đầu tiên thỏa là dài nhất.

C++
for (ll i = 1; i < n; i++) {
    if (z[i] == n - i && mx >= n - i) {
        cout << s.substr(0, n - i) << '\n';
        return 0;
    }
    mx = max(mx, z[i]);
}

mx cập nhật sau khi xét ii, để chỉ tính các jj đứng trước.

Code đầy đủ
#include <bits/stdc++.h>
using namespace std;
#define ll long long

const ll N = 1e6 + 5;
ll z[N];
string s;

void zfunc(string &s, ll z[]) {
    ll n = s.size();
    z[0] = n;
    for (ll i = 1, l = 0, r = 0; i < n; i++) {
        z[i] = 0;
        if (i < r) z[i] = min(r - i, z[i - l]);
        while (i + z[i] < n && s[z[i]] == s[i + z[i]]) z[i]++;
        if (i + z[i] > r) l = i, r = i + z[i];
    }
}

signed main() {
    ios::sync_with_stdio(false);
    cin.tie(0);

    cin >> s;
    ll n = s.size();
    zfunc(s, z);
    ll mx = 0;
    for (ll i = 1; i < n; i++) {
        if (z[i] == n - i && mx >= n - i) {
            cout << s.substr(0, n - i) << '\n';
            return 0;
        }
        mx = max(mx, z[i]);
    }
    cout << "Just a legend\n";
}
aaaaa

O(n)O(n).

Ghép bằng tiền tố

Hàm Z của S+#+TS + \# + T cho, tại mỗi vị trí ii của TT, độ dài LiL_i của tiền tố dài nhất của SS bắt đầu ở đó. Mọi tiền tố ngắn hơn cũng bắt đầu được ở ii. Vậy từ vị trí ii của TT có thể nhảy tới bất kỳ vị trí nào trong (i,i+Li](i, i + L_i] bằng một mảnh. Ghép TT bằng ít mảnh nhất là bài nhảy xa quen thuộc: duyệt theo từng “tầng” của số mảnh, mỗi tầng mở rộng tới vị trí xa nhất mà các điểm trong tầng trước nhảy tới được, O(∣T∣)O(|T|).

Ví dụGhép Đích Từ Các Tiền Tố

Đề. Xâu SS, TT dài tới 5⋅1055 \cdot 10^5. Ghép TT từ ít tiền tố của SS nhất (được dùng lại), hoặc in −1-1.

Lời giải. Từ vị trí ii của TT, một mảnh là tiền tố của SS khớp tại ii. Tiền tố dài nhất khớp tại ii là LiL_i, lấy từ hàm Z của S+#+TS + \# + T; mọi tiền tố ngắn hơn cũng khớp, nên từ ii nhảy được tới bất kỳ vị trí nào trong (i,i+Li](i, i + L_i]. Bài thành: đi từ 0 tới ∣T∣|T| bằng ít bước nhảy nhất.

Bước 1: độ dài nhảy. z[m + 1 + i] là LiL_i.

Bước 2: tham lam theo tầng. cur là vị trí xa nhất tới được bằng ans mảnh, far là vị trí xa nhất tới được bằng thêm một mảnh từ các vị trí ≤i\le i. Khi ii chạm cur, mọi vị trí tới được bằng ans mảnh đã xét, nên phải dùng thêm một mảnh và đặt cur = far. Nếu far không vượt ii thì kẹt, in −1-1.

C++
for (ll i = 0; i < n && cur < n; i++) {
    far = max(far, i + z[m + 1 + i]);
    if (i == cur) {
        if (far <= i) {
            cout << -1 << '\n';
            return 0;
        }
        ans++;
        cur = far;
    }
}

Mỗi tầng đi xa nhất có thể, nên số mảnh ít nhất.

Code đầy đủ
#include <bits/stdc++.h>
using namespace std;
#define ll long long

const ll N = 1e6 + 5;
ll z[N];
string a, t;

void zfunc(string &s, ll z[]) {
    ll n = s.size();
    z[0] = n;
    for (ll i = 1, l = 0, r = 0; i < n; i++) {
        z[i] = 0;
        if (i < r) z[i] = min(r - i, z[i - l]);
        while (i + z[i] < n && s[z[i]] == s[i + z[i]]) z[i]++;
        if (i + z[i] > r) l = i, r = i + z[i];
    }
}

signed main() {
    ios::sync_with_stdio(false);
    cin.tie(0);

    cin >> a >> t;
    string s = a + "#" + t;
    zfunc(s, z);
    ll m = a.size(), n = t.size(), ans = 0, cur = 0, far = 0;
    for (ll i = 0; i < n && cur < n; i++) {
        far = max(far, i + z[m + 1 + i]);
        if (i == cur) {
            if (far <= i) {
                cout << -1 << '\n';
                return 0;
            }
            ans++;
            cur = far;
        }
    }
    cout << ans << '\n';
}
aba
ababa

O(∣S∣+∣T∣)O(|S| + |T|).

Khi đi thi

  • Hàm Z và hàm tiền tố làm được cùng lớp bài; Z tiện khi câu hỏi là “khớp với đầu xâu bao xa” tại từng vị trí, hàm tiền tố tiện khi đi một chiều qua văn bản dài (luồng vào) hay cần tự động.
  • Xâu ghép P+#+TP + \# + T phải dùng ký tự ngăn không có trong hai xâu; muốn hỏi khớp với cuối xâu, đảo ngược cả hai trước.
  • z[i]=n−iz[i] = n - i là viền và cũng là chu kỳ ii; đếm lần xuất hiện của mọi tiền tố bằng đếm theo giá trị zz rồi cộng dồn.

Tóm tắt

  • z[i]z[i]: độ dài khớp dài nhất giữa đoạn từ ii và tiền tố; tính trong O(n)O(n) nhờ hộp [l,r)[l, r) và z[i]≥min⁡(r−i,z[i−l])z[i] \ge \min(r - i, z[i - l]).
  • Tìm mẫu: z=∣P∣z = |P| trong hàm Z của P+#+TP + \# + T.
  • Viền dài n−in - i và chu kỳ ii khi z[i]=n−iz[i] = n - i.
  • Số lần xuất hiện của tiền tố dài LL là số ii có z[i]≥Lz[i] \ge L.
  • Hàm Z trên xâu ghép cho bước nhảy xa nhất của bài ghép bằng tiền tố.

Bài tập

Đọc thêm