JAVA代码实现字符串匹配(一)——BF、KMP

话不多说,直接进入主题:
        题目描述:给定两个字符串text和pattern,请你在text字符串中找出pattern字符串出现的第一个位置(下标从0开始),如果不存在,则返回-1;
        LeetCode字符串匹配的题目:https://leetcode-cn.com/problems/implement-strstr/
        举个例子:

字符串text(以下简称T,也叫主串)T = “ABABABABCABAAB”;
字符串pattern(以下简称P,也叫模式串)P = “ABABCABAA”;

        字符串匹配的问题有很多种方法,最简单的就是暴力匹配(BF),也是最容易想到的。首先还是得先介绍以下BF法,只有清楚了BF的缺点才能更好的理解后续的优化算法。

        1. 暴力匹配(BF)

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

        首先,将T和P左对齐,定义指针i用于遍历T,指针j用于遍历P,每次比较指针i和j所指的元素是否相同,相同就同时移动两个指针,当碰到不同时(也就是“坏字符”),将j指针回到P的起始位置,i指针移动到上一次的起始位置的下一位:
在这里插入图片描述
        以此类推,直到j指针移动到P的最后一位,说明在T中找到了P的位置,此时i的位置指向了T中P字符串的末尾,因此起始位置应该是i-j;

/**
 1. BF
 2. @param ts 主串
 3. @param ps 模式串
 4. @return 如果找到,返回在主串中第一个字符出现的下标,否则为-1
 */
public static int bf(String ts, String ps) {
    char[] t = ts.toCharArray();
    char[] p = ps.toCharArray();
    int i = 0; // 主串的位置
    int j = 0; // 模式串的位置
    while (i < t.length && j < p.length) {
       if (t[i] == p[j]) { // 当两个字符相同,就比较下一个
           i++;
           j++;
       } else {
           i = i - j + 1; // 一旦不匹配,i后退
           j = 0; // j归0
       }
    }
    if (j == p.length) {
       return i - j;
    } else {
       return -1;
    }
}

       在使用BF暴力匹配字符串的时候,每次遇到“坏字符”,就会回溯i指针,使其回到上一次起始位置的下一个位置上重新进行匹配,这个过程浪费了很多不必要的匹配,比如这种极端情况:
在这里插入图片描述
。。。
在这里插入图片描述
在这里插入图片描述
       不难发现,这种情况时,模式串P的前8个S参与很多次重复的匹配,导致匹配的效率大打折扣。假设主串T的长度为n,模式串长度为m,可以计算出BF算法的时间复杂度为O(mn)。

2. 如何优化暴力匹配这种方式,减少重复匹配的次数?

       其实,通常情况下,在匹配过程中,碰到“坏字符”时,我们潜意识中不会那么笨的把i指针回溯到上一次的起始位置,再往前挪一位,再进行比较,而是直接会这么操作:
在这里插入图片描述
在这里插入图片描述
       正是这种“潜意识”告诉我们没有必要一步一步往下比较,在“坏字符”出现之前的字符串肯定是已经匹配上的了,不妨我们将这些已经匹配的字符串记为K。这样主串T和模式串P中各会有一个K串。匹配过程中,每次出现“坏字符”的时候,我们只需要将主串中K的后缀和模式串中K的前缀对应上,这样就避免了指针i的回溯。
在这里插入图片描述
       注意这里说的主串中K的后缀和模式串中K的前缀对应上这句话,首先要理解一个字符串的前缀和后缀:
       前缀:这里说的字符串的前缀是指这个字符串除掉最后一个字符,剩下字符的有序集合,例如ABAB的前缀是{A,AB,ABA};
       后缀:这里说的字符串的后缀是指这个字符串除掉第一个字符,剩下字符的有序集合,例如ABAB的后缀是{B,AB,BAB};
       其次,要理解这句话中说的对应这个词的含义:这里是指前缀和后缀集合中公共最长字符串。
       那么,还是以ABAB为例,主串中K的后缀集合是{B,AB,BAB},模式串中K的前缀集合是{A,AB,ABA},连个集合中公共的最长字符串是AB,那么对应上的意思就是:
在这里插入图片描述
在这里插入图片描述
       到这,会发现指针i并没有移动,但是指针j的位置移动了,移动到哪了呢?正好是K的公共最长字符串长度在模式串P的下标位置上,例如此时K的最长公共字符串AB的长度是2,那么指针j的位置移动到了P下标为2的位置上。
       接下来,会继续拿指针i和j位置上的字符进行对比,碰到“坏字符”,会和上面说的过程一样,找出K中公共最长字符串,然后对应上,继续判断。。。
在这里插入图片描述
在这里插入图片描述
       此时,指针j匹配到最后发现都匹配上了,也就是说在主串T中找到了模式串第一次出现的位置,需要将主串T中第一次出现模式串P的起始下标返回:
在这里插入图片描述
       注意此时i的位置,其实是指在T中P串的末尾处,所以应该返回的是i-j。

       到此,这就是KMP算法的大体步骤,相信你一眼就能看出,KMP算法的核心之处,就是这个公共最长字符串,也就是大家所说的前缀表(next数组)如何生成。

3.如何生成前缀表(next数组)

       既然到这了,是时候放出b站的一个大牛对KMP算法的教学视频了:

  •        https://www.bilibili.com/video/BV1Px411z7Yo
  •        https://www.bilibili.com/video/BV1hW411a7ys

       这位大佬对KMP的算法讲解的算是很透彻了,我研究了大半天都没搞懂的代码,看了他讲解的视频,恍然大悟,直呼666。
       当然了,我也在此说一下是怎么生成这个前缀表的。首先,要搞清楚一个事情,我们为什么要生成这个前缀表(next数组)?其次,这个前缀表(next数组)到底是个什么结构,它的作用是什么?好的,现在开始一一解答。

       3.1.为什么要生成这个前缀表(next数组)?

       从上面的过程中不难发现,我们之所以“潜意识”会直接将模式串P往后挪到一个合理的位置,再进行匹配,从本质上来说,就是我们找到了K串中的公共最长字符串,那么我们为什么要找这个公共最长字符串呢?我们用到了这个公共最长字符串的哪些信息可以让我们将模式串后移到合理的位置再进行匹配呢?答案是:长度。我们费尽心思的拿到这个公共最长字符串的目的有其二:1:我们不想让指针i回溯,而是当停在出现“坏字符”的位置,通过移动模式串P到一个合理的位置后,然后从指针i这个位置继续往后匹配。怎么体现合理的位置呢?就是要保证当指针i停留在“坏字符”处不动时,如何移动指针j,使其指针j前面已经匹配的字符最大程度上不用再重复匹配了,这个最大程度的长度就是公共最长字符串的长度。2:就是通过公共最长字符串长度来确定指针j应该移动到哪个位置,然后重新和指针i进行匹配。
       这就是为什么要生成这个前缀表(next数组),好像一不小心把前缀表(next数组)的作用也说了。。。

       3.2.前缀表(next数组)是个什么结构?

       通过上面的分析,我们仿佛发现,好像只需要公共最长字符串的长度就行了。但是,仔细琢磨会发现,这个公共最长字符串并不是固定的,他是一个动态的过程,因为这个K串可能会是一个字符、两个字符、三个字符…,最多也就是模式串P的长度。因为谁也不能保证,在匹配的过程中,在哪个位置碰到“坏字符”。如果在第一个位置碰到“坏字符”,那K串就是"",空串,如果在P的最后一个字符上碰到“坏字符”,那K串就是P串去掉最后一个“坏字符”剩下的串。所以,我们已经知道这个前缀表(next数组)其实就是K串与下一次指针j移动到合理位置的对应关系。这个合理位置就是下一次指针j需要移动到以公共最长字符串的长度值为P数组下标的位置上。说起来可能比较绕,直接上一个实际的例子来看吧:
       T = “ABABABABCABAAB”
       P = “ABABCABAA”
       
       当i和j匹配到A和C时,出现“坏字符”,这个时候K串是ABAB,此时公共最长字符串是AB,长度len=2,那么我们只需要将指针j移动到P串下标为2的位置上重新开始匹配就行了,也就是下图中A的位置:
在这里插入图片描述
       此时,主串T中K串的后缀AB正好和模式串P中K串的前缀AB对应上了,保证了j指针这次移动后,j之前的元素已经是匹配好了的。那么为什么j指针移动到这个位置就一定能保证j之前的元素四匹配好了的呢?其实这就是一个T和P前后缀对应后指针j停留在哪的问题。所以就很好理解了。因为为了让T和P的前后缀对应上,我们只需要把P向后移动len个单位就可以了,这个len就是公共最长字符串的长度,程序中如何体现把P往后移动呢,很简单,就是把指针j往前移动。因为数组的下标是从0开始的,所以j往前移动len个单位,自然最后的位置就是停留在公共最长字符串AB的下一个位置上,也就是P[len]这个位置上了。
       花了这么大精力解释完这个问题后,终于到了最关键的步骤,如何生成这个前缀表(next数组)?

       3.2如何生成前缀表(next数组)

       在生成next数组(以下都叫next数组了)之前,我们已经明确一种对应关系,也就是K串的长度和下一次指针j移动到什么位置的对应,其实通过上面的解释,这种对应关系可以换种说法了:K串的长度和len的对应关系。我们直接把next数组的下标当做K串的长度,下标对应的值当做len,这种对应关系就建立了。到这,那就好办了,因为主串的K和模式串的K是一样的,那直接开始遍历模式串P不就行了。是的,实际也是这么干的,我们还是通过图的形式展示一下:
在这里插入图片描述
       我们一轮一轮分析:
       第一轮:因为K串是"",没有公共最长前后缀,所以len=0,next[0]=0;
       第二轮:多了一个B字符,此时K串是A,显然单个字符也没有公共最长前后缀,所以len=0,next[1]=0;
       第三轮:多了一个字符A,此时K串是AB,前缀是{A,AB},后缀是{A,BA},公共最长前后缀是A,len=1,next[2]=1;注意!!!从这里开始,我们需要观察了。每一轮都会多一个字符,多出来的字符直接影响到这一轮的len是多少。如果我们每一轮都重新去判断K串的公共最长前后缀,那太繁琐了。经过观察(我是没想到,大佬们想到了。。。),每一轮多出来的这个字符串只需要跟上一轮公共最长前后缀的下一个字符对比,就可以判断len需不需要加1了。这句话很重要!!!(据说用到了动态规划的思想,咱也不知道,咱也不敢问)。还是以第三轮来说吧,这一轮多出来的字符是A,我标红了,因为上一轮len=0,也就是说上一轮没有公共最长前后缀,没有的话,那公共最长前后缀的下一个字符是谁呢?答案是第二轮前面的那个A,我标绿了。为什么是A呢,我想也很好理解,我们可以想象,第二轮没有公共最长前后缀,也就是一个"",空串,我们假设这个空串也是一个字符,他就在第二轮A的前面,那他下一个字符就是A了,这么想应该就好理解了。也就是说我们比较P[i]和P[len]是否相等,如果相等,说明这一轮新加的字符和上一轮公共最长前后缀的下一个位置的字符相同,那就说明这一轮的len可以加1了。所以第三轮len=1,next[2]=1;
       第四轮:新加了一个字符B,那么P[3]和P[1]相等,说明公共最长前后缀又变长了,len=2,next[3]=2;
       第五轮:这轮多出来的字符是C,P[4]和P[2]不相等了。之前都是判断相等的情况len++,这回不相等了,怎么办。说明我拿上一轮的公共最长前后缀的下一位和这轮多出来的字符对比不行了,那就得和上上一轮的len做对比了。这种做法就像是在验证,多出来的这个字符已经不能让这一轮的len++了,会不会直接就是0了呢,得赶紧去看上上一轮公共最长前后缀的下一位,那从图中来看,第四轮的公共最长前后缀是AB,下一位是A,此时A!=C,所以我们去拿上上一轮的len,第三轮的公共前后缀的下一位是B,那我们是跟这个B做对比吗。不是!这块也是最难理解的点。仔细观察我们会知道,第五轮,在这个C没加进来之前,公共最长前后缀已经是AB了,由于C和第四轮的公共最长前后缀的下一位A不相同,导致第五轮公共最长前后缀没有增1,没有增1不可怕,可怕的是每一轮只能多出来一个字符,如果这个字符没有让公共最长前后缀加1,那必然已经让上一轮的公共最长前后缀失效了,比如说第四轮K串是ABAB,公共最长前后缀是AB,但是第五轮进来的是C不是A,这样第五轮的公共前后缀已经不可能是ABA了,也不可能跟第四轮一样是AB了,所以去第三轮判断中间的B是没有意义的(因为第三轮中间的B实际是用来判断公共最长前后缀AB的),所以应该直接去看第二轮的A是不是和这个C匹配。这种跨这么多维度去判断,应该怎么实现呢,在之前说的那个b站大佬讲解的视频当中,他对这块也是直接上来就说出了解决方案,那就是当P[i]!=P[len]时,len=next[len-1];然后重复本轮循环。拿第五轮来说,当C和第四轮的第三个元素A对比,发现不相等时,此时len还是第四轮的值len=2;这时候直接跨过第三轮,len=next[2-1]=0;然后再让这个C去和公共最长前后缀”“的下一位去判断,此时len=0了,公共最长前后缀已经是空串了。下一位当然就是首字母A了,C和A判断,发现还不相等,此时前面已经不会有公共最长前后缀了,那第五轮的公共最长前后缀只能是空串了,所以第五轮的len=0,next[4]=0;注意!!!这块又需要注意了,因为len=next[len-1]这个-1的操作,很可能会出现-1的情况,比如咱们直接看第二轮,当多出来的字符B和上一轮公共最长前后缀的下一位A不一样,按理来说也应该len=next[len-1],但是此时第一轮的len=0,所以这块就会出现next[-1]数组下标越界的情况,所以还需要有一个限定条件,那就是如果P[i]!=P[len],并且len=0的时候,那就不需要len=next[len-1]这个操作了,直接将本轮的next[i]=0就行了。
       第六、第七、第八轮:情况和第三轮、第四轮一样。
       第九轮:经过了上述的分析,咱们具体来看一下第九轮的情况:首先,第九轮多出来的字符是A,第八轮的公共最长前后缀是ABA,len=3,next[7]=3;1、P[8]!=P[3],说明第九轮的len不会加1了,len!=0,所以len=next[len-1]=next[3-1]=1,此时P[8]!=P[1],又不相等,此时len=1!=0,所以len=next[len-1]=next[1-1]=0,此时P[8]==P[0],终于相等了,len++,next[8]=1。
       至此,模式串遍历完毕,我们得到了next数组next={0,0,1,2,0,1,2,3,1};后续我们进行KMP算法就方便多了,比如说在模式串P[j]处碰到”坏字符“,那直接j=next[j],就将指针移动到了新的位置,并且在j之前的字符就是公共最长前后缀,都是已经匹配通过的,然后和指针i继续往后判断即可。但是目前这个next数组还不是最终形态,存在两个问题:1.第九轮已经是P串本身了,实际匹配的时候,指针j如果在模式串P的最后一个字符上匹配通过,那就说明模式串P在主串T中找到了,所以K=P这中情况实际上压根用不上,所以next数组的最后一个元素可以去除;2.next[0]和next[1]永远都会是0,因为一个字符不会有公共最长前后缀,两个字符也不会有公共最长前后缀,那怎么区分这两种情况呢。我们不妨让next[0]=-1,这样就区分开了,为什么要设置成-1,是有原因的,那就是匹配过程中碰到”坏字符“时,理论上是j=next[j],但是如果next[0]和next[1]都是0的话,这里就出现死循环了。其实这是一种临界场景,就是当j指针已经移动到P串的起始位置了,但是发现P[0]和T[i]还是不相等,这时候已经不能再移动j了,说明已经没有公共最长前后缀了,必须将i的指针也往前移动一位,那如果我们把next[0]设置成了-1,那碰到这种临界情况,就更好理解了,当j已经到P[0]的位置,,发现P[0]还是不等于T[i],此时j=next[0]=-1,接下来判断如果j=-1,说明遇到这种临界情况了,我们需要将i和j同时往后移动一位,i++,j++,此时j又回到P[0]位置,而i也往后移动了一位,继续判断P[J]和T[i]即可。
       基于上述分析,我们只需要将next数组整体后移一位,挤掉最后一个元素,并且将next[0]赋值为-1,就是一个最终形态的next数组了。说了这么多,终于到代码了,其实刚才分析了这么多,都是代码的每一个细节,这时候理解代码就好接受多了吧。

/**
     * 获取前缀表。数组下标代表当前字符串的长度,值代表当前字符串前缀和后缀共有最大字符串的长度
     * @param pattern
     * @return
     */
    public static int[] getNext(String pattern){
        // 先求出字符串的前缀表
        char[] charArr = pattern.toCharArray();
        int[] next = new int[charArr.length];
        // 因为字符串的第一个元素没有前后缀,所以共有最大字符串长度为0
        next[0] = 0;
        int len = 0;
        int i = 1;
        /*
            i   str          next[i]
            0   A            0
            1   AB           0
            2   ABA          1
            3   ABAB         2
            4   ABABC        0
            5   ABABCA       1
            6   ABABCAB      2
            7   ABABCABA     3
            8   ABABCABAA    1
        */
        while (i < charArr.length){
            // 1.举例:比如这次进来的字符串是上面的AB,此时上一次的共有字符串长度是len=0(因为上一次就一个A字符,没有共有字符串,当然是0),
            // 要想判断这次共有字符串长度会不会加1,只需要判断这次的字符串AB比上次字符串A多出来的字符(也就是B)是不是和上次共有字符串长度位置上的字符相等
            // 也就是charArr[1(i)] == charArr[0(len)]?,这里是不等,所以不能加1
            // 2.比如这次进来的是ABA,上一次是AB,那么多出来的这个A和上次AB的共有字符串长度位置(len=0)上的字符是否相等,显然charArr[0] == charArr[2],所以len++;
            // 3.再比如:这次进来的是ABAB,上一次是ABA,上一次的共有字符串长度是len=1,判断这次多出来的字符B是不是和charArr[1]相等,显然相等,len++;
            if(charArr[i] == charArr[len]){
                len++;
                next[i] = len;
                i++;
            }else{
                // 如果不相等,说明这次多出来的这个字符并没有让共有字符串的长度加1,而且,可能还会直接影响到上一次的共有字符串长度
                // 这里的做法是:因为多出来一个字符,而且charArr[i] != charArr[len],那这次已经不能拿上一次共有字符串位置上的字符来做比较了,必须拿上上一次的结果
                // 比如:这次进来的是ABABC,上一次是ABAB,上一次共有字符串是AB,len=2,那charArr[2(len)]是A,和这次的多出来的C已经不一样了,那上次的len已经不能作为判断依据了,
                // 必须拿上上一次的len,于是i不变,也就是说下一轮循环还是ABABC,但是len要拿上上一轮的长度,也就是AB这个字符串共有字符串的长度值,len=1,
                // 此时charArr[1(len)]是B,还是和C不相同,说明这次的len还是不能作为判断,于是i继续不变,下一轮还是ABABC,len拿A的共有字符串长度值,len=0,
                // 此时charArr[0(len)]是A,还是和C不相同,说明这次的len还是不能作为判断,理论上还得去那更早一次的len值,但是这时候有个临界情况,因为已经拿到第一次进来的len了,
                // len拿不到更早一次的值了,或者说到这已经没有共有字符串了,说明这次加多出来的字符C。彻底让这个字符串ABABC没有了共有字符串,也就是len=0,可以放心的将这一轮字符串
                // 的共有字符串长度设为0了,这轮len值设置完毕,i++,进行下一轮设置
                if(len > 0){
                    len = next[len-1];
                }else{
                    next[i] = len;
                    i++;
                }
            }
        }
        // 到此,前缀表已经设置完毕,但是有个问题,就是next[0]和next[1]的位置一直都是0,为了后续使用的方便,需要将""和只有一个字符的字符串共有前缀区别开,
        // 而且,对共有字符串来说,前缀表的最后一项就是字符串本身的共有字符串长度,这个在实际使用的时候没有意义,所以直接将整个前缀表往后平移一位,空出来的
        // next[0]赋值为-1
        for (int j = next.length  -1; j > 0; j--) {
            next[j] = next[j-1];
        }
        next[0] = -1;
//        for (int m = 0; m < next.length; m++) {
//            System.out.print(next[m] + "");
//        }
        return next;
    }

       终于把KMP算法中最核心的next数组生成问题说完了。。。
       最后一个问题,如何写KMP算法匹配字符串?

       4.KMP匹配字符串

       其实到这,就是临门一脚的事情了,大致说一下KMP的步骤吧

        * 循环遍历主串T和模式串P,指针i用来遍历T,指针j用来遍历j;
        * 当T[i]!=P[j]时,j=next[j],如果j=-1,i++,j++;
        * 当T[i]==P[j]时,i++,j++即可;
        * 循环结束后,判断j是否到了P的末尾,如果相等,则说明在T中找到了第一次出现P的位置,此时i也在T中P的末尾处,所以P在T的起始位置应该是i-j;如果不相等,返回-1;

       话不多说,赶紧附上代码

/**
     * KMP算法
     * @param text
     * @param pattern
     * @return
     */
    public static int findIndexForKMP(String text, String pattern){
        if(text == null || pattern == null){
            return -1;
        }
        if(pattern.length() == 0){
            return 0;
        }
        // 首先得到前缀表
        // 前缀表是一个一维数组,数组的下标表示遍历pattern的指针位置(j),数组的值表示当pattern的指针j和text的指针i对应的字符内容不同时(坏字符),j指针需要移动
        // 到pattern的位置
        int[] next = getNext(pattern);
        // 定义两个指针,i用来遍历text,j用来遍历pattern
        int i = 0;
        int j = 0;
        char[] charsT = text.toCharArray();
        char[] charsP = pattern.toCharArray();
        while(j < charsP.length && i < charsT.length){
            // 当指针i和j位置上的元素不匹配时,需要将j指针通过next数组移动到指定位置上
            if(charsT[i] != charsP[j]){
                j = next[j];
                // 如果j等于-1,说明j指针已经pattern的最前面了,并且已经没有共有字符串了,直接将j指针和i指针同时往后移动一位j++
                // 这里就能体现将next[0]设置成-1的巧妙了,这样j++,i++的时候就意味着把text当做一个全新的字符串,除去了[0,i)之前的元素,j=0也从pattern
                // 的起始位置开始匹配
                if(j == -1){
                  i++;
                  j++;
                }
            }else{
                //如果匹配上,那就正常进行下一个字符的匹配
                i++;
                j++;
            }
        }
        // 循环结束后,如果j和pattern长度相同,说明全部匹配完了,也就是在text中找到了第一次出现pattern的位置,这时候i指针已经到了pattern字符位置的最后一个字符的位置
        // 起始位置需要减掉pattern的长度
        if(j == charsP.length){
            return i - j;
        }
        return -1;
    }

       KMP算法是通过next数组来完成匹配的,如果P串的长度为m,则生成next数组的复杂度是O(m),T串的长度为n,则匹配的复杂度是O(n),最终整个算法的复杂度是O(m+n),确实是比暴力匹配复杂度低,但是这是人能想出来的事吗???只能说,你确实降低了计算机的复杂度,但是你提高了人脑的复杂度。。。我们发明计算机的目的不就是不想动脑吗!!!好了,开个小玩笑。。。这些大佬们还是很牛逼的。
       终于讲完了,如果有一些说的不对的地方,麻烦直接指出,或者有什么更好的理解,可以及时交流一下心得。
       最后还是得特别感谢一下b站的这位大佬,解决了我很多代码上的困扰,欢迎各位积极讨论。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值