KMP算法---通俗易懂不老旧(java)

文章详细介绍了KMP算法的工作原理,对比了暴力匹配的效率问题,并重点讲解了如何构建Next数组来提高匹配效率,通过实例解释了Next数组的生成过程和匹配过程中回溯的优化策略。
摘要由CSDN通过智能技术生成

前言:

        KMP是数据结构里较为复杂的一类,能够高效地从一个字符串中找到另一个字符串第一次出现的位置,时间复杂度为O(m+n),其中m为目标字符串的长度,n为模式字符串的长度。和传统的暴力匹配O(m*n)相比,时间复杂度大大减少,特别是在目标字符串和模式字符串较长的情况下,性能优势更加明显。

        在学KMP算法时,难点在于Next数组的寻找,在写本文之前也曾浏览过很多文章,翻看过很多视频,在这些基础之上用一种较为新的方式来寻找Next数组。希望能对你有所帮助。

1.暴力匹配

        何为暴力匹配?就是两个字符串都一个一个碾过去,遇到相同的就同时右移,不同的时候主串回溯到(该次匹配开始时的位置 + 1),直到目标字符串搜寻完毕。图示如下:

        暴力匹配很好理解,在这里就不过多阐述,以下是暴力匹配的代码:

private static int count(String str1, String str2){
        int str1len = str1.length();  // str1的指针
        int str2len = str2.length();

        int count = -1;  // 返回值

        int i = 0;  // str2的指针
        int j = 0;  // str2的指针
        while ((i != str1len) && (j != str2len)){
            if (str1.charAt(i) == str2.charAt(j)){  // 如果匹配成功
                i++;
                j++;
            } else{  // 匹配失败
                i = (i - j) + 1;  // i回溯到匹配开始的下一个位置
                j = 0;
            }
            if (j == str2len){  // 如果匹配完成,返回匹配成功的索引值,失败则-1不变
                count = i - j;
            }
        }
        return count;
    }

         整体来说,暴力匹配是新手解决这一问题的最佳途径,但是时间效率较为低下,因此可以考虑KMP算法。

2.KMP算法

        KMP算法和暴力匹配的最大区别就在于回溯,我们可以看到,暴力匹配如果失败,每次都要回溯到第一次匹配成功的下一位,字符串少了还好说,如果目标和模式字符串都很长,那么暴力匹配的时间花费就太大了!KMP则是在已经匹配过的字符串里寻找有用条件来进行回溯,大大减少了回溯的次数,并且借助"字符匹配表"使回溯的效率更高。

2.1  字符匹配表(Next数组)

        2.1.1  前缀

                在搞清楚字符匹配表之前,我们先来搞明白前缀。拿单词"monologue"来说,前缀就是含m但不含e的部分,即"m","mo","mon","mono"......"monologu".

        2.1.2  后缀

                后缀与前缀相背,同样拿上面的例子,后缀就是含e但不含m的部分,即"e","ue","gue","ogue"......"onologue".

        2.1.3  共有前后缀

                在理解了前缀和后缀之后,共同前后缀的概念就很好理解了,就是二者取交集。

        在搞清楚上面三个概念之后,我们就可以开始着手于寻找我们的字符匹配表了。对字符匹配表而言,就是共有前后缀的最大长度。我们以"ABACABAB"为例来给大家仔细地讲述如何去找到字符匹配表。

         上图就是“ABACABAB”的字符匹配表,我们首先要明白一点,字符匹配表是共有前后缀的最大长度,所以当只有一个字母时,是没有共有前后缀,所以next[0] = 0毋庸置疑,并且字符匹配表的长度=字符串的长度。 

        (1) 字符匹配表是从头走到尾得到的,我们这里用prefix来代表共有前后缀的最大长度。以上图为例,当走到AB时,前缀为“A”,后缀为"B",没有共同的前后缀,那么prefix = 0,即next[i = 1] = 0,这里的i就相当于指针。

        当我们走到ABA时,前缀为”A“,”AB“,后缀为"BA","A",所以prefix = 1,即next[i = 2] = 1。

       (2)  此时我们已经走到了ABAC,我们发现,如果索引为3的”C“如果和索引为1的”B“相同,那么是不是就在ABA的基础之上构成了一个更长的前后缀?很显然他们不同,这里也没有共同的前后缀,所以很遗憾ABAC在C的位置,prefix = 0,即next[i = 3] = 0。

       (3)  当我们接下来走到第五位的时候,不是正好和走到第三位一样的吗?新的一位都和第一位相同,均为A,而且此时prefix是为0的,注意这里prefix为0,有伏笔。所以在进行第五位的相同前后缀查找时,我们发现next[i = 4] == next[prefix] = "A",所以可以得到第五位的prefix = 1。

         (4) 走到第六位第七位的时候,我们发现第六位和第二位相同,第七位和第三位相同。即第六位和第二位均为"B",第七位和第三位均为”A“,并且我们注意到,走到第六位时,prefix为1,此时因为第六位和第二位相同,不刚好在1的基础之上构成了一个更长的前后缀了吗?我们直接+1即可,则第六位的prefix为2。同理,第七位的prefix为3。

        这里我们不难发现,如果prefix != 0,那么就找prefix的下一位,如果它和最新的字母相同,那不就刚好在之前的基础上有更长的前后缀了吗?但如果下一位不同呢?

 

         (5)走到最后,即第八位时,prefix = 3,我们很容易发现第八位 != 第四位,意味着无法构成更长的前后缀,那么我们就没有简单的方法来得到第八位的匹配值吗?

        如得!我们不难发现,prefix为3,代表着已经成功匹配过三个数,同时我们能清楚的看到,左边框无法与右边框一起构成prefix = 4的前后缀,但是左边框的前缀"AB"能和右边框的后缀"AB"构成长度为2的相同前后缀,那么我们在两个共有的部分寻找共同的前后缀,使得左边框的前缀能和右边框的后缀相同不就好了吗?如何回到共有的部分呢?我们有prefix = 3,不就是代表成功匹配三次吗?那我们要做的就是回到第三次匹配的位置。根据索引从0开始的特性,new_prefix = next[prefix - 1] 是不是就代表这共有部分的相同前后缀长度呢?我们根据之前得到的表可以得到,new_prefix = 1。

 

        在得到新的prefix = 1后,我们容易惊讶的发现,这就是我们指针走到第三位的情况!按之前的表格我们有"ABA"最长的共有前后缀长度为1,即prefix = 1,所以这又回到了之前的想法,因为第八位和第二位相同,所以能在第一位和第七位相同的基础上构成更长的前后缀,不就是2了吗!

 

        至此,我们得到了完整的字符匹配表,综合之前的过程我们能够明白:

        (以下的prefix都是在比较之前的数值,比较之后就要在指针的位置变化prefix的值)

        (1)如果next[prefix] == next[指针(>0)],那么prefix(共有前后缀) + 1,next[指针] = prefix

        (2)如果next[prefix] != next[指针(>0)],我们又分为两种情况:

                (a)如果prefix = 0,那么next[指针] = 0 是顺理成章的事情,因为prefix = 0 就表明在之前没有能构成共同前后缀的字母。

                (b)如果prefix != 0,就代表在已匹配的部分存在两个更小的部分完全相同,那么我们就要去在这两个小部分去查找共同前后缀,然后重新比较 next[new_prefix] 和 next[指针] 找出是否存在以下情况,即共同前后缀"A",分别能和第二位和第八位的"B"构成一个更长的前后缀。

                而这一步,也就又回到了上面的条件判断中去,即如果next[new_prefix] == next[指针],那么prefix + 1,如果next[prefix] != next[指针(>0)],就继续上述操作,直至next[new_prefix] == next[指针]或者prefix == 0。

        将以上的思路整理成代码如下:

private static int[] NextArr(String str){  // 得到str的字符匹配表
        // 计算next数组
        int strLen = str.length();  // 记录字符串的长度
        int[] next = new int[strLen];  // next数组的容量就是字符串的长度
        next[0] = 0;
        int i = 1;
        int prefix_len = 0;  // 前后缀共有的最长长度
        while (i != strLen){  // 结束条件
            if (str.charAt(prefix_len) == str.charAt(i)){  //   如果相同
                prefix_len++;  // 共有前后缀+1
                next[i] = prefix_len;
                i++;
            } else{  // 没有共同的前后缀
                if (prefix_len == 0){   //  之前没有共同的前后缀
                    next[i] = 0;
                    i++;
                } else{
                    prefix_len = next[prefix_len - 1];  //  寻找两个小部分的共同前后缀
                    // 这一行的代码是核心!很多教程没有讲清楚这一点,请结合上述例子反复食用揣摩!
                }
            }
        }
        return next;
    }

2.2  第一次匹配成功的索引值

        2.2.1  移动位数 = 已匹配长度 - 对应的部分匹配值

                这是KMP算法的使用公式,具体的推导比较复杂繁琐,而且不是核心点,大家知道有这样的一个公式并且会使用即可。

        当我们明白字符匹配表和公式之后,我们就可以开始着手匹配了,之前讲过,KMP的精髓在于回溯,至于匹配的方法还是相同的,所以匹配这部分就不用细说了,直接给大家上代码。

private static int kmpSearch(String str1, String str2){
        int i = 0;  // str1的指针
        int j = 0;  // str2的指针,也可以理解为共同前后缀的长度
        int count = -1;  // 记录最终匹配成功的索引
        int[] next = NextArr(str2);  // 得到字符匹配表
        while (i != str1.length() && j != str2.length()){  // 循环体结束条件
            if (str1.charAt(i) == str2.charAt(j)){  // 如果成功匹配,就继续下一个匹配
                i++;
                j++;
                if (j == str2.length()){  // 如果str2的指针已经到最后了,就代表匹配完成
                    count = i - j;  // 返回匹配开始的指针位置,即索引值
                }
            } else{  // 如果匹配不成功,分两种情况讨论
                if (j != 0){  // 如果之前匹配成功,就跳过  [起始的i + (已匹配的长度 - 表[j])]
                    i = (i - j) + (j - next[j - 1]);  // 这里是写出了完整表达式,也可以简化成i - next[j - 1]
                    j = 0;
                } else{  // 如果之前匹配没有成功,即没有相同的前后缀,那么i++,j不动
                    i++;
                }
            }
        }
        return count;  // 返回结束的索引值
    }

 

  2.3    匹配

       至此KMP的代码全部完成,输入目标字符串和模式字符串后就可以得到匹配成功的索引值了(如果你的result = -1当我没说......)

public static void main(String[] args) {
        String target = "BBC ABCDAB ABCDABCDABDE";
        String parse = "ABCDABD";
        int result = kmpSearch(target,parse);
        if (result != -1){
            System.out.println("从索引为" + result + "开始匹配成功...");
        } else{
            System.out.println("匹配失败...");
        }
    }

3. 感悟

        KMP算法的核心点在于Next数组的寻找,各种方法的核心都是一样的,只是在寻找这个核心的过程中不同道罢了,没有真正意义上的优劣,选择你自己能够明白的方法,才是最适合自己的方法。也正如生活中路,适合他人的路可能并不合适你,总要找到一条属于自己的道路。

        KMP算法是我们在算法路上必须要经过的一道坎,当然如果要实现相同的功能,暴力匹配和正则都是可以实现的,真正想要明白一个算法原理需要花费一定的时间,所以不要厌倦算法的复杂,相信自己一定可以的!

        

 

 

 

 

  • 6
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 2
    评论
评论 2
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值