KMP算法讲解
一、从暴力匹配到KMP:一个思考的演进
想象你有一个文本串(比如一篇文章)和一个模式串(一个你想查找的单词)。最直观的查找方式(暴力匹配,BF算法)是这样的:
- 将模式串与文本串的开头对齐,然后从左到右逐个比较字符。
- 如果某个字符不匹配,就将模式串整体向右移动一位,再从模式串的开头开始比较。
- 重复这个过程,直到找到匹配或文本串结束。
这个方法的缺点是,它丢弃了之前所有的比较信息。每当匹配失败,模式串的指针(j)就不得不退回到起点,文本串的指针(i)也要回溯。这在很多场景下做了大量的重复工作。
KMP算法的核心思想正是“利用已匹配的信息,让模式串的指针不需要完全回溯”。它通过一个预处理过的数组(通常称为next数组或部分匹配表),在匹配失败时,知道应该将模式串的指针j回退到哪个位置,而文本串的指针i则永远不回溯,继续向前走。
二、KMP算法的核心:部分匹配表(Next数组)
这个“已匹配的信息”就是模式串自身的对称性,即真前缀与真后缀的相等长度。next数组就是用来存储这些信息的。
约定:为了后面代码编写的方便,通常将
next[j]定义为:当模式串第j位(从0开始)字符与文本串失配时,模式串指针j应该跳转到的位置。一个经典且巧妙的初始化是将next[0]设为-1。
1. Next数组的递推计算
next数组的求解过程,本质上也是一个“自我匹配”的过程。我们用两个“指针”j(当前要求解next值的位置)和k(j之前已经匹配的前缀长度,也就是next[j]的候选值)。
- 初始状态:
j = 0,k = -1,next[0] = -1。-1是一个特殊标志,表示整个模式串都无前缀可匹配,需要将模式串整体右移一位。 - 递推规则:
- 如果
k == -1或者p[j] == p[k],说明找到了更长的相同前后缀,则j++,k++,并且令next[j] = k。 - 如果
p[j] != p[k],说明当前长度k的前缀不能作为j位置的后缀,我们需要寻找更短的可能相同前后缀。这正是k = next[k]这句代码的精髓,它利用了已计算好的next信息进行递归,保证了查找的效率。
- 如果
下面是求解next数组的C++代码模板,包含了详细注释:
#include <iostream>
#include <vector>
#include <string>
using namespace std;
/**
* 构建模式串 p 的部分匹配表(next数组)
* @param p 模式串
* @return next数组,next[i] 表示当模式串第 i 位失配时,j 应该跳转到的位置
*/
vector<int> buildNext(const string &p) {
int m = p.size();
// next[0] = -1 是一个巧妙的设置,使得当第一个字符就失配时,j 能变为 -1
// 这样在匹配函数中,j == -1 就代表要从模式串的第一个字符重新开始比较
vector<int> next(m, -1);
int j = 0; // j 是当前正在计算 next 值的索引
int k = -1; // k 是当前已知的、j 之前的最长相同前后缀的长度,也是 next[j] 的候选值
while (j < m - 1) {
if (k == -1 || p[j] == p[k]) {
// 情况1: k 为 -1,说明已无更短前后缀,next[j+1] 只能为 0 (k+1)
// 情况2: p[j] == p[k],说明找到了一个长度为 k+1 的相同前后缀
j++;
k++;
// 优化:如果 p[j] 和 p[k] 相等,那么当 p[j] 失配时,跳转到 p[k] 也会失配
// 所以可以直接再跳一次,即 next[j] = next[k];
// 这里为了清晰展示基础版本,先不写优化,后续题目中再使用
next[j] = k;
} else {
// 不匹配,则尝试寻找更短的相同前后缀
// 这正是 KMP 算法的核心递归:k = next[k]
// 因为 p[0...k-1] 是 p[0...j-1] 的后缀,我们要在这个后缀中寻找更短的前缀
k = next[k];
}
}
return next;
}
图解 k = next[k]:假设在计算next数组时,对于模式串p,我们已经有 p[0..k-1] == p[j-k..j-1],但 p[j] != p[k]。我们需要为j寻找一个更短的相同前后缀。因为p[0..k-1]本身就是p[0..j-1]的一个后缀,它的相同前后缀信息就存储在next[k]中。所以,我们将k更新为next[k],继续尝试匹配p[j]与新的p[k]。这个过程一直持续,直到找到匹配或k回退到-1。
2. KMP匹配过程
有了next数组,匹配过程就非常清晰了。
/**
* KMP 字符串匹配算法
* @param s 文本串
* @param p 模式串
* @return 模式串在文本串中所有出现位置的起始下标 (从0开始)
*/
vector<int> kmpSearch(const string &s, const string &p) {
vector<int> next = buildNext(p);
vector<int> positions;
int n = s.size();
int m = p.size();
int i = 0; // 文本串 s 的指针
int j = 0; // 模式串 p 的指针
while (i < n) {
if (j == -1 || s[i] == p[j]) {
// j == -1 表示模式串的第一个字符都不匹配,需要 i 和 j 都前进
// 或者字符匹配成功,指针同时前进
i++;
j++;
} else {
// 字符失配,j 根据 next 数组跳转,i 不变
j = next[j];
}
// 当 j 移动到模式串末尾,说明找到了一个完整匹配
if (j == m) {
// 匹配的起始位置为 i - m
positions.push_back(i - m);
// 找到匹配后,我们继续寻找下一个匹配。j 需要按照 next 数组回退一位,
// 因为模式串的最后一个字符已经匹配成功,我们需要利用它的 next 信息继续匹配。
// 回退到 next[j] 的位置,然后循环继续。
j = next[j];
}
}
return positions;
}
三、洛谷题目实战
下面我们通过三道洛谷上的经典题目,来巩固KMP算法的应用。
1. P3375 【模板】KMP字符串匹配
这是KMP算法最直接的模板题,要求输出所有出现位置以及模式串每个前缀的最长border(即next数组的值)。
题目分析
直接套用上述KMP算法即可。需要注意的是,题目中的next数组(题目中称为前缀数组)定义略有不同,通常是从1开始索引,且next[i]表示前i个字符组成的子串的最长相同前后缀长度。这与我们代码中从0开始、next[0]=-1的实现需要做一个映射。但核心思想完全一致。我们只需将求出的next数组按题目要求的格式输出即可。
AC代码
#include <iostream>
#include <string>
#include <vector>
using namespace std;
int main() {
string s1, s2;
cin >> s1 >> s2;
int n = s1.length();
int m = s2.length();
// ----- 构建模式串 s2 的 next 数组 -----
vector<int> next(m, -1);
int j = 0, k = -1;
while (j < m - 1) {
if (k == -1 || s2[j] == s2[k]) {
j++;
k++;
// 这里使用了一个常见的优化:如果 s2[j] == s2[k],则 next[j] 应该等于 next[k]
// 避免在匹配时进行无意义的跳转(因为跳转后还是相等的字符,依然会失配)
if (s2[j] == s2[k]) {
next[j] = next[k];
} else {
next[j] = k;
}
} else {
k = next[k];
}
}
// ----- KMP 匹配过程 -----
j = 0;
for (int i = 0; i < n; i++) {
while (j > 0 && s1[i] != s2[j]) {
j = next[j]; // 失配时,j 根据 next 数组跳转
}
if (s1[i] == s2[j]) {
j++;
}
if (j == m) {
// 题目要求输出位置从 1 开始计数
cout << i - m + 2 << endl;
j = next[j]; // 找到一个匹配后,继续寻找下一个
}
}
// ----- 输出 next 数组,题目要求格式(从 1 开始,无 -1)-----
// 注意:我们求的 next[0] 是 -1,但题目第一个输出对应的是长度为 1 的前缀的 border 长度,应该是 0
// 所以从索引 1 开始输出,并且值为我们 next 数组的值 +1? 需要仔细验证。
// 更稳妥的方法是,根据题目定义的 next 数组重新生成一份。
// 但观察我们的 next 数组:对于模式串 "ABA",
// j (代码索引): 0 1 2
// next[j]: -1 0 0 (经过优化后)
// 长度为1的前缀 "A" 的border长应为0 -> 对应 next[1]? 0
// 长度为2的前缀 "AB" border长0 -> next[2]? 0
// 长度为3的前缀 "ABA" border长1 -> 但我们的next[2]是0,不对应。
// 这说明我们的 next 数组定义与题目不同。题目要求的 next[i] (i从1开始) 表示前 i 个字符最长 border 长度。
// 它可以通过我们构建的未优化的 next 数组得到,或者我们重新构建一个。
// 为了不引入过多复杂性,这里采用更直观的方式:再求一次题目要求的 next 数组。
vector<int> pmt(m, 0);
j = 0;
for (int i = 1; i < m; i++) {
while (j > 0 && s2[i] != s2[j]) {
j = pmt[j - 1];
}
if (s2[i] == s2[j]) {
j++;
}
pmt[i] = j;
}
for (int i = 0; i < m; i++) {
cout << pmt[i] << " ";
}
cout << endl;
return 0;
}
2. P4391 [BOI2009]Radio Transmission 无线传输
这道题要求我们找到一个字符串的最小周期。它巧妙地利用了KMP算法中next数组的性质。
题目分析
假设原始字符串是s,它的一个周期是t,意思是s可以由若干个t重复拼接而成(最后一段可能不完整)。例如,s = "abcabcab",周期可以是"abc"。
KMP算法中的next数组(指未优化的基础版本)给出了字符串的最长相同前后缀长度。对于长度为L的字符串,其最小周期长度等于 L - next[L](这里next数组索引从1开始,next[L]表示整个字符串的最长相同前后缀长度)。
证明:如果字符串存在长度为p的周期,那么字符串的前L-p个字符必然等于后L-p个字符。即 s[0..L-p-1] == s[p..L-1],这正是长度为L-p的相同前后缀。我们要找最小周期,就要找最大的L-p,也就是最长的相同前后缀。因此 min_period = L - max_border。
AC代码
#include <iostream>
#include <string>
#include <vector>
using namespace std;
int main() {
int n;
string s;
cin >> n >> s;
// 构建 next 数组(基础版,未优化)
vector<int> next(n + 1, 0); // 为了方便理解,我们让索引从1开始,next[1]=0
int j = 0;
for (int i = 2; i <= n; i++) {
while (j > 0 && s[i - 1] != s[j]) {
j = next[j];
}
if (s[i - 1] == s[j]) {
j++;
}
next[i] = j;
}
// 最小周期长度 = n - 最长相同前后缀长度
int min_period = n - next[n];
cout << min_period << endl;
return 0;
}
3. P4824 [USACO15FEB]Censoring S
这道题要求我们从字符串中反复删除一个指定的模式串,直到不再包含该模式串。直接使用KMP配合栈可以高效解决。
题目分析
- 对模式串
t构建next数组。 - 遍历文本串
s的每个字符,将其压入栈中。 - 在遍历每个字符时,就像KMP匹配一样,维护一个
j(当前已匹配的模式串长度)。 - 如果当前字符
c与模式串的t[j]匹配,则j++;否则j = next[j](回溯)。 - 如果
j达到了模式串的长度m,说明在栈顶的m个字符恰好构成了一个模式串。这时,我们就将这m个字符从栈中弹出,同时,j需要更新为弹出前栈顶元素所对应的已匹配长度。因此,我们还需要一个辅助数组pos[i]来记录栈中第i个元素在匹配过程中对应的j值。
AC代码
#include <iostream>
#include <string>
#include <vector>
#include <stack>
using namespace std;
int main() {
string s, t;
cin >> s >> t;
int n = s.length();
int m = t.length();
// 1. 构建模式串 t 的 next 数组
vector<int> next(m, -1);
int j = 0, k = -1;
while (j < m - 1) {
if (k == -1 || t[j] == t[k]) {
j++; k++;
next[j] = k;
} else {
k = next[k];
}
}
// 2. 遍历 s,使用栈和 KMP 进行匹配和删除
stack<char> stk; // 存储最终结果的栈
vector<int> pos(n + 1, 0); // pos[i] 记录栈中第 i 个字符时,匹配到模式串的位置 j
j = 0;
for (int i = 0; i < n; i++) {
char c = s[i];
// KMP 匹配逻辑
while (j > 0 && c != t[j]) {
j = next[j];
}
if (c == t[j]) {
j++;
}
// 将当前字符和其匹配长度 j 入栈
stk.push(c);
pos[stk.size()] = j;
// 如果匹配长度 j 等于模式串长度,说明找到了一个完整的模式串
if (j == m) {
// 从栈中弹出 m 个字符
for (int cnt = 0; cnt < m; cnt++) {
stk.pop();
}
// 更新 j 为栈顶元素对应的匹配长度。如果栈为空,则 j = 0
j = stk.empty() ? 0 : pos[stk.size()];
}
}
// 3. 输出栈中剩余的字符
string result;
while (!stk.empty()) {
result.push_back(stk.top());
stk.pop();
}
// 由于栈是后进先出,需要反转
for (int i = result.size() - 1; i >= 0; i--) {
cout << result[i];
}
cout << endl;
return 0;
}
希望这份讲解能帮助你理解和掌握KMP算法。从模板题到思维题,逐步深入,是学习算法的有效路径。祝你刷题愉快!

浙公网安备 33010602011771号