Hàm tiền tố và thuật toán KMP
Hàm tiền tố (prefix function) của một xâu cho biết, tại mỗi vị trí , độ dài đoạn đầu dài nhất của xâu, ngắn hơn , đồng thời kết thúc ở vị trí đó. Từ một mảng duy nhất này, thuật toán KMP (Knuth, Morris, Pratt) tìm mọi lần xuất hiện của mẫu (độ dài ) trong văn bản (độ dài ) trong , thay vì của cách so từng vị trí. Cũng mảng đó trả lời được chu kỳ ngắn nhất của xâu, số lần xuất hiện của mọi tiền tố, và dựng được automaton KMP để quy hoạch động đếm các xâu không chứa mẫu.
Ý tưởng / Trực giác
Border và hàm tiền tố
Border (biên) của xâu là một xâu vừa là tiền tố vừa là hậu tố của , nhưng không phải chính . Ví dụ abacaba có các border a, aba (và xâu rỗng).
Định nghĩa (đánh chỉ số từ ):
Theo định nghĩa . Ví dụ với aabaaab:
i : 0 1 2 3 4 5 6
s[i] : a a b a a a b
pi[i]: 0 1 0 1 2 2 3
vì aab vừa là tiền tố của aabaaab, vừa là hậu tố của nó, và không có border nào dài hơn.
Tại sao KMP nhanh: không bao giờ lùi con trỏ văn bản
Cách ngây thơ đặt mẫu ở vị trí , so tới khi lệch, rồi dịch mẫu sang một ô và so lại từ đầu. Nhưng khi đã khớp được ký tự rồi mới lệch, ta đã biết ký tự cuối vừa đọc của văn bản chính là . Câu hỏi "dịch mẫu tới đâu thì phần đã đọc vẫn còn khớp?" chỉ phụ thuộc vào , và câu trả lời là: dịch sao cho một border của nằm đúng chỗ. Dịch ít nhất tương ứng với border dài nhất, tức giữ lại ký tự đã khớp.
Vậy con trỏ trên văn bản chỉ tiến, còn "độ dài đang khớp" thì có lúc tăng , có lúc tụt về .
Chuỗi các border lồng nhau
Hai tính chất then chốt:
- Border của border cũng là border. Nếu là border của và là border của thì là tiền tố của (nên là tiền tố của ) và là hậu tố của (nên là hậu tố của ).
- Mọi border của đều nằm trong chuỗi Lý do: gọi là border dài nhất. Một border ngắn hơn của là hậu tố của , nên cũng là hậu tố của border ; nó lại là tiền tố, nên nó là border của , và border dài nhất trong số đó có độ dài . Lặp lại lập luận.
Vì thế khi cần "border dài nhất tiếp theo ngắn hơn " ta chỉ việc nhảy , không phải thử mọi độ dài.
Tính từ các giá trị trước
Một border của có độ dài thì bỏ ký tự cuối đi sẽ được một border (có thể rỗng) của độ dài , kèm điều kiện . Do đó:
- Thử các border của từ dài đến ngắn: , rồi , ...
- Dừng ở đầu tiên có , khi đó . Nếu tụt về mà vẫn thì .
Hệ quả: , hàm tiền tố tăng tối đa mỗi bước. Đây là chìa khóa của chứng minh độ phức tạp.
Ví dụ chạy tay
Tính hàm tiền tố của aabaaab
Ở mỗi bước, bắt đầu bằng ; mũi tên ^ chỉ hai ký tự đang so và .
i=1: j=pi[0]=0. s[0]='a' vs s[1]='a' bằng -> pi[1]=1
i=2: j=pi[1]=1. s[1]='a' vs s[2]='b' lệch -> j=pi[0]=0
s[0]='a' vs s[2]='b' lệch, j=0 -> pi[2]=0
i=3: j=0. s[0]='a' vs s[3]='a' bằng -> pi[3]=1
i=4: j=1. s[1]='a' vs s[4]='a' bằng -> pi[4]=2
a a b a a
^ ^ ^ ^ border "aa" của s[0..4]
i=5: j=2. s[2]='b' vs s[5]='a' lệch -> j=pi[1]=1 (nhảy tới border ngắn hơn)
a a b a a a
^ ^
s[1]='a' vs s[5]='a' bằng -> pi[5]=2
i=6: j=2. s[2]='b' vs s[6]='b' bằng -> pi[6]=3
a a b a a a b
[a a b] [a a b] border "aab"
Bước minh họa phép nhảy: border dài nhất của aabaa là aa, nhưng aa + a không phải tiền tố (aab); nhảy xuống border của aa là a, và a + a = aa khớp.
Tìm aba trong abababa
Mẫu aba có . Chạy trực tiếp trên văn bản, là số ký tự của mẫu đang khớp:
i : 0 1 2 3 4 5 6
t[i] : a b a b a b a
j sau: 1 2 3 2 3 2 3
^ ^ ^
khớp đủ ở i=2, 4, 6 -> vị trí bắt đầu 0, 2, 4
Mỗi lần ta ghi nhận một lần xuất hiện rồi gán (giữ lại chữ a cuối làm đầu của lần khớp kế tiếp), nhờ vậy bắt được cả các lần xuất hiện chồng lấn.
Cài đặt
C++
#include <bits/stdc++.h>
using namespace std;
// pi[i] = length of the longest proper border of s[0..i]
vector<int> prefix_function(const string& s) {
int n = s.size();
vector<int> pi(n, 0);
for (int i = 1; i < n; i++) {
int j = pi[i - 1]; // longest border of s[0..i-1]
while (j > 0 && s[i] != s[j]) j = pi[j - 1]; // fall back to next shorter border
if (s[i] == s[j]) j++;
pi[i] = j;
}
return pi;
}
// All 0-indexed start positions of p in t (no p + '#' + t needed)
vector<int> kmp_search(const string& t, const string& p) {
vector<int> pi = prefix_function(p), res;
int m = p.size(), j = 0; // j = length of current match
for (int i = 0; i < (int)t.size(); i++) {
while (j > 0 && t[i] != p[j]) j = pi[j - 1];
if (t[i] == p[j]) j++;
if (j == m) { // a full match ends at i
res.push_back(i - m + 1);
j = pi[j - 1]; // continue, overlaps allowed
}
}
return res;
}
int main() {
ios::sync_with_stdio(false);
cin.tie(nullptr);
string t, p;
cin >> t >> p;
vector<int> pos = kmp_search(t, p);
cout << pos.size() << '\n';
for (int x : pos) cout << x + 1 << ' '; // print 1-indexed
cout << '\n';
}
Cách ghép xâu. Có thể thay kmp_search bằng một lần prefix_function(p + '#' + t): vì # không có trong hai xâu nên không bao giờ vượt , và vị trí (trong xâu ghép) có ứng với lần xuất hiện bắt đầu tại trong . Cách này ngắn nhưng tốn bộ nhớ cho mảng của cả xâu ghép; cách chạy trực tiếp ở trên chỉ cần và đọc được văn bản theo luồng.
Python
def prefix_function(s):
n = len(s)
pi = [0] * n
for i in range(1, n):
j = pi[i - 1]
while j > 0 and s[i] != s[j]:
j = pi[j - 1]
if s[i] == s[j]:
j += 1
pi[i] = j
return pi
def kmp_search(t, p):
pi = prefix_function(p)
m, j, res = len(p), 0, []
for i, ch in enumerate(t):
while j > 0 and ch != p[j]:
j = pi[j - 1]
if ch == p[j]:
j += 1
if j == m:
res.append(i - m + 1)
j = pi[j - 1]
return res
Với cỡ , vòng lặp Python thuần mất khoảng một giây trở lên; nếu chỉ cần có/không hoặc đếm số lần xuất hiện không chồng lấn, str.find/str.count có sẵn nhanh hơn nhiều.
Ứng dụng 1: đếm số lần xuất hiện của mọi tiền tố
Tiền tố độ dài xuất hiện kết thúc tại khi và chỉ khi là độ dài một border của (hoặc ). Mỗi vị trí góp cho cả chuỗi . Thay vì đi hết chuỗi cho từng , ta cộng vào rồi "đẩy" số đếm từ độ dài dài xuống ngắn, giống cộng dồn trên cây:
// cnt[k] = occurrences of the prefix of length k inside s (k >= 1)
vector<long long> count_prefix_occurrences(const string& s) {
int n = s.size();
vector<int> pi = prefix_function(s);
vector<long long> cnt(n + 1, 0);
for (int i = 0; i < n; i++) cnt[pi[i]]++; // longest border ending at i
for (int k = n; k > 0; k--) cnt[pi[k - 1]] += cnt[k]; // push down to shorter borders
for (int k = 1; k <= n; k++) cnt[k]++; // the prefix itself
return cnt;
}
Vòng thứ hai phải chạy giảm dần: vì , khi xử lý thì mọi đóng góp vào cnt[k] từ các độ dài lớn hơn đã được cộng xong. Với ABACABA kết quả là A: 4, AB: 2, ABA: 2, ABAC...ABACABA: 1.
Đếm tiền tố của trong một xâu khác . Chạy hàm tiền tố trên và sửa ba chỗ so với count_prefix_occurrences:
- Vòng đầu chỉ cộng cho các vị trí thuộc phần .
- Vòng đẩy vẫn chạy từ xuống , dùng của phần (các chỉ số của mảng ghép).
- Bỏ vòng cuối
cnt[k]++: "chính tiền tố đó" nằm trong chứ không nằm trong . Chép nguyêncount_prefix_occurrencesthì mỗi độ dài bị thừa .
// cnt[k] = occurrences of the prefix of length k of s inside t (k >= 1)
vector<long long> count_prefix_in(const string& s, const string& t) {
int n = s.size();
vector<int> pi = prefix_function(s + '#' + t);
vector<long long> cnt(n + 1, 0);
for (int i = n + 1; i < (int)pi.size(); i++) cnt[pi[i]]++; // only positions inside t
for (int k = n; k > 0; k--) cnt[pi[k - 1]] += cnt[k]; // push down using pi of s only
return cnt; // no "+1": the prefix lies in s, not t
}
Ứng dụng 2: chu kỳ ngắn nhất và nén xâu
Số là một chu kỳ của (độ dài ) nếu với mọi hợp lệ. có chu kỳ khi và chỉ khi có border độ dài (dịch xâu đi ô thì phần chồng lên nhau trùng nhau). Border dài nhất cho chu kỳ ngắn nhất:
Mọi chu kỳ của là với chạy trên chuỗi border (kể cả border rỗng , cho chu kỳ ), nên liệt kê được hết.
Nén thành lũy thừa. viết được dưới dạng ( lặp lần, ) khi và chỉ khi chia hết và ; khi đó là gốc ngắn nhất. Nếu thì không có cách chia nào, kể cả với các chu kỳ dài hơn (một chu kỳ mà sẽ kéo theo theo định lý Fine-Wilf, nên ).
s = a b c a b c a b c n=9, pi[8]=6, d=3, 9 % 3 == 0 -> (abc)^3
s = a b c a b c a b n=8, pi[7]=5, d=3, 8 % 3 != 0 -> không nén được
// length of the shortest x with s = x^k (returns n when s is not a power)
int shortest_root(const string& s) {
int n = s.size();
int d = n - prefix_function(s)[n - 1];
return (n % d == 0) ? d : n;
}
Ứng dụng 3: automaton KMP và DP đếm xâu không chứa mẫu
Trạng thái của KMP khi đọc văn bản chỉ là một số (độ dài đang khớp). Ta tính trước bảng chuyển = trạng thái mới sau khi đọc ký tự ở trạng thái , để mỗi bước là thay vì một vòng while:
- Nếu và : .
- Ngược lại: (dùng lại hàng của border ngắn hơn, đã tính xong vì ), và .
Ví dụ aba trên bảng chữ , :
| trạng thái | đọc a |
đọc b |
|---|---|---|
| 0 | 1 | 0 |
| 1 | 1 | 2 |
| 2 | 3 (khớp đủ) | 0 |
| 3 | 1 | 2 |
Đếm xâu độ dài không chứa . Gọi là số xâu độ dài mà khi chạy automaton kết thúc ở trạng thái (chưa từng chạm ). Chuyển: nếu . Đáp án là ; số xâu có chứa bằng trừ đi số đó.
Với aba, bắt đầu từ :
| tổng | ||||
|---|---|---|---|---|
| 0 | 1 | 0 | 0 | 1 |
| 1 | 1 | 1 | 0 | 2 |
| 2 | 1 | 2 | 1 | 4 |
| 3 | 2 | 3 | 2 | 7 |
| 4 | 4 | 5 | 3 | 12 |
Kiểm tra: xâu, có đúng xâu chứa aba (abaa, abab, aaba, baba), còn .
const long long MOD = 1e9 + 7;
// aut[j][c] for pattern over 'A'..'Z'; state m means "pattern just matched"
vector<array<int, 26>> build_automaton(const string& p) {
int m = p.size();
vector<int> pi = prefix_function(p);
vector<array<int, 26>> aut(m + 1);
for (int j = 0; j <= m; j++)
for (int c = 0; c < 26; c++) {
if (j < m && p[j] - 'A' == c) aut[j][c] = j + 1; // extend the match
else aut[j][c] = (j == 0) ? 0 : aut[pi[j - 1]][c]; // reuse a shorter border's row
}
return aut;
}
// number of strings of length N over 'A'..'Z' that contain p, modulo MOD
long long count_containing(int N, const string& p) {
int m = p.size();
auto aut = build_automaton(p);
vector<long long> f(m, 0), g(m);
f[0] = 1;
for (int k = 0; k < N; k++) {
fill(g.begin(), g.end(), 0);
for (int j = 0; j < m; j++) if (f[j])
for (int c = 0; c < 26; c++) {
int to = aut[j][c];
if (to < m) g[to] = (g[to] + f[j]) % MOD; // never reach state m
}
swap(f, g);
}
long long avoid = 0, total = 1;
for (long long v : f) avoid = (avoid + v) % MOD;
for (int k = 0; k < N; k++) total = total * 26 % MOD;
return (total - avoid + MOD) % MOD;
}
Hàng của trạng thái chỉ dùng khi đếm số lần xuất hiện (văn bản được phép chứa mẫu nhiều lần): sau khi khớp đủ, automaton đi tiếp như từ border , tương đương dòng j = pi[j - 1] trong kmp_search.
Độ phức tạp
| Thao tác | Thời gian | Bộ nhớ |
|---|---|---|
| Hàm tiền tố của xâu độ dài | ||
| Tìm mẫu trực tiếp trên | ||
| Tìm mẫu bằng | ||
| Đếm xuất hiện mọi tiền tố, chu kỳ | ||
| Dựng automaton | ||
| DP đếm xâu độ dài | (hai hàng) |
Vì sao hàm tiền tố là dù có while lồng trong for? Theo dõi biến (giá trị hiện tại của ):
- Mỗi bước
for, tăng nhiều nhất (dòngj++). Qua cả thuật toán, tổng mức tăng . - Mỗi lần lặp
while, giảm ít nhất (vì ), và không bao giờ âm.
Tổng mức giảm không thể vượt tổng mức tăng, nên tổng số lần lặp while qua toàn bộ thuật toán . Cộng với bước for được . Lập luận khấu hao y hệt cho kmp_search: tăng tối đa lần (một lần mỗi ký tự văn bản), nên phần while tổng cộng , cộng tính của mẫu.
Chú ý đây là cận tổng: một bước đơn lẻ vẫn có thể lặp while tới lần. Khi cần đảm bảo mỗi ký tự xử lý trong (chẳng hạn trong DP phải xử lý từng trạng thái riêng lẻ, nơi lập luận khấu hao không còn đúng), hãy dùng automaton.
So sánh với Z-function và hash
| Hàm tiền tố / KMP | Z-function | Hash xâu | |
|---|---|---|---|
| Thông tin lưu | border dài nhất kết thúc tại | tiền tố chung dài nhất bắt đầu tại | mã số của mọi đoạn con |
| Tìm mẫu | , chạy theo luồng, bộ nhớ | , thường ghép xâu, bộ nhớ | , có xác suất sai |
| Chu kỳ, border | trực tiếp (, chuỗi border) | được, qua | thử từng độ dài |
| So hai đoạn con bất kỳ | không | không | |
| Automaton, DP trên trạng thái khớp | có | không tự nhiên | không |
| Nhiều mẫu | Aho-Corasick | không | gom theo độ dài |
Quy tắc chọn nhanh: cần trạng thái khớp (DP, automaton, xóa mẫu khi đang đọc) thì dùng KMP; cần "đoạn bắt đầu tại khớp tiền tố dài bao nhiêu" thì Z-function tự nhiên hơn; cần so sánh đoạn con tùy ý hoặc kết hợp chặt nhị phân thì dùng hash.
⚠️ Lỗi thường gặp
- Dùng
ifthay chowhilekhi lùi. Chỉ lùi một bướcj = pi[j-1]rồi thôi sẽ bỏ sót các border ngắn hơn. Vớiaabaaablỗi này không lộ ra (mỗi bước chỉ cần lùi một lần), nhưng vớiaaabbước cuối phải lùi : bản dùngifcho thay vì đúng là . Luôn lùi cho tới khi khớp hoặc . - Không gán lại sau khi khớp đủ, hoặc gán sai. Trong
kmp_search, nếu để nguyên thì bước sau đọcp[m]: vớivector<int>hay mảng số đó là truy cập ra ngoài mảng. Vớistd::string,p[m]trả về'\0'(hành vi được chuẩn định nghĩa từ C++11) nên code vẫn chạy đúng khi không chứa'\0', nhưng đó chỉ là nhờ ký tự'\0'ở cuối, không nên dựa vào. Nếu gánj = 0thì bỏ sót các lần xuất hiện chồng lấn: tìmaatrongaaaađược thay vì . Gán đúng làj = pi[m-1]. - Ký tự ngăn cách có trong dữ liệu. Khi ghép , nếu
#(hoặc ký tự nào đó bạn chọn) có thể xuất hiện trong hay thì có thể vượt và đếm sai. Với dữ liệu là dãy số, ghép bằng một giá trị chắc chắn không có (ví dụ ) thay vì ký tự. - Lệch chỉ số khi ghép. Trong , vị trí có ứng với vị trí bắt đầu trong (chỉ số từ ), không phải . Đề đánh số từ thì cộng .
- Tưởng luôn nén được xâu. Đó là chu kỳ ngắn nhất, nhưng chỉ nén thành khi nó chia hết .
abcabcó chu kỳ nhưng không là lũy thừa của xâu nào ngắn hơn. Ngược lại, đừng thử các chu kỳ dài hơn khi : không cái nào chia hết . - Cộng dồn số lần xuất hiện sai chiều. Trong
count_prefix_occurrences, vòng đẩycnt[pi[k-1]] += cnt[k]phải chạy giảm dần. Chạy tăng dần thìcnt[k]được đẩy xuống trước khi nhận đủ đóng góp từ các độ dài lớn hơn. - Tưởng KMP luôn là mỗi ký tự. Cận là khấu hao. Nếu DP gọi hàm "chuyển trạng thái bằng
while" từ mọi trạng thái ở mọi bước, không còn tính khấu hao, chi phí có thể thành . Tính trước bảngautrồi mới DP. - Quên modulo hoặc tràn số khi đếm. và các giá trị tăng rất nhanh; lấy modulo ở mỗi phép cộng và cộng thêm
MODtrước khi lấy phần dư của hiệu .
Biến thể / Mở rộng
- Nhiều mẫu cùng lúc. Dựng trie của các mẫu và thay bằng liên kết hậu tố (suffix link): đó chính là Aho-Corasick, "KMP trên cây". Bảng chuyển
autở trên là trường hợp trie chỉ có một nhánh; DP đếm xâu không chứa bất kỳ mẫu nào hoạt động y hệt trên automaton Aho-Corasick. - rất lớn. Chuyển trạng thái là tuyến tính và không phụ thuộc , nên viết thành ma trận và lũy thừa trong , xem DP nhân ma trận và lũy thừa ma trận.
- Trạng thái KMP làm một chiều của DP. Bài "xóa ít ký tự nhất để không chứa ", "dựng xâu chứa nhiều lần nhất"... đều là DP với trạng thái (vị trí trong , trạng thái automaton). Tương tự DP chữ số khi yêu cầu "số không chứa dãy chữ số cho trước".
- Xóa mẫu khi đang đọc. Dùng một ngăn xếp các ký tự còn lại và lưu trạng thái KMP tại mỗi ký tự; khi khớp đủ thì bỏ phần tử đỉnh và khôi phục trạng thái của phần tử mới ở đỉnh. Tổng dù mỗi lần xóa có thể tạo ra lần xuất hiện mới.
- Xoay vòng và dãy số. là một phép xoay của khi và chỉ khi và xuất hiện trong . Thiếu điều kiện cùng độ dài thì mọi xâu con ngắn hơn của cũng bị nhận nhầm là phép xoay. KMP chạy được trên mảng số bất kỳ có phép so sánh bằng, ví dụ so khớp "hình dạng" của dãy bằng cách tìm trên dãy hiệu .
- Hàm tiền tố và Z-function chuyển đổi được cho nhau trong , nên bài nào giải được bằng cái này thì về lý thuyết cũng giải được bằng cái kia; chọn cái cho code gọn nhất. Các cấu trúc mạnh hơn cho câu hỏi về mọi đoạn con: suffix array, suffix automaton.
Bài tập luyện
Luyện kỹ thuật
- Kiểm duyệt (Silver) (censorsi) (Trung cấp) xóa lặp đi lặp lại lần xuất hiện sớm nhất của mẫu: dùng ngăn xếp ký tự kèm trạng thái KMP để xóa ngay khi khớp đủ, không phải quét lại từ đầu.
- Khớp Xâu (strmatch) (Trung cấp) đếm số lần xuất hiện (kể cả chồng lấn) của mẫu trong xâu dài tới : cài đặt trực tiếp
kmp_search. - Tìm Chu Kỳ (periods) (Nâng cao) liệt kê mọi chu kỳ: đi theo chuỗi border , mỗi border cho chu kỳ , kể cả cho chu kỳ .
- Xâu Con Bắt Buộc (reqsubstr) (Kỳ cựu) đếm xâu độ dài chứa mẫu: DP trên automaton KMP đếm phần bù, đúng như
count_containingở trên. - Tiền tố trùng Hậu tố (borderocc) (Kỳ cựu) liệt kê border của cả xâu và số lần xuất hiện của từng border: kết hợp chuỗi border với
count_prefix_occurrences.
Muốn luyện thêm: Xóa Ký Tự Trong Chuỗi (necklace) (Kỳ cựu) là DP tối ưu (xóa ít ký tự nhất) với trạng thái automaton KMP thay vì DP đếm.
Đề VOI
Bài VOI 2005 dưới đây dùng automaton KMP như một thành phần của lời giải chính. Hai bài còn lại chỉ có một phần giải được gọn bằng hàm tiền tố, nhưng đó chỉ là một cách làm, không phải hướng chính thức:
- Pháo hoa (voi05_firewk) (Chuyên gia) VOI 2005: một bước của lời giải là DP trên automaton KMP của mã cho trước để đếm các từ vòng tránh một tập mẫu, tương tự
count_containingở trên nhưng chạy trên vòng. - Bản vanxơ Fibonacci (voi12_fibval) (Nâng cao) VOI 2012: lời giải chính dựa vào tính tuần hoàn của dãy Fibonacci. Với các đoạn ngắn cần kiểm tra trực tiếp "đoạn có chia được thành khối giống nhau không": với mỗi điểm bắt đầu, tính hàm tiền tố của đoạn phía sau rồi dùng điều kiện chu kỳ ngắn nhất chia hết độ dài, thay cho việc thử mọi ước.
- Virus (voi17_virus) (Nâng cao) VOI 2017: lời giải chính thức là cửa sổ trượt trên mảng sai khác. Riêng subtask (tìm đoạn dạng dài nhất), có thể chạy hàm tiền tố từ mỗi điểm bắt đầu và nhận ra một đoạn độ dài có dạng khi và chỉ khi chu kỳ ngắn nhất của nó chia hết .
Bình luận