一看就懂的字符串匹配算法 之 KMP算法

在所有的字符串匹配算法中,KMP算法是最为知名的,现在我们就来讨论一下KMP算法。

KMP算法的基本原理

KMP算法的核心思想与之前我们讨论过的BM算法的思想相类似。在模式串与主串匹配的过程中,当遇到不可匹配的字符时,我们希望找到一些规律,将模式串向后多滑动几位,跳过那些肯定不会匹配的元素。

先来回顾一下BM算法的思想,在BM算法中,模式串与主串匹配的时候,是按照模式串中字符下标从大到小的顺序进行比对的,也就是倒着匹配。然而KMP算法正好相反,是按照模式串字符下标从小到大的顺序比对的。之前我们还讨论过BM算法的坏字符和好后缀,在KMP算法中,在模式串和主串匹配的过程中,我们把不能匹配的那个字符任然叫做坏字符,把已经匹配的那段字符串称为好前缀。

关于BM算法,这里是链接,不过很难,大家感兴趣可以去看看。BM算法

当遇到坏字符时,我们就要把模式串往后滑动,在滑动的过程中,只要模式串和主串中的好前缀有重合,此时的模式串与主串的匹配就相当于好前缀的后缀子串与模式串的前缀子串在比较。

 

KMP算法的核心处理思想:在模式串和主串匹配的过程中,当遇到坏字符时,对于已经匹配好的好前缀,找到一种规律,将模式串一次性往后滑动很多位。

我们在好前缀的所有前缀子串中,查找能与好前缀的后缀子串匹配的最长的那个前缀子串。这么说可能有点拗口,一会看个图。如果最长的可匹配的前缀子串{v}的长度时k,我们就把模式串一次性往后滑动j-k位,相当于,每当遇到坏字符时,我们就把j更新位k,不变,然后继续比较。

 

 之前那段拗口的话,我们把他改一改,把好前缀的所有后缀子串中最长的最长可匹配前缀子串的那个后缀子串,称为最长可匹配后缀子串,对应的前缀子串,称为最长可匹配前缀子串。

 如何求好前缀的最长可匹配前缀子串和最长可匹配后缀子串呢?实际上,这个问题的求解并不涉及主串,只涉及模式串本身。那么,能不能预处理,事先计算好,在模式串和主串匹配的过程中,直接拿过来用呢?

我们可以类似BM算法中的bc,suffix,prefix数组,KMP算法也可以提前构建一个数组,用来存储模式串每个前缀(这些前缀也可能是好前缀)的最长可匹配前缀子串的结尾字符下标,我们把这个数组定义为next数组,也称失效函数(failure function)。

next数组的下标对应每个前缀结尾字符下标 ,对应的数组值存储这个前缀的最长可匹配前缀子串的结尾字符下标。

 有了next数组,KMP算法的事先就简单了,现在假设先有了计算好的next数组。

//a和b分别是主串和模式串,n,m分别是主串和模式串的长度。
public static int kmp(char[] a,int n,char[] b,int m){
   int[] next = getNexts(b,m);
   int j = 0;
   for(int i = 0;i < n;++i){
       while(j > 0 && a[i] != b[j]){
          j = next[j - 1] + 1;
      }
       if(a[i] == b[j]){
         ++j;
      }
       if(j == m){
         return i - m + 1;
      }
   }
      return -1;
}

失效函数的计算方法

现在来看KMP算法最复杂的部分,也就是计算next数组。

先来一个笨方法来计算next数组的值,(一会看下图)要计算next[4],我们就把模式串b的前缀子串b[0,4]的所有后缀子串都罗列出来,然后,从中能找到与模式串的前缀子串匹配的最长的那个后缀子串,也就是"aba"。next[4]记录的是"aba"的最后一个字符的下标,因此,next[4] = 2。可以看出来,这种方法比较低效。

下面来看另一种方法,我们按照下标从小到大的顺序依次计算next数组的值。我们利用已经计算好的next[0],next[1]......next[i-1],快速推导出next[i]的值。

如果next[i-1]=k-1,那么子串b[0,k-1]是b[0,i-1]的最长可匹配前缀子串。如果子串b[0.k-1]的下一个字符b[k]与b[o,i-1]的下一个字符b[i]匹配,那么子串b[0.k]就是b[0,i]的最长可匹配前缀子串。因此,next[i]等于k。

 

 但是,最长可匹配前缀子串b[0,k-1]的下一个字符b[k]与b[0,i-1]的下一个字符b[i]不相等,那么我们就不能简单地通过next[i - 1]得到next[i]。

既然b[0,i-1]的最长可匹配后缀子串对应地模式串地前缀子串的下一个字符并不等于b[i],那么我们就可以考察b[0,i-1]的次长可匹配后缀子串b[x,i-1]对应的可匹配前缀子串b[0,i-1-x]的下一个字符b[i-x]是否与b[i]相等。如果相等,那么b[x,i]就是b[0,i]的最长可匹配后缀子串。

 可是,如何求得b[0,i-1]的次长可匹配后缀子串呢?次长可匹配后缀子串C肯定被包含在最长可匹配后缀子串D中,并且C是D的最长可匹配后缀子串。查找b[0,i-1]的次长可匹配后缀子串这个问题就变成了查找b[0,y](y = next[i-1])的最长可匹配后缀子串。

按照这个思路,我们可以考察完所有的 b[0, i-1] 的可匹配后缀子串 b[y, i-1],直到找到一 个可匹配的后缀子串,它对应的前缀子串的下一个字符等于 b[i],那这个 b[y, i] 就是 b[0, i] 的最长可匹配后缀子串。
前面我已经给出 KMP 算法的框架代码了,现在我把这部分的代码也写出来了。这两部分代 码合在一起,就是整个 KMP 算法的代码实现。
//b表示模式串,m表示模式串的长度
private static int[] getNexts(char[] b,int m){
   int[] next = new int[m];
   next[0] = -1;
   int k = -1;
   for(int i = 1;i < m;i++){
      while(k != -1 && b[k + 1] =!= b[i]){
         k = next[k];
      }
      if(b[k + 1] == b[i]){
         ++k;
      }
      next[i] = k;
   }
   return next;
}

KMP算法的性能分析

现在来看一下KMP算法的时间复杂度和空间复杂度

KMP算法的执行过程只需要创建一个额外的next数组,next数组的大小与模式串长度相同,因此,KMP算法的空间复杂度为O(m),m表示模式串的长度

KMP算法包含两部分核心逻辑:第一部分是构建next数组,第二部分是借助next数组匹配模式串和主串。所以对于时间复杂度的分析需要分析两部分。

第一部分:

计算 next 数组的代码中,第一层 for 循环中 i 从 1 到 m-1,也就是说,内部的代码被执行 了 m-1 次。for 循环内部代码有一个 while 循环,如果我们能知道每次 for 循环、while 循环平均执行的次数,假设是 k,那时间复杂度就是 O(k*m)。但是,while 循环执行的次 数不怎么好统计,所以我们放弃这种分析方法。
我们可以找一些参照变量,i 和 k。i 从 1 开始一直增加到 m,而 k 并不是每次 for 循环都 会增加,所以,k 累积增加的值肯定小于 m。而 while 循环里 k=next[k],实际上是在减 小 k 的值,k 累积都没有增加超过 m,所以 while 循环里面 k=next[k] 总的执行次数也不 可能超过 m。因此,next 数组计算的时间复杂度是 O(m)。

第二部分:

分析的方法是类似的。 i 从 0 循环增长到 n-1,j 的增长量不可能超过 i,所以肯定小于 n。而 while 循环中的那条 语句 j=next[j-1]+1,不会让 j 增长的,那有没有可能让 j 不变呢?也没有可能。因为
next[j-1] 的值肯定小于 j-1,所以 while 循环中的这条语句实际上也是在让 j 的值减少。而 j 总共增长的量都不会超过 n,那减少的量也不可能超过 n,所以 while 循环中的这条语句 总的执行次数也不会超过 n,所以这部分的时间复杂度是 O(n)。
所以,综合两部分的时间复杂度,KMP 算法的时间复杂度就是 O(m+n)。

参考《数据结构与算法之美》

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值