Tìm mẫu trong văn bản bằng cách thử từng vị trí bắt đầu rồi so từng ký tự tốn tới phép so sánh; với aaaa…ab tìm trong aaaa…a dài , mỗi vị trí gần như so hết mẫu rồi mới hỏng. Phí phạm nằm ở chỗ mỗi lần hỏng ta vứt hết phần đã khớp, trong khi phần ấy đã cho biết khá nhiều về những vị trí kế tiếp. Thuật toán Knuth-Morris-Pratt (KMP) giữ lại đúng phần còn dùng được, nhờ một mảng tính sẵn trên mẫu là hàm tiền tố. Con trỏ trên văn bản không bao giờ lùi, nên cả bài chạy trong .
Hàm tiền tố còn là công cụ riêng: nó liệt kê mọi viền của xâu, cho chu kỳ ngắn nhất của mọi tiền tố, và biến thành một tự động dùng được trong quy hoạch động đếm xâu chứa mẫu.
Viền và hàm tiền tố
Một viền (border) của xâu là một xâu khác rỗng, ngắn hơn , vừa là tiền tố vừa là hậu tố của . Xâu aabaabaa có ba viền: aabaa, aa và a. Đánh số từ 1 và gọi là độ dài viền dài nhất của tiền tố , bằng 0 nếu không có viền. Với ababa, : tiền tố dài 5 có viền dài nhất aba.
Tính chất then chốt: viền của một viền cũng là viền. Nếu là viền của và là viền 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 . Ngược lại, mọi viền ngắn hơn của cũng là viền của viền dài nhất. Vậy các viền của chính là dãy cho tới 0; với aabaabaa là .
Tính từ trái sang phải. Giả sử đã có và đặt . Viền của là một viền của nối thêm . Thử viền dài nhất trước: nếu thì . Nếu không, thử viền dài nhất kế tiếp của , chính là , và cứ thế; về tới mà vẫn không khớp thì .
for (ll i = 2, j = 0; i <= n; i++) {
while (j && s[i] != s[j + 1]) j = pi[j];
if (s[i] == s[j + 1]) j++;
pi[i] = j;
}
Dù có vòng while, tổng thời gian là . Biến tăng nhiều nhất 1 ở mỗi , mỗi lần lùi làm giảm ít nhất 1, và không bao giờ âm. Tổng số lần giảm không thể vượt tổng số lần tăng, tức không quá .
Hỏi nhanh
Trong xâu ababa, giá trị nói lên điều gì?
là độ dài viền của một tiền tố, không phải tần suất hay số lần xuất hiện. Với ababa, tiền tố và hậu tố dài 3 đều là aba; độ dài 4 không được vì abab khác baba.
Ví dụCác Viền Của Xâu
Đề. Cho xâu dài tới . In độ dài mọi viền theo thứ tự tăng dần, không có viền thì in 0.
Lời giải. Tính của cả xâu, rồi đi theo dãy tới 0: đó đúng là mọi viền, từ dài tới ngắn. Lưu lại rồi in ngược. Thử mọi độ dài và so hai đầu bằng tay là . Với aaaaa, và dãy là .
#include <bits/stdc++.h>
using namespace std;
#define ll long long
const ll N = 1e6 + 5;
ll n, pi[N];
string s;
signed main() {
ios::sync_with_stdio(false);
cin.tie(0);
cin >> s;
n = s.size();
s = " " + s;
for (ll i = 2, j = 0; i <= n; i++) {
while (j && s[i] != s[j + 1]) j = pi[j];
if (s[i] == s[j + 1]) j++;
pi[i] = j;
}
vector<ll> v;
for (ll k = pi[n]; k; k = pi[k]) v.push_back(k);
reverse(v.begin(), v.end());
if (v.empty()) cout << 0;
for (ll x : v) cout << x << ' ';
cout << '\n';
}
aaaaa
1 2 3 4
Tìm mẫu mà không quay lại văn bản
Quét văn bản từ trái sang, giữ là số ký tự đầu của khớp với hậu tố dài nhất của phần văn bản đã đọc. Đọc thêm : nếu thì tăng; nếu không, phần khớp mới phải là một viền của nối thêm , nên lùi và thử lại, đúng như lúc tính . Khi là một lần xuất hiện kết thúc tại ; ghi nhận rồi đặt để tiếp tục, chứ không về 0, nhờ vậy các lần xuất hiện chồng lấn không bị bỏ sót.
Con trỏ chạy đúng một lượt qua văn bản, còn tăng nhiều nhất 1 mỗi bước và mỗi lần lùi giảm ít nhất 1; tổng thời gian kể cả tính , bộ nhớ . Một mẹo cài gọn khác: tính của xâu ghép với # không xuất hiện trong hai xâu; mỗi vị trí trong phần có là một lần xuất hiện. KMP chỉ cần so hai phần tử bằng nhau, nên chạy nguyên vẹn trên dãy số, dãy cặp hay bất cứ thứ gì so sánh được.
Hỏi nhanh
Khi ký tự đầu của mẫu đã khớp nhưng ký tự kế tiếp không khớp, vì sao có thể chuyển thành ?
Sau khi hỏng, phần hậu tố đã đọc muốn còn hữu ích thì phải là tiền tố của ; nó đồng thời là hậu tố của đoạn vừa khớp . Xâu dài nhất thỏa cả hai là viền dài nhất, ; các viền ngắn hơn được thử tiếp ở những lần lùi sau. không phải chỉ số văn bản, không phải độ dài nào cũng hợp lệ, và bước lùi không nhất thiết chỉ một ký tự.
Ví dụĐếm Lần Khớp
Đề. Cho văn bản và mẫu , mỗi xâu dài tới . Đếm số lần xuất hiện trong , kể cả chồng lấn.
Lời giải. Tính trên mẫu, quét văn bản như trên, mỗi lần chạm thì đếm và lùi về . Đặt về 0 sau mỗi lần khớp là lỗi kinh điển: với aaa trong aaaaa nó đếm 1 thay vì 3. Hai xâu đánh số từ 1 bằng cách thêm một ký tự đệm ở đầu. Với ví dụ, mỗi khối năm chữ a có ba vị trí khớp, tổng 6.
#include <bits/stdc++.h>
using namespace std;
#define ll long long
const ll N = 1e6 + 5;
ll pi[N];
string t, p;
signed main() {
ios::sync_with_stdio(false);
cin.tie(0);
cin >> t >> p;
ll n = t.size(), m = p.size();
t = " " + t;
p = " " + p;
for (ll i = 2, j = 0; i <= m; i++) {
while (j && p[i] != p[j + 1]) j = pi[j];
if (p[i] == p[j + 1]) j++;
pi[i] = j;
}
ll ans = 0;
for (ll i = 1, j = 0; i <= n; i++) {
while (j && t[i] != p[j + 1]) j = pi[j];
if (t[i] == p[j + 1]) j++;
if (j == m) {
ans++;
j = pi[j];
}
}
cout << ans << '\n';
}
aaaaabaaaaa
aaa
6
Ví dụXóa Mẫu Khi Vừa Xuất Hiện
Đề. Đọc lần lượt các ký tự của (), nối vào cuối một xâu tạm; ngay khi mẫu () thành hậu tố của xâu tạm thì xóa đoạn đó. In xâu cuối cùng, rỗng thì in FRULA.
Lời giải. Xóa thật rồi quét lại từ đầu là . Giữ xâu tạm như một ngăn xếp ký tự, kèm theo mỗi ký tự là trạng thái KMP ngay sau khi đặt nó. Thêm ký tự mới: lấy trạng thái của ký tự đang ở đỉnh (0 nếu ngăn xếp rỗng), chạy một bước KMP. Nếu trạng thái thành thì bật ra ký tự; trạng thái của ký tự mới ở đỉnh chính là trạng thái đúng của phần còn lại, nên không cần tính lại gì. Mỗi ký tự được đẩy và bật nhiều nhất một lần, mỗi bước KMP tốn trung bình . Với ví dụ, zab bị xóa hai lần, còn lại xyab.
#include <bits/stdc++.h>
using namespace std;
#define ll long long
const ll N = 1e6 + 5;
ll pi[40], st[N], top;
char ch[N];
string s, p;
signed main() {
ios::sync_with_stdio(false);
cin.tie(0);
cin >> s >> p;
ll m = p.size();
p = " " + p;
for (ll i = 2, j = 0; i <= m; i++) {
while (j && p[i] != p[j + 1]) j = pi[j];
if (p[i] == p[j + 1]) j++;
pi[i] = j;
}
for (char c : s) {
ll j = top ? st[top] : 0;
while (j && c != p[j + 1]) j = pi[j];
if (c == p[j + 1]) j++;
ch[++top] = c;
st[top] = j;
if (j == m) top -= m;
}
if (!top) cout << "FRULA";
for (ll i = 1; i <= top; i++) cout << ch[i];
cout << '\n';
}
xyabzabzab
zab
xyab
Chu kỳ, lặp và cây viền
Số là một chu kỳ của xâu dài nếu với mọi hợp lệ, tức dịch sang phải ô thì phần chồng lên nhau trùng khớp. Xâu có chu kỳ khi và chỉ khi nó có viền dài : phần chồng lên nhau chính là tiền tố và hậu tố dài . Vì vậy chu kỳ ngắn nhất là , ứng với viền dài nhất.
Chu kỳ chưa chắc chia hết độ dài: abcabca có chu kỳ 3 dù dài 7. Xâu là lặp nguyên vẹn lần khi và chỉ khi chu kỳ ngắn nhất chia hết và ; khi đó số lần lặp lớn nhất là . Điều này đúng nhờ một bổ đề quen: nếu là chu kỳ nhỏ nhất và một chu kỳ chia hết thì cũng chia hết , nên không có cách lặp nào “thô” hơn bị bỏ sót.
Cây viền. Nối mỗi độ dài với cha ta được một cây gốc 0: mỗi số có đúng một cha nhỏ hơn nó, cứ đi theo cha mãi sẽ về 0. Các số gặp trên đường từ về 0 gọi là tổ tiên của , còn các số mà đi theo cha sẽ qua (kể cả ) tạo thành cây con của ; chương 3 sẽ học kỹ về cây. Các viền của tiền tố dài là các tổ tiên của . Ngược lại, tiền tố dài xuất hiện kết thúc tại vị trí khi và chỉ khi là một viền của (hoặc chính ), tức là tổ tiên của . Vậy số lần tiền tố dài xuất hiện trong bằng số nút trong cây con của , tính được bằng cách cộng dồn với giảm dần, xuất phát từ .
Ví dụChu Kỳ Của Các Tiền Tố
Đề. Nhiều test (tổng độ dài ). Với mỗi xâu , liệt kê theo độ dài tăng dần mọi tiền tố là một xâu ngắn hơn lặp nguyên vẹn ít nhất 2 lần, kèm số lần lặp lớn nhất. Mỗi test in dòng Test case #x rồi một dòng trống ở cuối.
Lời giải. Với tiền tố dài , chu kỳ ngắn nhất là . Tiền tố là lặp khi và chia hết ; số lần lặp là . Một lượt tính trả lời mọi tiền tố, . Với ababab, cho và cho ; có chu kỳ 2 không chia hết 5.
#include <bits/stdc++.h>
using namespace std;
#define ll long long
const ll N = 1e6 + 5;
ll pi[N];
signed main() {
ios::sync_with_stdio(false);
cin.tie(0);
ll T;
cin >> T;
for (ll tc = 1; tc <= T; tc++) {
ll n;
string s;
cin >> n >> s;
s = " " + s;
for (ll i = 2, j = 0; i <= n; i++) {
while (j && s[i] != s[j + 1]) j = pi[j];
if (s[i] == s[j + 1]) j++;
pi[i] = j;
}
cout << "Test case #" << tc << '\n';
for (ll i = 2; i <= n; i++) {
ll k = i - pi[i];
if (pi[i] && i % k == 0) cout << i << ' ' << i / k << '\n';
}
cout << '\n';
}
}
1
6
ababab
Test case #1
4 2
6 3
Ví dụThông Điệp Ngắn Nhất
Đề. Một thông điệp được viết lặp đi lặp lại vô hạn; ta thấy đúng ký tự đầu, xâu . In độ dài nhỏ nhất có thể của thông điệp.
Lời giải. Thông điệp dài thì có chu kỳ ; ngược lại có chu kỳ thì thông điệp lặp vô hạn sinh ra đúng . Ở đây không đòi chia hết , vì đoạn quan sát có thể dừng giữa một lần lặp. Đáp án là chu kỳ ngắn nhất . Với abcabca, (viền abca), đáp án 3.
#include <bits/stdc++.h>
using namespace std;
#define ll long long
const ll N = 1e6 + 5;
ll n, pi[N];
string s;
signed main() {
ios::sync_with_stdio(false);
cin.tie(0);
cin >> n >> s;
s = " " + s;
for (ll i = 2, j = 0; i <= n; i++) {
while (j && s[i] != s[j + 1]) j = pi[j];
if (s[i] == s[j + 1]) j++;
pi[i] = j;
}
cout << n - pi[n] << '\n';
}
7
abcabca
3
KMP trên dãy số và xâu vòng
Dãy sai khác một hằng số. Muốn biết đoạn có bằng cộng một hằng số không, so hai dãy hiệu kề nhau: cộng cùng một số không đổi hiệu, và hai dãy có cùng dãy hiệu thì sai khác một hằng số. Bài thành tìm dãy hiệu của trong dãy hiệu của bằng KMP trên số nguyên, nhớ trường hợp có dãy hiệu rỗng và khớp mọi vị trí.
Xâu vòng. Các phép xoay của là các đoạn dài của . Hỏi có phải một phép xoay của không, hay có bao nhiêu điểm bắt đầu trên vòng cho ra , là tìm trong (bỏ ký tự cuối để không đếm trùng điểm đầu). Với các tập điểm trên vòng tròn, như vạch trên mặt đồng hồ, ta sắp các điểm rồi lấy khoảng cách giữa hai điểm liên tiếp theo vòng: quay cả hình không đổi các khoảng cách, chỉ xoay vòng dãy của chúng.
Ví dụHai Mặt Số Không Mốc
Đề. Hai hình, mỗi hình có vạch ở các góc nguyên khác nhau trong . Hỏi có quay được hình thứ nhất (theo modulo 360000) để hai tập vạch trùng nhau không; in possible hoặc impossible.
Lời giải. Sắp góc của mỗi hình, lấy dãy khoảng cách theo vòng và . Quay hình là cộng cùng một góc vào mọi vạch, nên tập vạch của hình thứ nhất sau khi quay trùng hình thứ hai khi và chỉ khi dãy khoảng cách của hình hai là một phép xoay vòng của dãy khoảng cách hình một. Tìm dãy của hình hai trong dãy hình một viết hai lần bằng KMP trên số nguyên, sau khi sắp. Với ví dụ, cả hai dãy đều là .
#include <bits/stdc++.h>
using namespace std;
#define ll long long
const ll N = 2e5 + 5, M = 360000;
ll n, a[N], b[N], t[2 * N], p[N], pi[N];
signed main() {
ios::sync_with_stdio(false);
cin.tie(0);
cin >> n;
for (ll i = 1; i <= n; i++) cin >> a[i];
for (ll i = 1; i <= n; i++) cin >> b[i];
sort(a + 1, a + n + 1);
sort(b + 1, b + n + 1);
for (ll i = 1; i <= n; i++) {
t[i] = t[i + n] = (i < n ? a[i + 1] - a[i] : a[1] + M - a[n]);
p[i] = (i < n ? b[i + 1] - b[i] : b[1] + M - b[n]);
}
for (ll i = 2, j = 0; i <= n; i++) {
while (j && p[i] != p[j + 1]) j = pi[j];
if (p[i] == p[j + 1]) j++;
pi[i] = j;
}
bool ok = 0;
for (ll i = 1, j = 0; i < 2 * n && !ok; i++) {
while (j && t[i] != p[j + 1]) j = pi[j];
if (t[i] == p[j + 1]) j++;
if (j == n) ok = 1;
}
cout << (ok ? "possible" : "impossible") << '\n';
}
4
0 90000 180000 270000
45000 135000 225000 315000
possible
Tự động KMP trong quy hoạch động
Trạng thái của KMP, “đang khớp ký tự đầu của mẫu”, gói gọn mọi thứ cần biết về phần văn bản đã đọc để quyết định mẫu có xuất hiện tiếp hay không. Tính sẵn bảng chuyển : từ trạng thái đọc chữ thì sang trạng thái nào. Bảng tính được trong , với là số chữ cái, bằng chính quy tắc lùi: nếu , ngược lại bằng (với khi ). Đây là một tự động (automaton) hữu hạn có trạng thái.
Tự động này làm trạng thái cho quy hoạch động trên xâu: đếm xâu độ dài chứa (hoặc không chứa) mẫu, xâu con chung dài nhất tránh một mẫu cấm, điền ký tự để mẫu xuất hiện nhiều nhất. là số cách viết ký tự đầu mà tự động đang ở trạng thái ; thêm một chữ là đi theo . Đây cũng là khuôn của quy hoạch động chữ số (bài 13) với trạng thái là một tự động, và mở rộng tự nhiên sang nhiều mẫu với Aho-Corasick (bài 73).
Ví dụĐếm Xâu Có Mẫu Bắt Buộc
Đề. Cho và mẫu chữ hoa (). Đếm số xâu độ dài trên 26 chữ hoa có chứa ít nhất một lần, theo modulo .
Lời giải. Đếm bù: lấy trừ số xâu không chứa . Đọc một xâu từng chữ qua tự động KMP của ; xâu không chứa khi và chỉ khi trạng thái chưa bao giờ chạm . Vậy chỉ cần đếm số xâu theo trạng thái hiện tại. Với và AB: có xâu, trong đó 52 xâu chứa AB (26 xâu dạng ABx và 26 xâu dạng xAB, không xâu nào thuộc cả hai).
Bước 1: hàm tiền tố của mẫu. Như mọi bài KMP, mẫu đánh số từ 1 nhờ ký tự đệm ở đầu.
for (ll i = 2, j = 0; i <= m; i++) {
while (j && p[i] != p[j + 1]) j = pi[j];
if (p[i] == p[j + 1]) j++;
pi[i] = j;
}
Bước 2: bảng chuyển của tự động. go[j][c] là trạng thái mới khi đang ở (đã khớp ký tự đầu của mẫu) và đọc chữ . Khớp ký tự kế thì lên ; không khớp thì đi như từ trạng thái , đã tính xong vì . Bảng biến mỗi bước của KMP thành một lần tra, không còn vòng while.
for (ll j = 0; j < m; j++)
for (ll c = 0; c < 26; c++) {
if (p[j + 1] - 'A' == c) go[j][c] = j + 1;
else go[j][c] = j ? go[pi[j]][c] : 0;
}
Bước 3: đếm theo trạng thái. là số xâu đã dựng có trạng thái . Thêm một chữ: mọi xâu ở đi sang go[j][c] với 26 chữ ; chuyển nào chạm thì bỏ, vì xâu đó đã chứa mẫu. Song song tính .
for (ll i = 0; i < n; i++) {
all = all * 26 % MOD;
for (ll j = 0; j < m; j++) nd[j] = 0;
for (ll j = 0; j < m; j++)
for (ll c = 0; c < 26; c++)
if (go[j][c] < m) nd[go[j][c]] = (nd[go[j][c]] + dp[j]) % MOD;
for (ll j = 0; j < m; j++) dp[j] = nd[j];
}
Đáp án là trừ tổng các , cộng thêm MOD để không âm.
#include <bits/stdc++.h>
using namespace std;
#define ll long long
const ll MOD = 1e9 + 7;
ll n, m, pi[105], go[105][26], dp[105], nd[105];
string p;
signed main() {
ios::sync_with_stdio(false);
cin.tie(0);
cin >> n >> p;
m = p.size();
p = " " + p;
for (ll i = 2, j = 0; i <= m; i++) {
while (j && p[i] != p[j + 1]) j = pi[j];
if (p[i] == p[j + 1]) j++;
pi[i] = j;
}
for (ll j = 0; j < m; j++)
for (ll c = 0; c < 26; c++) {
if (p[j + 1] - 'A' == c) go[j][c] = j + 1;
else go[j][c] = j ? go[pi[j]][c] : 0;
}
dp[0] = 1;
ll all = 1;
for (ll i = 0; i < n; i++) {
all = all * 26 % MOD;
for (ll j = 0; j < m; j++) nd[j] = 0;
for (ll j = 0; j < m; j++)
for (ll c = 0; c < 26; c++)
if (go[j][c] < m) nd[go[j][c]] = (nd[go[j][c]] + dp[j]) % MOD;
for (ll j = 0; j < m; j++) dp[j] = nd[j];
}
ll bad = 0;
for (ll j = 0; j < m; j++) bad = (bad + dp[j]) % MOD;
cout << (all - bad + MOD) % MOD << '\n';
}
3
AB
52
Chi phí .
Khi đi thi
- Thấy tìm mẫu, viền, chu kỳ hay “lặp nguyên vẹn” trên xâu dài tới : tính ; KMP không có xác suất sai như hash (bài 19) và không tốn bộ nhớ như cây hậu tố.
- Thống nhất một quy ước chỉ số (ở đây đánh số từ 1, theo độ dài) và giữ nó trong cả bài; sau mỗi lần khớp lùi về , không về 0, để giữ các lần chồng lấn.
- Bài so sánh có biến đổi (cộng hằng số, xoay vòng, đổi bảng chữ cái) thường quy về KMP trên dãy hiệu hoặc trên ; bài đếm xâu chứa hay tránh mẫu thì dùng tự động KMP làm trạng thái quy hoạch động.
Tóm tắt
- là độ dài viền dài nhất của ; mọi viền là dãy ; tính trong vì tăng tối đa lần.
- KMP giữ là độ dài khớp hiện tại, lùi theo khi hỏng, không quay lại văn bản; tổng .
- Chu kỳ ngắn nhất là ; xâu là lặp nguyên vẹn khi chu kỳ ấy chia hết và nhỏ hơn .
- Cây viền (cha của là ) cho số lần xuất hiện của mọi tiền tố bằng cộng dồn cây con.
- Bảng chuyển biến KMP thành tự động, làm trạng thái cho quy hoạch động đếm xâu.
Bài tập
Đọc thêm
- Thuật toán KMPVNOI Wiki
- Prefix function. Knuth-Morris-Pratt algorithmcp-algorithms
- 前缀函数与 KMP 算法OI Wiki
- String SearchingUSACO Guide
- Knuth-Morris-Pratt algorithmWikipedia