KMP算法
有点难的东西,彻底理解大概需要一两天的思考。
接下来的讲解将会分为引子,正文,以及常见问题三部分。
引子
我们首先看暴力法。
这不需要任何脑子,就是使用 \(i\) , \(j\) 两个指针分别指向文本串和模式串进行比对,复杂度显然 \(O(n^2)\)。
考虑到底是哪里浪费了时间。每次的匹配我们其实已经知道了文本串的一些信息,因此我们应该避免获取重复的信息。
顺藤摸瓜,我们的主要目标明确了。即避免 \(i\) 指针的回溯。
正文
介绍一下 KMP 算法的核心点:公共前后缀数组。
意思就是一个字符串的前缀串的最长公共前后缀的长度。记作 \(next\)。
abbab 的 next 数组如下
0 0 0 1 2
这玩意有啥用呢?就是你匹配的时候模式串移动到的位置。
现在给出一个例子。
假设你已经求出了模式串的 \(next\) 数组。那么 \(next_3 = 2\),你就可以直接把整个模式串往右移动到下标为 2 的地方。
然后你再看现在移动完成后两个串的下一位能否匹配来决定是否要继续往前查 \(next\)。这个图里是相同的,所以 \(i\), \(j\) 都增加。
此时 \(i = 4,j = 3\)。
如果不相同,你就要继续往前找 \(next\) 数组。在这个图里就是 \(next_{next_i} = 1\)。当然这只是假设。
概括一下我们干的事情其实就是找一个最长的可以接在文本串第 \(i\) 位前面的模式串的前缀。这样一来就大大优化了比对的次数。
这部分的完整代码如下。
j = 0;
for(register int i = 1; i <= n; ++i) {
while(s2[j + 1] != s1[i] && j >= 1) {
j = phi[j]; //如果不相同就继续往前找更小的公共前后缀
}
if(s2[j + 1] == s1[i]) {
++j; //相同就增加
}
if(j == m) {
cout << i - j + 1 << '\n';
j = phi[j]; //这里别忘了匹配也要跳
}
}
然后就是怎么求 \(next\) 数组了。这个好理解,比上文简单些。
我们依然采用刚才的代码结构。我们可以发现 \(next_i\) 一定是要比 \(next_{next_i}\) 长的,因此可以直接暴力的比对,第一个找到的一定是最长的。
也就相当于是要找一个公共前后缀能接上新的这一位。听不懂没关系,看代码就懂了。
int j = 0;
for(register int i = 2; i <= m; ++i) { //phi[1]是 0,就不从 1 开始写了
while(s2[j + 1] != s2[i] && j >= 1) {
j = phi[j]; //不一样就往前找更小的
}
if(s2[j + 1] == s2[i]) {
++j;//一样就增加
}
phi[i] = j; //记录
}
完整代码如下
#include <bits/stdc++.h>
const int N = 1e6 + 7;
using namespace std;
string s1 , s2;
int phi[N];
int main() {
ios :: sync_with_stdio(0) , cin.tie(0) , cout.tie(0);
cin >> s1 >> s2;
int n = s1.size() , m = s2.size();
s1 = '$' + s1 , s2 = '$' + s2;
int j = 0;
for(register int i = 2; i <= m; ++i) {
while(s2[j + 1] != s2[i] && j >= 1) {
j = phi[j];
}
if(s2[j + 1] == s2[i]) {
++j;
}
phi[i] = j;
}
j = 0;
for(register int i = 1; i <= n; ++i) {
while(s2[j + 1] != s1[i] && j >= 1) {
j = phi[j];
}
if(s2[j + 1] == s1[i]) {
++j;
}
if(j == m) {
cout << i - j + 1 << '\n';
j = phi[j];
}
}
for(register int i = 1; i <= m; ++i) {
cout << phi[i] <<' ';
}
return 0;
}
常见问题
-
关于复杂度?
这个好讲。在预处理 \(next\) 的时候,可以发现 \(j\) 一共增加不会超过 \(m\) 次,你跳至少跳一个,所以也不会跳超过 \(m\) 次。因此你的预处理复杂度是 \(O(m)\)。
而匹配的时候,遍历整个文本串就需要 \(O(n)\) 的时间,因此总复杂度是 \(O(n + m)\) 的。
-
不会漏情况嘛?
这算法看着很不靠谱。如果在你 \(j\) 和 \(next_j\) 之间还有能匹配的不就漏了吗?
然而这是不会发生的。因为你 \(i\) 是一位一位往右移的,因此漏掉的情况总会有一个 \(i\) 给判断掉。不能只考虑一个 \(i\),而是要全盘思考。我不认为除我会有人为这个简单的事情疑惑很久罢。
总结: KMP 其实本质上还是枚举文本串的每一位,只不过是跳过了前面已知的相同前缀的比对。是十分精妙的巧思。