KMP模式匹配算法原理分析
对本文快速理解
一个串(Z)存在重复结构的定义:
Z串存在两个相等的子串(前串和后串),前串的第一个字符是Z的第一个字符且前串的最后一位不是Z串的最后一位。后串的最后一位是Z串的最后一个位且后串的第一位不是Z的第一个位。
本文提到的串,它的存储结构是顺序存储结构(数组),数组index=0是存储串的长度,index=1是串的第1个字符,index=2是串的第2个字符,..., index=n是串的第n个字符;
因此串的字符位置是从1开始(有别于C语言中index从0开始)
朴素的模式匹配算法
用你个简单例子介绍一下朴素模式匹配。
比如要从主串S=“gccdgccgle”中找到T=“gccgle”这个字串的位置。
第一步:
将T的第一位与S的第一位对齐,S的前三位与T的前三位相等,第四位d与g不等;
第二步:
将T的第一位与S的第二位对齐,S的第二位c与T的第一位g不等;
第三步:
将T的第一位与S的第三位对齐,S的第三位c与T的第一位g不等;
....
第N步:
将T的第一位与S的第五位对齐,S的第五、六、七、八、九、十位分别和T的第一、二、三、四、五、六位相等。
即找到了T=“gccgle”在S=“gccdgccgle"中首次出现的位置是5;
朴素匹配代码如下:
/** 在S中寻找与T串相等的字串的位置
* input:
* char *S: 主串
* char *T: 希望匹配的字串
* output:
* int: T在S中的位置,T和S的位置都是从1开始,如果返回0,则表示S中不存在T串
**/
int Index(char *S, char *T)
{
// i是主串S中当前位置下标
int i = 1;
// j是字串T中当前位置的下标
int j = 1;
// S[0]是S串的长度, T[0]是T串的长度
// i大于S长度或者j大于T串长度,循环结束
while (i <= S[0] && j <= T[0]) {
// 如果i,j位置两串的字母相等,则i,j都向前移动一位
if (S[i] == S[j]) {
++i;
++j;
} else {
// 如果i,j位置两串的字母不相等,则i退回到上一次匹配时首位的下一位,
i = i - j + 2;
// j退回到字串T的首位
j = 1;
}
}
// 如果j大于T串的长度,说明T串的所有字母都已经比较过,并且在S中找到了和它相等的字串,
// 因为如果在某次匹配中,但凡T中有一个字母与S对应位置的字母不相等,则j会回退到T串的首位。
if (j > T[0]) {
return i - T[0];
} else {
return 0;
}
}
上面介绍的朴素模式匹配算法中,i和j会不停的回退。一旦在某次匹配中,有一个字母不相等,则表示本次匹配结束。j会回退到T串的首位,i会回退到本次匹配首位的下一位,为下一次匹配做准备。对于S来说,下一次匹配的首位是前一次匹配的首位的下一位置;对于T来说,每一次匹配都是从T的首位开始。
有没有更好的算法来减少回退的次数呢?回答是:有,就是下面介绍的KMP模式匹配算法。
KMP模式匹配算法
KMP算法其实在某次匹配不成功时,让i不回退,仅仅让j回退。K\M\P三位前辈大佬通过分析j回退的规律,总结出当某次匹配不成功时,i不回退,至于j回退到哪一个位置,与T串中重复结构有关,与S串结构无关。也就是说给定T串,对于任意的j,我们事先就可以知道j要回退到T串什么位置。我们用一个数组next表示j的回退位置。
如果某次匹配不成功,此时的j应该回退到next[j]。
// 如果某次匹配中S[i] != T[j]
j = next[j];
如何构造next[j]数组
上文已经提到了next数组与T串重复结构有关,其实next[j]对应着T的字串Z=T[1][T2]....T[j-1]中的"重复结构数"(作者自己取得名字)。
Z串的"重复结构数"可以用一个数值表示,规定Z串的重复结构数的定义是:
- 当j > 1时,存在一个最大的k,1<=k<j-1,使得Z串的前串Z[1]..Z[k]和后串Z[j-k]..Z[j-1]完全相等(即Z的前k个字符与后k个字符相等),则next[j] = k + 1;
- 当j = 1时,next[j] = 0;
- 其他时候,next[j] = 1;
现在用简单的例子来说明。
T=”ababaaaba",一共有9个字母
- 当j=1,属于第2种情况,next[1] = 0;
- 当j=2,Z="a", 不存在这样的前串和后串,属于第3种情况,next[1] = 1
- 当j=3,Z="ab", Z[1]不等于Z[2],不存在满足1的前串和后串,next[3] = 1;
- 当j=4,Z="aba", 前串是a, 后串是a,此时k=1,next[4] = k + 1 = 2;
- 当j=5,Z=”abab",前串ab, 后串ab,此时k=2,next[5] = k + 1 = 3;
- 当j=6,Z="ababa",前串aba,后串aba,此时k=3,next[6] = k + 1 = 4;
---------------------------------------------------
我们在这里稍作停留,观察观察,其实可以发现,在满足第一种情况时,k是此时的Z串的前串长度,也是后串的长度。这个结论对后面理解很重要。next[j] = k + 1其实表示j回退时应该回退到前串末位置的后面一个位置。这是什么原因呢?这是由Z串的重复结构决定的。next[j]的值是要说明Z串的前next[j]-1个字符组成的字串与后next[j]-1个字串重复。
如果要计算next[j+1],此时上一步的Z串(下标从1到j-1)后面多了一个字符Z[j]。我们先让k = k + 1;此时next[j] = k。
将这个末尾字符Z[j]与Z串的第next[j]个字符(z[k])比较,如果相等,则前串长度等于k(注意k已经加了1),next[j+1] = k + 1 = next[j] + 1;如果不等呢?
则符合情况1时Z串(从Z[1]到Z[j])的前串长度肯定小于k,因此k要回退,回退到k-1?回退到k-2?还是回退到哪里?我们已经知道Z[j-1]=z[k-1],z[j-k-1] = z[1](注意k已经加了1);我们也知道Z串的 前k-1个字符的重复结构,我们知道next[k]代表Z串前k-1个字符的重复结构数,满足情况1时Z串 前k-1个字符组成的子串的 前串长度是next[k] - 1。k回退到next[k]是最好的选择!原因我在唠叨一下:
我们已经知道Z[j]与Z[k](注意k已经加了1)不等,接着要比较Z[j]与Z[k-1](注意k已经加了1),Z串 前k-1个字符具有一定重复结构,Z[k-1]与Z[1]Z[2]...Z[next[k] - 1]中的某个字符很有可能相等,我们让k回到next[k]其实是回到 前k-1个字符的前串末尾位置的下一个位置。next[k]我们前面几步已经计算过它的值。直接用就可以。接着可以比较Z[j]与z[next[k]],如果不等,则比较z[next[next[k]]....,直到相等为止或者k回到了1为止。
现在知道为什么k的回退位置是next[k]了。我们在接着看j=7时的推导。
--------------------------------------------------
- 当j=7,Z=“ababaa",我们先让k=k+1,k=4;我们发现按照上一步j=6的节奏,k应该是4,本来应该是前串abab与后串abab,而事实上是前串abab和后串abaa不相等,所以k不能继续增大,k要往小的走,k应该回退,回退到哪里呢?回退到next[k]还是回退到k-1?如果回退到k = k -1 =3,要比较aba和baa,仍然不相等,按照上面的分析,应该回退到next[k]。k=next[k] = next[4]=2;b与a不等,k = next[k] = next[2] = 1, a等于a,next[7] = k + 1 = 2;
- 当j = 8,Z=“ababaaa",我们先让k=k+1=2; b不等于a,k = next[k] = next[2] = 1; a等于a, next[8] = k + 1 = 2;
- 当j = 9, Z="ababaaab",我们先让k=k+1=2; b等于b,next[9] = k + 1 = 3;
现在我们可以写一下求T串的next数组的代码了。
/**
* 功能:计算T串的next数组
* input:
* char *T: T串
* int *next: T串每一个位置回退数组
**/
void GetNext(char *T, int *next)
{
int i, k;
i = 1;
k = 0;
next[1] = 0;
while (i < T[0]) {
if (k == 0 || T[i] == T[k]) {
next[i + 1] = k + 1; // 这样写好理解一些,和上文的讲解对应。
i++;
k++;
} else {
k = next[k]; // 此处回退到next[k],上文已经详细的讲解了原因了。
}
}
}
然后在写一下KMP的算法过程:
/** 在S中寻找与T串相等的字串的位置
* input:
* char *S: 主串
* char *T: 希望匹配的字串
* output:
* int: T在S中的位置,T和S的位置都是从1开始,如果返回0,则表示S中不存在T串
**/
int KmpIndex(char *S, char *T)
{
// i是主串S中当前位置下标
int i = 1;
// j是字串T中当前位置的下标
int j = 1;
// next数组用于存储T串每个j的回退位置
int next[255];
GetNext(T, next);
// S[0]是S串的长度, T[0]是T串的长度
// i大于S长度或者j大于T串长度,循环结束
while (i <= S[0] && j <= T[0]) {
// 如果i,j位置两串的字母相等,则i,j都向前移动一位
if (j == 0 || S[i] == S[j]) { // j = next[]中的值,有可能会等于0
++i;
++j;
} else {
// i不回退
// j回退到next[j]
j = next[j];
}
}
// 如果j大于T串的长度,说明T串的所有字母都已经比较过,并且在S中找到了和它相等的字串,
// 因为如果在某次匹配中,但凡T中有一个字母与S对应位置的字母不相等,则j会回退到T串的首位。
if (j > T[0]) {
return i - T[0];
} else {
return 0;
}
}