很详细的KMP算法的讲解

注:本文仅供学习交流,禁止抄袭,转载参考请注明出处。

一、问题描述

使用该算法解决字符串的匹配的相关问题。

具体问题

给出两个字符串,有一个文本串S,和一个模式串P,现在要查找P在S中的位置,怎么查找?(前提条件,一个串是另外一个串的子串)。

算法的输入:输出两个字符串(S和P)

算法的输出:P在S中的位置(即下标值)

二、暴力匹配和KMP算法

对于上述提到的问题我们先使用传统的方法进行解答,然后分析传统的方法的弊端,由此来引出我们所要讲述的算法。

无论是传统的方法还是我们的KMP算法,都是存在两个大的情况:

1、字符串匹配成功。

2、字符串匹配失败。

但是对于字符串失配的时候,传统方法和KMP算法的处理方式是截然不同的,我们先来看传统方法。

2.1传统方法(即暴力法)

首先在匹配之前的时候,我们使用指针i指向字符串S的开头,即i=0,指针j指向字符串P的开头,即j=0。

1、如果当前字符串匹配成功(S[i]==P[j]),则让i++,j++,继续匹配下一个字符。

2、如果失配(即S[i]!=P[j]),令i=i-(j-1),j=0。即每次失配的时候,i回溯,j被置为0。

对此我们可以写出代码

public  static  int KmpMatch(char [] s,char[] p){
        if(s==null||p==null||p.length<1||s.length< p.length){
            return  -1;
        }
        int i1=0;
        int i2=0;
        int [] next=getnextArray(p);
        while(i1<s.length&&i2< p.length){
            if(s[i1]==p[i2]){
                i1++;
                i2++;
            }else if(next[i2]==-1){
                i1++;
            }else{
                i2=next[i2];
            }
        }
        return  i2== p.length ? i1-i2 :-1;
    }

分析:传统的方法每次失败的时候都需要去回溯

最坏的情况下(即每次的失配都是发生在子串的最后一个位置),时间复杂度O(n*m),n是母串的长度,m是子串的长度。

那么有没有一种算法,可以让i位置不动,只要j来移动,这就是KMP算法。

2.2KMP算法

2.2.1前置知识

在讲述KMP算法之前,我们首先必须知道什么是最长前缀和后缀的长度

我们首先给出一个具体的例子(解释在表格下方),给定模式串ABCDABD,从左到右遍历这个串,各个子串的最长前缀后缀如下:

模式串的各个子串

前缀

后缀

最长公共前缀后缀

A

0

AB

A

B

0

ABC

A,AB

C,BC

0

ABCD

A,AB,ABC

D,CD,BCD

0

ABCDA

A,AB,ABC,ABCD

A,DA,CDA,BCDA

1

ABCDAB

A,AB,ABC,ABCD,

ABCDA

B,AB,DAB,CDAB

BCDAB

2

ABCDABD

A,AB,ABC,ABCD,

ABCDA,ABCDAB

D,BD,ABD,DABD,

CDABD,BCDABD

0

 

解释:首先第一点我们需要去注意的就是,当只有一个串的时候,前缀后缀就是空,第二前缀是从前往后,不包括最后一个,后缀反之。

得到如下的结果

模式串

A

B

C

D

A

B

D

最长公共前缀后缀

0

0

0

0

1

2

0

 

解释:这个表格的意思就是当前字符串包含当前的字符往前最长公共前缀后缀,例如第二个B位置就是ABCDAB这个串的最长公共前缀后缀。

KMP算法是一种空间去换时间的算法,在KMP的算法中,我们需要去维护一个名为next数组的数组(这个数组怎么具体怎么通过代码求得,在下面进行说明),我们现在只需要知道一件事,就是当我们失配的时候,我们保持i位置不动,至于j位置如何移动,我们去查看next数组,例如next[j]=k,我们失配的时候,我们的j就移动到k位置即可。

2.2.2next数组的初步引出

这个next数组中,就是我们上面的最长公共前缀后缀演化而来的,具体的演化过程就是将最长公共前缀后缀整体右移一位,然后next[0]赋值为-1(为什么赋值为-1,将在后面进行说明)。

演化后的结果

 

模式串

A

B

C

D

A

B

D

next

-1

0

0

0

0

1

2

 

解释:演化完之后的结果和之前的最长公共前缀后缀最大的区别在于当前子串不包含当前位置的字符往前长公共前缀后缀长度。

举个例子:例如D位置是2,在之前是0,因为在之前的最长公共前缀后缀长度的表格中,我们是包含了D这个字符的,表示ABCDABD这个串的最长公共前缀后缀,但是现在是不包含D,也就是ABCDAB这个串的最长公共前缀后缀。

再次提醒不要去纠结为什么是这样的,这个数组到底怎么通过代码实现,看到后面你就知道了,你只要去知道这个next数组存在的意义就是,当你失配的时候,他去告诉你的子串,应该移动到哪个位置。

稍微总结一下,上面我们做了什么,我们就是创造了一个next数组,这个数组的意义就是,当我们两个串失配的时候,告诉了我们的子串往哪里走。

2.2.3 KMP算法的具体步骤

接下来我们来看,我们如何使用这个next数组,去加速我们的匹配的过程。

我在这里画了一个图,来辅助理解匹配的过程,如图,首先假设字符串S想用i位置去和子串P位置0进行匹配,二者一直匹配,直到字符串S到X位置,字符串P到Y位置时,二者失配。

我们首先来回顾一下传统的方法,再来看看KMP的做法。

传统方法

失配时,我们将X移动i+1的位置,然后Y回到0,二者重新开始进行匹配。

KMP算法

我们保持X位置不动,然后让我们的Y位置回溯至next[Y]处。

其本质是将P向右移动了,如图

 

 

 

我们来解释一下这个图的过程,首先j是Y在字符串P最长公共后缀中,对应到字符串S的位置,Y回到next[Y],就是回到最长公共前缀后面的一个位置上,等效的就是上图中画出的一样,字符串P向右移动了Y-next[Y]个位置。

重点:此次的向右移动的流程有两个本质:

 

2.2.3.1KMP算法本质一

 

绿色圈1和绿色圈3是完全相等的,我们再次匹配的时候,他们之间的内容可以完全跳过。

 

我们结合下面这幅图来理解,如图:

证明:首先图中绿色圈1和绿色圈2是相等的,因为我们这个j是我们指定好的,它是字符串P在S中的对应的位置,又因为到了X和Y的时候才不匹配,所以绿色圈1和绿色圈2是完全相等的。

然后绿色圈2和3是一定相等的,因为他们是最长公共前缀后缀,所以绿色圈1和绿色圈3是完全的相等,我们再次匹配的时候,圈1和圈3之间的内容就可以直接跳过了,直接从X和Y开始匹配了。

有人就要问了,那i到j之间呢?你怎么就知道了i到j之间没有和字符串P匹配的呢?这就是本质二。

2.2.3.2KMP算法本质二

我们默认了i到j之间是没有能匹配字符串P的。

我们结合下面这幅图来理解,如图:

 

 

证明:如图所示我们假设i到j之间的k位置是可以匹配到字符串P的,记住是匹配整个字符串,那么至少蓝色圈1和蓝色圈3是完全的匹配吧,

但是我们字符串S和字符串P分别从i和0开始,直到X和Y才不想等的,所以蓝色圈1和蓝色圈2是完全的相等吧,那么蓝色圈2和蓝色圈3是完全的相等吧,所以说明了什么,说明了我们找到了Y之前更长的公共前缀后缀,比红色圈还要大,这显然是不成立的,next数组是我们时先求出来的正确的next数组,所以假设不成立,即i到j之间是不可能存在一个k的。

假设我们将Y移动到next[Y]的时候,还是不匹配,我们继续将Y再次移动到next[Y]的位置,直到next[Y]等于负一,我们让X++。

这就是两个本质的讲解,这么说完你肯定很懵,看完下面这个例子。

2.2.3.3一个具体的例子

 

 

第一次在e和w位置处失配,我们让Y到next[Y]处,就是t处,相当于第一部分的红色圈的地方被我们放弃了(后面以此类推),第二次e和t再次失配,我们再Y回到next[Y]的地方就是s处,s和t还是失配,我们再次Y回到next[Y]处,也就是a处,a的next[Y]的值就是-1,这里解释一下为什么要为-1,如果不是-1,就是一直循环了,当-1的时候,我们就要让X加一了,因为这时已经没有可以匹配的了。

现在就剩下最后一个问题了,next数组怎么求,我们来看一下.

2.2.4next数组的详细求解

 

 

如图,设这个数组为array,我们一开始是知道next[0]的值等于-1的对吧,因为他就只有一个字符啊,最长公共前缀后缀为0,右移后等于-1,所以我们假设我们已知next[i-1]去求解next[i],重点记住这个大前提,你是知道i-1位置的next数组的值,那么你也就知道了next[i-1]之前的next的值,而且都是完全正确的,一定记住了,不然后面你会懵掉的。

next[i]的位置是取决于next[i-1]和array[i-1]的,和他本身是无关的。

首先我们去找最长公共前缀后缀,就是拿array[i-1]和array[next[i-1]]去比较,如果他们相等了,是不是就代表了绿色圈是完全一致的,所以next[i]就等于了next[i-1]+1。

那么如果不等呢,不等代表了什么代表了你找不到这么长的前缀和后缀,我们去找更短的公共前缀后缀,我们让next[i-1]继续往前走,走到next[next[i-1]]的位置,一直往前,直到next值为-1,代表跳到了0之前的位置,就是没有,当前next[i]的值就是0,没有公共前缀后缀。

那为什么不跳到next[next[i-1]]到next[i-1]之间的位置上,好比如图中所示的k的位置,我们采用反证法来解决这个问题

假设我们跳到图中所示k的位置上,是可以找到的,首先红色的圈1和圈2是不是完全相等的,答案是完全肯定的,一定是完全相等的,因为这是i-1位置的最长公共前缀后缀,如果说k位置是可以找到的,那么粉色圈1和圈2是不是相等的,那么就出问题了啊,这代表我们找到了next[i-1]位置处更长的公共前缀后缀,这显然是不成立的,因为一开始的大前提我就告诉过你,你是知道的next[i-1]的值以及之前的next值,并且都是完全正确的,所以说假设不成立,我们没有必要跳到next[next[i-1]]到next[i-1]之间的位置上,因为根本找不到,直接跳到next[next[i-1]]即可。

接下来我们来看代码

public  static  int KmpMatch(char [] s,char[] p){
        if(s==null||p==null||p.length<1||s.length< p.length){
            return  -1;
        }
        int i1=0;
        int i2=0;
        int [] next=getnextArray(p);
        while(i1<s.length&&i2< p.length){
            if(s[i1]==p[i2]){
                i1++;
                i2++;
            }else if(next[i2]==-1){
                i1++;
            }else{
                i2=next[i2];
            }
        }
        return  i2== p.length ? i1-i2 :-1;
    }

    public  static  int[] getnextArray(char[] p) {
        if(p.length==1){
            return new int[] {-1};
        }
        int [] next=new int[p.length];
        next[0]=-1;
        next[1]=0;
        int i=2;
        int cn=0;//cn代表的是用cn这个位置的值和i-1的位置的值去比较
        while(i< next.length){
            if(p[i-1]==p[cn]){//即情况一,就是相等的情况 next [i+1]的值就等于next[i]+1
                next[i++]=++cn;
                //next[i]=cn+1;
                //i++;
                //cn++;
            }else if(cn>0){
                cn=next[cn];
            }else{
                next[i++]=0;
            }
        }
        return  next;
    }

三、KMP的时间复杂度

分析时间复杂度,先来看求解next数组的复杂度,维护两个变量,变量一i,变量二i-cn的值,i的最大值是m,代表母串的长度,第二个i-cn最大值是当cn等于0的时候,也为m,我们求解next数组的三个分支,第一个分支,i++,cn++,所以变量一增加,变量二不变,第二个分支cn减小,第三个分支i增大,所以总体的变化的幅度是2m,所以时间复杂度为O(m),上面匹配的过程,m一定是小于母串的长度n的,所以时间复杂度就是O(n)。

四、参考资料

左程云老师的算法课 小破站观看链接:硬核!一周刷爆LeetCode,算法大神(左程云)耗时112天打造出算法与数据结构基础到高级全家桶教程+大厂面试真题详解_哔哩哔哩_bilibili

一位大佬的讲解 链接:从头到尾彻底理解KMP(2014年8月22日版)_结构之法 算法之道-CSDn博客_kmp

五、小结

本文写于2021年12月19日,本人还是学习算法的一名小白,也是第一次写博客,在这里也只是和大家分享一下我的学习经验,若文中出现错误,还请各位小伙伴谅解和及时指出,谢谢各位小伙伴的耐心观看啊!!

 2021-12-19 15:53:02

posted @ 2021-12-19 16:00  把美好都装进晚风  阅读(91)  评论(1)    收藏  举报