KMP算法
---目录
---前言
有句话这么来形容KMP算法:
一个人能走的多远不在于他在顺境时能走的多快,而在于他在逆境时多久能找到曾经的自己。
在字符串的领域,KMP算法是一个很重要的算法,其主要用来解决字符串匹配的问题,作为一个算法来说,它的效率十分之高,并且思维十分精妙,曾在世界“最美算法”大赛中荣获第一,其提出者Knuth,Morris,Pratt(也是KMP名字的来源)也十分有名,其中最著名的Knuth曾著一本书《计算机程序设计艺术》,这本书在计算机领域的地位相当于欧几里得的《几何原本》在数学界的地位,这也可见Knuth的能力之强,而本章提到的KMP算法,就是这种“艺术”的一个体现。
(可能初赛会考T_T)
---正文
KMP算法的意义
(本文中全部默认以1为起始下标)
在解决关于字符串的题目中,我们经常要求一个模式串在某个文本串中出现的次数(字符串匹配)类型的问题,那么该如何解决这一类问题呢,最容易想到的就是暴力枚举,如何暴力枚举呢?(比如求AB在BAB中出现的次数):
我们考虑枚举文本串的每一个字符,如果该字符与模式串的第一个字符匹配,则继续枚举文本串的下一个字符和模式串的下一个字符,直到文本串与模式串的某个字符不匹配或完全匹配,然后我们还要将文本串回溯到开始字符的下一个字符(如下图)。
那么,这种比较方式有什么弊端呢?观察上述图片我们可以发现,在第三张图片时我们已经遍历过了文本串的第三个字符,知道了它是B,不可能与模式串的第一个字符匹配,但是我们仍然回溯文本串遍历了这个字符,这样就会花费大量的时间,最坏时间复杂度为O(nm),最好时间复杂度为O(n),空间复杂度为O(1),那么我们如何根据上述条件考虑优化时间呢,观察到,这种方式并不消耗空间,所以我们可以尝试以空间换时间。
说到这里,很多人都会想到哈希,如果使用双哈希的话时间复杂度为O(3n+2m),已经达到了线性,但是,有没有比其更优的算法呢?
这种算法就是KMP,那它是如何运作的呢?其核心思想就是让文本串不走回头路(见前言),不去枚举那些不可能匹配的字符串开头,那么就可以在O(n+m)的极优时间复杂度下完成查找,那么KMP算法的过程是什么呢,请见下文。
KMP算法的过程
想要了解KMP算法的运作过程,我们首先要了解一个概念:border(最长公共前后缀),它是什么意思呢?
前缀:除最后一个字符外,字符串的所有头部子串;
后缀:除第一个字符外,字符串的所有尾部子串;
那么对于一个字符串来说,它的border就是它最长的一对相同的前缀和后缀,比如说对于ababa这个字符串,它的最长公共前后缀为aba,长度为3,这个是怎么求出来的呢?它是这个子串中的前缀ababa和后缀ababa,因为它们两个相同,且这是一对最长的公共前后缀,所以它就是ababa这个字符串的border。
注意:一个字符串的border不能等于该串本身。
了解了border的概念后,我们还需要了解一个辅助我们进行KMP算法的容器,Next表,其中,Next[i]表示“字符串中以 i 结尾的非前缀子串”与“字符串的前缀”能够匹配的最长长度。换句话说,如果引用border的概念,那么Next[i]就表示字符串中以1开头,以i结尾的字符串的border长度,比如说,对于字符串ababa来说,它的每位Next[i]如下表
i | 1 | 2 | 3 | 4 | 5 |
Nexr[i] | 0 | 0 | 1 | 2 | 3 |
border | / | / | a | ab | aba |
那么,我们再进行考虑:如何求解 Next 表呢?我们先来考虑对于一个字符串 ts,如何求得 next 数组:我们依次求出 Next[1],Next[2],... ,假设当前已经求出了 Next[1],Next[2],...,Next[i] 的值,接下来要求 Next[i+1] 的值。有如下结论:如果 s[Next[i]+1]==s[i+1] ,那么 Next[i+1] 显然应该等于 Next[i]+1 。这个结论是如何得到的呢?我们看一下Next数组的概念就可以发现,s[Next[i]+1]就是以i结尾的字符串的最长公共前后缀里前缀的下一个字符,而s[i+1]就是要求Next值的字符,如下图,我们已经知道了两个蓝圈(Next[i])内的字符完全匹配,那么如果两个红框内的字符也匹配,两边的字符就会完全匹配,那么第i+1位的Next值明显就是第i位的Next值(蓝圈)+1得到。
举个例子,对于字符串 ababa,我们已经求出前4位的Next值分别为0.0.1.2,然后通过上式发现s[5]=s[2+1],ababa,所以Next[5]=Next[4]+1=3,与暴力求解答案一致。
继续考虑,如果s[Next[i]+1]!=s[i+1]呢,难道又要重新从头判断吗?,当然不是,对于这种情况,如下图,我们发现前缀 Next[i] 的 border,也即前缀 Next[Next[i]],它同时是前缀 Next[i] 的后缀,而前缀 Next[i] 又是前缀 i 的后缀,所以前缀 Next[Next[i]] 也是前缀 i 的后缀。
所以说,对于这种情况,我们可以再判断s[Next[Next[i]]+1] 是否等于 s[i+1] ,如果相等则说明Next[i]=Next[Next[i]]+1,否则则继续嵌套判断,直到相等或[Next[Next...[i]]+1]=0,如果其等于0,就说明前面没有任何一个字符与目前字符相等,则Next[i+1]=0。
例如,对于字符串 s=ababaccababab,我们已经求出 next[1] 到 next[12],
现在要求Next[13] 的值。
Next[12] = 5,s[5+1]=c,s[13]=b,ababacabababab。所以 s[Next12+1] != s[13]。Next[5] = 3 , s[3+1]=b,s[13]=b , ababaccabababab 。 二 者 相 等 , 即s[Next[Next[12]]+1]=s[13]=b,所以得到 Next[13]=Next[Next[12]]+1=4。
假如在比较过程中,继续不相等呢?
我们再去找 Next[3]... 如果都不行,就说明 Next[13]=0
由于过程比较复杂,所以这里贴一下模板(其实实现码量并不大):
int Next[1000005];
void get_next(string s){
int i,j;
for(Next[1]=j=0,i=2;s[i];i++){
while(j&&s[i]!=s[j+1]){
j=Next[j];
}
if(s[i]==s[j+1]) j++;
Next[i]=j;
}
}
经过了这么多步骤,我们终于要开始进行KMP了,那么先前讲的border和Next数组和求字符串匹配有什么关系呢?如果我们考虑把主串当成“尺”,由模式串不停的在主串上移动进行比较,(见上述暴力枚举过程图片),那么暴力枚举的方案就是每次移动一下模式串,而KMP算法则是一次移动多次模式串(或者说直接移下部分原模式串),如下图
那么,上面的图片是怎么更改模式串的呢?
可以看到图中的蓝色箭头,旧的后缀要与新的前缀保持一致。回忆 Next 数组的意义,P[0]到 P[i]这一段子串中,前 Next[i]个字符与后 Next[i]个字符一模一样。如果失配在 P[r], 那么 P[0]~P[r-1]这一段里面,前 Next[r-1]个字符恰好和后 Next[r-1]个字符相等——也就是说,我们可以拿长度为 Next[r-1] 的那一段前缀,来顶替当前后缀的位置,让匹配继续下去。
而当模式串遍历完时,文本串当前的字符位置就是匹配成功的子串的结尾下标。
如果想要深入理解KMP算法,可以自行根据上述过程推导下图,但是由于推导过程过长,此处不予展示。
由于过程也比较复杂,所以这里还是贴一下模板(码量仍然不大):
void get_ans(){
int i,j;
for(j=0,i=1;s[i];i++){
while(j&&s[i]!=b[j+1]){
j=Next[j];
}
if(s[i]==b[j+1]){
j++;
}
if(!b[j+1]){
j=Next[j];
}
}
}
KMP算法的实现
经历了这么多的推导过程,相信大家也对KMP算法有了深入的了解,也惊叹于KMP的巧妙,下面就让我们做几道题练练手吧。
T1.KMP算法字符串匹配
题面概括:
给定字符串s1和s2,从小到大输出s2在s1中出现的所有位置,并输出s2每个前缀的border值。
思路:
真正的模板题,直接根据上述过程求出位置输出即可,注意:开始下标是i-j+1(i,j见上述模板)。
代码:
#include<bits/stdc++.h>
using namespace std;
int Next[1000005],cnt;
map<string,int> ma;
string s,b;
void get_next(string s){
int i,j;
for(Next[1]=j=0,i=2;s[i];i++){
while(j&&s[i]!=s[j+1]){
j=Next[j];
}
if(s[i]==s[j+1]){
j++;
}
Next[i]=j;
}
}
void get_ans(){
int i,j;
for(j=0,i=1;s[i];i++){
while(j&&s[i]!=b[j+1]){
j=Next[j];
}
if(s[i]==b[j+1]){
j++;
}
if(!b[j+1]){
cout<<i-j+1<<endl;
j=Next[j];
}
}
}
int main(){
cin>>s>>b;
s=' '+s;
b=' '+b;
get_next(b);
get_ans();
for(int i=1;i<b.size();i++){
cout<<Next[i]<<" ";
}
return 0;
}
T2.字符串最大值
题面概括:
给定字符串s,求其所有前缀长度*前缀出现次数的最大值
思路:
从 border 的角度出发,可以发现对于一个长度为 i 的前缀,其 border 是另外一个比它小的前缀,同时这个前缀在长度为 i 的前缀中作为后缀也出现了一次。对于这个小的前缀的border,这个性质依旧成立。因此,我们先将 Next 数组求出来,然后从后向前求出每个前缀 i 在字符串中出现的次数cnt[i] ,根据上面的结论我们可以得到递推式(cnt初始全为1,因为至少出现了一次):
cnt[Next[i]]=cnt[Next[i]]+cnt[i]
代码:
#include<bits/stdc++.h>
using namespace std;
int Next[100005],cnt[100005];
string s,b;
void get_next(string s){
int i,j;
for(Next[1]=j=0,i=2;s[i];i++){
while(j&&s[i]!=s[j+1]){
j=Next[j];
}
if(s[i]==s[j+1]){
j++;
}
Next[i]=j;
}
}
int main(){
cin>>s;
s=' '+s;
get_next(s);
for(int i=1;i<s.size();i++){
cnt[i]=1;
}
for(int i=s.size()-1;i>=1;i--){
cnt[Next[i]]+=cnt[i];
}
int maxx=0;
for(int i=1;i<s.size();i++){
maxx=max(maxx,cnt[i]*i);
}
cout<<maxx;
return 0;
}
T3.剪布条
题面概括:
给定字符串s1和s2,从小到大输出s2在s1中出现的所有位置,并且这些子串不能有重叠部分。
思路:
也是模板题,只需要每找到一个匹配的就将模式串从头开始就可以。
代码:
#include<bits/stdc++.h>
using namespace std;
int Next[1000005],cnt;
map<string,int> ma;
string s,b;
void get_next(string s){
int i,j;
for(Next[1]=j=0,i=2;s[i];i++){
while(j&&s[i]!=s[j+1]){
j=Next[j];
}
if(s[i]==s[j+1]){
j++;
}
Next[i]=j;
}
}
void get_ans(){
int i,j;
for(j=0,i=1;s[i];i++){
while(j&&s[i]!=b[j+1]){
j=Next[j];
}
if(s[i]==b[j+1]){
j++;
}
if(!b[j+1]){
cnt++;
j=0;
}
}
}
int main(){
while(cin>>s>>b&&s!="#"){
cnt=0;
memset(Next,0,sizeof Next);
s=' '+s;
b=' '+b;
get_next(b);
get_ans();
cout<<cnt<<endl;
}
return 0;
}
T4.拼接字符串
题面概括:
给定两个字符串s1和s2,求出最长的相同的s1的后缀和s2的前缀,并去掉其中一个,再将s2拼接到s1后面输出,其中s1和s2可以互换。
思路:
我们这里要求的是最长的公共的 s1 的前缀、s2 的后缀,或者最长的公共的 s2 的前缀、s1 的后缀。那么可以用 KMP 求 s1+s2 的 border,如果 border 长度不超过 s1.size()和 s2.size(),这个 border 就是 s1 的前缀和 s2 的后缀,满足题目要求。求 s2+s1 的 border 与此同理。就算 border 长度超出限制了,也可以通过不断跳 p 数组来找到最大的满足长度要求的公共前后缀
因为假设 s1+s2 总长为 n,Next[n]、Next[Next[n]]、Next[next[next[n]]]、...都是公共前后缀
所以对 s1+s2 和 s2+s1 分别做 KMP 求 border,并求出满足条件的最长公共前后缀,取最大值就能得到答案了。
代码:
#include<iostream>
using namespace std;
int Next[2000005],cnt=0;
int n;
int getnext(string s){
Next[1] =0 ;
int j =0;
for(int i=2;i<=n;i++){
while(j&&s[j+1]!=s[i])j=Next[j];
if(s[j+1]==s[i])j++;
Next[i]=j;
}
return Next[n];
}
void KMP(string s,string p){
cnt=0;
int i,j,k=0;
for(j=0,i=1;i<=s.size();i++){
while(j&&s[i]!=p[j+1]) j=Next[j];
if(s[i]==p[j+1]) j++;
if(!p[j+1]) cnt++,j=0;
}
}
int main(){
string s1,s2;
cin>>s1>>s2;
string s=s1,t=s2;
n=s.size()+t.size();
s1="0"+s+t;
s2="0"+t+s;
s1=t+s.substr(getnext(s1));
s2=s+t.substr(getnext(s2));
if(s1.size()<s2.size())cout<<s1;
else if(s1.size()>s2.size())cout<<s2;
else if(s1<s2)cout<<s1;
else cout<<s2;
return 0;
}
T5.终曲(加强版)
题面概括:
给定三个字符串,在第一个字符串内找一段子串,同时包含第二和第三个字符串,并且要求这段子串的长度最短,字典序最小。
思路:
这道题要求最短的子串,我们可以考虑统计第二和第三个字符串在第一个字符串中所有出现的位置的左右端点,然后对于每个第二个字符串,通过二分法求出其对应的第三个字符串,并考虑两种情况:第三个字符串的右端点在第二个字符串的左端点左边,和第三个字符串的右端点在第二个字符串的左端点右边,分别比较字串长短,将相同长度的子串记录下来,最后比较字典序再输出。
代码:
#include<bits/stdc++.h>
using namespace std;
int nextt[200005],cnt;
string s;
int ansl,ansr=0x3f3f3f3f;
vector<int> l1,r1,l2,r2;
vector<pair<int,int> > ve;
void update(int l,int r){
if(ansr-ansl+1>r-l+1){
ve.clear();
ansr=r;
ansl=l;
ve.push_back(make_pair(l,r));
}else if(ansr-ansl+1==r-l+1){
ve.push_back(make_pair(l,r));
}
}
void get_next(string s){
int i,j;
for(nextt[1]=j=0,i=2;s[i];i++){
while(j&&s[i]!=s[j+1]){
j=nextt[j];
}if(s[i]==s[j+1]){
j++;
}
nextt[i]=j;
}
}
void get_ansb(string s,string b){
int i,j;
for(j=0,i=1;s[i];i++){
while(j&&s[i]!=b[j+1]){
j=nextt[j];
}
if(s[i]==b[j+1]){
j++;
}
if(!b[j+1]){
l2.push_back(i-j+1) ;
r2.push_back(i);
j=nextt[j];
}
}
}
void get_ans(string s,string b){
int i,j;
for(j=0,i=1;s[i];i++){
while(j&&s[i]!=b[j+1]){
j=nextt[j];
}
if(s[i]==b[j+1]){
j++;
}
if(!b[j+1]){
l1.push_back(i-j+1) ;
r1.push_back(i);
j=nextt[j];
}
}
}
int main(){
string a,b,c;
cin>>a>>b>>c;
a=' '+a;
b=' '+b;
c=' '+c;
get_next(b);
get_ans(a,b);
memset(nextt,0,sizeof nextt);
get_next(c);
get_ansb(a,c);
if(l1.size()==0||r1.size()==0){
cout<<"No";
return 0;
}
int n=l1.size(),m=l2.size();
for(int i=0;i<n;i++){
//cout<<l1[i]<<" "<<r1[i]<<" / "<<l2[i]<<" "<<r2[i]<<endl;
int idx=lower_bound(r2.begin(),r2.end(),l1[i]) -r2.begin();
if(idx!=m){
update(min(l1[i],l2[idx]),max(r1[i],r2[idx]));
}
idx--;
if(idx>=0){
update(min(l1[i],l2[idx]),max(r1[i],r2[idx]));
}
}
int len=ve.size();
string answer=a.substr(ve[0].first,ve[0].second-ve[0].first+1);
for(int i=1;i<len;i++){
answer=min(answer,a.substr(ve[i].first,ve[i].second-ve[i].first+1));
}
cout<<answer;
return 0;
}
T6.无线传输
题面概括:
给定一个字符串 s1,它是由某个字符串 s2 不断自我连接形成的(保证至少重复 2 次)。但是字符串 s2 是不确定的,现在只想知道它的最短长度是多少。
思路:
这道题思路十分巧妙,我们考虑如果Next[x]==len(0<len<x),那么就有s[len]==s[x],那么去掉s[x]后得到的[1,x-1]依旧是原串的循环子串,因为 x 为最短长度,所以可得 next[x]一定为0,那么我们就可以得到公式:
ans=n-Next[n];
代码:
#include<bits/stdc++.h>
using namespace std;
int nextt[1000005],n;
string s;
void get_next(string s){
int i,j;
for(nextt[1]=j=0,i=2;s[i];i++){
while(j&&s[i]!=s[j+1]){
j=nextt[j];
}if(s[i]==s[j+1]){
j++;
}
nextt[i]=j;
}
}
int main(){
cin>>n>>s;
s=' '+s;
get_next(s);
cout<<n-nextt[n];
return 0;
}
T7.字符串大师
题面概括:
给定1~n每个前缀的最短循环节per[i],求出字典序最小的完整字符串。
思路:
这道题可以直接套用上个题求出来的公式,逆应用,即:
Next[i]=i-per[i];
然后我们就求出了Next数组,反推回去求出整个字符串即可
注意:因为字典序要求最小,所以s[1]一定等于a。
代码:
#include<bits/stdc++.h>
using namespace std;
int nextt[1000005],cnt,per[1000005];
string s;
int n;
void get_ans(){
cout<<s[1];
for(int i=2;i<=n;i++){
if(nextt[i]!=0){
s[i]=s[nextt[i]];
}else{
if(nextt[i-1]==0) s[i]=s[1]+1;
else s[i]=s[nextt[i-1]+1]+1;
}
cout<<s[i];
}
}
//0 0 1 2 0
int main(){
cin>>n;
for(int i=1;i<=n;i++){
cin>>per[i];
nextt[i]=i-per[i];
}
s[1]='a';
get_ans();
return 0;
}
---后记
看完这篇博客,希望大家能够真正理解KMP算法,并熟练应用,最后,
创作不易,给个赞吧