前言
最近学习了数据结构中一个重要的内容——字符串.涉及到一个重要也是难理解的算法——KMP算法,即字符串快速匹配模式串的算法.在这里写一下心得体会,以便后来者参考.
在开始之前,还是先讲一讲朴素的字符串匹配算法:暴力搜索.先讲几个基本概念:
- 模式串:需要被匹配的字符串
- 主串:匹配模式串的字符串,一般主串长度 ≥ \ge ≥ 模式串长度
- 模式串匹配的基本题型为:主串中是否存在和模式串相等的子串,出现多少次?
所谓暴力搜索,就是从主串和模式串开头字符开始,一个一个匹配,匹配失败就从主串本次匹配开头的下一位和模式串开头字符重新开始匹配,直到主串结束.假设主串长度为n,模式串长度为m(m ≤ \le ≤ n),则暴力搜索的时间复杂度为O(mn).当m和n比较大时,这是一个糟糕的算法.
暴力搜索就不实现了,非常简单,应该不存在问题.让我们进入今天的正题–KMP算法.
KMP算法
什么是KMP算法?
KMP是三个计算机大神:D.E.Knuth、J.H.Morris、V.R.Pratt同时发现的,因此叫做KMP算法.这是一种模式串匹配算法.通常朴素的模式匹配算法是一一查找,最坏的时间复杂度为O(mn),m、n为主串、模式串的长度.KMP算法则是线性优化,平均时间复杂度为O(m+n).
KMP算法的原理
如果我们将匹配过程看成两个指针(一个指向主串,一个指向模式串)的移动过程,则:
暴力搜索匹配失败时相当于两次回溯(主串+模式串回溯,因此时间复杂度是做乘法:mn).
而KMP的想法是,匹配失败了主串指针不进行回溯,而是通过一些"奇(bian)技(tai)淫(ji)巧(shu)"来决定模式串指针的回溯行为.从而使得时间复杂度做加法:m+n.这个技巧就是:前缀表(prefix table)
.
最长公共前后缀
在开始讲前缀表之前,这里需要提一下,字符串的前后缀和公共前后缀:
- 前缀:对于一个字符串,除去其最后一个字符,剩下的所有第一个字符开头的子串构成其全体前缀
- 后缀:对于一个字符串,除去其第一个字符,剩下的所有最后一个字符结尾的子串构成其全体后缀
- 公共前后缀:一个字符串某些前缀和后缀相等,称这一个前(后)缀为其公共前后缀
比如字符串aabaa,它的所有前后缀和公共前后缀如下表:
前缀 | 后缀 | 公共前后缀 |
---|---|---|
a | a | ✓ \checkmark ✓ |
aa | aa | ✓ \checkmark ✓ |
aab | baa | |
aaba | abaa |
因此我们知道aabaa的最长公共前后缀是aa,这样就可以求出前缀表了,也就可以接触到KMP的核心内容了.
前缀表
KMP算法的精髓在于:对于模式串的每一个前缀,求其最长公共前后缀的长度,构成前缀表,从而确定模式串指针回溯的行为规律,下图演示了求前缀表的过程.
![](https://i-blog.csdnimg.cn/blog_migrate/6bca25c4fc1410b23a7a4e3c626d0847.png)
- 为什么要求字符串每一个前缀的最长公共前后缀的长度?
- 为什么要在前面补-1并将每一个最长公共前后缀的长度依次往后推一个?
要回答着两个问题,实际上就要弄清楚一个问题–前缀表能干什么?这个问题的答案将在下面通过演示说明:
模式串匹配
下图模拟了KMP模式串匹配的具体过程:
![](https://i-blog.csdnimg.cn/blog_migrate/f8c1b4858ef255a5ca658ee8426de179.png)
看到这里我们可以得出prefix table
的作用了:
-
当字符失配时,根据字符串对应的
prefix table
,对于失配字符可得到一个失配值,对其讨论如下: -
if(失配值 != -1) {
主串指针不移动;
将模式串指针移动到失配值对应的数组下标处;
重新匹配; //对齐主串和模式串指针,并从该位置之后开始匹配,该位置之前的一定已匹配上;
}
else {
主串指针向后移动一位;
模式串指针指向下标0;
重新匹配; //同上;
}
-
根据
prefix table
进行操作,直到主串被匹配完为止,KMP算法就结束了
答疑
至此很多人一定会有疑问,最大的一个问题大概是:为什么根据prefix table
的值就可以匹配了?这是巧合吗?
这不是巧合,更不是上天的安排,而是一切都在我们的掌控之中啊!这正是数学之美啊(编不下去了).
好的废话不多说,让我们看看字符失配的时候,会发生什么:
- 出现失配说明模式串当前字符和主串不同(废话)
- 说明模式串在当前字符之前的整个前缀串都已匹配上(重点)
那失配了,我们该干嘛呢?当然是移动模式串指针啊!那么如何移动最优呢?
首先,为作区分,我们将最长公共前后缀(前缀)称为最长公共前缀,最长公共前后缀(后缀)称为最长公共后缀.
显然移动必然会带来损失(移动必然会造成从前匹配的前缀串会部分甚至全部失配),那么为了最大限度降低这个损失,我们应该对匹配的前缀串作如下移动:
将该前缀串的最长公共前缀移动到和它的最长公共后缀重合的位置.
可以从以下方面证明(非严格证明):
- 该前缀串全部匹配,则最长公共前后缀(后缀)必然匹配上
- 将该前缀串的最长公共前缀移动到和它的最长公共后缀重合,不改变最长公共后缀的匹配状态
- 这样我们就相当于降低了最长公共前后缀长度的损失
再想想prefix table
的内容,若将其看成一个长度为模式串长的数组,命名prefix[]
,则:
prefix[i]
表示模式串第i个字符之前的整个前缀串最长公共前后缀的长度.
为什么是第i个字符之前的整个前缀串?
因为在生成
prefix[]
的时候我们在前面补-1并将每一个最长公共前后缀的长度依次往后推了一个啊!因此
prefix[i]
实际上表示的是[0,i-1]前缀串的最长公共前后缀的长度而
prefix[0]
实际上是当做结束标志处理的,当回溯到prefix[0]
,实际上模式串就不能回溯了,因此用-1来表示最终的结束回溯状态.
这样我们就回答了"前缀表"小节的两个问题.
另外,还有一个特别巧妙的细节:
- 将失配字符下标i对应到
prefix[i]
的失配值作为模式串指针下次指向的下标,恰好就是最优移动方案. - 即前缀串的最长公共前缀和它的最长公共后缀重合
至于为什么,因为数组下标从0开始啊!还不懂?自己好好想想吧.
KMP算法的实现
说完了原理,再说实现,就两个函数:prefix_table(args)
和kmp(args)
;用来求prefix[]
以及匹配主串和模式串.
实现如下:
void prefix_table(char *mo,int *prefix) { //求模式串的prefix[];
int len = strlen(mo),i = 1,maxlen = 0;
//i为当前正在匹配的位置,maxlen代表序列{0,1,...,i-1}的最长公共前后缀长度;
prefix[0] = -1,prefix[1] = 0;
while(i < len) {
if(maxlen == -1 || mo[i] == mo[maxlen]) {
//若相等,则说明序列{0,1,...,i}的最长公共前后缀应在之前的基础上加1;
prefix[++i] = ++maxlen;
} //或者当maxlen = -1,说明已经无路可退,此时最长公共前后缀设为0,并让i往下移一位;
else
maxlen = prefix[maxlen]; //否则回溯到前一个前缀继续匹配;
}
}
int kmp(char *mo,char *str,int *prefix) { //已知prefix[],实现匹配的kmp算法;
int cnt = 0,i = 0,j = 0,Len = strlen(str),len = strlen(mo);
while(i < Len) {
if(str[i] == mo[j] || j == -1) //j = -1已经无法再回溯(模式串无法向后移动)了,
//则匹配下一轮;
i++,j++; //对应位置匹配上则匹配下一个位置;
else
j = prefix[j]; //这里相当于将模式串向后移动了;
if(j == len) //匹配到模式串最后一个的话即说明找到子串;
cnt++; //找到计数器+1;
}
return cnt;
}
对于这个实现,注释都写得很清楚了,讲几个注意点:
- 对于
prefix_table(args)
这个函数,实际上就是拿模式串和模式串匹配,从而得到prefix[]
. - 对于
kmp(args)
这个函数,和prefix_table(args)
极其神似,不过就是匹配是主串和模式串之间进行的罢了.
其它的不多讲,代码兴许稍微难以理解,但弄懂了原理,相信完全掌握实现也只是时间问题而已.
需要注意的是,KMP是一个允许重叠匹配的算法,如在aaaaaa中匹配aa,一共能匹配上5次,每次匹配上的部分如下:
![](https://i-blog.csdnimg.cn/blog_migrate/6669b8b9f1cdc9387536986e1c7060d8.png)
代码弄懂了就去做一个裸题试试吧:HDU-1686,这年头这么裸的题已经很少见了?