kmp算法原理分析 next数组原理解释 代码详细注释

解决什么问题

在了解一个算法的开始,首先我们需要很清楚的知道该算法的目的才对,就跟我们去了解一个函数或者方法,得需要知道其输入输出以及function。
kmp算法是针对字符串,它是用来从一个字符串中找到目标字符串。

针对这个问题,首先我们来想一下暴力的解法,因为大部分算法都是去改进暴力解法,取一个巧。从一个字符串中寻找目标字符串。
比如

S; abcabccabcaaabcabcd
T: abcabcd

上面的例子便是要去从S中寻找T的位置,返回找到的S的索引位置。
我们暴力的解法其实很好去想。
就是遍历S的每个字符,每次都是与T的字母逐个进行对比,这种肯定能找的到,当然时间复杂度也很高。很容易就分析出来,时间复杂度是O(n*m),n是S的长度,m是T的长度。

那么kmp算法就是在该暴力解法的基础上进行“些许”的改进,排除掉一些重复的比较,从而使得整体的时间复杂度降低。降低到O(n+m)。

kmp为什么更快

明白算法解决什么问题之后,也知道问题的一般暴力解法之后,我们需要进一步想一个问题,为什么该算法能够更快,哦,是因为该算法相较暴力解去除掉了很多的重复情况(或者采取了一种讨巧的不同于暴力解的方法,不过也可以理解成就是不考虑很多情况)。
那么kmp算法去除掉哪些重复情况呢?
下面我们来看一下。

S; abcabccabcaaabcabcd
T: abcabcd

我们用暴力解法来看,遍历到S的第一位,下面我们发现第一位与T的第一位相同,那么往后比较,比着比着发现T的第七位与S的第七位不同,这时候,暴力解法会干什么呢?
暴力解法会怎么做呢?
很明显:

S; abcabccabcaaabcabcd
T:  abcabcd

将T往右移动一位,然后继续挨个比价。

但是很明显,这样比较肯定是不符合的,只能再往右移动一位,又不对,只能再往后比较一位,哦终于发现,当移动到下面这样,第一位终于对上了

S; abcabccabcaaabcabcd
T:    abcabcd

然后我又呆呼呼的去比较第二位,嗯相同,不错,第三位相同不错。第四位不同,完了,又要重来。

所以暴力解法从第一次匹配失败之后总共移动了多少次?数一数,移动了3次,然后比较了3次,发现不对。一共操作了6次。

然而,我们人来看,明眼人一眼就看出来,当我第一次匹配失败,直接把T往右移动三格,T的前三位和S对应的三位不是直接相同吗?你暴力解要6次,我移动一次不就好了?

确实是这样,这里一步就能做到的事,暴力方法用了6步。然而问题来了,我们如何一步就做到如此?

仔细观察我们第一次匹配失败的情形:

S; abc"abc"cabcaaabcabcd
T: "abc"abcd

看我用引号标记的这些部分,它们是不是相等呢,是的,于是我便将T往后移动,使得引号标记的绿色部分对齐,因为它们相等,我们直接从引号绿色部分之后的一个字母开始匹配(比较),从而节省了开销。

S; abc"abc"cabcaaabcabcd
T:    "abc"abcd

以上便是kmp之所以更快的原因,但是我们想一下,为什么我们能想到引号绿色部分能够相等呢?

我们在进行第一次匹配的时候,是这样进行匹配的,a与a一样,b与b一样,c与c一样,a与a一样,b与b一样,c与c一样,嗯?abc,好像在哪里见过,对的。我们已经匹配上的这一段里首尾有相同的部分。那么因为T也能够匹配的上,所以T中也是首尾有相同的部分,那么,我便可以直接将T的头部拉到S之前匹配部分的尾部。

如果上面的还不好理解,下面我们用一个更清晰的示意图来理解。
在紫色箭头处匹配失败。
在这里插入图片描述
蓝色和黄色的区域是相等的,即已经匹配成功的部分,首部和尾部是有一块相等(最大)的地方,那么自然而然,S的黄色能和T的黄色区域直接匹配的上。我们移动T,使得黄色和黄色对齐,然后直接从紫色简单重新开始匹配
在这里插入图片描述

以上便是kmp算法为什么更快的原因,该算法发现,由于我们有时候目标字符串有一定的特性,所以它不用每次都从头开始寻找,而是直接将首尾相等的部分对齐,从而节省时间开销。

但问题来了,上面讲的是我们人工寻找的逻辑,具体算法中如何来实现这种发现已匹配部分的首尾相等部分呢?
用next数组。下面我们就来讲next数组。

next数组

由于我们每次已经匹配的部分都是字符串T的从0号位开始的子串,我们也不知道匹配何时会失败,但无论什么时候失败,我们都需要知道当前已匹配部分首部与尾部相等的部分。
那么这个思路就简单了,反正都是从0号位开始嘛,那么我对每个位置都求一个子串,然后求这些子串的首尾相等部分的长度。
就用上面的例子好了。

	T	:	a b c a b c d           (首尾相等的部分最长要小于子串长度)
	子串:                           已匹配部分的首尾相等部分长度
	0-0:	a								0
	0-1:	a b								0
	0-2:	a b c                           0
	0-3:	a b c a                         1
	0-4:	a b c a b                       2
	0-5:	a b c a b c                     3
	0-6:	a b c a b c d                   0

由此,我们便知道了对于子串a,ab,abc,很明显找不到他们首尾相等的部分,在kmp算法中,我么只能老老实实的一步一步往右移动。
而对于abcabc这个就舒服了,当匹配到这里失败了,我们发现它的首尾相等部分长度为3,则我们可以直接往右移动3格,并且T的前三个字母我们不用匹配,直接用之前匹配失败的S的那个字母与T的第四个字母进行匹配即可。
这里0-6的这个子串没有意义,因为它不可能出现,它出现,则说明已经匹配成功了。

所以next数组实际上存储了当前匹配情况下的首尾公共部分的长度,当匹配失败时,我们可以通过查询next数组next[i]值,直接往右移动next[i]个单位。
例如如果我匹配了abcabc失败,则我直接往右移动3个单位。如果我匹配了abcab失败,则直接往右移动2个单位。

当然,上面的next数组还不是最终的next数组(其实是prefix table),实际上我们要进行一点处理。因为0-6其实没有意义,所以我们将0-6的删去,把整体的数组往后移动一格,然后首尾置为-1,从而形成了最终的next数组(实际上只是为了编程的方便)
最终的next数组就是[-1,0,0,0,1,2,3]

讲完了next数组是什么之后,又有一个很重要的问题要解决了,就是我们如何生成next数组,我们人工的进行分析子串当然容易,但是计算机不行啊,它哪会像咱们这样数,如果暴力的解法来生成next数组的话,就会很慢,反而会导致最终的kmp算法更慢,还不如暴力解,所以我们需要一个很快的生成next数组的办法。下面我们就来讲一讲如何生成next数组。

如何生成next数组

这里我们的思想是这样的:
我们当前的首尾相等部分的长度取决于前一次的首尾相等部分长度。

比如上一次首尾相等长度是2,abcab。(这里字符起始是0号位)
那么我这一次是abcabc(新多了最后一个字符c),我只需要去比较该子串中第2号位字符与我最新的字符c是不是相等,然后一看,果然相等,那么此时,我们的首尾相等长度 = 上一次首尾相等长度+1。
而当我上一次的首尾相等长度是0?,那么这一次我就直接比较第0号位字符与最新的字符是否相等,如果相等,则当前长度 = 0+1,如果不是则还是0。

如此一看,很简单,但事实并不是如此简单。
考虑一下,如果上一次长度是i,而这一次第i号位的字符与最新的字符不相等。此时怎么办?直接长度为0?不是的,我们看一个例子。

                                                 首尾相等部分长度长度
上一次:  (a b c d a b c)(a b c d a b c)                  7  
这一次:  (a b c d a b c)(a b c d a b c) d                ?

看这个例子,这一次的首尾相等部分长度为多少?我们知道肯定小于7,但是多少呢?是0吗,肯定不是。我们可以人工的找到:

                                                 首尾相等部分长度长度
上一次:  (a b c d a b c)(a b c d a b c)                  7  
这一次:  (a b c d)a b c  a b c d (a b c d)

所以肯定不为0,但是,这是我们人眼找到的,真正如何构建算法找到呢?

这里我们有这样几个事实:

  1. 我们新的相等部分长度一定比之前长度更小。
  2. 新的相等一定在之前的相等内部。

对于这两个事实,我们做这样的解释。对于1,如果新的相等部分长度大于之前的,显然不对。而对于2,如果新的相等部分不在之前相等部分的内部,我们就从头部来看嘛,新的相等部分不在之前相等部分的内部,则代表新的相等部分长度>之前相等部分长度,则这与事实1矛盾,显然不对。

基于这两个事实,我们进行寻找。
我们要找新的相等部分只需要在之前相等部分的内部寻找。
在这里插入图片描述
于是,我们只看后半截,我们去寻找后半截里的 首尾相等部分,如上图蓝色与黄色所示,二者相等。
然后我们知道,这里的长度是3,那么我们用后半截的3号位d字符与我们新的字符d进行比较,所以我们这里的首尾相等部分长度为4。

这里为啥不考虑前面呢?这是因为前半截和后半截是完全相等的呀,我们用这样一个图能更直观的理解。
在这里插入图片描述
因为相等,所以黄色部分与黄色部分首尾对应,又因为相等里的子相等,所以黄色部分与蓝色部分也相等,所以首尾黄色部分相等。

当然,找到了子相等部分,也有可能这里的相等部分长度不为4,比如我这里将最后的d改为e,那么显然就不是4了。
不过无妨,我们一直这样迭代下去就可以了。

至此,next数组如何构建的逻辑就讲明白了。下面结合代码来看一看,代码走一遍基本就没啥问题了。

java代码实现与分析

这里的代码我已经做了很详细的注释

public class kmp {
    /**
     * 构建prefix table,也就是求目标字符串子串的首尾相等部分
     * @param pattern
     * @return
     */
    public int[] setPrefix(char[] pattern){
        int len = pattern.length;
        int[] prefix = new int[len];

        for(int i=1; i<len; i++){
            int k = prefix[i-1];//获取前一个子串的最长首尾相等部分长度
                                //同时k刚好是相等子串首部的后一个,需要判断的当前一个

            while(pattern[i]!=pattern[k]&& k!=0){//如果不等于的话就一直找,找的逻辑是相等部分的首部相等部分,如果不是,继续寻找,这个要想一下是为什么                
                k = prefix[k-1];
                //来想想为什么是k = prefix[k-1]
                //其实蛮好理解的,如果不等于,那么出去i点,前面相等的部分一定在当前的相等部分内部,也就是说在相等部分的内部还存在子相等
                //这个子相等才是当前点i需要的子相等
                //那么就去寻找首部相等部分里的子相等,因为首部相等部分里的 子首部相等 与 子尾部 相等,那么同理,尾部相等部分 中的子首部也对称与它的子尾部 相等,所以首部相等部分里的子首部 与 尾部相等部分 里的子尾部相等
                //从而就找到了一个更小的相等部分
                //那么再来想一个问题,有没有比这个子首部更大的子首部呢?肯定没有,如果有的话,最大想等的又要修改了,所以,这已经是最大的了。
            }
            if(pattern[i]==pattern[k]){//如果找到了,则直接在基础上加1即可
                prefix[i] = k+1;
            }
            else{//如果找不到,则直接命名为0
                prefix[i] = 0;
            }
        }
        return prefix;
    }

    /**
     * 对prefix table 进行一个后移,然后初值赋值为-1,从而就获得了真正的next数组
     * @param prefix
     * @return
     */
    public int[] movePrefix(int[] prefix){
        for(int i = prefix.length-1; i>0; i--){
            prefix[i] = prefix[i-1];
        }
        prefix[0] = -1;
        return prefix;
    }

    /**
     * kmp算法
     * @param pattern
     * @param text
     */
    public void kmpSearch(char[] pattern,char[] text){
        //获取netx数组
        int[] prefix = setPrefix(pattern);
        prefix = movePrefix(prefix);
        //进行kmp查询
        //text[i]     len(text)     = M
        //pattern[j]  len(pattern)  = N
        int i = 0, j = 0, M = text.length, N = pattern.length;

        while(i<M){
            if(j>=N){//为了排除j>=N导致数组越界的问题
                j = 0;
            }
            if(j == N-1 && text[i] == pattern[j]){
                System.out.println("found pattern at :"+String.valueOf(i-j));
                //当找到第一个后,还得继续进行匹配
                j = prefix[j];
                if(j==-1){//排除AA中找A的问题
                    j++;
                }
            }
            if(text[i] == pattern[j]){
                i++;
                j++;
            }
            else {
                j = prefix[j];
                if(j == -1){//当移动到-1时
                    i++;
                    j++;
                }
            }
        }
    }

    public static void main(String[] args) {
        kmp demo = new kmp();
        char[] pattern = {'A','B','A','B','C','A','B','A','A'};
//        char[] pattern = {'A'};
        char[] text = {'A','B','A','B','A','B','C','A','B','A','A','B','A','C','A','B','A','B','C','A','B','A','A'};
//        char[] text = {'A','A'};

        demo.kmpSearch(pattern,text);

    }
}

参考资料

https://blog.csdn.net/yearn520/article/details/6729426
https://www.bilibili.com/video/BV1Px411z7Yo
感谢

  • 8
    点赞
  • 13
    收藏
    觉得还不错? 一键收藏
  • 2
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 2
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值