Chương 4·Bài 48

Z Function

Với xâu ss dài nn (đánh số từ 0), 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. Mảng zz trả lời trực tiếp những câu hỏi “hậu tố này khớp với đầu xâu tới đâu”, nên 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ố. Thuật toán tính cả mảng trong O(n)O(n) nhờ một “hộp” ghi nhớ đoạn khớp xa nhất đã thấy.

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

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. Giữ khung [l,r)[l, r): đoạn khớp tiền tố có đầu phải xa nhất đã tìm được, tức s[l..r)s[l..r) trùng s[0..r−l)s[0..r - l). Vị trí ii nằm trong khung thì đã biết trước một phần độ dài khớp, chỉ so tiếp từ đó.

Bước 1: mượn kết quả cũ. i<ri < r thì s[i..r)s[i..r) trùng s[i−l..r−l)s[i - l..r - l), mà ở vị trí i−li - l đã biết z[i−l]z[i - l]. Lấy min với r - i vì ngoài khung chưa biết gì.

C++
if (i < r) z[i] = min(r - i, z[i - l]);

Bước 2: so tiếp và dời khung. So từng ký tự; đoạn khớp vươn xa hơn r thì dời khung về ii. Mỗi lần so thành công khi i+z[i]≥ri + z[i] \ge r là một ký tự mới ngoài khung, nên tổng số lần so thêm không quá nn.

C++
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];

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 bằng xâu ghép

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ỳ đọc từ Z

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.

Hỏi nhanh

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

Ví dụViền Của Chuỗi

Đề. Xâu dài tới 10610^6. In độ dài mọi viền (ngắn hơn cả xâu) theo thứ tự tăng dần, không có thì in 0.

Lời giải. z[i] == n - i nghĩa là hậu tố bắt đầu ở ii khớp trọn với tiền tố cùng độ dài: nó là một viền dài n−in - i. Duyệt ii giảm dần thì độ dài tăng dần, in đúng thứ tự. Bỏ i=0i = 0 vì đó là cả xâu.

C++
for (ll i = n - 1; i >= 1; i--)
    if (z[i] == n - i) {
        cout << n - i << ' ';
        any = 1;
    }
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);
    bool any = 0;
    for (ll i = n - 1; i >= 1; i--)
        if (z[i] == n - i) {
            cout << n - i << ' ';
            any = 1;
        }
    if (!any) cout << 0;
    cout << '\n';
}
abcababcab

O(n)O(n).

Ví dụNhịp Lặp Khuyết

Đề. Xâu dài tới 10610^6. In mọi chu kỳ pp (bản cuối được phép bị cắt) theo thứ tự tăng dần.

Lời giải. pp là chu kỳ 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: z[p] == n - p. Đây cũng chính là điều kiện có viền dài n−pn - p. nn luôn là chu kỳ (không có cặp nào để kiểm), in ở cuối.

C++
for (ll p = 1; p < n; p++)
    if (z[p] == n - p) cout << p << ' ';
cout << n << '\n';
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);
    for (ll p = 1; p < n; p++)
        if (z[p] == n - p) cout << p << ' ';
    cout << n << '\n';
}
abababa

O(n)O(n).

Đếm lần xuất hiện của mọi tiền tố

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 xâu bằng các 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