快速字符串匹配一: 看毛片算法(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];
}
<span class="hljs-keyword">if</span>(str[i]==str[k])
next[i]=k+<span class="hljs-number">1</span>;
<span class="hljs-keyword">else</span>
next[i]=<span class="hljs-number">0</span>;
}
}
这就是我对Next 数组的理解,我觉得这样理解,我能记得住。
还有一种很精简版的Next 数组实现,我不打算贴出来,乱我心志,我就用我能理解,能看懂的代码。
Next 数组求出来,就是字符串匹配了。也很简单哦。
int KMP(string content,string str)
{
getNext(str);
<span class="hljs-keyword">int</span> i=<span class="hljs-number">0</span>,j=<span class="hljs-number">0</span>;
<span class="hljs-keyword">while</span>(i<content.length()&&j<str.length())
{
<span class="hljs-keyword">if</span> (j==<span class="hljs-number">0</span> ||content[i]==str[j])
{
<span class="hljs-keyword">if</span>(content[i]==str[j])
j++;
i++;
}
<span class="hljs-keyword">else</span>
{
j=next[j<span class="hljs-number">-1</span>];
}
}
<span class="hljs-keyword">if</span>(j>=str.length())
{
<span class="hljs-keyword">return</span> i-str.length();
}
<span class="hljs-keyword">else</span>
<span class="hljs-keyword">return</span> <span class="hljs-number">-1</span>;
}
j = next [j-1]
就是我上面所有的,移动的过程,其他的也很好理解的。
然后可以用KMP 去通过LeetCode 的一道题目,以检测自己写的代码是否正确:https://leetcode.com/problems/implement-strstr/
总结
KMP 算法就介绍到这里了,关于KMP 还有很多升级的版本。
字符串快速匹配,第一弹,看毛片。回顾一下,感觉以后应该都不会忘记了吧。
开头说的 把 KMP 和C#的 String.Contains 进行PK ,要留到下一篇博文里。下一篇博文将对 字符串的匹配的性能来个大排序,并且见识一下微软的黑科技。