KMP算法讲解
- 我们知道KMP算法相比于暴力匹配字符串算法更快速,因为它不需要每次一单位地逐步移动,它可以根据模式串的特点策略地一次比较失败下次移动多个单位长。
- 如何根据字串的策略进行多单位移动呢?
3. 如图所示,当前三个字符匹配成功后,第四个字符出现匹配错误。按照暴力回溯算法的思想,模式串应该右移一个单位,但是在KMP算法中,它会根据前面已经匹配过的三个字符进行一种策略选择,它可能不再以一个单位移动,也可能移动多个单位。它的下一次移动如下
4… 模式串向右移动了两个单位,难道中间那个位置不会刚好匹配成功吗?
事实是确实不会在中间匹配成功,为什么呢?我们引入一个知识点,叫做字符串的最长公共前后缀
5… 例如上个案例中的模式串ABAA,在最后一个A处匹配错误,那么它已经匹配成功的子串ABA,它的前缀可以是A,AB。它的后缀可以是BA,A。概念相信不难理解,注意,不是前后对称。ABA的前缀和后缀一样且最长的是A,说明我们
- 把最长的公共前缀移动到最长的公共后缀上,不就是代表着将前缀直接匹配。而不用管中间步骤
next数组
- 根据最长的前后缀我们可以确定移动策略,那么一个模式串它的每一位都可能发生匹配错误,说明每一位上都应该有一个int数值,用它确定我们该怎么移动。
- 那么我们用一个next数组来存储这些值
那么如何得到next数组呢?为了方便理解,我们这样计算
1.注意:当模式串ABABA的最后一位发生错误时,我们利用的是除了这个字符外的它前面的子串,也就是ABAB,和发生错误的位没有关系。
2. ABAB的的最长前缀和后缀计算想法很简单
定义两个索引,left和right。left指向最后一位的前一位(倒数第二位),right指向第二位。
这样就会形成两个区域,由左边界和left组成的前缀区域,由right和右边界组成的后缀区域。
这样就容易确定最长的公共前后缀了,每次比较两块区域的子串是否相等,相等返回长度即是最长的公共前后缀,如果不相等,就不断缩小两块前后缀区域,直到找到公共前后缀,如果没有,返回0
next数组代码
public static int[] next(String pat){
//第一个字符和第二个字符前的最长公共前后缀为0
int[] next = new int[pat.length()];
for(int i = 2;i<pat.length();i++){
int left = i-2;
int right = 1;
while(left>=0 && right<=i-1){
if(pat.substring(0,left+1).equals(pat.substring(right,i))){
next[i]=pat.substring(0,left+1).length();
break;
}
else{
left--;
right++;
}
}
}
return next;
}
next求解II
我们也可以使用从左递推的方式来求得next数组,思路是每增加一个字符,新的串的最大前后缀长会与旧的串有关系。
当前一个字符串的最大前后缀已经确定,当它增加一个字符,也就是next数组的下一位如何求解。
-
新增的字符与前缀后一个字符相同
这样 next[k+1] = next[k]+1 即可 -
第二种情况就是不相同
注意:不相同,我们需要在后缀区右部分找一块区域,使得它和前缀区左部分一块区域是相同的。
如图
为什么要找这样两块区域?因为后缀和前缀的这两块区域如果相同的话,那么只需判断一下新增字符和前缀区下一个字符是不是相同即可
注意:因为前缀和后缀是相同的,所以等价于如下
发没发现?使得这两块区域相等,那不就是求前缀的公共前后缀吗?
第二种方式求next数组代码
public static int[] getNext(String pat){
int[] next = new int[pat.length()];
int k = 1;
int j;
while(k<pat.length()-1){
j = k;
while(j!=0){
if(pat.charAt(k)==pat.charAt(next[j])){
next[k+1] = next[j] +1;
break;
}
else{
j = next[j];
}
}
k++;
}
return next;
}
注意:这两块区域可能是前缀最长的公共前后缀,也可能不是。我们需要找的是后缀区右部分加上新增字符,前缀区左部分加上下一个字符,这样的两块区域完全一样。
有了next数组怎么办?
- 我们怎么利用next数组,next数组代表的意思是当前字符串发生不匹配时,要对模式串进行相应的移动,但是模式串是不会动的,我们可以用两个索引分别指向主串和模式串现在正在匹配的字符位置,这样思路会很清晰
t索引指向主串正在匹配的字符位置,p索引指向模式串正在匹配的字符位置,此时匹配错误,t索引字符与p索引字符并不相等,匹配错误,根据前面所说,p索引应该向左移动一些单位以便下次比较。这些单位长度也很容易计算,next[p] 表示的是p字符之前的子串最长公共前后缀长度,也就是说我们应该移动n-k单位长
说明,当模式串每一位字符发生匹配错误时,我们用 **n - next[p]**表示移动的长度,注意,当模式串第一个字符匹配错误,我们应该右移t索引,这样才能继续比较。为了清晰,我们使用 gap[]数组 存放n - next[p]
gap[]数组
/**
* 把next转成gap数组
* gap:表示当模式串在该字符位置匹配失败时,将要把模式指针左移的间距
* @param next
* @return
*/
public static int[] gap(int[] next){
for(int i=0;i<next.length;i++){
next[i] = i- next[i];
}
return next;
}
最后
- 我们最后只需要轻松移动两个索引即可控制字符串进行匹配了,剩下的都是代码上手问题了
- 注意:模式串匹配成功的条件是 p==模式串的长度
匹配失败的条件是 t索引在不断右移的,所以控制循环条件为
主串长-t>=子串长-p,保证模式串不会飘出主串外。
KMP代码
/**
* KMP算法,匹配字符串
*/
public class KMP {
/**
* 处理模式串,返回一个数组,数组中的值表示这个在字符前的串的最长公共前后缀大小
* 如:pat串为:ABAA
* 返回数组 {0,0,0,1}
* @param pat:模式串
* @return
*/
public static int[] next(String pat){
//第一个字符和第二个字符前的最长公共前后缀为0
int[] next = new int[pat.length()];
for(int i = 2;i<pat.length();i++){
int left = i-2;
int right = 1;
while(left>=0 && right<=i-1){
if(pat.substring(0,left+1).equals(pat.substring(right,i))){
next[i]=pat.substring(0,left+1).length();
break;
}
else{
left--;
right++;
}
}
}
return next;
}
/**
* 把next转成gap数组
* gap:表示当模式串在该字符位置匹配失败时,将要把模式指针左移的间距
* @param next
* @return
*/
public static int[] gap(int[] next){
for(int i=0;i<next.length;i++){
next[i] = i- next[i];
}
return next;
}
public static int kmp(String txt,String pat){
int[] next = next(pat);
int[] gap = gap(next);
System.out.println("gap: "+Arrays.toString(gap));
int t = 0;
int p = 0;
while(txt.length()-t>=pat.length()-p){
if(p==pat.length())
return t-pat.length();
if(txt.charAt(t)==pat.charAt(p)){
t++;
p++;
}
else if(p==0)
t++;
else{
p -= gap[p];
}
}
return -1;
}
总结
代码比较冗余,但是逐步分析有助于理解KMP算法的思路,帮助日后加深印象。