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

作者: 程序之心 | 来源:发表于2018-03-18 23:49 被阅读25次

    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 位提高了比对效率。

    image

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

    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。

    image

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

    image

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

    image

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

    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。

    image

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

    public static List<Integer> indexOf(String text,String find){
        int[] next = getNext(find);
        List<Integer> 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 技术、软件架构、前沿技术、开源框架、数据结构与算法、编程感悟等多个领域,欢迎关注。本文首发于微信公众号“后端开发那点事儿”。

    image

    相关文章

      网友评论

        本文标题:通俗理解 KMP 字符串匹配算法

        本文链接:https://www.haomeiwen.com/subject/tyqifftx.html