KMP 回炉重造
前言
模拟赛的时候 T2 正解要用 KMP,然而本咕在学长讲 KMP 时没有认真听…… (STO @int_R 学长)
来 回炉重造 复习了。
引入
我们经常会遇上诸如这一类题:
现在给定一个字符串 \(S\),判断字符串 \(P\) 是否是字符串 \(S\) 的一个子串。我们可以称 \(P\) 为模板串,\(S\) 为文本串。
好的先打超级大暴力!
暴力做法
直接遍历每一个起点,检查是否能匹配模板串。
Code:
#include<bits/stdc++.h>
using namespace std;
string s,p;
int main(){
cin>>s>>p;
for(int i=0; i+p.size()-1<s.size(); i++){
if(s.substr(i,p.size())==p) exit(puts("Yes")*0);
}
else cout<<"No";
return 0;
}
由于 string 的内置 substr 函数也是 \(\mathcal{O}(n)\) 的复杂度,因此这种做法就是稳定的 \(\mathcal{O}(nm)\) 的,其中,\(n\) 表示文本串长度,\(m\) 表示模板串长度。
显然只要字符串长度略大一点就会直接炸掉呢……
然后我们还有一种看起来不那么暴力的暴力:
#include<bits/stdc++.h>
using namespace std;
string s,p;
int main(){
cin>>s>>p;
int n=s.size(), m=p.size();
int i=0,j=0;
while(i<n && j<m){
if(s[i]==p[j]) i++,j++;//匹配上了,同时后移
else{
i-=j-1;
j=0;//没匹配上,之前的都要重新算
}
}
cout<<i-j<<endl;
return 0;
}
实际上没有什么区别,复杂度都是 \(\mathcal{O}(nm)\) 的。
然后就有人发明了 KMP 算法!
正文
首先还是这个问题,我们能优化显然是因为暴力有能优化的地方。 废话
所以到底是哪里出了问题才导致这个暴力如此 TLE 呢?
我们考虑极端情况:
S="010101010100";
P="0100";
那么按照上面的程序,我们就会让模板串每次右移一个字符找匹配,然后发现只有到了最后一个字符才会出现不匹配,这样的话前面的所有匹配就要全部扔掉……
但是实际上前面这些已经匹配上的 0 实际上还是有用的,我们不应该直接扔掉啊,
要是能有什么办法把那些还能用的已匹配字符存下来就好了……
思路
很好你已经找到 KMP 的大致方向了!我们就是要找一个指针 \(next_j\) 使得在模板串上某一位置 \(j\) 匹配失败时,应该回退到哪一个合法匹配位置,使得能够充分利用之前已经匹配上的值。
怎么理解呢?我们考虑上面那个例子:

咕咕咕,十分生动形象的例子呢!
等会我要说啥来着
然后我们该怎么算 \(next_j\) 捏……
关于 \(next\)
在算 \(next\) 指针之前,先来认真想一想他的本质是什么。什么情况下我们会用到 \(next\) 指针?以及它让我们干了什么?
事实上,由于之前的文本串的某一段已经完美匹配模板串 \([0,j)\) 了,那么也就是说 这一段和模板串一模一样,也就可以当做模板串。
要把前面已经匹配上的区间再次利用,那么这一段就必须 和之前匹配上的 后面的一部分匹配。以上图为例,我们在第四位 \(0\) 失配后,应该跳转到 \(next_j=1\) 表示由第四位失配后直接可以沿用 \(\left[0,1 \right)\),再在第一位的后面继续接。
换句话说,现在已经匹配到第 \(j\) 位且第一次失配,那么显然前 \([0,j-1)\) 是已经完美匹配的了。但是第 \(j\) 位失配了,所以现在要把模板串右移一些,让之前的 \([0,next_j)\) 重新匹配上 \([j-next_j,j)\)。
于是,问题就变成了:
在模板串的一段区间 \([0,j)\) 内,找到一段区间的 真前缀 \([0,next_j)\),使得它与这段区间的 真后缀 \([j-next_j,j)\) 完全相同,而且尽可能长。
也许不是很好理解?我们还是以图为例:

好懂多了吧
然后这个 \(next\) 要肿么求嘞……
是啊怎么求啊
求 \(nexy\) 指针
首先我们钦定我们已经知道前 \([0,k)\) 位的 \(next\) 指针了,现在要求 \(next_j\)。
那么一共两种情况:
- \(P_k=P_{next_k+1}\),也即这一位能够直接接在上一位后面且仍然满足要求。显然直接接上就行。
- \(P_k\ne P_{next_k+1}\),不能直接接上,这时候怎么办?
对于第二种情况,考虑现在已有了 \(k-1\) 位的最长前后同缀,那么现在我们希望 这个 前后同缀 的 一段后缀 和 他的一段前缀仍然完全相同,试试在这个 前后同缀 的 前后同缀 后面接上第 \(k\) 位。
哦啊,套娃好难懂,怎么理解呢?依旧上图:

啊啊啊画图好累
总结
很好,看到这我钦定你已经会求 \(next\) 了(?那么现在你可以自己 发明 写出 KMP 了!
来回顾一下:
- 第一步,求出模板串所有位置的 \(next\) 指针;
- 第二步,依次遍历文本串每一位寻找匹配。
- 第三步,若不匹配,无需回退文本串,将模板串右移使得 \(next_j\) 与现在的 \(j\) 对其即可。
- 依次遍历求解。
完结~
时间复杂度
分析一下时间复杂度:
- 我们会从左至右遍历模板串,\(\mathcal{O}(m)\) 预处理出每一个 \(next\) 指针;
- 然后从右向左遍历一遍文本串,无需回退和重载,直接算,\(\mathcal{O}(n)\);
总复杂度 \(\mathcal{O}(n+m)\),十分优秀!
Code
#include<bits/stdc++.h>
using namespace std;
const int N=1e5+7;
string s,p;
int nxt[N];
void build(){
int len=s.size();
int i=0,j=-1;
nxt[0]=-1;//小细节:处理归零匹配
while(i<len){
if(j<0 || p[j]==p[j]){
i++,j++;//匹配上了
nxt[i]=j;
}
else j=nxt[j];//沿着 next 找
}
}
int main(){
cin>>s>>p;
build();
int i=0, j=0;
int len=s.size(), lenp=p.size();
while(i<len && j<lenp){
if(j==-1 || s[i]==p[j]) i++,j++;
else j=nxt[j];
}//比较
if(j<lenp) cout<<"NO";
else cout<<"YES";
return 0;
}
引用
参考资料:
推荐习题:
画图真的很累的求求你给一个推荐叭……

浙公网安备 33010602011771号