关于KMP算法

KMP算法理解

KMP算法真的是百遇百新,只要隔一段时间再遇见,之前明明已经理解透彻了,结果又全忘光了,最后总得折腾好一阵子才能捡起来。大二数据结构课、考研专业课、去年暑假一刷,到今天再次遇到还是又忘记了。好记性不如烂笔头,为了节省之后遇到捡起来的时间,现在来写了写自己对于KMP算法的理解,分为下面四步:

1. 理解朴素模式匹配的思想

算法的思想是i指向文本串s,j指向模式串t。对于s的每一个和t长度一样的子串,t都去匹配,一旦遇到某一个元素不匹配的话,i就返回下一个需要匹配子串的开始元素,j也返回t的开始元素,以此类推。时间复杂度是O(n^2)。核心思想是如果匹配失败:i回退,j回退。

2. 理解KMP算法的思想

对照着朴素模式匹配来理解,KMP算法的核心思想是如果匹配失败,i保持原位置,j回退到合适的位置。那么j如何回退?合适的位置是哪里?这就引出了下一个概念:前缀、后缀以及最长相同前后缀长度。

以字符串str "aabaaab"为例,

  • 前缀是指不包含最后一个字符的所有以第一个字符开头的连续子串,str的前缀是:a, aa, aab, aaba, aabaa, aabaaa
  • 后缀是指不包含第一个字符的所有以最后一个字符结尾的连续子串,str的后缀是:b, ab, aab, aaab, baaab, abaaab
  • 最长相同前后缀长度就是前缀和后缀相同的时候最长的长度,比如str相同的前后缀只有:aab,长度为3。

在KMP算法中引入了一个next数组,next数组的长度跟模式串长度一样,数组的值表示从0到当前索引的模式串的子串的最长相同前后缀长度(有的版本是相对于这个减1,本文以不减1的版本为例),还是以str为例,它的next数组是:

索引 0 1 2 3 4 5 6
字符 a a b a a a b
next数组值 0 1 0 1 2 2 3

比如想求next[4]的值,我们就看从0到4的模式串的子串"aabaa",其前缀为:a, aa, aab, aaba,其后缀为:a, aa, baa, abaa,相同的有:a与aa,最长的长度是2,所以next[4]=2。

3. 理解为什么要按照next数组回退?

结合next数组值的来源------从0到当前索引的模式串的子串的最长相同前后缀长度,来进行思考。举一个例子:

文本串:s

索引 0 1 2 3 4 5 6 7 8 9
字符 a a b a a b a a f a

模式串:t

索引 0 1 2 3 4 5
字符 a a b a a f
next数组值 0 1 0 1 2 0

当i=5,j=5时,出现匹配失败,观察next[j - 1] = next[5 - 1] = next[4] = 2,得到:

(a) t.substring(0, 2) == t.substring(3, 5)

结合KMP算法思想我们得到,在i, j从0 - 4的过程中,一直都是匹配成功的,也就是:

(b) s.substring(0, 5) == t.substring(0, 5)

结合(a)与(b), 我们可以得到:

(c) s.substring(0, 2) == t.substring(0, 2) == t.substring(3, 5) == s.substring(3, 5)

简化之后,也就是:

(d) s.substring(3, 5) == t.substring(0, 2)

【注意,substring(start, end)方法截取的是索引start - (end - 1)的子串】

目前,i=5,也就是说,i前面的两个字符与模式串中第一个与第二个字符完全匹配,如果将j按照next[j - 1]移动到第三个字符,这样就可以省去前面两个字符的比较,更加快捷。如图所示:

现在,我们结合朴素匹配匹配理解了KMP算法的基本思想------匹配失败时i不回退,j按照next数组回退,以及为什么要按照next数组回退。现在还剩下最后一个问题?如何构造next数组?虽然我们知道了next数组值的含义,但是怎么用编程语言将其表达出来呢?

4. 理解如何构造next数组。

直接来介绍代码细节:我们假定i代表字符串后缀的结尾索引,j代表字符串前缀的结尾索引,在匹配成功的前提下,此字符串的最长相等前后缀长度可以用(j+1)来表示。假定完成之后,我们来设置初始条件,I = 1, j = 0。然后i与j同时开始移动并进行匹配:

  • 如果匹配成功的话,i与j同时移动,还需要判断j是否移动到了模式串的最后一个字符,如果是则匹配成功,直接输出结果。
  • 如果匹配失败的话,j需要向后回退(这何尝不也是运用了KMP思想呢?)。如果j能回退,那么就回退到next[j - 1],i保持不变,为了接下来的继续匹配;如果j不能回退,也就是j已经等于0,那么就说明没有相同的前后缀,因此为next[i]设置值。

5. 小结

在这篇文章中,通过四步理解,我们抽丝剥茧,逐渐捋清了KMP算法全貌。这四步原本是我在写LeetCode的时候总结的,目的是帮助我快速地理解与写清代码,现在我将其进行扩充写成了一篇文章,希望在未来的某一天,能帮助我快速地把KMP算法捡起来。最终来总结一下KMP算法的步骤:

  • 匹配过程:i指向文本串,j指向模式串。在遍历文本串的过程中,匹配i与j指向的字符。如果匹配成功,i与j同时移动,需要判断j是否移动到了模式串的最后一个字符,如果是表示匹配完成,输出结果。如果匹配失败,如果j能回退,j等于next[j - 1],i不动;如果j不能回退,j不动,i需要前进,继续进行匹配。

  • 构造next数组过程:i指向后缀的末尾,j指向前缀的末尾。在i移动的过程中,匹配i与j指向的字符。如果匹配成功,i与j同时移动,并更新next数组。如果匹配失败,如果j能回退,j等于next[j - 1],i保持不动;如果j不能回退,也就是j已经等于0,那么就说明没有相同的前后缀,因此为next[i]设置值。

代码如下:

java 复制代码
class Solution {
    public int strStr(String haystack, String needle) {
        // 特殊情况
        if (haystack.length() < needle.length()) {
            return -1;
        }

        // 构造next数组
        int[] next = new int[needle.length()];
        getNext(next, needle);

        int i = 0; // 指向haystcak
        int j = 0; // 指向needle
        for (; i < haystack.length(); i++) {
            // 匹配成功:i前进,j前进
            if (needle.charAt(j) == haystack.charAt(i)) {
                if (j == needle.length() - 1) {
                    return i - j;
                }
                j++;
            // 匹配失败:j能回退的话i就不动,j不能回退了的话i向前
            } else {
                // j能回退
                if (j > 0) {
                    j = next[j - 1];
                    i--;
                // j不能回退
                } else if (j == 0) {
                    // do nothing!
                }
            }
        }
        return -1;
    }
    
    private void getNext(int[] next, String needle) {
        next[0] = 0;
        int j = 0; // j是前缀末尾,也就是前缀长度 - 1
        int i = 1; // i是后缀末尾
        for (; i < needle.length(); i++) {
            // 匹配失败:j能回退的话,i不动,j回退;j不能回退的话,next[i]赋值
            if (needle.charAt(i) != needle.charAt(j)) {
                // j能回退
                if (j > 0) {
                    j = next[j - 1];
                    i--;
                // j不能回退
                } else if (j == 0) {
                    next[i] = j;
                }
            // 匹配成功
            } else {
                next[i] = ++j;
            }
        }
    }
}
相关推荐
passer__jw7674 小时前
【LeetCode】【算法】208. 实现 Trie (前缀树)
算法·leetcode
益达爱喝芬达6 小时前
力扣11.3
算法·leetcode
passer__jw7676 小时前
【LeetCode】【算法】406. 根据身高重建队列
算法·leetcode
__AtYou__6 小时前
Golang | Leetcode Golang题解之第535题TinyURL的加密与解密
leetcode·golang·题解
远望樱花兔6 小时前
【d63】【Java】【力扣】141.训练计划III
java·开发语言·leetcode
迃-幵6 小时前
力扣:225 用队列实现栈
android·javascript·leetcode
九圣残炎6 小时前
【从零开始的LeetCode-算法】3254. 长度为 K 的子数组的能量值 I
java·算法·leetcode
vir027 小时前
找出目标值在数组中的开始和结束位置(二分查找)
数据结构·c++·算法·leetcode
福楠10 小时前
[LeetCode] 1137. 第N个泰波那契数
数据结构·c++·算法·leetcode
付宇轩12 小时前
leetcode 1470.重新排列数组
数据结构·算法·leetcode