参考:KMP 算法详解 - 知乎 (zhihu.com)
【neko】KMP算法【算法编程#7】_哔哩哔哩_bilibili
KMP算法—终于全部弄懂了_June·D的博客-CSDN博客_kmp算法
「天勤公开课」KMP算法易懂版_哔哩哔哩_bilibili
目的
给两个字符串a和b,请找到b在a串中的位置,并返回其第一个字母的索引,若没有,返回-1.
注意为空串时的情况,a为空b不为空,明显是找不到的,应该返回-1,若b为空,应该返回0
先看最原始的暴力解
仔细想想就有思路,我们可以一个个比较,设置i
为a
字符串的索引,j
为字符串b
的索引,index为匹配位置。那么我们从i,j,index都为0时开始,一个个字符匹配,如果在其中有一个不匹配,就回溯i到++index,j为0,再继续。
理论上这样是可以求解的,但显然时间复杂度是比较大的。
这里给出暴力解的时间复杂度:O(mn)——m,n为a,b串的长度
仙贝们的伟大算法
KMP 算法是 D.E.Knuth、J,H,Morris 和 V.R.Pratt 三位仙贝共同提出的,称之为 Knuth-Morria-Pratt 算法,简称 KMP 算法。该算法相对于 Brute-Force(暴力)算法有比较大的改进,主要是消除了主串指针的回溯,从而使算法效率有了某种程度的提高。
我们用暴力解匹配失败时,i,j都回溯,有些浪费
而kmp算法,利用匹配失败后存储的信息,尽量减少a,b的匹配次数
了解一下字符串的前缀、后缀
好了,现在讲讲KMP算法的回溯策略:我们只回溯j
到指定位置,不回溯i
这个例子有一些特殊,但我们还是能清楚地看到,当b串最后一个字符匹配失败时,i和j没有回溯,而是把b串往后移了
再看一个例子
这是怎么回事呢?怎么回溯呢?
我们来看看木子(一个up主)的例子来说明
好的,现在我们看看怎么处理,我们不想i和j都回溯那么多,那就应该想或许i或j只回溯到应该回溯的位置。
我们令前面已经匹配的字符串分别为A子串和B子串,此时我们找A子串后缀和B子串的前缀的公共部分,我们发现,这时是有的,为aba,那我们就把j回溯这个公共前后缀长度,而i不用回溯,就变成了第三行那样
不过注意,我们是为了演示把B串“挪动”,但实际操作就只是j的变化而已
而找A子串、B子串的交集时,我们可以发现,因为A、B子串是已经匹配的,即相等的,所以我们找A子串后缀和B子串前缀相当于找B子串前、后缀的交集
所以我们可以认为,j指针回溯的位置只与B串有关
综上,j指针回溯的位置,就是B子串最长公共前后缀的长度
next数组
我们分析了j回溯到距离,那么怎么存储呢?
我们设置一个next数组,在A、B匹配之前,就通过B串计算回溯位置,将其存在next中
next[i]存放B[0]到B[i]最长公共前后缀的长度
此时,匹配策略就有了
来,估计只看这个理解不了,来模拟一下吧
画蓝线部分为已经匹配成功的子串,而后面匹配失败,我们找蓝线子串的最长公共前后缀为aba,此时长度为3,那么将j回溯到3(从0(a)开始,1(b),2(a),这些索引是已经匹配的,再加1,从3开始和A匹配),而 i 不动。这样我们看到第三行画黑线部分是可以匹配的,我们只需从黑线之后再匹配就行。
好,继续匹配,发现还是失败,我们再找最长公共前后缀,还是aba,那么 j 回溯
我们发现,回溯之后的第一个就不匹配了,再找aba(此时的子串)的最长公共前后缀,发现为a,j回溯
此时我们发现,有时第一个就不匹配了,再找a(此时的子串)的最长公共前后缀,发现,嗯?没有(就一个元素,算做没有),那长度为0,j回溯为-1(相当于0的前面)了,就是从头开始匹配
这里刚好匹配了,但是注意一点,如果此时还不匹配(比如画蓝线这里a换成其他的字母),这是就要往后移动 i ,使A串与B串匹配,不然只移动 j 的话明显进入死循环了是不是?
构建next数组
我们之前介绍了next数组,还没说怎么构造呢!
我们用B串自己和自己匹配,B[0]到B[i]的前缀和它的后缀匹配
好的,来看一下例子吧
注:这里的next数组从索引为0开始,我们最后会再看看从索引为1开始的
next数组,存的就是B子串相应位置往前的子串的最长公共前后缀长度
我们看到这个例子,i 、 j 索引后面匹配失败了,此时最后一个字符下面怎么填写方便呢?
那我们需要填写的长度,不就是画蓝线的子串找最长公共前后缀长度吗?
而这画蓝线的最长公共前后缀长度,我们已经算出来了(就在前面画蓝线的最后一个字符b的下面),刚好就是next[j],值为2
那么 j 就移到2的位置( j = next[ j ] )
我们发现此时是匹配的,那么next最后一个元素为 j + 1,也就是3
时间复杂度分析:O(m + n)
看看索引从1开始的next数组
第一个索引就和A串不配对,就让B串(也称为模式串)1号位(就是第一个字符)就和A串(也称主串)后一个索引比较
如果是2号位与A串不匹配,就让1号位(因为2号位前面的子串的最长公共前后缀长度为0,就移到B串的最前面,即1号位)和A串当前位比较
同理,我们看4号位,此时前面的最长公共前后缀长度为1,就让2号位(B串第二个)开始比较
我们可以发现,这些几号位比较,刚好等于最大公共前后缀长度 + 1
我们把不是与当前位比较(而是与后一位比较)的1号位的值改一改
好的,这就是next数组了
代码实现(只有Java)
next数组
public static int[] Getnext(int next[],String b)
{
char[] bStr = b.toCharArray();
char[] BStr = new char[b.length() + 1];
BStr[0] = (char)0;
for(int i = 0;i < bStr.length ; i ++ ){//这里是把字符串也弄成从索引为1开始
BStr[i+1] = bStr[i];
}
next[0] = b.length();//从索引1开始记录
next[1] = 0;//不管如何,第一位肯定是0
int j = 0;//j表示的是b串前缀码的索引
int i = 1;//i不仅是next数组的主索引,还兼职着后缀的索引(很不可思议,建议动手画一画就了解了)
while (i < next.length - 1 ){
if(j == 0 || BStr[i] == BStr[j]){//两个条件:1.j为0,此时就是从头开始匹配前后缀的时候; 2.i、j索引相同,也就是前后缀索引相同
next[++i] = ++j;//此时 i 和 j 都往前推进,且next数组也可以确定一位,就是前一位已经匹配的数目加1
}else {
j = next[j];//这里是非常妙的一步,大致意思是如果此位字符不匹配,就让 j 回溯到此字符之前子串的公共前后缀索引处(建议自己画个图理解一下)
}
}
return next;
}
strStr方法(返回找到的最初下标值)
public static int strStr (String a , String b) {
if(b.length() == 0){//b为空字符串,返回0
return 0;
}
if(a.length() == 0){//a为空字符串,若b也为空,返回0,否则找不到,为-1
if(b.length()==0){
return 0;
}else {
return -1;
}
}
int[] next = new int[b.length()+1];
next = Getnext(next,b);
for(int item:next){//打印检查一下,实际使用可以删除
System.out.println(item);
}
char[] aa = a.toCharArray();
char[] bb = b.toCharArray();
int i = 1,j = 1;
while (i<=a.length()&&j<=b.length()){
if(j==0||aa[i-1]==bb[j-1]){
i++;
j++;
}else {
j = next[j];
}
}
if(j>b.length()){
return i-b.length()-1;
}
return -1;
}