一、KMP算法介绍与核心思路
KMP算法是一种经典的字符串匹配算法,相较于朴素的字符串暴力匹配算法,其在时间复杂度上优化至O(n)的线性时间复杂度,极大提高了匹配效率。
在本文中,统一规定用【i】来表示指向主串的指针,用【j】来表示指向模式串的指针。用n来表示主串的长度,m来表示模式串的长度。
朴素的字符串暴力匹配算法枚举了所有的主串中开始匹配的位置(cp),当匹配失败,cp加1,i回退到cp,j回退到0,在最坏的情况下,需要匹配m*n次。
朴素算法时间复杂度如此大的原因主要在于每次匹配失败时i指针需要重新回退到起始位置,但是在某些情况下,通过已经成功匹配的内容,我们可以不必重新回退到起始位置开始匹配,这便是KMP算法的核心思路。我们看下面的例子:
图 1
当在主串第五个字符(i所指向位置)匹配失败时,观察已经成功匹配的部分【ABCDAB】,为了方便后续说明,我们把模式串中成功匹配部分记为matchPattern,我们观察matchPattern,可以发现它有着对称的性质(这里的对称不是中心对称),它的前两个字符与后两个字符相等,都是【AB】,我们将这样的性质称为相等前后缀,以下给出前后缀的定义:
前缀:包含首字母不包含尾字母的所有字串,对ABCDAB前缀有:A AB ABC ABCD ABCDA
后缀:包含尾字母不包含首字母的所有字串,对ABCDAB后缀有:B AB DAB CDAB BCDAB
图 2
于是,对ABCDAB来说,它有相等前后缀"AB",也就是说ABCDAB的前两个字符后最后两个字符是相同的。那么,第二次匹配时,我们可不可以直接跳过相同的这一部分呢?也就是在新的一次匹配中,主串就从之前匹配发生冲突i处开始,不回退,而模式串中j从前缀部分之后的第一个字符处开始与i匹配(注意不是后缀的第一个字符),如下图:
图 3
为方便描述问题,这里定义以下符号:
subStr(str, i : j) : 字符串str中以下标i为首字符,下标j为最后一个字符的长度为j-i+1的子串
例如:subStr(pattern, 0 : 5)表示模式串中的【ABCDAB】
matchPattern : 在匹配失败时,模式串与主串成功匹配部分
例如第一次匹配时(图2)模式串匹配部分为【ABCDAB】,长度为6
上述操作正是KMP算法相比暴力匹配的巧妙之处,在匹配失败后,新的一次匹配起始位置并不是在原来基础上直接加1,而是跳过了主串中部分长度为m的有可能与模式串匹配的子串(后面会证明这部分子串不可能与模式串相匹配),我们将跳过匹配的子串数目记为【移动位数】,用符号s表示,【移动位数】满足:
移动位数 s = matchPattern的长度 - matchPattern的最长相等前后缀长度
因此跳过的这些部分就是主串中以【从前缀第一个字符位置到后缀前一个字符,注意这里说的前后缀指的是上一次匹配时对应于matchPattern的部分】为首字符的所有长度为m的(s-1)个子串subStr(text, i-j+p : i+p-1)(1<=p<s),直接与以【后缀第一个字符】为首字符的子串匹配(红色部分),以上文的例子来说就是跳过了以下标1、2、3为首字符的3个子串。
除此之外,在新的一次匹配中,没有匹配主串和模式串中的某些字符(matchPattern的前缀部分,蓝色部分),而是认为它们已经可以匹配,以上文的例子来说就是默认要匹配的主串部分【ABEABCD(红色部分)】的前两个字符【AB(黄色部分)】已经可以和matchPattern的前缀【AB】相匹配。
于是每次当主串指针i和模式串指针j失配时,新的一次匹配只进行如下操作:
1. 【i】的指向保持不变
2. 【j】回退到matchPattern的前缀之后的第一个字符处
3. 判断text[i] 与 pattern[j] 是否相等
要想让上述操作不影响结果,只需要证明两点:主串跳过部分匹配部分(黄色部分)和模式串跳过部分相同(1),subStr(text, i-j+p, i+p-1)(1<=p<s)不可能与模式串pattern相匹配(2)。
证明(1):主串跳过匹配部分(黄色部分)就是mathPattern的后缀,模式串跳过匹配部分就是matchPattern前缀,由于我们选取了相同前后缀,(1)显然成立。
证明(2):设某次匹配失败时主串指针为【i】,模式串指针为【j】,matchPattern的最长前后缀长度为len。
这里用反证法来证明。如果subStr(text, i-j+p : i-j+p+m-1) == pattern,那么可以得到
subStr(text, i-j : i-j+tp-1) == subStr(text, i-j+tp : i-j+(t+1)p-1) , 其中t为正整数 …… (1)
说明:(1)式得到了matchPattern内部的相等性质,即可以将matchPattern分成多个长度为p的相等子串。
图 4
结合图4,可以得到主串和模式串中的相等关系,总可以找到一个【newLen】,满足newLen > len,使得
subStr(pattern, 0 : newLen-1) == subStr(pattern, i-1-newLen : i-1) ………… (2)
图 5 以下证明newLen的恒存在性:
由(1)(2) 可得: j - newLen = k * p (int k >= 1)
即若总是存在一个满足以下条件的k
1 <= k < (j - len) / p
那么newLen必然存在
由于 1 <= p < s = j - newLen,代入上式可得 (j - len) / p > 1
于是我们取 k = 1即可满足要求,此时newLen = j - p
此时,newLen可以作为matchPattern的最长相等前后缀,与matchPattern的最长相等前后缀长度为len相矛盾,于是(2)得证!
通过上述证明过程,可以发现,我们需要的是【模式串】中【matchPattern】的【最长相等前后缀】,这时候需要采取预处理的方式,先得到模式串中各个以模式串首字符为首字符的所有字串的最长相等前后缀,我们用一个数组来存储各个子串最长相等前后缀的长度,这个数组就称为【前缀表】或【next数组】,其中next[j]表示subStr(pattern, 0 : j)的最长相等前后缀长度。
通过next数组,我们就可以确定每次匹配失败时,新的匹配位置在哪里,下面是模式串的next数组:
matchPattern | A | B | C | D | A | B | D |
j | 0 | 1 | 2 | 3 | 4 | 5 | 6 |
next[j] | 0 | 0 | 0 | 0 | 1 | 2 | 0 |
这里省略用代码求next数组的过程,专门在第二节叙述。
例如,第一次匹配失败时j指向D(下标为6处),那么我们需要的就是0到j-1部分的最长前后缀,也就是next[j-1](next下标从0开始),查表可知ABAB最长相等前后缀为2,那么我们就让j = 2,i从当前位置保持不变开始匹配。
因此,我们得到,当匹配失败,即txt[i] != pattern[j],j的回退方式为
j = next[j-1],j>0
一直重复进行以上步骤,直到i指向主串末尾或者j指向模式串末尾。当i指向主串末尾而j还未指向模式串末尾,则主串无法和模式串匹配;当j指向模式串末尾,则表示主串可以于模式串匹配,并且模式串在主串中首次出现位置的下标为:【i - j】。
下图是KMP算法的执行过程:
i、j从0开始迭代,直到text[i] != pattern[j]
当不匹配时,j回退到next[j-1],i不变。然后i、j继续迭代,直到text[i] != pattern[j]。
当不匹配时,j回退到next[j-1],i不变。然后i、j继续迭代,直到text[i] != pattern[j]。
当【j = 0】时,j已经无法往前回溯,这时只需【i+=1】即可。然后i、j继续迭代,发现【j == n】,在主串中找到了模式串,返回模式串在主串中首字符的下标【i - j】。
时间复杂度分析:通过以上分析,可以发现主串中真正需要匹配的内容永远不需要回退,也就是主串的所有内容在最坏的情况下也只需匹配一次,即【i】不回退,每次只需要根据模式串匹配成功部分的前后缀长度改变【j】即可,这一匹配过程的时间为Θ(n)。
KMP算法的部分代码如下:
int my_strstr(const char* text, const char* pattern, int n, int m)
{
int i=0, j=0;
int* next = (int*)malloc(sizeof(int) * m);
getNext(next, pattern, m);
while (i < n)
{
if (text[i] == pattern[j])
{
i++;
j++;
}
else if (j > 0) //匹配失败,根据next跳过一部分
j = next[j - 1];
else //模式串第一个字符匹配就失败
i += 1;
if (j == m)
return i - j;
}
return -1;
}
二、得到next数组
求解next数组巧妙运用了动态规划的思想,我们先从一个比较简单的例子出发:当模式串为上文出现的【ABCDABD】时。
在动态规划中,要确定dp数组(就是这里的next数组)的含义、如何初始化、如何状态转移以及如何获取所需答案。
1. next数组的含义
next[j]表示pattern中下标从0到j的子字符串的最长相等前后缀的长度,例如【ABCDAB】的最长相等前后缀为【AB】,故next[5] = 2。
2. next数组的初始化
显然,当matchPattern只有一个字符,根据前后缀的定义它没有后缀,于是我们将next[0]初始化为0。
3. next数组的状态转移方向与状态转移方程
观察下图,可以发现,每个位置的next值依赖于之前的位置,因此进行状态转移的方向应该是从左到右。
对于下标为i处,其next[i]的值依赖于0到i-1处的情况,比如,当i=5时,由于next[4] = 1,说明subStr(pattern, 0, 0)和subStr(pattern, 4, 4)可以形成最长公共前后缀,这时,next[5]就由pattern[1]和pattern[5]决定,当pattern[1] == pattern[5]时,可以由subStr(pattern, 0, 1)和subPattern(pattern, 4, 5)形成最长公共前后缀,于是next[5] = 2。我们会得到这样的状态转移方程:
但是,上面给出的状态转移方程是有问题的,我们看这样的情况,当pattern为【ABCDABCABCDABCD】时通过上述状态转移方程得到的结果是错误的,这是因为我们判断pattern[i]和新的前缀尾不相同时直接将next[i]置为0,这一步是不正确的。
当i为14时,通过next[13]找到pattern[7],发现pattern[14] != pattern[7],但这并不说明不能找到对称的部分,我们通过subStr(pattern, 0, 6)(A1)与subStr(pattern, 7, 13)(A2)这两个相等前后缀可以在这两个前后缀的内部找到另一对相等前后缀subStr(pattern, 0 : 2)(B1)和subStr(pattern, 11:13)(B4),由于pattern[3] == pattern[14],于是next[14] = next[2] + 1。上述情况起始我们,每次只考虑寻找与subStr(pattern, 0 : next[i-1])的相同后缀是不可取的,当发现pattern[i]!=pattern[next[i-1]]时还要继续通过next[next[i-1]]进行回溯,直到j无法回溯为止才能确定最终的答案。
结合以上讨论,这里给出求解next数组的思路:
1. 初始化next[0] = 0
2. 用【i】表示后缀,【j】表示前缀。从i=1开始进行状态转移。
(1)A1=A2,并且A1、A2长度为j = next[i-1](next数组的含义),如果pattern[i] = pattern[j], 就可以将【i-1】的状态转移到【i】处,next[i] = next[i-1] + 1 = j + 1,递推完成。(如下图)
(2)如果pattern[i] != pattern[j],那么我们将【j】移动到next[j-1](j = next[j-1]),考虑除A1、A2之外的相等前后缀B1、B4(B1、B4长度为j),也就是subStr(pattern, 0 : j)与subStr(pattern, i-j : i)是否相等,显然,只需考虑pattern[j]和pattern[i],如果pattern[i] = pattern[j],说明B1+pattern[j]和B4+pattern[i]可以构成更长的相等前后缀,于是就可以将【i-1】的状态转移到【i】处,即next[i] = j + 1,递推完成。(如下图)
(3)若是pattern[i] != pattern[j],那么我们继续将【j】移动到next[j-1],考虑除A1、A2、B1、B2、B3、B4之外的最长相等前后缀,至此,我们发现这就是一个递推回溯的过程,并且满足这样的状态转移方程
next[ i ] = j + 1 , if pattern[ i ] == pattern[ j ]
当pattern[i] != pattern[j],应该不断更新j = next[j-1],直到pattern[i] == pattern[j] 或者 j = 0跳出循环。
求解next数组一般有两种写法:
void getNext(int* next, char* pattern, int m)
{
next[0] = 0;
//j 作为要比较的前缀尾
for (int i = 1; i < m; i++)
{
int j = next[i - 1];
//不断递归寻找相等串
while (pattern[i] != pattern[j] && j) {
j = next[j - 1];
}
if (pattern[i] == pattern[j]) next[i] = j + 1;
else next[i] = 0;
}
}
void getNext(int* next, char* pattern, int m)
{
next[0] = 0;
int j = 0;
for (int i = 1; i < m; i++) {
while (j && pattern[j] != pattern[i])
j = next[j - 1];
if (pattern[j] == pattern[i])
j++;
next[i] = j;
}
}
三、KMP算法的时间复杂度分析
KMP算法可以分为两个部分,第一部分是预处理模式串得到前缀表next数组,第二部分是根据next数组进行主串和模式串的匹配。
1.计算next数组
这里为了保证论证的方便,采用求解next的方法二的伪代码,传入参数为模式串P,用π代表之前的next数组,π[k]含义是subStr(P, 0 : k)的最长相等前后缀长度。
运用摊还分析的聚合方法分析,可得过程COMPUTE-PREFIX-FUNCTION(P)的运行时间为Θ(m)。得到π的过程有两层嵌套的循环,而该算法微妙的部分是6-7行的while循环的总执行时间为O(m)。下面说明它至多进行了m-1次迭代。
我们观察k的值,(1)在第四行k的值被初始化为0,并且增加k的唯一方式是第9行的递增操作,该操作在5-10行的for循环至多执行 m-1 次;(2)在第一次进入循环时 k<=i,并且之后for循环每执行一次k至多递增一次,而i一定递增一次,由数学归纳法可知 k<=i 总成立。因此,第三行和第十行的赋值确保了π[i] <= i 对所有的i = 0、1、……、m-1都成立,这意味着每次while循环里k的值都在递减;(3)k必须永远大于0。
考虑到以上三个因素,我们可以得到这样的结论:k的递减来自于while循环,它由k在for循环迭代中的增长所限定,k总共下降m-1次,因此while循环最多迭代m-1次,并且COMPUTE-PREFIX-FUNCTION的运行时间为Θ(m)。
2.主串和子串的匹配
运用类似的聚合分析可以证明KMP-MATCHER的匹配时间为Θ(n)。这里就不在赘述,给出另外一种不太严谨的方法。在第一部分介绍KMP算法的时候,我们就提到了KMP算法的核心就在于匹配失败时主串指针i不回退,于是自然匹配次数为O(n)。
3.总结
综上,KMP算法的时间复杂度为O(m+n),空间复杂度为O(m),为储存前缀表所需。