KMP算法详解-next函数数学推导

简介

     KMP 算法是 D.E.Knuth、J,H,Morris 和 V.R.Pratt 共同提出的,称之为 Knuth-Morria-Pratt 算法,简称 KMP 算法。主要用于解决字符串模式匹配。

一般的解法-BF算法

BF算法思想

     Brute-Force算法又称做"蛮力匹配"算法,从主串S的第pos个字符开始,和模式串T的第一个字符进行比较,若相等,继续逐个比较后续字符,否则回溯到主串S的pos+1个字符重新和模式串T进行比较。以此轮推,直到匹配成功,或者退出循环匹配失败。

图解

如初始化pos=0,S串和T串如下
在这里插入图片描述
比较i指针指向的字符和j指针指向的字符是否一致。如果一致就都向后移动,如果不一致,如下图:
在这里插入图片描述
A和E不相等,那就把i指针移回第1位(假设下标从0开始),j移动到模式串的第0位,然后又重新开始这个步骤:
在这里插入图片描述
直到匹配成功或退出循环。

程序代码

public static int bf(String S,int pos ,String T) {
    
    
	int i = pos; // 主串的位置
    int j = 0; // 模式串的位置
    while (i < S.length() && j < T.length()) {
    
    
   		if (S.charAt(i) == T.charAt(j)) {
    
     // 当两个字符相同,就比较下一个
            i++;
            j++;
   		} else {
    
    
            i = i - j + 1; // 一旦不匹配,i后退
            j = 0; // j归0
        }
    }
    if (j == T.length()) {
    
    
        return i - j;
    }
    else {
    
    
       return -1;
    }     
}

KMP算法

算法引入

     上面的算法思想比较简单,但当在最坏情况时,算法的时间复杂度为O(n*M),其中n,m分别为主串和模拟串的长度,其中主要的时间耗费在失配后的比较位置有回溯,而KMP算法正是对这一问题改进算法,改进后的时间复杂度为O(n+m)。

难点突破

移动问题思路分析

    整个KMP的重点就在于当某一个字符与主串不匹配时,我们应该知道j指针要移动到哪?
首先我们来看一下自己发现的j的移动规律:

在这里插入图片描述

如图:C和D不匹配了,我们要把j移动到哪?显然是第1位(所有下标都从0开始,如果觉得绕可理解为i,j不动,移动T数组)。因为前面有一个A相同:
在这里插入图片描述

如下图也是一样的情况:
在这里插入图片描述
可以把j指针移动到第2位,因为前面有两个字母是一样的:
在这里插入图片描述
至此我们可以大概看出一点端倪,当匹配失败时,j要移动的下一个位置k。存在着这样的性质:最前面的k个字符和j之前的最后k个字符是一样的。
用数学公式来表示是这样的:
             P[0 ~ k-1] == P[j-k ~ j-1]
这个相当重要,如果觉得不好记的话,可以通过下图来理解:
在这里插入图片描述
弄明白了这个就应该可能明白为什么可以直接将j移动到k位置了。

    该规律是KMP算法的关键,KMP算法是利用待匹配的子串自身的这种性质,来提高匹配速度。
至此,我们的主要问题就转变到求k(j指针要移动到的位置)。
 
    为了能更容易的去计算k值我们用另一种方法来解释:若字符的真前缀子串集和真后缀子串集中,重复的最长子串的长度为k,则下次匹配子串的j可以移动到第k位(下标为0为第0位)。
    真前缀集表示除去最后一个字符后的前面的所有子串集合,同理真后缀集指的的是除去第一个字符后的后面的子串组成的集合。
    在“ababa”中,前缀集是{a,ab,aba,abab},后缀集是{a,ba,aba,baba},二者最长重复子串是aba,k=3;
 

 
下面我们用这个解释,来实现上面的移动规则:

首先如下图所示:
在这里插入图片描述
如图:C和D不匹配了,我们要把j移动到哪?j位前面的子串是ABA,该子串的真前缀集是{A,AB},真后缀集是{A,BA},最大的重复子串是A,只有1个字符,所以j移到k即第1位。
在这里插入图片描述
再分析下图的情况:
在这里插入图片描述
在j位的时候,j前面的子串是ABCAB,前缀集是{A,AB,ABC,ABCA},后缀集是{B,AB,CAB,BCAB},最大重复子串是AB,个数是2个字符,因此j移到k即第2位。
在这里插入图片描述
    解释k的取值为何是重复的最长子串的长度:K取重复的最长子串的长度,意味着如果找到其他的重复的子串,我们把j移动到可移动的最大值,即离T的头最近,避免其他的重复的子串没有被计算到。
比如如下的情况:

在这里插入图片描述
此时需要移动j:
按重复的最长子串的长度移动:
在这里插入图片描述
按任一子串的长度移动:
在这里插入图片描述
可以发现如果不按最长子串的长度移动,那么可能会跳过某些可能的匹配,自然就不正确了。

上面说的,如果分解成计算机的步骤,则是如下的过程:
1.找出前缀pre,设为pre[0~m];
2.找出后缀post,设为post[0-n];
3.从前缀pre里,先以最大长度的s[0~j-2]为子串,即m = j-2,k初始值为m+1=j-1,跟post[1~j-1]进行比较:
  如果相同,则pre[0~m]则为最大重复子串,长度为m+1,则k=m+1;
  如果不相同,则k=k-1;缩小前缀的子串一个字符,在跟后缀的子串按照尾巴对齐,进行比较,是否相同。
   如此下去,直到找到重复子串,或者k没找到。

根据上面的计算过程,知道子串的j位前面,有j个字符,前后缀必然少掉首尾一个字符,因此重复子串的最大值为j-1,因此知道下一次的j指针最多移到第j-1位
 

next数组详讲

    上面的分析我们知道了问题的关键就是如果主串S和模式串T不相等时j指针的移动问题,即T[j]对应的k值,为此要计算每一个位置j对应的k,我们用一个next数组来保存,next[j] = k,表示当S[i] != T[j]时,j指针的下一个位置。 另外由于下标从零开始,k值实际是j位前的子串的最大重复子串的长度。(一定要始终清楚next[j]的含义和k的含义)
先看一下求解方法:

 private static int[] Get_Next(String T){
    
    

        int[] next = new int[T.length()];
        int j = 0,k = -1;

        next[0] = -1;

        while(j < T.length()-1){
    
    

            if( k==-1|| T.charAt(j)==T.charAt(k)){
    
    
                ++j;
                ++k;
                next[j] = k;
            }
            else
                k = next[k];
        }
        return next;
    }

首先来看第一个:当j为0时,如果这时候不匹配,怎么办?
在这里插入图片描述
像上图这种情况,j已经在最左边了,不可能再移动了,这时候要应该是i指针后移。所以在代码中才会有next[0] = -1;这个初始化,表示不移动。
当j = 1时:
在这里插入图片描述
显然,j指针一定是后移到0位置的。因为它前面也就只有这一个位置了
所以next[1] = 0;
下面这个是最重要的,请看如下图:
在这里插入图片描述
在这里插入图片描述
请仔细对比这两个图。
第一个图此时指向j,它的最长重复子串为AB,长度为2,即k = 2,由于从0开始,此时的T[k]指向的就是最长重复子串为AB的下一位
在第二个图中,现在指向的是j+1
由于T[k] == T[j],

则有next[j+1] == next[j] + 1,这个不难想到

    那如果T[k] != T[j]呢?比如下图所示:
在这里插入图片描述
我们看到代码的解释只有一句:k = next[k];为什么是这样呢,请看下面的解释。
记住我们此时的任务是求j+1对应的k,当然是用一种更巧的方法
在这里插入图片描述
        当T[j]!=T[k]时我们要找的就是j+1位前面的子串,即T[0-j]的最大重复子串长度。假设最长重复子串长度为k1,即T[0-k1-1],使得T[0,k1-1]==T[j+1-k1,j],此时k1即为所求的位置,即next[j+1]=k1;因为T[k]!=T[j]了,因此k1最大等于k,即最大可能的重复子串只可能是T[0,k-1]里的子串。
 
        此时有一个关键方法,在从T[0,k-1]里求解最大重复子串时,我们把选择的待比较的子串分为两部分,最后一个端点为一部分,前面为一部分,比如把T[0,k-1]分解为T[0,k-2]和T[k-1],则对应的后缀分解为T[j-k+1,j-1]和T[j],即此时如果要是最大重复子串要满足T[0,k-2]==T[j-k+1,j-1],T[k-1]==T[j],见图中的红色线段和绿色圆点;通过这个例子我们知道,只要前面一段能重复且尽可能的长,那么加上最后一个端点这个重复子串也必将是最长的。
 
        因为next[j]==k是已经求出的,即T[0,k-1]==T[j-k,j-1],此时可以吧上面的l例子做变换,变成比较T[0,k-2]与T[1,k-1]是否相等,T[k-1]与T[j]是否相等,见图中紫线箭头指示的漂移;看到没有,比较T[0,k-2]与T[1,k-1]是否相等,不相等k-1,这个过程就是求k位前的子串p[0~k-1]的最大重复子串,因为当比较后发现不相等时我们的做法是让k-1,继续去比较,比如在减一位那么比较的是T[0,k-3]与T[2,k-1]是否相等,由于next[k]存的是K前的最大重复子串的长度,很显然就是求next[k]。
 
        当k = next[k]后,此时,T[0,next[k]-1]和T[j-next[k],j-1]子串已经恒等了,接下来我们只需比较两个端点是否相等,T[ next[k] ] 和T [j] (对应于代码中的 T.charAt(j)==T.charAt(k) ,注意在上个循环p[k]!=p[j]时,k已经被赋值next[k],而j还是上次的那个j);如果这两者相等了,则重复子串的长度+1,next[j+1]=next[k]+1;如果不相等了,则说明倒数第二大的T[0~next[k]-1]都不行了,比这个重复子串小的最大的重复子串只能是T=next[next[k]]了,如此继续查找下去。因此比较的都是按序递减的最大重复子串,非常的有效,一点都没有多比较。找不到的话,k会被赋值为-1。
 

完整算法展示

 private static int[] Get_Next(String T){
    
    

        int[] next = new int[T.length()];
        int j = 0,k = -1;

        next[0] = -1;

        while(j < T.length()-1){
    
    

            if( k==-1|| T.charAt(j)==T.charAt(k)){
    
    
                ++j;
                ++k;
                next[j] = k;
            }
            else
                k = next[k];
        }
        return next;
    }

    public static int Index_KMP(String S,int pos,String T){
    
    

        int[] next = Get_Next(T);

        int i = pos,j = 0;
        while(i < S.length() && j < T.length()){
    
    
            if( j==-1 || S.charAt(i)==T.charAt(j) ){
    
    
                ++i;
                ++j;
            }
            else{
    
    
                j = next[j];
            }
        }
        if(j == T.length())
            return i-T.length();
        else
            return 0;
    }

next数组求解算法优化

最后,来看一下上边的算法存在的缺陷。来看第一个例子:
在这里插入图片描述

显然,当我们上边的算法得到的next数组应该是[ -1,0,0,1 ]

所以下一步我们应该是把j移动到第1个元素:
在这里插入图片描述
不难发现,这一步是完全没有意义的。因为第一次匹配时后面的B已经不匹配了,那前面的B也一定是不匹配的,同样的情况其实还发生在第2个元素A上。
显然,发生问题的原因在于T[j] == T[next[j]]。
所以我们需要添加一个判断:
修改后的Get_next方法如下

private static int[] Get_Next(String T){
    
    

        int[] next = new int[T.length()];
        int j = 0,k = -1;

        next[0] = -1;

        while(j < T.length()-1){
    
    

            if( k==-1|| T.charAt(j)==T.charAt(k)){
    
    
                ++j;
                ++k;
                if(T.charAt(j)==T.charAt(k))	//特判
                    next[j] = next[k];
                else
                next[j] = k;
            }
            else
                k = next[k];
        }
        return next;
    }

本文对sofu6的博客多有借鉴,在此感谢,附上博客地址

猜你喜欢

转载自blog.csdn.net/haazzz/article/details/109098262
今日推荐