④ 数据结构与算法 — 第 17 课
pi 数组(很多书上叫 next)。
j、不退 i。pi[i] = 前缀 s[0..i] 的「最长 border」长度(既是前缀又是后缀、不含自身)。while (j > 0 && s[i] != s[j]) j = pi[j - 1];最直接的做法:让短串 s2 从长串 s1 的每个位置开始,一个字符一个字符比:
for (int i = 0; i + m <= n; i++) {
int k = 0;
while (k < m && s1[i + k] == s2[k]) k++;
if (k == m) 找到一个匹配(位置 i + 1)
}
复杂度是 O(n·m)。最坏情况长这样:
s1 = AAAAAAAA…A (100 万个 A)
s2 = AAAAA…AAB (999 个 A 加一个 B)
每次都在最后一个字符上失败,于是每个起点都要白比 1000 个字符。实测(n1 = 106、n2 = 1000):247 ms;把 n2 加到 8000,直接 1885 ms。
pi 数组,失配时直接跳。
先看一个字符串:ABABCABAA。它的前缀 ABABCABA 有一个性质:
ABAB(…AB 里的 AB)「既是前缀、又是后缀」且不是自己的那个最长子串,叫 border。这里 ABABCABA 的最长 border 是 ABA(长度 3)。
pi[i] 的定义:前缀 s[0..i] 的最长 border 长度。pi[i] = k 表示 s[0..k-1] == s[i-k+1..i],且 k 取最大(k < i+1)。
ABABCABAA 的 pi| i | 字符 | 进来时 j = pi[i−1] | 回退过程 / 比较 | pi[i] |
|---|---|---|---|---|
| 0 | A | — | 单个字符没有非自身 border | 0 |
| 1 | B | j = 0 | B ≠ s[0]=A → 不动 | 0 |
| 2 | A | j = 0 | A == s[0]=A → j = 1 | 1 |
| 3 | B | j = 1 | B == s[1]=B → j = 2 | 2 |
| 4 | C | j = 2 | C ≠ s[2]=A → j = pi[1] = 0;C ≠ s[0] | 0 |
| 5 | A | j = 0 | A == s[0]=A → j = 1 | 1 |
| 6 | B | j = 1 | B == s[1]=B → j = 2 | 2 |
| 7 | A | j = 2 | A == s[2]=A → j = 3 | 3 |
| 8 | A | j = 3 | A ≠ s[3]=B → j = pi[2] = 1;A ≠ s[1]=B → j = pi[0] = 0;A == s[0] → j = 1 | 1 |
所以 pi = 0 0 1 2 0 1 2 3 1(程序实测输出一致)。
看 i = 8 那一行:它回退了两次(3 → 1 → 0),然后才重新配上。这就是 pi 数组最反直觉的地方 —— 失配时不是从 0 开始,而是沿着 pi 一层层往下退,每一层都还是「可能继续匹配的前缀」。
核心就三行:
for (int i = 1; i < m; i++) {
int j = pi[i - 1]; // 从「上一个前缀的最长 border」接着试
while (j > 0 && s2[i] != s2[j]) j = pi[j - 1]; // 配不上就退一层,退到 0 为止
if (s2[i] == s2[j]) j++; // 配上了,border 长度 +1
pi[i] = j;
}
pi[j - 1],不是 pi[j]?j 是「当前 border 的长度」,也就是「下一个待配字符的下标」。pi[j-1] 是「长度 j 的前缀的最长 border」—— 退一步就是去找「比 j 短的、仍然可能是前缀」的 border。写成 pi[j] 就整体错位一格,样例可能还能过,大数据必挂。
j 每次成功只 +1,而总共只加了 m 次;每次 j = pi[j-1] 都让 j 严格变小。所以「回退」的总次数不超过「增加」的总次数 = O(m)。有了 pi 数组,匹配就是同一套循环:
int j = 0;
for (int i = 0; i < n; i++) {
while (j > 0 && s1[i] != s2[j]) j = pi[j - 1]; // 失配:退 j,不动 i
if (s1[i] == s2[j]) j++; // 配上:j 往前走
if (j == m) { // 整个模式串配完了
cout << i - m + 2 << '\n'; // 出现位置(1-based)
j = pi[j - 1]; // ★ 继续找下一处(可能重叠)
}
}
用样例 s1 = ABABABC、s2 = ABA(pi = 0 0 1)逐字符走一遍:
| i | s1[i] | 进来时 j | 发生了什么 | 之后 j |
|---|---|---|---|---|
| 0 | A | 0 | 配 s2[0] | 1 |
| 1 | B | 1 | 配 s2[1] | 2 |
| 2 | A | 2 | 配 s2[2] → j = 3 = m,记录位置 1,回退 j = pi[2] = 1 | 1 |
| 3 | B | 1 | 配 s2[1] | 2 |
| 4 | A | 2 | 配 s2[2] → j = 3 = m,记录位置 3,回退 j = pi[2] = 1 | 1 |
| 5 | B | 1 | 配 s2[1] | 2 |
| 6 | C | 2 | C ≠ s2[2]=A → 退 j = pi[1] = 0;C ≠ s2[0] | 0 |
输出 1、3 —— 和样例一致。
j = pi[j-1]?s1 = AAAAA、s2 = AA,位置 1、2、3、4 都是答案。如果匹配成功后把 j 清成 0,就会漏掉 2、3、4。j == m 时如果继续用 s2[j] 就会越界,所以这一步也是必须的。
#include <bits/stdc++.h>
using namespace std;
int main() {
ios::sync_with_stdio(false); cin.tie(nullptr);
string s1, s2;
cin >> s1 >> s2;
int n = (int)s1.size(), m = (int)s2.size();
// ① 求 pi 数组
vector<int> pi(m, 0);
for (int i = 1; i < m; i++) {
int j = pi[i - 1];
while (j > 0 && s2[i] != s2[j]) j = pi[j - 1];
if (s2[i] == s2[j]) j++;
pi[i] = j;
}
// ② 匹配
int j = 0;
for (int i = 0; i < n; i++) {
while (j > 0 && s1[i] != s2[j]) j = pi[j - 1];
if (s1[i] == s2[j]) j++;
if (j == m) {
cout << i - m + 2 << '\n'; // 1-based 位置
j = pi[j - 1];
}
}
// ③ 输出 pi 数组(P3375 要求的最后一行)
for (int i = 0; i < m; i++) cout << pi[i] << (i + 1 == m ? '\n' : ' ');
}
实测四组(本机编译运行,-O2 -std=c++17 -Wall -Wextra 零警告):
| 输入 | 输出 | 说明 |
|---|---|---|
ABABABC / ABA | 1、3、0 0 1 | 样例 |
AAAAA / AA | 1234、0 1 | 重叠匹配:4 处 |
ABC / Z | (无位置行)、0 | m = 1、无匹配 |
A / A | 1、0 | 边界 |
固定 n1 = 106,模式串是「n2−1 个 A 加一个 B」(最坏情况:每次都在最后一个字符失败):
| n2 | KMP | 暴力 | KMP 快几倍 |
|---|---|---|---|
| 500 | 1.7 ms | 126 ms | 75× |
| 1000 | 1.6 ms | 247 ms | 151× |
| 2000 | 1.5 ms | 486 ms | 317× |
| 4000 | 1.9 ms | 959 ms | 512× |
| 8000 | 1.6 ms | 1885 ms | 1163× |
KMP 一列几乎不动(1.5–1.9 ms),暴力一列随 n2 线性涨 —— 这就是 O(n+m) 与 O(n·m) 的区别,不是常数快慢。
另外两组真实场景:
| 数据 | KMP 耗时 | 结果 |
|---|---|---|
n1 = 106 全 A,n2 = 105 全 A(匹配极多) | 2.0 ms | 900001 处匹配 |
n1 = 106 随机,n2 = 105 随机 | 1.0 ms | 0 处匹配 |
正确性也对拍过:500 组随机小串,KMP 的结果与暴力完全一致(0 组不一致)。
j = pi[j - 1],不是 pi[j]:差一格,小数据可能蒙对,大数据必挂j = pi[j-1]:否则漏掉重叠匹配(AAAAA 找 AA 只报 1 处)i - m + 2(因为 i 是 0-based 的末尾下标)|s2| 个 pi 值,含 pi[0] = 0;空格隔开、行末换行,别输出成一行位置j == m 时不能再用 s2[j]:越界;回退要立刻做ios::sync_with_stdio(false); cin.tie(nullptr);,字符串 106 级时差别明显int 就够(值 ≤ 106),不用 long longm = 1):求 pi 的循环一次都不跑,pi[0] = 0,别写成从 0 开始导致越界pi[i] 的定义是什么?pi[j-1]?j = pi[j-1]?|s1|, |s2| ≤ 106,全大写字母。三件必须自己对的东西:① 位置是 1-based(i - m + 2);② 最后一行输出 |s2| 个 pi 值、空格隔开;③ 匹配成功后立刻 j = pi[j-1]。AAAAA 找 AA,答案应该是 4 处(1、2、3、4)。如果你只输出 1 处,就是匹配后没回退。i = 1 开始的吗?pi[j-1] 吗?+1(或 i - m + 2)吗?m 个数吗?O(n·m),慢在「每次失败都扔掉已比过的信息」pi[i] = 前缀 s[0..i] 的最长 border 长度;ABABCABAA → 0 0 1 2 0 1 2 3 1j = pi[i-1] → while (j > 0 && s[i] != s[j]) j = pi[j-1] → 配上则 j++i 只前进,失配只退 j;j == m 时记录位置并 j = pi[j-1]O(n+m):j 回退总次数被「增加次数」卡住n1 = 106 最坏数据上 KMP 1.6 ms vs 暴力 247 ms;n2 从 500 加到 8000,暴力从 126 ms 涨到 1885 ms,KMP 几乎不动pi[j-1] 写成 pi[j]1 / 3 / 0 0 1、重叠 AAAAA/AA 得 4 处、ABC/Z 只输出 0、A/A 输出 1 和 0;500 组随机与暴力对拍 0 不一致;第 6 节所有耗时都是本机实测。哪一句看不懂 —— 比如「为什么 pi[j-1] 能保证不漏」「border 到底在算什么」—— 直接问我,我从零再讲一遍。