关于KMP算法的一些整理

214 阅读6分钟

在翻看了大量的CSDN、bilibili、blog等之后,发现关于KMP的博客是真的多,但是大多数对KMP的讲解都不是很清晰,当然我这篇也不是很清晰,但是关于KMP我确实做了比较详细的了解与阐述。

相信你能看到这篇文章,说明你已经知道KMP是因何而生的了,在此处我就不做过多的介绍了。

直接进入正题。

KMP算法

KMP 算法是 D.E.Knuth、J,H,Morris 和 V.R.Pratt 三人共同提出的,称之为 Knuth-Morria-Pratt 算法,简称 KMP 算法。

KMP算法相对于 Brute-Force(暴力)算法有比较大的改进,主要是消除了主串指针的回溯,从而使算法效率有了一定程度的提高。

想要搞明白KMP算法,首先需要直到前缀后缀

前缀

比如,有一个字符串abbaab,那么它的前缀有:a、ab、abb、abba、abbaa

后缀

同上字符串,它的后缀有:b、ab、aab、baab、bbaab

公共前后缀

公共前后缀指的是在一个字符串中一个字串即是前缀也是后缀。

前缀表

前缀表代表的是模式串i位置及之前的字串中的最长公共前后缀(包括i位置),其作用是当i位置发生不匹配时,可以根据前缀表来回溯。

比如字符串ababa

i == 0,字串为“a”,最长公共前后缀长度为0

i == 1,字串为“ab”,最长公共前后缀长度为0

i == 2,字串为“aba”,最长公共前后缀长度为1(a)

i == 3,字串为“abab”,最长公共前后缀长度为2(ab)

i == 4,字串为“ababa”,最长公共前后缀长度为3(aba)

但是,在很多实际应用中,前缀表(prefix)为了适应代码,出现了两种变形(next)。

第一种

将前缀表整体右移,第一位补-1.

第二种

将前缀表整体减一。

具体如下表,方式1代表初始的前缀表,方式2代表整体右移的前缀表,方式3代表整体减一的前缀表

image-20220331111311735

构建next数组

有了数组的前缀和后缀之后,我们就可以构建next数组了。根据要构建的next数组形式有不同的构建方式。

推荐(无广告,只是再查看了无数视频资料之后找到的讲解的比较好的分享一下):www.bilibili.com/video/BV1UL…

方式1

这种求出来的数组被叫做前缀表(prefix),也可以用作next,看个人习惯。

在该方法中next数组的含义是模式串 {0,i}区间的字符串的最长公共前后缀。

image-20220330164334731

求前缀和的过程(本人的思考过程)

在以下过程中next[i]数组代表的是i位置上的最长前后缀长度,str[]数组代表模式串转换成的字符数组。

由于在 i = 0 时,子串只有一个字符,前后缀都为空,所以规定next[0] = 0

image-20220331143949616

在求next数组时,我们需要比较子串的前缀和后缀,然后给数组的当前位置赋值,给数组赋值,我们只需要找个变量循环++即可,但是如何比较子串的前缀和后缀是我们需要解决的问题。

比较子串的前缀和后缀

子串区间:【0,i】,

由上可知,前缀必定以str[0]开头,后缀必定以str[i]结束,且next[i-1]代表的是【0,i-1】区间的最长公共前后缀长度。next[i-1]的取值又有两种情况。

情况1:如果next[i-1] > 0则说明【0,i - 1】区间有公共的前后缀,我们设定preLen代表 next[i-1]

str[preLen]即可代表【0,preLen】区间最长公共前后缀的前缀的下一个字符。

举个例子,模式串为 abcdabca,我们可以手算出其next数组为{0,0,0,0,1,2,3,1}

当 i=6 时,next[i-1] = next[5] = 2 = preLen,str[preLen] = str[2] = ‘c’。

str[preLen] 代表的就是【0,5】区间,即 abcdab 的最长公共前后缀(ab)的前缀的下一个字符。

此时我们只需要判断str[i]和str[preLen]是否相等

  • 相等,那么next[i] = next[i-1] + 1 = preLen + 1
  • 不相等,那么我们需要对preLen回退,让preLen = next[preLen - 1]

为什么让preLen = next[preLen - 1]?

举个:chestnut:

模式串 abababc,手算next[] = {0,0,1,2,3,4,0}

i = 6时,根据过程已经得到 j = 4,此时子串为 ababa

image-20220402152541749

根据图可知,回退一次之后,str[i] != str[j]仍然成立,

继续回退,此时j = next[1] = 0,不符合 j > 0的条件,退出循环。

情况2:如果next[i-1] <= 0,则说明【0,i-1】区间没有公共的前后缀,此时我们只需要判断str[i]和str[preLen]是否相等

  • 相等,那么next[i] = next[i-1] + 1 = preLen + 1
  • 不相等,next[i] = 0 = preLen,因为preLen不会小于0,只会大于等于0。

至于preLen为啥不会小于0,因为在给preLen赋值时,在它等于0时就会退出

方式1的代码如下:

public int[] getPrefix(String pattern) {
    int len = pattern.length();
    int[] prefix = new int[len];
    for (int i = 1, j = 0; i < len; i++) {
        // i 指向next,j 指向前缀
        while (j > 0 && pattern.charAt(i) != pattern.charAt(j)) {
            // j 回退 ,
            j = prefix[j - 1];
        }
        if (pattern.charAt(i) == pattern.charAt(j)) {
            j++;
        }
        prefix[i] = j;
    }
    return prefix;
}

一个完整的过程如下:

image-20220331144103819

image-20220402152958547

image-20220402153013089

image-20220402153025156

image-20220402153037308

image-20220402153048454

方式2

可参考:juejin.cn/post/695102…

求方式2的next数组就是求从0到发生不匹配位置之前模式子串的最长的公共前后缀的长度。

注意,此时next数组的含义发生了改变,此时next数组的含义是{0,i-1}区间最长的公共前后缀的长度。

比如,主串为ababacaacb,模式串为ababaa

第一次匹配时,发生不匹配的位置如下:

image-20220329171459891

此时的模式子串为:ababa,该字串的前缀有:a、ab、aba、abab,后缀有:a、ba、aba、baba。

所以,公共前后缀有:a、aba,最长公共前后缀就是:aba,所以next[5]=3。

举个:chestnut:.

对下面模式串求next数组

image-20220330164334731

求方式2的流程跟方式1是类似的,在此处不再赘述(因为过程口述起来实在是比较麻烦)。

方式2的代码为

public int[] getNext(String pattern) {
    int len = pattern.length();
    if (len == 1) {
        // 如果模式串长度为 1,直接返回即可
        return new int[]{-1};
    }
    char[] chars = pattern.toCharArray();
    int[] next = new int[len];
    next[0] = -1;
    next[1] = 0;
    //i = next 待赋值位置 , j = next[i - 1] = 0,代表{0,i-2}区间的最长公共前后缀长度
    int i = 2, j = 0;
    while (i < len) {
        if (chars[i - 1] == chars[j]) {
            // 判断chars[j]和chars[i-1]是否相等,相等的话,最大的公共前后缀长度+1,直接给next[i]赋值
            // 例:aab,
            next[i++] = ++j;
        } else if (j > 0) {
            // j > 0, j回滚
            j = next[j];
        } else {
            // j = 0,不能继续回滚了,此时只能设置next为0
            next[i++] = 0;
        }
    }
    return next;
}

方式3

求出方式1后,循环-1即可。直接求的方法,还没有研究,但是代码如下(没有详细验证):

public void getNextM3(String needle) {
    // len >= 2
    char[] chars = needle.toCharArray();
    int[] next = new int[chars.length];
    next[0] = -1;
    int k = -1;
    for (int i = 1; i < chars.length; ++i) {
        while (k != -1 && chars[k + 1] != chars[i]) {
            k = next[k];
        }
        if (chars[k + 1] == chars[i]) {
            ++k;
        }
        next[i] = k;
    }
}

好了,此时关于KMP的我个人的理解已经结束了,如果在看完此篇文章还有疑惑的话,建议多思考一下,想想如果这个问题摆在自己面前,自己该怎么做,然后再看代码是如何写的,作者又为什么这么写,然后就是多练习了,关于KMP,其实其思想没有很难,难的是将其用代码实现。这很考验我的逻辑思考能力。

希望你能有所收获,另外,如发现本文存在任何问题,随时欢迎指出。