前阵子在学习KMP相关的内容,其他部分都挺好理解的,最后在next数组和k=next[k]这个递推公式上迷糊了好久,看了不少人写的博客,有的写着写着最后的结论又跳跃了,有的是写清楚了,但是感觉写的有过于细节了。
不过总算是弄懂了,所以决定自己也来写一写这个KMP算法
单模匹配
字符串匹配是一个经典的算法问题,扩展开来讲个几天也讲不完,本篇博客仅讨论最简单的单模匹配问题。
假设已知两个字符串S和P,问P是否是S的子串,并且要求输出P在S中匹配的位置(即P的第一个字母在S中的位置)。我们称P为模式串,S为主串
。
比方说,S=“An apple a day, keeps doctor away” , P=“apple” 那么P是S的子串,其在S的位置为3(下标从0开始)
暴力破解法
解决这个问题最简单的思路是使用暴力破解法
将P[0]和S[i]对齐(初始化i=0),然后逐个比较,如果中间遇到P[j]!=S[i+j] 说明不匹配(我们也称之为"失配")。
则将P向右移动一位,让P[0]和S[i+1]对齐,再逐个比较,失配则P右移一位 , 直到P的尾部和S的尾部对齐,如果都没有找到匹配的位置就说明P不是S的子串,否则就返回匹配的位置
再画个图简单说明下吧,其中蓝色的字母表示是相等的,红色的表示不相等也就是失配
S: abadadcb
P: adca
第一轮匹配:
第二轮匹配:
第三轮匹配:
第四轮匹配:
第五轮匹配:
理解了上面的说明,我们就可以写出暴力破解法的代码
/**
* @param s 主串
* @param p 模式串
*/
public static int violentMatch(String s, String p) {
int sLen = s.length();
int pLen = p.length();
int i = 0;
int j = 0;
while (i < sLen && j < pLen) {
// 当两个字符相同,就比较下一个
if (Objects.equals(s.charAt(i), p.charAt(j))) {
i++;
j++;
} else {
//一旦不匹配,i后退 , j归0
j = 0;
i = i - j + 1;
}
}
if (j == pLen) {
return i - j;
} else {
return -1;
}
}
用上面的S和P代入计算结果为:-1
暴力法的优点就在于代码清晰,非常易于理解,其缺点也很明显,时间复杂度太高(O(m*n)
) 。那么有没有办法提高匹配的效率呢?
在暴力法中,匹配失败后模式串都需要向右移动1位,然后从头与主串进行匹配,要将前面已经匹配过的字符重新再匹配一遍,效率自然就差了,那么是不是有办法让模式串可以在失配时无需全部重新匹配呢?
比方说上面的第三轮匹配,从上帝视角来看,在S[4]与P[2]失配后,我们可以直接移动2位继续匹配
这是因为P[0~1]==S[2~3]
,而P[1]!=P[0],所以移动一位后一定有P[0]!=S[3] (如下图),这样我们就可以根据模式串P的一些性质来提高匹配的效率
失配后模式串P直接移动2位
KMP算法
KMP算法由D.E.Knuth、J,H,Morris 和 V.R.Pratt 三位神人于1977年共同提出,全称为 Knuth-Morria-Pratt 算法。该算法相对于暴力破解算法有了比较大的优化,主要是消除了主串指针的回溯
,从而提高了算法效率。
先说明几个概念:
前缀:空字符串或包含字符串第一个字符的任意连续的子串(不包含自身)。
如对于 name这个字符串, 空字符串,n,na,nam都是前缀。
后缀:空字符串或包含字符串最后一个字符的任意连续的子串(不包含自身)。
如对于 name这个字符串, 空字符串,e,me,ame都是后缀。
公共前后缀:对于某一个字符串,它的前缀和后缀中如果存在相同的,那么该字符串就是公共前后缀。其中长度最长的那个就是最大公共前后缀
。
假设两个模式串在红色部分(设为S[m])发生失配,此时我们需要向后移动模式串P, 如果在移动过程中S串的蓝色部分和P串的蓝色部分还能找到匹配的子串,那么就会出现下图的情形
那么在发生失配时,我们只需要将模式串移动到如下位置即可继续比较模式串和主串,从失配位S[m]开始进行比较而不需要对主串进行回溯
。这里的深蓝色部分就是前面蓝色部分字符串的最大公共前后缀。
为什么一定是最大公共前后缀的位置一定是最优的而不是其中的任一位置呢? (能理解的可以不用看这段叙述)
假设我们可以将模式串移动到中间某一位置,如上图所示,此时两个深蓝色块和深绿色块是相等的,如果P[i]!=S[j], 那么就说明在这个位置会发生失配,这就不符合我们一开始想要的最优的位置。如果P[i]==S[j],那么根据前后缀的定义,灰色框部分是红色框部分的最大公共前后缀,这样一来我们继续找S[j+1]和P[i+1], 如果不相等则不满足我们想要的最优位置,如果相等红色框的部分就可以继续向后扩大,直到等于一开始匹配的蓝色部分。
故而,假设模式串和主串在S[j]和P[i]的位置发生失配,那么我们只需要知道P[0…i-1]的最大公共前后缀是什么,就可以知道需要把模式串移动到什么位置就可以在避免主串回溯的情形下继续匹配
那么接下来需要的就是求模式串P的各个前缀的最大公共前后缀了
next数组
next数组的含义
假设 next[j] = k; 那么就是表示长度为j的数组的前缀和后缀相等的最大长度为k(k<j)
按照next数组的定义,有next[0]=0,next[1]=0,为了计算方便,我们规定next[0]=-1
那么已知next[0]…next[j], next[j+1]怎么计算?
假设next[j]=k
分两种情形考虑
1)P[j]==P[k], 按照next[j]的定义,我们可以知道P[0…k-1]==P[j-k…j-1]
这样就有P[0…k]==P[j-k…j],也就是next[j+1]=k+1
2)P[j]!=P[k],那么next[j+1]的最大公共前缀一定小于k,假设为字符串M,长度为m(m<k)
那么M一定是字符串A的前缀,也是字符串S的后缀,又由于S=A+P[j], 所以M一定是字符串S的公共前后缀,假设next[k]=x, 那么next[j+1]=m<=x+1,即next[j+1]<=next[k]+1
注意:两个蓝色块的字符串是相等的,也就是他们各自的最大公共前后缀也是一样的
接下来让我们来证明下这个结论(使用反证法)
如果next[j+1]>next[k]+1,也就是说next[j+1]>=next[k]+2
而又因为next[j+1]的最大公共前后缀是字符串S的公共前后缀,也就是说字符串S存在一个公共前后缀,其长度不小于next[k]+2 ,如下图所示:
根据公共前后缀的定义我们可以知道字符串A有一个长度不小于next[k]+1的公共前后缀,这与next[k]的定义矛盾,故而假设不成立,也就是说在P[j]!=P[k]的时候,next[j+1]<=next[k]+1
那么前j+1个字符串的最大公共前后缀最大可能就是next[k]+1,要想使next[j+1]=next[k]+1, 按照下图就需要有
P[j-k+next[k]]==P[j],根据next[j]=k可知P[j-k+next[k]]==P[next[k]], 故而当P[next[k]]==P[j]时,next[j+1]=next[k]+1
。
那万一P[next[k]]!=P[j]怎么办呢? 那么字符串S的最大公共前后缀一定小于next[k],和上面的推理一样,此时字符串S的最大公共前后缀一定是红色框字符串的公共前后缀,其长度一定<=next[next[k]]+1
我们将几次推论的图放在一起,你就可以看到其中类似的地方
我们可以得出一个结论,想要计算next[j+1],那么我们就需要比较P[j]和P[k]是否相等,如果相等,那么next[j+1]=k+1,否则继续按照k=next[k]进行递归,直到next…[next[k]…] == P[j] 或者next…[next[k]…] == -1
关于next数组的求解以及其推导过程就分析到这了,接下来就可以开始写代码了
感谢v_JULY_v
大佬的图,让我终于get到为什么要做k=next[k]的递归 (下图来自于v_JULY_v
从头到尾彻底理解KMP(2014年8月22日版)一文)
KMP算法实现
//计算next数组
public static void calNextArray(String str, int next[]) {
int k = -1;//k表示当前字符串的最大公共前后缀,初始为-1即next[0]=-1
int j = 0;//j代表字符串长度,从0(空字符串)开始递推
next[0] = k;//next[0]=-1
//递推过程 需要计算next[0],next[1],....next[str.length()-1]
while (j < str.length() - 1) {
//k == -1代表特殊边界条件,str[k] == str[j]表示新增字符和str[k]的字符相同
if (k == -1 || str.charAt(k) == str.charAt(j)) {
k++;
j++;
next[j] = k;
} else {
//此处代表str[k] != str[j],去找最大公共前后缀加上新字符后的最大公共前后缀
k = next[k];
}
}
}
如果觉得递归和next[0]=-1还有点迷糊,可以找个字符串然后计算下其next数组
public static int kmp(String s, String p) {
int i = 0;
int j = 0;
int sLen = s.length();
int pLen = p.length();
int[] next = new int[pLen];
calNextArray(p, next);
while (i < sLen && j < pLen) {
//如果j = -1,或者当前字符匹配成功(即S[i] == P[j]),都令i++,j++
if (j == -1 || Objects.equals(s.charAt(i), p.charAt(j))) {
i++;
j++;
} else {
//如果j != -1,且当前字符匹配失败(即S[i] != P[j]),则令 i 不变,j = next[j]
//next[j]即为j所对应的next值
j = next[j];
}
}
if (j == pLen)
return i - j;
else
return -1;
}
参考资料:
https://blog.csdn.net/Iseno_V/article/details/100114480
https://blog.csdn.net/v_july_v/article/details/7041827
题外话:
抛开前面讲的kmp算法,如果光从主串S和模式串P进行字符串匹配的角度来看的话,那么其效率最高的办法就是每一轮比较完之后,主串完全不回溯,而模式串P的直接移动到S[m]的位置开始第二轮匹配(n为主串的长度,m为子串的长度),其最优的时间复杂度为O(n/m)
但是这样比较的话如果在S[0-m-1]有部分字符串可以和模式串P匹配,那么这个单模匹配返回的结果就会失去精准性,为了可以"发现"这种可能匹配的情形,所以模式串不能移动m位置,而需要移动到如上图所示的位置, 这就需要利用到模式串P的对称性,也是KMP引入最大公共前后缀(next数组)的原因
KMP算法的时间复杂度
KMP算法流程:
1.计算出模式串P的next数组(对应的最大公共前后缀)
2.比较模式串P[j]和主串S[i]对应的字符是否相等
如果j == -1或者P[j] == S[i],则i和j都递增,匹配下一个字符;
如果j != -1且P[j] != S[i],则i不变,j = next[j],也就是在发生失配的时候,模式串向右移动j - next [j] 位。
那么其时间复杂度就是计算next数组的时间复杂度加上匹配的时间复杂度 : O(next[])+O(匹配)
计算next数组的时间复杂度为O(m) (m为模式串的长度);匹配的次数为 n (因为主串匹配的时候不会回溯) + 失配后移动模式串之后失配位S[i]会再重复比较一次,也就是说O(2*n)
所以整个KMP算法的时间复杂度就是O(m+n)