通俗理解 KMP 字符串匹配算法

KMP 算法是一个高效的字符串匹配算法,由Knuth、Morris、Pratt三人提出,并使用三人名字的首字母命名。在KMP之前,字符串匹配算法往往是遍历字符串的每一个字符进行比对,算法复杂度是O(mn)。而KMP算法通过预处理能够把复杂度降低到O(m+n)。

KMP算法

假设给定一个字符串 1 ABCABCABDEF,现在需要搜索字符串 2 ABCABD 在字符串 1 中出现的位置。从 0 位置开始比对,到位置 5 时发现字符不相同,字符串 1 的字符为 C,字符串 2 的字符为 D。接下来如何继续比对呢?KMP 算法的核心就在于这里。考虑到字符串 2 中有两个 AB 重复出现,因此可以把第一个 AB 移动到第二个 AB 处继续比对。这个移动过程就是利用了已经匹配的字符信息,直接将字符串前移 3 位提高了比对效率。

通俗理解 KMP 字符串匹配算法_第1张图片
image

假设给定一个长度为 n 的字符串 O ,查找长度为 m 的字符串 f 在 O中出现的位置,如下图所示(图片来自网络)。

通俗理解 KMP 字符串匹配算法_第2张图片
image

当比对到第 i 个字符不相等时,需要把字符串 f 向前移动继续比对,KMP算法通过计算最大公共长度来移动。当满足如下条件时,f 可以前移 k 位继续比对。

  • 字符串 A 是 f 的一个前缀;

  • 字符串 B 是 f 的一个后缀;

  • 字符串 A 和字符串 B 相等。

KMP 算法的核心即在于求解 f 中每一个位置之前的字符串的前缀和后缀的最大公共长度。注意,这个最大长度不包括字符串本身。当比较到第 i 位置时,如果不相同,而此时最大公共长度为 j,则 f 前移的距离为 k = i – j 。

最大公共长度的计算

前缀是除了最后一个字符的子字符串,后缀是指除了第一个字符的子字符串。对于字符串 ABCA,前缀有 A、AB、ABC,后缀有 A、CA、BCA,相同的前缀后缀只有 A。最大公共长度是指当前位置前面的字符串相同前缀后缀的最大长度,使用 next 数组表示。根据这个规则,前面的字符串 2 ABCABD 的 next 数组如下表格所示。对于长度为 m 的字符串,next 数组的长度为 m + 1。显而易见,next[0] = next[1] = 0。

通俗理解 KMP 字符串匹配算法_第3张图片
image

已知 A3 = A0,next[4] = 1,计算 next[5] 时只需要比对 B4 = B1 则 A3B4 = A0B1,next[5] = next[4] + 1 = 2。

通俗理解 KMP 字符串匹配算法_第4张图片
image

已知 A3B4 = A0B1,next[5] = 2,计算 next[6] 时首先比较 D5 ≠ C2 则 A3B4D5 ≠ A0B1C2。如果存在一个以 B 结尾的公共前缀,那么这个前缀一定在 C2 的前面,这个前缀的长度为 next[next[5]] = 0,因此 C2 的前面没有这样的前缀,并且 D5 ≠ A0,next[6] = 0。

通俗理解 KMP 字符串匹配算法_第5张图片
image

假设我们现在已经求得 next[1]、next[2]、……、next[i],现在要求next[i+1]。可以看出,如果位置 i 和位置 next[i] 处的两个字符相同,则 next[i+1] = next[i] + 1;如果两个位置的字符不相同,可以继续向前搜索,获得其最大公共长度 next[next[i]],然后再和位置 i 的字符比较,直到能最终确定结果。(图片来自网络)

通俗理解 KMP 字符串匹配算法_第6张图片
image

根据上面的分析,不难写出求解 next 数组的代码如下。

public static int[] getNext(String find){
    int[] next = new int[find.length() + 1];
    //0 和 1 的值肯定是 0
    next[0] = 0;
    next[1] = 0;

    //根据 next[i] 推算 next[i+1]
    for(int i = 1; i < find.length(); i ++){
        int j = next[i];
        //比较 i 位置与 j 位置的字符
        //如果不等,则 j 取 next[j]
        while (j > 0 && (find.charAt(i) != find.charAt(j))){
            j = next[j];
        }
        //如果相等,则 j 加一即可
        if(find.charAt(i) == find.charAt(j)){
            j ++;
        }
        next[i+1] = j;
    }

    return next;
}

字符串匹配

字符串匹配的过程和求解 next 数组的过程类似。假设搜索字符串 f 在字符串 O 中出现的位置,f 的下标为 j,O 的下标为 i。如果 O(i) = f(j),则 j = j + 1,继续比较 f 中的下一个字符;如果 O(i) ≠ f(j),则 j = next[j],继续比较 f 中位置为 next[j] 的字符,相当于 f 前移了 i - j 位。当 f 的下标 j 移动到末尾时,说明匹配成功,此时可以算出第一次出现的位置是 i - j。

如下图所示,假设 O 为 ABCABCABDEF,f 为 ABCABD。O(4) = f(4),下一步继续比对 O(5) 和 f(5) 即可;O(5) ≠ f(5),则 f 的下一个比对字符位置为 next[5] = 2,下一步继续比对 O(5) 和 f(2),相当于 f 前移了 5 - 2 = 3 位。当比对到 O(8) = f(5) 时,f 已经比对到末尾,匹配成功,此时算出 O 中出现的位置为 8 - 5 = 3。

通俗理解 KMP 字符串匹配算法_第7张图片
image

根据上面的分析,不难写出匹配字符串的代码如下。

public static List indexOf(String text,String find){
    int[] next = getNext(find);
    List index = new LinkedList<>();

    //i 表示 text 中的位置,j 表示 find 中的位置
    int j = 0;
    //遍历 text 中的字符
    for(int i = 0; i < text.length(); i ++){
        //这里是 KMP 算法的关键点,移动位置为 next[j]
        while (j > 0 && (text.charAt(i) != find.charAt(j))){
            j = next[j];
        }
        //如果 i 位置和 j 位置的字符相同,移动一位
        if(text.charAt(i) == find.charAt(j)){
            j ++;
        }
        //如果 j 已经到了 find 的尾部,表示已经找到
        if(j == find.length()) {
            // i - j + 1 即为 find 在 text 中出现的位置
            index.add(i - j + 1);
            //这里是 KMP 算法的关键点,移动位置为 next[j]
            j = next[j];
        }
    }

    return index;
}

推荐阅读

Java 虚拟机 JIT 即时编译器

九种排序算法的可视化及比较

深入理解 Java 枚举类型,这篇文章就够了

【Java技术】盘点 Java 中的队列

MyBatis 动态 SQL 常用功能

Java 9 新增的 3 个语言新特性

分享学习笔记和技术总结,内容涉及 Java 技术、软件架构、前沿技术、开源框架、数据结构与算法、编程感悟等多个领域,欢迎关注。本文首发于微信公众号“后端开发那点事儿”。

通俗理解 KMP 字符串匹配算法_第8张图片
image

你可能感兴趣的:(通俗理解 KMP 字符串匹配算法)