kmp算法
一:背景
给定一个主字符串(以 S 代替)和模式串(以 P 代替),要求找出 P 在 S 中出现的位置,即串的模式匹配问题。今天来介绍解决这一问题的常用算法之一,Knuth-Morris-Pratt 算法(简称 KMP),这个算法是由高德纳(Donald Ervin Knuth)和沃恩·普拉特在1974年构思,同年詹姆斯·H·莫里斯也独立地设计出该算法,最终由三人于1977年联合发表。
在继续下面的内容之前,有必要在这里介绍下两个概念:前缀和后缀。
由上图所得, "前缀"指除了最后一个字符以外,一个字符串的全部头部组合;"后缀"指除了第一个字符以外,一个字符串的全部尾部组合。
二:朴素字符串匹配算法
初遇串的模式匹配问题,我们脑海中的第一反应,必是朴素字符串匹配(暴力匹配),即遍历 S 的每个字符,以该字符为始与 P 比较,全部匹配就输出;否则直到 S 结束。代码如下:
/* 字符串下标始于0 */
int NaiveStringSearch(string S, string P)
{
int i = 0; //S的下标
int j = 0; //P的下标
int s_len = S.size();
int p_len = P.size();
while (i < s_len && j < p_len)
{
if (S[i] == P[j]) //若相等,都前进一步
{
i++;
j++;
}
else //不相等
{
i = i - j + 1;
j = 0;
}
}
if (j == p_len) //匹配成功
return i - j;
return -1;
}
上述算法的时间复杂度为 $O(nm)$,其中 $n$ 为 S 的长度,$m$ 为 P 的长度。这种时间复杂度很难满足我们的需求,接下来进入正题:时间复杂度为 $Θ(n+m)$ 的 KMP 算法。
三:KMP字符串匹配算法
3.1 算法流程
以下摘自阮一峰的字符串匹配的KMP算法,并作稍微修改。
(1)
首先,主串"BBC ABCDAB ABCDABCDABDE"的第一个字符与模式串"ABCDABD"的第一个字符,进行比较。因为B与A不匹配,所以模式串后移一位。
(2)
因为B与A又不匹配,模式串再往后移。
(3)
就这样,直到主串有一个字符,与模式串的第一个字符相同为止。
(4)
接着比较主串和模式串的下一个字符,还是相同。
(5)
直到主串有一个字符,与模式串对应的字符不相同为止。
(6)
这时,最自然的反应是,将模式串整个后移一位,再从头逐个比较。这样做虽然可行,但是效率很差,因为你要把"搜索位置"移到已经比较过的位置,重比一遍。
(7)
一个基本事实是,当空格与D不匹配时,你其实知道前面六个字符是"ABCDAB"。KMP算法的想法是,设法利用这个已知信息,不要把"搜索位置"移回已经比较过的位置,而是继续把它向后移,这样就提高了效率。
(8)
i | 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 |
---|---|---|---|---|---|---|---|---|
模式串 | A | B | C | D | A | B | D | '\0' |
next[ i ] | -1 | 0 | 0 | 0 | 0 | 1 | 2 | 0 |
怎么做到这一点呢?可以针对模式串,设置一个跳转数组int next[ ]
,这个数组是怎么计算出来的,后面再介绍,这里只要会用就可以了。
(9)
已知空格与D不匹配时,前面六个字符"ABCDAB"是匹配的。根据跳转数组可知,不匹配处D的next值为2,因此接下来从模式串下标为2的位置开始匹配。
(10)
因为空格与C不匹配,C处的next值为0,因此接下来模式串从下标为0处开始匹配。
(11)
因为空格与A不匹配,此处next值为-1,表示模式串的第一个字符就不匹配,那么直接往后移一位。
(12)
逐位比较,直到发现C与D不匹配。于是,下一步从下标为2的地方开始匹配。
(13)
逐位比较,直到模式串的最后一位,发现完全匹配,于是搜索完成。
3.2 next数组是如何求出的
next数组的求解基于“前缀”和“后缀”,即next[i]
等于P[0]...P[i-1]
最长的相同前后缀的长度(请暂时忽视i等于0时的情况,下面会有解释)。我们依旧以上述的表格为例,为了方便阅读,我复制在下方了。
i | 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 |
---|---|---|---|---|---|---|---|---|
模式串 | A | B | C | D | A | B | D | '\0' |
next[ i ] | -1 | 0 | 0 | 0 | 0 | 1 | 2 | 0 |
(1)i=0,对于模式串的首字符,我们统一为next[0]=-1
;
(2)i=1,前面的字符串为A
,其最长相同前后缀长度为0,即next[1]=0
;
(3)i=2,前面的字符串为AB
,其最长相同前后缀长度为0,即next[2]=0
;
(4)i=3,前面的字符串为ABC
,其最长相同前后缀长度为0,即next[3]=0
;
(5)i=4,前面的字符串为ABCD
,其最长相同前后缀长度为0,即next[4]=0
;
(6)i=5,前面的字符串为ABCDA
,其最长相同前后缀为A
,即next[5]=1
;
(7)i=6,前面的字符串为ABCDAB
,其最长相同前后缀为AB
,即next[6]=2
;
(8)i=7,前面的字符串为ABCDABD
,其最长相同前后缀长度为0,即next[7]=0
。
那么,为什么根据最长相同前后缀的长度就可以实现在不匹配情况下的跳转呢?举个代表性的例子:假如i=6
时不匹配,此时我们是知道其位置前的字符串为ABCDAB
,仔细观察这个字符串,首尾都有一个AB
,既然在i=6
处的D不匹配,我们为何不直接把i=2
处的C拿过来继续比较呢,因为都有一个AB
啊,而这个AB
就是ABCDAB
的最长相同前后缀,其长度2正好是跳转的下标位置。
有的读者可能存在疑问,若在i=5
时匹配失败,按照我讲解的思路,此时应该把i=1
处的字符拿过来继续比较,但是这两个位置的字符是一样的啊,都是B
,既然一样,拿过来比较不就是无用功了么?其实不是我讲解的有问题,也不是这个算法有问题,而是这个算法还未优化,关于这个问题在下面会详细说明,不过建议读者不要这里纠结,跳过这个,下面你自然会恍然大悟。
思路如此简单,接下来的问题就是代码实现了,如下:
/* P为模式串,下标从0开始 */
void GetNext(string P, int next[])
{
int p_len = P.size();
int i = 0; //P的下标
int j = -1;
next[0] = -1;
while (i < p_len)
{
if (j == -1 || P[i] == P[j])
{
i++;
j++;
next[i] = j;
}
else
j = next[j];
}
}
一脸懵逼,是不是。。。
上述代码是求解每个位置的next值,即求解每个位置前面字符串的最长相同前后缀的长度。下面具体分析,我把代码分为3部分来讲:
(1)i的作用是什么?
i为模式串P的下标,从0开始,程序中我们依次求出next[i]
的值,这很简单。
(2)j的作用是什么?
其一,从if语句中P[i] == P[j]
,它作为模式串的下标,判断下一个字符是否相等。其二,从next[i] = j;
可以很容易推断出,j代表最长相同前后缀的长度。两者在数值上正好相等。(之所以正好相等,是因为i和j初始化的时候,正好差1,这点读者仔细思考下就能明白的)
(3)if...else...语句里做了什么?
首先我们必须要明确一个事实:若此时i = 3
,那我们接下来要求解的便是P[0]...p[3]
的最长相同前后缀的长度,也就是next[4]
,而非next[3]
,这从下面的代码就可以得到证明:
/* P为模式串,下标从0开始 */
void GetNext(string P, int next[])
{
int p_len = P.size();
int i = 0; //P的下标
int j = -1;
next[0] = -1;
while (i < p_len)
{
if (j == -1 || P[i] == P[j])
{
i++;
j++;
next[i] = j;
}
else
j = next[j];
}
}
有了这个事实,下面具体分析:
假设i和j的位置如上图,由next[i]=j
得,也就是对于位置i来说,区段0到i-1的最长相同前后缀分别是0到j-1和i-j到i-1,即这两区段内容相同。
按照算法流程,if(P[i]==P[j])
,则i++;j++;next[i]=j;
;若不等,则j=next[j]
,见下图:
next[j]
代表0到j-1区段中最长相同前后缀的长度。如图,用左侧两个椭圆来表示这个最长相同前后缀,即这两个椭圆代表的区段内容相同;同理,右侧也有相同的两个椭圆。所以else语句就是利用第一个椭圆和第四个椭圆内容相同来加快得到0到i-1区段的相同前后缀的长度。
细心的朋友会问if语句中j==-1
存在的意义是何?第一,程序刚运行时,j是被初始为-1,直接进行P[i]==P[j]
判断无疑会边界溢出;第二,else语句中j=next[j]
,j是不断后退的,若j在后退中被赋值为-1(也就是j=next[0]),在P[i]==P[j]
判断也会边界溢出。综上两点,其意义就是为了特殊边界判断。
四:KMP优化
i | 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 |
---|---|---|---|---|---|---|---|---|
模式串 | A | B | C | D | A | B | D | '\0' |
next[ i ] | -1 | 0 | 0 | 0 | 0 | 1 | 2 | 0 |
以3.2的表格为例(已复制在上方),若在i=5
时匹配失败,按照3.2的代码,此时应该把i=1
处的字符拿过来继续比较,但是这两个位置的字符是一样的,都是B
,既然一样,拿过来比较不就是无用功了么?这我在3.2已经解释过,之所以会这样是因为KMP不够完美。那怎么改写代码就可以解决这个问题呢?很简单。
/* P为模式串,下标从0开始 */
void GetNextval(string P, int nextval[])
{
int p_len = P.size();
int i = 0; //P的下标
int j = -1;
nextval[0] = -1;
while (i < p_len)
{
if (j == -1 || P[i] == P[j])
{
i++;
j++;
if (P[i] != P[j])
nextval[i] = j;
else
nextval[i] = nextval[j]; //既然相同就继续往前找前缀
}
else
j = nextval[j];
}
}
在此也给各位读者提个醒,KMP算法严格来说分为KMP算法(未优化版)和KMP算法(优化版),所以建议读者在表述KMP算法时,最好告知你的版本,因为两者在某些情况下区别很大,这里简单说下。
KMP算法(未优化版): next数组表示最长的相同前后缀的长度,我们不仅可以利用next来解决模式串的匹配问题,也可以用来解决类似字符串重复问题等等,这类问题大家可以在各大OJ找到,这里不作过多表述。
KMP算法(优化版): 根据代码很容易知道(名称也改为了nextval),优化后的next仅仅表示相同前后缀的长度,但不一定是最长(我个人称之为“最优相同前后缀”)。此时我们利用优化后的next可以在模式串匹配问题中以更快的速度得到我们的答案(相较于未优化版),但是上述所说的字符串重复问题,优化版本则束手无策。
所以,该采用哪个版本,取决于你在现实中遇到的实际问题
五:算法复杂度
假设在M字符串中找N字符串的起始位置,长度分别为m和n,使用KMP算法,一般认为时间复杂度是O(m+n),也就是计算next数组的时间复杂度是O(n),而匹配的时候是O(m).,但是在匹配的时候假设i用来标识M中的位置,j用来标识N中的位置,i是只增不减的可以理解,但是j也会按照next数组来回溯呀,也可能会回溯多次呀,为什么匹配的时候时间复杂度不是O(m*n)呢?
j的回溯只会影响搜索主循环次数的上下界([m, 2m]),而不会使其变成m*n
的关系.在这种情况下,对于M中的每个字符,实际上都比较了2次,所以一共执行了2m次循环。这是循环次数的上限.
实际上最坏情况不是M中的每个字符都比较了两次哦而是平均下来是两次