java数据结构与算法基础-----字符串------KMP算法

java数据结构与算法刷题目录(剑指Offer、LeetCode、ACM)-----主目录-----持续更新(进不去说明我没写完):https://blog.csdn.net/grd_java/article/details/123063846

一、概述

什么是KMP算法
  1. 我们拿LeetCode28题为例,暴力解法时间复杂度O(m*n), 而KMP算法时间复杂度O(n + m),空间复杂度O(m),所以,KMP算法是一个提升字符串匹配效率很好的一个算法。但是我们要注意一点,不同的环境和不同的编程语言,KMP的效率未必好过其它的算法,例如优化后的BM算法拥有比KMP更好的平均时间复杂度(注意是平均时间复杂度更好,算法本身理论上的时间复杂度,KMP更好)
  1. 暴力解法的效率
    在这里插入图片描述
  2. KMP的效率提升
    在这里插入图片描述
  1. 上面这道28题,仅仅说明,在这道题所规定的场景下,KMP确实发挥出了它应有的实力。但是在实际工作场景中,有时候会遇到种种不太适配的场景,这些时候KMP的表现也确实会受到多方面的影响,从而导致综合下来看,此时其它算法的表现更加优秀。

KMP算法很难,如果拿下,可以很好的提升你的编程思维,但是实际工作中也确实很少有场景会使用到它。不过,虽然完整的KMP算法不常用,但是如果单拿出它其中某一个步骤的思想,却往往能在很多不同的场景中,发挥奇效。

总结起来就是,很多问题,其实只需要使用KMP算法中的某一个步骤的思想就可以很好的解决并提高效率。但是如果你只记住了代码,每次使用KMP都必须把完整代码写出来使用的话,就会陷入杀鸡焉用牛刀的困境,造成不必要的时间和空间上的浪费。

KMP的简单概括,以及暴力解法的不足
  1. KMP算法:重复出现的,不要重复比较,跳过它。如何实现这个效果呢?只需要使用一个数组来记录重复出现的次数。
  1. 解决模式串在文本串是否出现过,如果出现过,获取最早出现的位置的经典算法
  2. 常用于在一个文本串S内查找一个模式串P出现的位置,此算法由Donald Knuth、Vaughan Pratt、James H.Morris三人于1977年联合发表,故取三人姓氏命名此算法为Knuth-Morris-Pratt,简称KMP算法
  3. 利用之前判断过的信息,通过一个next数组,保存模式串中前后最常公共子序列的长度,每次回溯时,通过next数组找到,前面匹配过的位置,省去大量计算时间
  1. 暴力匹配存在的问题:例如ABCDABEABCDABD 匹配 ABCDABD
  1. ABCDABEABCDABD从头匹配子串ABCDABD,可以发现最后一个字母D不匹配E,也就是说ABCDABE 这个串 肯定不匹配ABCDABD
  2. 但是在未知的情况下,我们无法像人脑一样,知道这个串匹配不了,从而向后移一位,从E开始继续观察是否匹配,比如:后面的EABCDABD继续匹配子串ABCDABD, 此时我们发现E不匹配,再次向后移一位进行ABCDABD匹配ABCDABD ,此时发现成功匹配了。但是计算机可不像我们人脑一样,可以进行这样的思考
  3. 所以暴力匹配中,我们不确定是否后移一位就匹配了,因此,只能一个个后移比较
  1. 暴力匹配哪里可以优化呢?
  1. 我们先看简单的匹配:ABCDABE和ABCDABD发现E和D不匹配,使用暴力解法,需要后移,将剩余的BCDABE再次匹配ABCDABD
  2. 此时,我们发现,又不匹配,如果我们想要匹配,最起码应该A开头吧,也就是说,再往后面移动,也没用,直到下一个A开头的
  3. 但是计算机不是人,使用暴力解法,还是会按部就班的继续匹配。例如接下来它会用CDABE匹配 ABCDABD,然后又发现不匹配,再次进行DABE匹配 ABCDABD… 以此类推
  4. 那么我们可以发现,BCD这三个开头,已经确定不匹配,没必要重复匹配,应该跳过,直接匹配下一个A。例如:直接跳到ABE匹配 ABCDABD

二、KMP思想

各位注意KMP解决的最大的问题是:假设我们匹配时,发现匹配到某个字符匹配不下去了,此时我们究竟是将已匹配的字符全部直接跳过,还是说只能跳过一部分。

  1. 例如:ABABABD匹配ABABD,从头匹配时,我们发现红色的位置是匹配不上的
  2. 如果我们全部跳过,就只剩下BD匹配ABABD,显然不正确
  3. 此时我们应该跳过字符串ABABABD中前面两个标红的AB,进行剩下的ABABD匹配ABABD。此时成功匹配。
KMP的解决思路:例如ABCDABEABCDABD 匹配 ABCDABD
  1. 对匹配串进行处理。我们先对ABCDABD这个子串做文章,判断匹配串某个位置不匹配时,应该怎么跳过,最终存储到next数组中。
  1. ABCDABE匹配ABCDABD,如果到D不匹配,应该直接变成ABE匹配ABCDABD,因为我们要找到下一个AB开头的
  2. 再比如ABCDEFGTTTTT匹配ABCDABD,如果第一次到E不匹配,应该直接变成EFGTTTTT匹配ABCDEFG,因为前面的ABCD全部不匹配,直接跳过
  3. 总结一下,可以发现,ABCDABD这个子串,出现两次AB,那么如果一直匹配到ABCDABD才发现不对,就应该直接跳到后面的AB继续匹配
  4. 而如果没有匹配到出现重复字符,比如匹配到ABCD就发现不对了,那么就应该直接跳过ABCD
  1. Next数组,如何记录,如何生成数组:是否有重复缀,应该用一个数组记录。比如ABCDABD对应数组应该是[0,0,0,0,1,2,0],代表A重复,长度1,AB重复,长度为2. 这是怎么算出来的呢?-----用前缀,后缀共同匹配法,就是从左到右,依次把前几个字母所组成的字符串列出来,然后再将前缀和后缀列出来,看看前后缀共同拥有的元素长度
  1. A的前后缀都是空集,前后缀共有0
  2. AB前缀A,后缀B,共有0
  3. ABC前缀A,AB。后缀BC,C,共有0
  4. ABCD前缀A,AB,ABC,后缀BCD,CD,D,共有0
  5. ABCDA前缀A,AB,ABC,ABCD,后缀BCDA,CDA,DA,A,共有A,长度1
  6. ABCDAB前缀A,AB,ABC,ABCD,ABCDA,后缀BCDAB,CDAB,DAB,AB,B,共有AB,长度2
  7. ABCDABD前缀A,AB,ABC,ABCD,ABCDA,ABCDAB,ABCDABD后缀BCDABD,CDABD,DABD,ABD,BD,共有0
  1. 如何利用数组,跳过该跳过的:ABCDABCABCDABD 匹配ABCDABD,前面我们已经得出Next数组为:[0,0,0,0,1,2,0]
  1. ABCDABCABCDABD 匹配ABCDABD, C不匹配D,对应Next数组[0,0,0,0,1,2,0],发现是0. 也就是说,当前匹配串ABCDABD,匹配到D后不匹配了,然后Next数组记录的ABCDABD的前后缀重复缀个数为0.
  2. 这就代表着,前面已经匹配的这7个字符,没有重和部分,无需重复匹配。直接全部跳过。跳过公式为:已匹配成功字符个数-匹配不成功字符对应Next数组值。也就是7-0 = 7.
  3. 所以下一次直接跳过7个ABCDABCABCDABD(红色的为跳过的),下一次直接匹配后面的ABCDABD。也就是ABCDABD匹配ABCDABD。此时匹配成功
另一个例子

在这里插入图片描述在这里插入图片描述在这里插入图片描述在这里插入图片描述在这里插入图片描述在这里插入图片描述在这里插入图片描述在这里插入图片描述在这里插入图片描述在这里插入图片描述在这里插入图片描述在这里插入图片描述在这里插入图片描述在这里插入图片描述

三、代码实现

大家可以简单浏览一下下面这个,如果不好理解,可以看再下面的多注释版本
     public int strStr(String haystack, String needle) {
        int n = haystack.length(),m = needle.length();//为了更快的速度
        if(m==0) return 0;
        //1、kmp数组
        int[] kmpArr = new int[m];
        kmpArr[0] = 0;
        for (int i = 1,j = 0; i < m; i++) {
            //"ABCDABD"
            //"i***i"两个i对应的匹配成功,那么匹配成功长度+1,j++,长度为1。说明如果匹配到ABCDA,那么下次可以把ABCD跳过,末尾的A不可以跳过
            //" i***i"然后两个i位置又成功,j++,长度为2,也就是说匹配到了ABCDAB,下次ABCD可以跳过,末尾的AB不可以,因为重复了
            //最后,j = 2,i = 6,C不匹配D
            //j = kmp[j-1] 看看后移后的缀匹配程度,一直到匹配成功,或者j = 0,从开头重新匹配
            //这个while循环,主要负责,如果当前不匹配了,是全跳过,还是只能跳过部分
            while(j>0 && needle.charAt(i)!=needle.charAt(j)) j = kmpArr[j-1];
            if(needle.charAt(i)==needle.charAt(j)) j++;//匹配成功,j长度+1
            kmpArr[i] = j;
        }
        //2、用kmp数组
        for (int i = 0,j = 0; i < n; i++) {
            //这里和上面代码一样,只不过原来是needle自己比,现在是haystack和needle比较
            while(j>0 && haystack.charAt(i)!=needle.charAt(j)) j = kmpArr[j-1];
            if(haystack.charAt(i)==needle.charAt(j)) j++;//匹配成功,j长度+1
            if(j==m) return i-j + 1;//匹配成功,返回子串起始下标
        }
        return -1;//没成功
    }
多注释版本
  1. 首先,我们需要一个数组,作为NEXT数组,表示匹配不成功后,应该跳过几个字符,进行下次匹配
    public int strStr(String haystack, String needle) {
        //先搞出kmpArr数组
        int[] kmpArr = kmpNext(needle);
        //用数组跳过没必要重复比较的
        return kmpSearch(haystack,needle,kmpArr);
    }
  1. NEXT数组:这里使用的方法是,记录当前位置匹配成功后,如果要跳,应该跳到哪里。这样的含义是,如果发现匹配到某字符匹配失败,那么证明前面都是匹配成功的,那么就访问失败字符的前一个字符的Next数组值,从而得知应该跳到哪里重新尝试匹配。例如:ABCDABD [0,0,0,0,1,2,0].C匹配失败,但是前面B匹配成功了,那么看看B成功,要跳,应该跳什么位置,我们发现Next数组中记录的是0,所以下次只能从头匹配。
  1. 首先用一个for循环,来处理从0位置开始,依次的每个子串,例如A,AB,ABC,ABCA,循环变量i代表每个子串的末尾。
  2. 然后通过while循环,来处理:如果当前的,前后缀不匹配,可以跳过几个字符。
  1. 如果匹配成功则很简单,例如:ABCDABD [0,0,0,0,1,2,0]。假设标红的下标为 i,标黄的下标为 j。我们发现标红的这个A,和开头标黄的A成功的进行了前后缀匹配。

那么我们可以让j++。它有两个作用

  1. 指向前缀的位置,也就是它在j++前,指向的就是开头的A。那么现在这个A匹配成功了,我们下个位置需要继续从A匹配吗?显然不用。下次直接尝试匹配B就好了。所以进行j++
  2. 代表当前,连续的情况下,成功匹配了多少。很明显我们现在仅仅连续匹配成功了这个标红的A。所以长度为1.
  1. 然后:ABCDABD [0,0,0,0,1,2,0]。我们发现标红的B,和开头的B成功的进行了前后缀匹配。

此时我们就可以再次进行j++;

  1. 刚刚 j 已经 =1 了。因为前面A匹配成功了,现在B也匹配成功了,那么ABCDA这个子串的前后缀,A与A匹配成功,现在末尾添加一个B,ABCDAB这个子串B与B也匹配成功了,那么代表AB与AB匹配成功了。所以下次应该匹配C
  2. A已经匹配成,然后紧跟着,B也匹配成功,所以连续情况下,连续匹配了AB,所以长度为2.
  1. 再然后:ABCDABD [0,0,0,0,1,2,0]。我们发现标红的D和标黄的C,没有成功进行匹配

也就是前缀ABC和后缀ABD没有成功匹配,我们知道KMP算法思想就是跳过没必要重复比较的

  1. 此时如果继续比较字符更多的前后缀已经没有意义了,因为ABC和ABD已经不匹配了,接下来的ABCD,和DABD就更不行了
  2. 所以我们此时就需要考虑,是全跳呢?还是只跳一部分,然后看看剩余部分是否前后缀还能匹配
  3. 因为前面的子串如果匹配失败,应该跳到哪个位置,都已经记录在NEXT数组中了,所以直接从next数组获取即可。
  4. 既然ABC和ABD匹配失败,但是我们的NEXT数组记录的是,字符匹配成功后,应该跳跃的位置,D和C匹配失败了。但是前面的都匹配成功了,所以我们访问NEXT[ j - 1],也就是B的位置,我们想看看B匹配成功,但是我们要跳跃,应该跳到哪里去。
  5. 我们发现AB这个前缀,压根没有公共前后缀,没办法,只能全跳。也就是回到0位置继续。
  1. 再举个例子,AACDAAA[0,1,0,0,1,2,2]

AAC和AAA没有匹配成功

  1. 此时访问[0,1,0,0,1,2,2]. NEXT[ j-1 ] = 1,我们发现前缀AA如果匹配成功,下次只需要跳到1位置继续匹配即可。同时j–。
  2. 此时再次匹配,我们发现AACDAAA匹配成功,则j++,代表公共前后缀长度为2.
  1. 最后通过一个if语句来处理,是否前后缀匹配,如果匹配,记录成功匹配的字符个数。
    //第一步:获取到一个字符串(子串) 的部分匹配值表,KMP数组
    public static  int[] kmpNext(String dest) {
        //ABCDABD [0,0,0,0,1,2,0]
        int[] kmpArr = new int[dest.length()];//kmp数组,记录前后缀共同匹配长度
        kmpArr[0]=0;//单个字符,例如A的前后缀都是空集,前后缀共有0
        //i代表后缀下标,j代表前缀下标和数组对应下标
        for (int i = 1,j = 0; i < dest.length(); i++) {
            //i = 0 代表A字符,前后缀都是空串
            //i = 3 代表ABCD的D,j = 0,最前面的A
            //不相等,依然是0
            //i = 4 代表ABCDA,的A。j = 0代表最前面的A
            //前后缀相等了,但此时j = 0,说明前面没有前后缀匹配的,那么直接记录本次
            //j++ = 1 , kmpArr[4] = 1
            //i = 5 代表ABCDAB,代表后缀B,j = 1代表前缀B
            //相等了,此时j>0,并且相等,那么直接j++即可
            //j = 2,kmpArr[5] = 2
            //i = 6 代表ABCDABD,代表后缀D,j = 2代表前缀C(ABC)
            //此时j>0,并且前后不相等,那么后缀D和C不匹配,需要尝试向前匹配
            //剩下他能匹配的,也就是,A和AB,对应j是0和1
            //j = kmpArr[j-1],向前移动前缀,kmpArr[1] = 0
            //j = 0,前面没有了,判断前后缀现在是否相等
            //D 不等于 A 因此D这个后缀,没的匹配
            while(j>0&&dest.charAt(j)!=dest.charAt(i)){//如果本次前后缀不匹配
                j=kmpArr[j-1];//看看可以跳到哪里,然后继续比较是否前后缀匹配
            }
            if(dest.charAt(i)==dest.charAt(j)){//如果匹配,长度+1,
                j++;
            }
            //j 就是 长度
            kmpArr[i] = j;
        }
        return kmpArr;
    }
  1. KMP搜索:和NEXT数组逻辑几乎一样,如果遇到匹配不成功的字符,那么就考虑NEXT数组中,前一个位置匹配成功后,应该跳到哪个位置重新匹配。
    /**第二步
     * kmp搜索
     *  依然进行匹配
     *  和上面一样,j就是匹配成功的前后缀共同长度
     *  
     */
    public static int kmpSearch(String str1, String str2, int[] kmpArr) {

        //遍历
        for(int i = 0, j = 0; i < str1.length(); i++) {
            //匹配不成功就跳过
            //需要处理 str1.charAt(i) != str2.charAt(j), 去调整j的大小
            //KMP算法核心点, 可以验证...
            //String str1 = "BBC ABCDAB ABCDABCDABDE";
            //    String str2 = "ABCDABD";
            //匹配表next=[0, 0, 0, 0, 1, 2, 0]
            //当i = 10,j = 6时,前面ABCDAB都匹配成功,但是现在空格比较D,不相等,此时说明不是子串
            //此时进入循环,j = 2,比较空格和C,不相等,再进入循环
            //j = 1,比较空格和B,不相等,再进入循环
            //j = 0,条件不满足,退出循环
            //下一次,将从空格后面继续匹配,避免前面比较过的,重复比较
            while( j > 0 && str1.charAt(i) != str2.charAt(j)) {
                j = kmpArr[j-1];
            }
            //匹配成功就继续
            if(str1.charAt(i) == str2.charAt(j)) {
                j++;
            }
            //和上面建立kmp数组一样,获得j,匹配成功的长度
            if(j == str2.length()) {//如果相等,匹配完全成功
                return i - j + 1;//返回子串起始下标
            }
            //否则,匹配不成功
        }
        return  -1;
    }
  • 61
    点赞
  • 40
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

殷丿grd_志鹏

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值