快速字符串匹配一: 看毛片算法(KMP)

前言##

由于需要做一个快速匹配敏感关键词的服务,为了提供一个高效,准确,低能耗的关键词匹配服务,我进行了漫长的探索。这里把过程记录成系列博客,供大家参考。

在一开始,接收到快速敏感词匹配时,我就想到了 KMP 翻译过来叫“看毛片“的算法,因为大学的时候就学过它。听说到它的效率非常高。把原本字符串匹配效率 O(n*m) 缩短到了O(n+m),把✖️变成了➕,真是了不得。

每次我回顾 KMP 算法时,都会发现自己是个小白,或者每次回顾时,都发现上次因为回顾而写的总结居然是错的!所以为了学习快速字符串匹配,并再次温故 KMP ,所以我决定使用 KMP 算法试一试。如果以后在面试的时候,可以将KMP 完整的写出来,那岂不是很牛逼?

孔子说过的“温故而知新” 真的是很有道理的,经过这次回顾,我觉得是时候为此写一篇全新的博客了,因为这次的理解肯定是正确的!

KMP 快是因为啥呢?是因为利用了字符串公共前后缀的特性,加快了匹配速度,但是转念一想,敏感关键词公共前后缀相等的情况可是很少的呀。那还有必要用KMP 吗?

当然有必要了,所谓技多不压身,了解掌握一种算法准没坏处,而且还可以比较 KMP 和 C# 中String.Contains() 的效率,开拓自己的眼界。

KMP##

以前在学习 KMP 的时候,我也看了网上很多博客,关于这个算法讲解的博客是非常多的,而且讲解的都很细致。奈何我每看过一次,就会忘记一次。所以这次温故,我是完全在纸上画画,自己理解的。毕竟自己的思路不容易忘,而别人的思路总是很容易忘的。并且,理解一个算法,得找到适合自己的角度。

因此我理解 KMP 算法的角度,就是 字符串前缀和后缀,在我的脑子里,用前缀和后缀去理解 KMP 是很容易的。

公共前后缀长度##

前缀和后缀,很容易理解,就是一个字符串的前半部分和后半部分。比如字符串 a b c x y a b c 的前缀有

a
a b
a b c 等等,后缀有

c
b c
a b c等等。

那么公共前后缀的意思就是,前缀和后缀相等。在上面这个例子中,公共前后缀 就是 a b c,长度为3。请注意,公共前后缀 和 回文串是不一样的哦。

a b c x y c b a 的公共前后缀,只是a ,而不是a b c

原始的字符串匹配##

了解完 公共前后缀后。暂且放在一旁,去了解一下,原始的字符串匹配。

首先我们把 待匹配的字符串叫做 文本字符串,匹配的字符串叫做匹配字符串,比如我们要在 a b c x y a b c x y a 中匹配 a b c x y a b c y 是否存在。

于是 文本字符串 S 就是 :a b c x y a b c x y a

**匹配字符串 ** P 就是: a b c x y a b c y

从肉眼看出来,匹配一定是失败的,因为 匹配字符串 最后一个字母 y 不匹配。

那么原始的字符串匹配过程就是 暴力的一位一位去比。首先,从第一位开始比较:

   0   1   2   3   4   5   6   7   8   9  10
   ↓
S  a   b   c   x   y   a   b   c   x   y   a
P  a   b   c   x   y   a   b   c   y
   ↑

第一位,相同比较第二位,一直比到第 8 位

   0   1   2   3   4   5   6   7   8   9  10
                                   ↓
S  a   b   c   x   y   a   b   c   x   y   a
P  a   b   c   x   y   a   b   c   y
                                   ↑

发现不相同,匹配失败,于是把 匹配字符串 向右移动一位。

   0   1   2   3   4   5   6   7   8   9  10
       ↓
S  a   b   c   x   y   a   b   c   x   y   a
P      a   b   c   x   y   a   b   c   y
       ↑

继续重复上面的过程,直到 文本字符串全部遍历完。 这种方法的效率最差的时候是 O( n*m ) ,就是那种每次都是最后一个字符匹配不了的情况。

快速移动##

有没有更快的方法呢? 肯定是有的。但是不着急,我们还是按照上面的步骤,继续走下去。

当 匹配字符串 一直向右移动,移动到第 5 位的时候,终于发现首字母是匹配的情况了。,如下

   0   1   2   3   4   5   6   7   8   9  10
                       ↓
S  a   b   c   x   y   a   b   c   x   y   a
P                      a   b   c   x   y   a   b   c   y
                       ↑

其实我们发现,从 文本字符串 第一位之后的 b c x y 其实都没必要匹配的,因为它们和 匹配字符串首字母都不一样,如果可以直接跳过就好了。

那么有什么依据可以直接跳过吗?当然有,之前的 公共前后缀 就发挥作用了。

a b c x y a b c y 中的子串 a b c x y a b c 的公共前后缀是 a b c

当一开始,我们发现第 8 位不匹配时,

    0   1   2   3   4   5   6   7   8   9  10
                                    ↓
 S  a   b   c   x   y   a   b   c   x   y  a
 P  a   b   c   x   y   a   b   c   y
                                    ↑

我们可以直接将 匹配字符串向右移到第五位,然后再从第 8 位继续进行判断

   0   1    2   3   4   5   6   7   8   9  10
                        |           ↓
S  a   b   c    x   y   a   b   c   x   y   a
P                       a   b   c   x   y   a   b   c   y
                        |           ↑

为什么呢?

因为a b c = a b c啊,在0 - 7 位的字符串中,它有公共前后缀a b c,所以我们可以把匹配字符串直接移到 公共后缀的起始位置,也就是 第 5位。

因为前面都不用去看,是一定不匹配的!,只有在第五位开始匹配,才有可能成功。

移动的结果,起始就是将一个字符串的前缀部分,移到和后缀部分对齐。这是成功匹配的前提。你可以想象成 :匹配字符串的子串一直在找自己的后缀,然后靠上去,去匹配。

如下

          后缀              
a b c x y a b c
          a b c  x y a b c
          前缀

那么这样移动之后,咱们就可以接着 第 8 位 继续往下匹配,而不用从头再来了。所以这种方法下,文本字符串只遍历一次,它不会倒退的。

这就是我所理解的 KMP 算法的核心思想。** KMP 就是利用字符串的前缀和后缀做文章**

具体过程##

KMP 算法的物理核心思想理解了,接下来就是代码实现了。如果保存 匹配字符串的公共前后缀信息,以及它的子串的公共前后缀信息呢?一旦匹配不成功,我怎么确定匹配字符串的子串移动多少位,恰好靠上后缀呢?

第一个问题,用一个数组就可以维护,这是大家都耳熟能详的Next数组

Next 数组,Next[i] 表示的是 从 0 开始到 i 结束子串最长公共前后缀的长度 ,咱们举个栗子就很好理解了。比如下面的字符串 s :

a   b   a   b   c   a   b

Next [ 0 ] => a ,只有一个字符,前缀和后缀的概念这里就不存在了,所以 Next [ 0 ] = 0

Next [ 1 ] => a b,前缀 a 不等于后缀 b ,所以也是 0,Next[ 1 ] = 0

Next [ 2 ] => a b a,前缀 a 等于后缀b,但是前缀a b不等于后缀b a ,所以 Next[ 2 ] = 1

Next [ 3 ] => a b a b,前缀 a b等于后缀 a b,所以Next[ 3 ] = 3

经过上面的栗子,大概就可以知道 Next 数组是干嘛的了吧。回到之前的匹配字符串 P

P  a b c x y a b c y

它的 Next 数组是啥呢?看着字符串算法一下就可以得出了

Next[9]= { 0 , 0 , 0 , 0 , 0 , 1 , 2 , 3, 0 }

当我们匹配到第8位,也就是最后一个字符的时候,发现不匹配了

    0   1   2   3   4   5   6   7   8   9  10
                                    ↓
S   a   b   c   x   y   a   b   c   x   y   a
P   a   b   c   x   y   a   b   c   y
                                    ↑

于是我们可以直接将 匹配字符串 向右移动 5位,

   0   1    2   3   4   5   6   7   8   9  10
                        |           ↓
S  a   b   c    x   y   a   b   c   x   y   a
P                       a   b   c   x   y   a   b   c   y
                        |           ↑
                        0   1   2   3   4   5   6   7   8

这个过程其实就是,当 S [ 8 ] != P [ 8 ] 时 ,S [ 8 ] 直接继续和 P [ 3 ] 进行比较,依据就是 Next [ 7 ] 的值是 3

因为子串 P[ 0-7 ] 的最大公共前后缀长度是 3,所以S[ 8 ] 只要和 公共前缀的下一个字符P[ Next[ 7 ] ] (Next[ i ] 同样也是公共前缀的下一个字符的下标,这很好理解)进行比较,也就是 P[ 3 ],这么做的的原因是 P[ 0 ],P[ 1 ],P[ 2 ] 和 S[ 5 ] ,S[ 6 ],S[ 7 ] 是公共前后缀,它们都是一样的!

以上,就是经典 KMP 算法的全部过程。

代码实现##

先是要求 Next[] 数组,怎么求呢?很简单,咱们利用动态规划的思想。Next[ i ]的值要么是在已有最长公共前后缀的字符串基础上 +1 ,要么子串一个符合的都没有,自己另起炉灶。

Next[ i ] 的值有两种情况:

  • Next [ i - 1 ]不为 0,说明子串 中有公共前后缀,那我就去字符串中公共前缀的下一个字符串 P[ Next [ i-1 ] ],如果P[ i ] == P [ Next [ i - 1] ],那么公共前后缀长度就+1 也就是 Next [ i ] = Next[ i -1 ]+1。那如果不相等呢?那就去找 P [ Next [ i-1 ] ] 的 Next 值,重复上面的过程,有点递归的意思。其实这个过程就是在找字符串里的公共前缀,看看有没有符合条件的(即P [ i ] == P[Next [ k] ]),没有的话,就在前缀里再去找前缀,直到找到为止,或者发现已经没用公共前缀了,那就跳出来。
  • 发现子串没有符合条件,让自己+1的,于是只能从自己开始,看看P[ i ] == P[ 0 ] 如果相等,那就是1 ,如果不相等,那就只能是0 了。

代码实现如下,理解了其实还是很简单的,随时都能手写出来,也不会忘记。

    void getNext(string str)
    {
        next[0]=0;
        for(int i=1;i<str.length();i++)
        {
            int k=next[i-1];
            while(k!=0&&str[i]!=str[k])
            {
                k=next[k-1];
            }
            
            
            if(str[i]==str[k])
                next[i]=k+1;
            else
                next[i]=0;
            
        }
    }

这就是我对Next 数组的理解,我觉得这样理解,我能记得住。

还有一种很精简版的Next 数组实现,我不打算贴出来,乱我心志,我就用我能理解,能看懂的代码。

Next 数组求出来,就是字符串匹配了。也很简单哦。

int KMP(string content,string str)
    {
        getNext(str);
        
        int i=0,j=0;
        while(i<content.length()&&j<str.length())
        {
            if (j==0 ||content[i]==str[j])
            {
                if(content[i]==str[j])
                    j++;
                i++;
            }
            else
            {
                j=next[j-1];
            }
        }
        
        if(j>=str.length())
        {
            return i-str.length();
        }
        else
            return -1;
    }

j = next [j-1] 就是我上面所有的,移动的过程,其他的也很好理解的。

然后可以用KMP 去通过LeetCode 的一道题目,以检测自己写的代码是否正确:https://leetcode.com/problems/implement-strstr/

总结

KMP 算法就介绍到这里了,关于KMP 还有很多升级的版本。

字符串快速匹配,第一弹,看毛片。回顾一下,感觉以后应该都不会忘记了吧。

开头说的 把 KMP 和C#的 String.Contains 进行PK ,要留到下一篇博文里。下一篇博文将对 字符串的匹配的性能来个大排序,并且见识一下微软的黑科技。

posted @ 2019-08-05 14:36  Shendu.CC  阅读(6205)  评论(1编辑  收藏  举报