这次一定要学会KMP
概述
KMP是一种快速的字符串匹配算法,字符串匹配的暴力做法中,当匹配到某一个字符时失配,模式串需要重新回到第一个字符串,对应的,主串需要在上一个起点的基础上前进一个字符,而KMP解决的问题就在这里,通过预处理,在这里可以让模式串跳多个字符,从而加快匹配速度,最终时间复杂度是O(n+m),n是主串长度,m是模式串长度,其中预处理复杂度是O(m),匹配复杂度是O(n)。
首先计算一个nex数组(也有叫做fail函数,意思相同,指匹配失败之后的处理),简单来说,nex[i]表示模式串前缀pat[0…i]的最长相同前后缀长度(可重叠,但不可以是整个子串),这里也有另一种表示法,nex[0]为-1,然后用nex[i]表示前i的字符,也就是模式串前缀pat[0…i-1]的最长相同前后缀长度。
匹配
思路
先不考虑nex数组的计算,直接考虑匹配,假设采用的是第二种表示方法,也就是nex[i]表示模式串前i个字符的最长相同前缀后缀长度,那么,现在假设匹配到第8个字符串(i=7)失配了,按照上一节提到的,原串不动,模式串跳多个字符,那么跳多少呢,就得看此时的nex值,nex[7]表示此时模式串前7个字符的最长相同前缀后缀长度,注意这7个字符是和原串匹配的,所以如果nex[7]=3,大概就是abcdabc
这种情况,那么原串的前7个字符也是abcdabc
,这时候,很显然,我们把模式串跳到原串的第5个字符,也就是第二段abc
开始匹配,就可以节省很多没必要的移动了,这里跳的数量就是i-nex[i]
。
代码
// kmp基本查找有几种:查找出现位置(第一次/多次),查找数量(可重叠/不可重叠)
int kmp(char* s,int n,char* p,int m){
kmp_pre(p,m);
// i,j分别是主串和模式串比较字符下标
int i=0;
int j=0;
while(i<n && j<m){
printf("%d %d\n",i,j);
// 这里j=-1表示没有相同前后缀了,所以实际上应该是i++;j=0;只是这里合并了分支
if(j==-1 || s[i]==p[j]){
i++;
j++;
}else{
j=nex[j];
}
// 匹配个数
// if(j==m){
// 统计数量,多次出现的位置同理
// cnt++;
// 可重叠
// j=nex[j];
// 不可重叠
// j=0;
// }
}
// 匹配
if(j==m){
// 返回第一个出现位置
return i-j;
}
return -1;
}
预处理
思路
解决了匹配,剩下的就是如何求出nex数组,有两种情况。
第一种,比如abaabaabaabaa,我们已经求出了nex[12]=9,要求nex[13],就需要比较pat[12]和pat[9],这里两个字符相等,都是a,刚好可以接上,所以nex[13]=nex[12]+1。
第二种,比如bacababacabac,我们已经求出了nex[12]=6,要求nex[13],同样需要比较pat[12]和pat[6],这里字符c和b是不相等的,那说明我们无法直接接上nex[12]对应的前缀pat[0…5],所以我们尝试把前缀缩短,缩短有个重要的前提,我们必须保证我们的字符pat[12]能够接上后缀,因此我们并不是把前缀从pat[0…5]缩短到pat[0…4],我们需要保证缩短后的前缀pat[0…k]和pat[12]之前的某一段子串是相同的,所以我们直接跳到nex[6]即可(文字解释非常无力),比如bacababacabac,一开始我们尝试pat[12]和后缀pat[6…11]拼接,拼接失败,因为pat[12]不等于pat[6],所以前缀pat[0…5]不能延伸作为nex[13],我们考虑缩短到,根据nex[6]=2,我们将前缀缩短到pat[0..1],此时pat[12]要拼接的后缀对应的就是pat[10…11],拼接成功,所以nex[13]=2+1=3,总之就是抓住nex数组的定义,前缀和后缀相同,那么前缀的前缀(特指nex数组对应的最大相同前缀,后缀同理)和后缀的后缀也相同,不断尝试拼接后缀,直到可以拼接上或者nex值为-1。
一个类似于并查集路径压缩的小优化:比如模式串是abab,主串是abacababc,当匹配到s[3]和p[3],失配,模式串向右移动两位,然后比较s[3]和p[1],但是实际上这两个肯定失配,因为从模式串abab来看,当某一个字符p[i]失配,下一个要匹配的字符肯定是p[nex[i]],而对于这个模式串的p[3],p[3]=p[nex[3]],所以显然失配,为了避免这些无谓的比较,采用一种类似压缩路径的方法,当计算nex[3]时,也就是即将nex[++i]=++j,i是当前比较字符,j是前缀长度,我们继续看下一个字符,也就是p[3],如果p[3]=p[j],那么我们就要避免nex[++i]=nex[++j]的出现,而是使用nex[++i]=nex[++j],这里可以这么写是因为nex[++j]已经在之前迭代求出来,所以这相当于一个非递归的路径压缩,这样子当失配的时候,因为nex[3]是-1了,所以模式串不再是右移到p[1]。
代码
代码乍一看和思路不太一样,其实是经过了一些写法上的优化。
void kmp_pre(char* p,int m){
// i初始化为0表示要比较的字符,也就是要求nex[i+1]
int i=0;
// j表示尝试拼接的后缀长度(也等于前缀长度),所以也是要比较的字符下标
// 初始化为-1,因为一开始前缀为空
int j=-1;
nex[0]=-1;
while(i<m){
// j==-1只有可能是前缀为p[0],此时nex值为0,合并分支
if(j==-1 || p[i]==p[j]){
// 因为要求的是nex[i+1],之后求下一个,所以是++i
// j是前缀长度,拼接上所以是j+1
if(p[i+1]==p[j+1]){
// 优化
nex[++i]=nex[++j];
}else{
nex[++i]=++j;
}
}else{
j=nex[j];
}
}
}
拓展
周期和border
来源金策的字符串ppt,省去其中看起来比较复杂的定义。
border其实就是字符串的相同前后缀长度,所以nex数组其实求的就是前缀的最长border。
周期是子串的长度,且该可以通过重复构成原串(可能不是完整的)。
比如abaaaba,周期是4(abaa),6(abaaab),7(abaaaba),border是aba,a和空串。