模式匹配和KMP算法
模式匹配
-
字串的定位操作通常称作串的 模式匹配。
-
最基本的模式匹配算法就是从主串(
S)的第一个字符开始,逐字符与模式串(T)进行匹配。这种匹配方式简单但费时,下面给出这种匹配算法的简单实现int patternMatch(char *S,char *T,int pos) { //返回字串在主串第pos个字符之后的位置,若不存在,则返回0 int lenS = strlen(S);//储存字符串S和T的长度 int lenT = strlen(T); if (pos<1 || pos>lenS) return 0; int i,j,loc=0; for( i= pos - 1;i<lenS - lenT;i++){ for( j = 0;j<lenT;j++){ if(S[i + j] == T[j]) continue; else break; }//for if (j == lenT) { loc = i + 1; break; }//if }//for return loc; }//patternMatch -
根据模式匹配算法的两重循环可知,模式匹配算法的时间复杂度为 O(n∗m)O(n*m)O(n∗m)
其中,n和m为主串和模式串的长度。
上面的时间复杂度是模式匹配算法的最坏情况,在这种情况下,主串除了首尾的几个字符,几乎每个字符都有可能与模式串的匹配m次,这在很多情况下效率是极低的。
那么,我们有没有可能让主串的每个字符基本只和模式串匹配一次呢?
这就得提到KMP算法了
KMP算法 (The Knuth-Morris-Pratt Algorithm)
-
KMP算法是上面模式匹配算法的改进版,其优势在于可以在 O(n+m)O(n+m)O(n+m) 的时间数量级上完成模式匹配的操作。
我们可以发现上面的算法在每一次匹配时,无论上一次匹配了几个字符,i都是逐一递增的,再叠加上
j的注意递增,整个算法的时间复杂度就被大大提高了。就比如下面这个例子:

首先我们要明确,在模式匹配中主串
S的字符对于我们来说就像一个个黑盒,我们是拿T中的一个个字符去和S串比对。从这个角度出发,我们可以发现在这个示例中,模式串的最后一个字符与主串不匹配,且
S[9]~S[11]是什么字符我们是不知道的。那么根据一开始的模式匹配算法,我们会将
i从S[0]移动到S[1]然后重新开始与T逐个匹配,但这种效率是极低的,因为我们发现一开始a和b就不匹配了,直到i移动到3时才重新匹配上。可是通过第一轮匹配,我们已经知道
S从0~7和T就是匹配的,而且如果我们把T的字符当作已知,我们可以发现S[1]和S[2]和T[0]不匹配,同时,我们惊奇地发现,S[3]~S[7]和T[0]~T[4]是相同的(为什么不是到S[8]和T[5]为止呢?因为此时,我们假设S未知,T已知,当两个字符匹配时我们能知道S串的字符就是相对应的T的字符,但如果不匹配,我们只能知道两个字符不相同,而无法确定S相应的字符是什么),也就是说,我们可以把T串直接向右平移三个字符然后让T[5]去和S[8]配对就行了,在实际的算法中,我们直接将j回溯到T[5]然后接着配对,这样就可以避免主串的过多的无用的重复匹配,那么我们要怎么知道j要回溯到之前的那个位置呢?不难发现,
j可回溯的部分已经和S配对上了,也就是说这部分S和T是完全相同的,也就是说我们可以将这部分的S直接看作相应部分的T那么先让我们一步步来试试。我们将每一个j所回溯到的位置设为k,T中每一个字符所对应的K就组成了一个数组,我们不妨称为next。并且我们设next[0](也就是示例的a对应的k)为-1.因为每当j回溯到首字符时我们就知道,在这个部分,T没有任何一个子串是能和主串对应的,在这种情况下我们就要从主串的下一个字符开始重新匹配。于是,我们可以得到下图所示的next数组:
根据图示和刚刚的分析,我们可以得到每个
j所对一个的k需要满足的要求是:-
T[0]~T[k-1]要和T[j-k]~T[j-1]对应相等 -
不存在更大的
k'使得T[0]~T[k' - 1]和T[j-k']~T[j-1]对应相等(因为如果存在这样的k',那么显然选择k'会使得我们后续需要匹配的字符数更少)
应用到算法中,我们可以用数学归纳法的思想来理解。
首先,假设对于
T[j-1],有next[j] = k-1使得上述条件成立(当然j,k>0),那么对于T[j],我们有以下两种情况:-
T[j-1] == T[k-1]此时依照假设内容,
T[0]~T[k-2]已经是T[j-1]满足要求的最长串,且T[j-1]还能和T[k-1]配对,那么T[0]~T[k-1]一定就是T[j]满足要求的最长串
因此在这种情况下,我们可以得到:next[j] = k;证明图示如下:

这是对要求1的图解 -
T[j-1] != T[k-1]这种情况就有些令人头大了,吗?来,我们慢慢分析。如果想要
next[j]某个k,依据第一种情况,我们会先判断T[j-1]是否等于T[k-1],现在这个条件显然不成立,但是,对于T[k-1]我们还可能有一个next[k-1],欸,我们不妨假设为k'-1,那我们就很自然地设想如果这个k'-1对应的T[k'-1]会等于T[j-1],那么k'会不会就是我们想要得到的k呢?对于这个设想,要求1肯定是满足的,那么会满足要求2吗?答案是显然的。如果一定要证明,那我们可以采用反证法。
假设存在某个
p(k'<p<k)满足所有要求,那么它就是j所对应的next[j]的值,于是我们可以推出T[0]~T[p-1]就会与T[j-p]~T[j-1]对应相等,由此我们还可以得出T[0]~T[p-1]还会等于T[(k-1)-p]~T[k-2]。由于
k'是小于p的,因为k'是T[k-1]所对应的next的值,这也就意味着不存在比k'还长的串能够满足要求1.但根据我们刚刚的分析,对于p要求1显然是满足的。矛盾产生,因此k'就是我们想要得到的k。由此,如果
k'还不成立,那么我们就继续往前回溯,直到相应next值为-1(也就是回到了首字符,表明没有可以匹配的子模式串),那就从主串的下一个字符开始重新匹配。
证明图示如下:
这是对要求二的图解
至此,我们已经将求
next数组的证明过程理清楚了,那么具体应该如何实现呢?程序应该怎么写呢?其实不过就是将我们刚刚的证明过程一步步翻译成代码而已。下面我们来看具体实现。 -
求next函数值的算法
```c
void get_next(char *T,int next[]){
next[0] = -1;//将第一个字符的next值设为-1
int k = -1,j = 0;//这里的k就是每个T[j]所对应的next的值
while(j<strlen(T) - 1){
if (k == -1 || T[j] == T[k]) {//这里if的条件就对应我们所分析的
// T[j-1] == T[k-1]的情况
//k==0是初始条件
//为了让循环能从第一个字符开始进行
j++; //根据分析,满足条件时T[j]的next值即为k
k++; //这三行代码即实现这一过程
next[j] = k ;
}//if
else k = next[k]; //这行代码就对应了上面T[j-1] != T[k-1]的情况
//整个思路是不是就非常清晰了?
}//while
}//get_next
```
KMP算法主体
有了next数组之后,我们就可以开始实现整体的KMP算法了,只要在最开始的模式匹配算法的基础上稍加改进就行
下面是具体实现:
int KMP(char *S,char *T,int next[]){
//用KMP算法返回成功匹配串的首字符的位置,不成功则返回-1
int i = 0,j = 0;
int lenS = strlen(S) - 1,lenT = strlen(T) - 1;//注意字符串末尾的0
while(i < lenS && j < lenT){
if(j == -1 || S[i] == T[j]) {
//当成功匹配,或者j回溯到首字符时,继续匹配下一字符
i++;
j++;
}//if
else {
j = next[j];//否则j回溯
}//else
}//while
if (j >=lenT) return i - lenT + 1;//如果j大于T的长度,说明完全匹配
//此时返回首字符的位置
else return j;
}//KMP
现在再看这个算法是不是就显得特别容易特别简短了?
O(m+n)O(m+n)O(m+n)?
看到这里,你可能会有疑问,为什么说KMP算法的时间复杂度是O(m+n)O(m+n)O(m+n)?对于KMP算法来说,i确实是不用回溯了,但j不是还要回溯吗?j每次回溯多少怎么确定呢?接下来我们来讨论这个问题。
对于最坏的情况来说,就是j每次都回溯一个字符,直到回溯到首字符为止,那让我们再看一眼KMP算法。在每单轮匹配中,我们假设i往前移动了m个字符,那么此时模式串也将成功匹配m个字符,那么在最坏的情况下,j就是最多回溯m个字符。
由于i是不回溯的,因此i的时间复杂度显然是O(n)O(n)O(n),那么对于j来说最多也就是回溯n次,这就是j在KMP算法中的最坏情况,对于j的回溯来说,复杂度也是O(n)O(n)O(n),现在,让我们回到算法的循环中,我们可以看到,在每一轮循环中,要么i和j同时前进,要么j回溯,这两种情况并没有嵌套关系,因此KMP算法的整体复杂度就是 O(n)+O(n)=O(n)O(n) + O(n) = O(n)O(n)+O(n)=O(n) ,不要忘了,在进行KMP算法之前,我们还需要求得next数组,对于这个函数来说,求复杂度就很简单啦,因为我们是对每个模式串的每个字符求值,不存在什么回溯啦乱七八糟的,因此复杂度就是很简单的O(m)O(m)O(m)于是,整体加起来,完整的KMP算法的复杂度就是O(n+m)O(n+m)O(n+m)!
让我们开看个具体的例子:

这个例子很好地展现了最坏的情况。依照KMP算法,一开始,i和j一起从0移动到4发现S[4]和T[4]不匹配,此时,j会按照next数组一个个往前移,然而最多也只会移动四个字符回到T[0]然后从S[5]开始重新匹配,这就很好地印证了我们求得的时间复杂度。
BUT!
作为一个聪明的人,你会发现这个现在的匹配方式在这种情况下挺蠢的,明明模式串全是a,最后一个不匹配那显然前面所有的都不匹配啊,因此,我们还可以在现在算法的基础上继续改进next函数。
复杂度还能降低吗?
依据上面的例子,我们很自然地发现,在发现T[j]不匹配的时候,如果T[j]与T[next[j]]相等,那就没有匹配的必要了,显然不匹配。
在这种情况下我们就得继续回溯,直到发现next不与T[j]相等或直到为-1那么就继续往下匹配就行了,应用到具体的算法中,我们可以将next函数改进为:
void nextval(char *T,int nextval[]){
int j = 0,k = 0;
nextval[0] = -1;
while(j<strlen(T)){
if (k == 0 || T[j] == T[k]){
j++;
k++; //到目前为止一切照旧
if(T[j] != T[k]) nextval[j] = k;
//此时,我们要多判断一下T[j]是否等于T[k],
// 如果不相等,那么一切照旧,
// 当S和T不匹配的时候,
// 自然移动到k处继续尝试
else nextval[j] = nextval[k];
//如果相等,那就直接等于nextval[k]
//并且,由于i是逐渐增加的,
//在算法中我们其实不用考虑j到底和前面的几个k相等
//因为找到第一个相等的值的时候nextval[j]自然就变成了nextval[k]
//如果之后还有和T[j]相等的T[j'],
//那么当nextval[j']=nextval[j]时,
//其值自然也就直接等于nextval[k],而不用回溯两次
}//if
else k = nextval[k];//此步依然照旧
}//while
}//nextval
到这里,我们的KMP算法总算是完美结束了AAAAAAAA!
1931

被折叠的 条评论
为什么被折叠?



