KMP笔记

319 阅读11分钟

楔子 - 暴力搜索

我们知道:对于字符串匹配,最简单也是最容易理解的BF(暴力破解)的方式,就是对于给定的字符串,索引从头到尾,一个个地去与目标匹配的字符串进行匹配。

从1-2-3-4-5-6-7,查找1-2-3-5

那么我们会如此去匹配:

1-2-3-4-5-6-7

1-2-3-5

5不匹配,往后走一步(0是为了排版方便,仅代表占位):

1-2-3-4-5-6-7

0-1

不匹配,我们继续往后走,直到走到剩余字符串长度和匹配字符串长度相等。

在分析暴力匹配的时候,根据空间复杂度等考虑,很容易会产生一个联想:

  • 如果我在匹配之前,先分析目标的字符串,那么是否在匹配工作开始后,我们就可以根据目标的特征,在一次匹配失败后跳过一些位置的检查?

    • 还是上面的例子:

      1-2-3-4-5-6-7,查找1-2-3-5

      那么我们会如此去匹配:

      1-2-3-4-5-6-7

      1-2-3-5

      到这一步我们已经知道前面4个数都不匹配了,也不能作为下一个匹配的开始位置,那么可以跳过,继续往下执行判断:

      1-2-3-4-5-6-7-0

      0-0-0-0-1-2-3-5

      此时长度已经超出末尾了,我们结束匹配,并得出没有字符串匹配的结论。

    • 假设这个基于直觉的判断是有效的,我们需要明确定义相应的步骤的可执行方案:

      • 如何确定已走过的那些数,它是不匹配的?
      • 随后,匹配失败后我们从哪里来跳过?

      如果可以用逻辑化的语言来描述这两个步骤,那么我们的假设就可以转化为可以实现的算法,来改进暴力搜索这个较慢的算法。

BF的改进 - KMP

因为很多KMP算法有一些看不懂,参照别人的博客,希望自己来推导一次,这样记忆得更加深刻。

其中有一些地方我实际上确实看不懂,比如:

  • next数组为什么在发生i,j不匹配情况时,我们是取next[i-1]来进行判断的?

从头开始

我们先从第一个字符的下一次比较开始。

还是上面的例子:

1-2-1-2-3-1-5-1-2-3-5(str),查找1-2-3-1-5(pattern)

此时我们最直观的改进方式就是:

  • 找到pattern中,第二个和首字符相同的字符,例如上面的

    1-2-3-1-5

    第二个和首字符相同的字符就是第四个位置的1。

    这样子,如果出现了匹配到第四个字符,第五个字符不相同的情况例如:

    1-2-3-1-2-3-1-5-1-1-1-1-1

    1-2-3-1-5

    我们在第一次进行匹配的时候匹配到标粗的部分,因为我们知道pattern中第四个字符是和第一个字符相同的,同时第一次验已经验证了str中第四个字符和第一个字符相同,那么我们第二次在str中进行模式的匹配,只要从str的第四个字符开始:

    1-2-3-1-2-3-1-5-1-1-1-1-1

    1-2-3-1-5 对于这一改进可以很清楚地知道,在字符串第一个字符在后面出现的频次不高的情况下,这个方法确实可以提高我们搜索的效率,伪代码描述如下:

 int strStr(String str,String pattern){
     int idx = 0;
     for(int i =1;i<pattern.length();i++){
        if(pattern.charAt(i) == pattern.charAt(0)){
        idx = i;
        break;
        }
     }
     
     //compare
     for(int comp = 0;comp<str.length-pattern.length();comp++){
     ...
         for(int j =0 ;j<pattern.length();j++){
            if(str.charAt(comp+j)!=pattern.charAt(j) && j>idx){
            //下次我们就可以前进j步,-1是因为上面for循环里的j++
            comp+=j-1;
            }
            //...其他处理
         }
     }
 }

结论1

现在我们验证了我们方法至少是可行的,即:

  • 通过对pattern进行一次顺序的检查,就可以在匹配进行到若干位置时,根据检查结果,跳过一些不需要检查的点,例如上述的头检查,就可以跳过第一个字符到下一个第一个字符出现的位置。

但如果是以下的情况呢?

1-1-1-1-1-1-1-1-1-1-1-1-1-5

1-1-1-1-5

这种情况我们的改进就无效了,因为我们总需要从下一个字符开始比较。我们能否利用从头字符开始检查进行优化的经验,来让这种改进更加有效呢?

记录和开头连续的最长公共真子串

我们继续上面的例子进行优化:

1-1-1-1-1-1-1-1-1-1-1-1-1-5

1-1-1-1-5

第一次匹配,匹配到第五个字符发现不同了。我们可以用非逻辑化的特定情况的语言,来进行描述:

  • 我们既然发现都是1,那么我们直接找下一个是5的是不是就可以了?

1-1-1-1-1-1-1-1-1-1-1-1-1-5

1-1-1-1-5

对于模式字符串,我们可以知道:

  • 除了最后一个5,前面都是1

那么,我们在匹配的时候,只要l读到几个1例如上文,我们将读到1的次数写下来就可以了,那么上面的字符串我们只需要每次都判断下一个字符是否是5就可以:

1-1-1-1-1-1-1-1-1-1-1-1-1-5

1-1-1-1-5

不匹配,进行下次判断,此时只比较后面的5:

1-1-1-1-1-1-1-1-1-1-1-1-1-5

0-1-1-1-1-5

。。。。。

这样子我们就可以更多地利用一次匹配中所得到的信息。

总结一下我们目前为止的目标以及我们认为可行的改进方式:

  • 目标:尽可能地利用我们在一次匹配中所得到的信息。
  • 改进方式:分析我们的模式字符串,进行到某些位置之后,如果发生了不匹配的情况,我们就可以根据模式字符串的特征,跳过一些下一次不再需要检测的字符。

描述我们的改进方案

我们如此定义:

匹配工作进行到模式中的索引i+1的位置发生了不匹配情况(说明到i位置是相同的匹配情况),下一次str保持在匹配不成功的那个位置上(如果第一个就不匹配,我们就让str下一次匹配的开始位置往下走一步),同时从模式的第j索引位置开始进行匹配工作,关系为KMPF,则:

KMPF(i) = j

此时我们的KMPF(i,j)可以描述为:

我们在第i个位置作为结束,下一次开始时我们从模式的第j个字符开始进行匹配

将描述转化为函数

我们的描述实际上是很简单的:

我们在str的第i个位置匹配失败结束,下一次开始时我们从模式的第j个字符开始进行匹配

因为我们期望在O(length(pattern))的时间复杂度中,一次完成我们函数的所有结果,因此通常我们需要一个数组来记录我们的结果,我们记这个数组为next数组

在实现中我们需要考虑一些额外的情况:

  • 是否能利用到上一步推到出来的过程?
  • 能利用到多少?
  • 如何求出我们下一步的j

我们考虑以下的example作为模式串:

aabaaab

分析我们实现上已知和未知的相关问题:

  • 已知:既然我们求的是连续真子串,那么我们下一次的匹配如果跟上一次一样都匹配上了,那么直接加一填到结果数组中就可以了。

  • 未知:如果我们匹配失败了,那么我们将要填入数组的值不一定为0,需要怎么得出该值?

    例如我们对例子求F:

    aabaa-a-b

    当我们的F进行到标注的a位置时,容易知道:

    • 上一步得到的F结果为2,标出的a需要和b进行比较,此时比较结果为不同。

      我们该如何进行下一次计算?

      • 置为0/1肯定是错误的,从字符串中我们知道此时F(a)应该为2才对。

    我们回顾一下,我们对于KMPF()的定义:

    我们在str的第i个位置匹配失败结束,下一次开始时我们从模式的第j个字符开始进行匹配

    事实上等价于:

    在第i个位置,往前数j个字符,和开头的j个字符相同

    回到上面的问题,既然我们在aa-b-aa-a-b这个地方发生了不匹配,发生不匹配的位置设x=5,y=2,那么其实我们可以推出:

    • 从(x-y)到(x-1),即索引(3,4)的位置上,和(0,y-1)即(0,1)是相同的

    既然此时我们发生了不匹配的情况,我们根据已知结果:str(x-y,x-1 )= str(0,y-1),可以得知:

    • 如果把str[y]替换成str[x],此时str[y] = str[x],此时(0,y)和(x-y,x)的字符是相同的

    • 替换到原文中,就是:

      aa-b-aa-a-b,第一个加粗的b替换为加粗的a:

      aa-a-aa-a-b

      这样子我们在原来b的位置上换成了a,那么aaa (str(0-2)) = aaa(str(3-5))

    • 那么,替换之后,我们可以通过计算KMPF(y+1),来求解KMPF(x+1)的大小

    • 为什么可以这么做?

      • 回顾一下我们的定义:KMPF(x) = 数组中的[x-1]

      • 因为我们使用这一个数组的目的就是:为了我们可以减少匹配次数

      • 因为KMPF(y),指的是y-1这个节点上,这一子串和前面开始的多少个字符相同

      • 既然子串(x-y,x)和(0,y)在y节点上不相同,但(x-y,x-1)和(0,y-1)相同,那么我们当然希望我们依然可以用到之前推导出的结果,那么如果:

        str(x) =str(KMPF(y+1))

        那么说明从 (0,y) 的某一个地方(记索引位置为z)开始, (z,y-1)(x-y+z,x-1) 相同且和开头的(0,y-z-2) 的地方是相同的,而且str(y-z) = str(x)相同,说明(0,y-z)和(x-y+z,x)相同,因此KMPF(x+1) 可以通过KMPF(y) 来推导。

    • KMPF(y)可以通过KMPF(y-1)推知,因此我们此时只需要关注KMPF(y-1)指向的元素即可。

    • 如果依旧发生了不匹配的情况,我们设置新的x,y变量,重新计算即可,直到y=0。

    完成我们的KMPF算法

至此我们已经完成了我们所希望得到的这个函数了,它的具体描述如下:

方法目标:我们在第i个位置作为结束,下一次开始时我们从模式的第j个字符开始进行匹配

方法回溯过程:通过匹配以及回溯,构建我们的next数组。(见上述)

完整的算法如下:

  public  static int[] arr = null;
 ​
     public  static int KMPF(String str, int k){
         if(arr==null){
             arr = new int[str.length()];
             arr[0] = 0;
             for (int i = 1; i < str.length(); i++) {
                 int nxt = arr[i-1];
                 while(str.charAt(nxt)!=str.charAt(i) && nxt!=0){
                     nxt = arr[nxt-1];
                 }
                 if(str.charAt(nxt) == str.charAt(i)){
                     nxt++;
                 }
                 arr[i] = nxt;
             }
         }
         //防止k=-1的时候溢出
         if(k<0) return 0;
         return arr[k];
     }
 ​

调用我们的方法实现匹配

我们的KMPF(i) = j描述为:

在i的位置上,往前数j个字符,和开头的j个字符相同。

拟出调用KMPF进行字符串匹配的代码如下:

     public static int strStr(String str,String pattern) {
         if("".equals(pattern)) {
             arr = null;
             return 0;
         }
 ​
         int fx = 0,j;
         for (int i = 0; i < str.length(); i++) {
 ​
             for(j = fx;j<pattern.length();j++){
                 if(i == str.length()){
                     arr = null;
                     return -1;
                 }
                 if(str.charAt(i)!=pattern.charAt(j)){
                     fx = KMPF(pattern,j-1);
                     if(j!=0){
                         i--;
                     }
                     break;
                 }
                 i++;
             }
             if(j == pattern.length() && str.charAt(i-1) == pattern.charAt(j-1)){
                 arr = null;
                 return i-j;
             }
 ​
         }
         arr = null;
         return -1;
 ​
     }

注意此时我们加上了一些限制条件,来判断是否是正常退出匹配条件,以及字符串中是否越界。

在方法的基础上,可以进一步做优化使得代码更加简练。

总结

KMP算法的求解实际上是我们从头开始这一个简单优化思路的延申,核心思想都是:

  • 不改变判断顺序(即从头到尾),在匹配失败的时候尽可能地利用那些已匹配成功的部分的信息,避免多次重复地判断

KMP算法的核心实现,难点在于KMP中next数组(就是实例代码中的arr数组) 这一相关信息求解,核心点在于:

  • 从前一步推导后一步的过程 - 这个过程实际上用动态规划的思路会更好理解一些
  • 模式字符串匹配失败的时候,回溯的节点为next[i-1] ,这一点也是我之前一直没有搞懂的地方