数学功底偏弱,最近花了1天时间才理解了用于字符串匹配的KMP算法。
KMP算法是由 Knuth、Pratt 和 Morris 三个人同时发现的,因此取名为KMP,并不是什么功能性短语的缩写。该算法主要希望:“在字符串匹配的过程中尽可能地减少重复比较的次数,从而提高匹配效率”。
假设有源字符串S和模式字符串T,现在需要设计一个函数来查找S串中与T串相同的子串所在的位置i,若子串不存在,则返回0。
typedef struct string
{
uint Length;
char *Ch;
} STRING, *P_STRING;
P_STRING p_S;
P_STRING p_T;
一种最容易想到且最为简单的方法是,定义两个索引 i、j,分别指向S串的T串中的字符,从第1个字符开始逐一比较;若字符相等,则 i、j 进位;若不相等,则将 i 索引 -j+2,再将 j 索引归 1,重新开始进行比较。直到 i 索引超出S串最后一个字符。
代码实现如下:
int SubStrIndex(P_STRING S, P_STRING T)
{
uint j = 1;
uint i = 1;
while(i<=S->Length && j<=T->Length)
{
if(S->Ch[i] == T->Ch[j])
{
i++; j++; // 进位比较后续字符
}
else
{
i = i-j+2;
j = 1;
}
}
if(j == T->Length) // 若T串完全匹配
{
return i;
}
else
{
return 0;
}
}
然而,当遇到 S = “aaaaaaaaaaaaaaaaaaaaab” 而 T = “aaab” 这类情况时,前面的所有重复的字符 ‘a’ 都要进行T->Length轮比较,这样的匹配效率十分低下。
那么使用KMP算法就可以解决这个问题。
我们先来分析一下上述算法效率低下的原因:无论前面存在多少个已配对的字符,每当发生“失配”时就要回溯索引 i,并放弃所有已经配对的T串字符(j 归 1),并重新头开始配对。当S串中存在大量与T串“相似”的子串时,将会导致大量的回溯和重复比较。
然而实际上,这些已配对的T串字符本身已经包含了S串的信息,如果能够利用好这些信息,就能够全程不回溯索引 i。
除此之外,若在已配对的T串字符中也存在重复的子串,那么我们还可以在“失配”时减少索引 j 的回溯位数(而不需要每次都归1)。
基于以上假设,每当发生“失配”时,我们只需要为索引 j 指定一个新的值即可,在KMP算法中,通常将该值命名为 Next[],代码实现如下:
int SubStrIndex_KMP(P_STRING S, P_STRING T)
{
/* 计算T串的Next[]数组 */
// ...
/* 开始进行字符匹配 */
uint j = 1;
uint i = 1;
while(i<=S->Length && j<=T->Length)
{
if(S->Ch[i] == T->Ch[j])
{
i++; j++; // 进位比较后续字符
}
else
{
j = Next[j]; // 直接为索引 j 赋新值
}
}
if(j == T->Length) // 若T串完全匹配
{
return i;
}
else
{
return 0;
}
}
可见,KMP算法与前文所述的普通算法最大的区别在于引入了数组“Next[]”,且该数组仅和T串相关。
那么 Next[] 又该如何获得呢?这里我们推导一下 Next(j) 函数的定义:
- 假设现在T串与S串已经匹配了 j-1 个字符,而在比较第 j 个字符时“失配”,既 T[1]...T[j-1] = S[i-j+1]...S[i-1],T[j] ≠ S[i],此时,我们需要:“判断是否存在一个尽可能大的索引值 k,且1 < k < j,使得 T[1]...T[k-1] = S[i-j+1]...S[i-1],且 T[j-k+1]...T[j-1] = S[i-j+1]...S[i-1],也就是说 T[1]...T[k-1] = T[j-k+1]...T[j-1]”。
> 若 k 存在,则可将该索引值 k 作为 赋值给 j,既:
Next[j] = k , k ≠ ∅
如下图所示,这等同于令索引 j 的值刷新为 k,从而可以在T串的 k 处开始再次进行比较。
说白了就是:如果已知在已被匹配的T串的一头一尾还存在着两个相同的“头、尾子串”(图中的绿色部分),当“尾子串”的后一个字符 T[j] 与 S[i] 失配时,就可以从“头子串”的后一个字符 T[k] 开始重新与 S[i] 比较,从而避免了将索引 j 归1导致一切从头开始。
> 若 k 不存在(相同“头、尾子串”不存在),则只能将索引 j 归1了,既:
Next[j] = 1, k = ∅
这就意味着重头开始匹配。
至此,我们不难发现 Next(j) 函数的实际意义 —— “已匹配模式串中,最长的相同头尾子串中,头子串的后1位”。
> 最后,定义Next的初始条件:
Next[1] = 0
意味着当已匹配的T串的大小为0时,串内相匹配的头尾子串不存在,因此其头子串的后1位也不存在。这将作为求解Next(j) 函数时,向前嵌套索引最大k值的终止条件(详细描述见后文)。
综上,Next(j) 的数学表达式如下:
获得Next(j) 函数的表达式后,我们可以利用递推法,来设计数组 Next[] 的求解程序:
初始条件,根据定义有:
Next[1] = 0;
现假设已有 T[1]...T[k-1] = T[j-k+1]...T[j-1],根据定义可知 Next[j] = k;接着我们要比较 T[k] 和 T[j] 以计算 Next[j+1]:
- 若 T[k] = T[j],则:
Next[j+1] = Next[j]+1 = k+1
- 若 T[k] ≠ T[j],则:
Next[j+1] = Next[ Next[k] ] +1 , j > k > 1
意思是:当T[k] ≠ T[j] 时,T串与S串在该位“失配”时,索引 j 就不能简单地回到 Next[j] 所指示的索引 k 上了,但根据Next函数的定义,还是希望返回给 j 的 k 尽可能地大;于是就往前索引上一个满足条件的 k' 值(1 < k' < k,已知该值存储在 Next[k] 中)+1并赋给 Next[j+1];若T[k'+1] = T[j] 就与第一种情况相同了,若T[k'+1] ≠ T[j] 则还需要继续向前索引下一个k',以此类推。最终获得一个在 T[j] 位与主串失配时,索引 j 所能返回的距离最近的 k'+1 位。
可见,在上述向前嵌套索引的过程中,只要遇到一个Next[k] = 1,都将直接导致:
Next[j+1] = Next[1] = 0
这就意味着满足条件的 k 不存在,终止向前嵌套索引操作,并重头开始比较。
根据上述推导,可设计如下代码:
int SubStrIndex_KMP(P_STRING S, P_STRING T)
{
/* 计算T串的Next[]数组 */
// 为 Next 数组动态分配空间
uint j = 1;
uint k = 0;
Next[1] = 0;
while( j < T->Length )
{
if(k==0)
{
k=1;
++j;
Next[j] = 1;
}
if(T->Ch[k] == T->Ch[j])
{
++k;
++j;
Next[j] = k;
}
else
{
k = Next[k]; //若右k为1,则表明k'不存在,需要将j归1
}
}
/* 开始进行字符匹配 */
j = 1;
uint i = 1;
while(i<=S->Length && j<=T->Length)
{
if (j == 0)
{
j=1;
i++;
}
if(S->Ch[i] == T->Ch[j])
{
i++; j++; // 进位比较后续字符
}
else
{
j = Next[j]; // 直接为索引 j 赋新值,若赋值为0,则表明重头开始
}
}
if(j == T->Length+1) // 若T串完全匹配
{
return i;
}
else
{
return 0;
}
}
注意Next函数的计算部分。将 Next[1] 的值定义为 0 而不是 1,就是为了可以在 T[k'] ≠ T[j] 时,让任何 Next[ Next[k] ](Next[k] = 1)都返回 0,并在下一轮循环中+1后返回给 Next[j+1],使得 Next[j+1] = 1,既命令失配时索引 j 归 1;否则,Next[j+1] 将变为2,索引 j 归 2,这将导致错误。
实际上,判断k不存在的条件还可以与T[k'] ≠ T[j] 条件融合,更简洁的代码如下:
int SubStrIndex_KMP(P_STRING S, P_STRING T)
{
/* 计算T串的Next[]数组 */
// 为 Next 数组动态分配空间
uint j = 1;
uint k = 0;
Next[1] = 0;
while( j < T->Length )
{
if(k==0 || T->Ch[k] == T->Ch[j])
{
++k;
++j;
Next[j] = k;
}
else
{
k = Next[k]; //若右k为1,则表明k'不存在,需要将j归1
}
}
/* 开始进行字符匹配 */
j = 1;
uint i = 1;
while(i<=S->Length && j<=T->Length)
{
if(j==0 || S->Ch[i] == T->Ch[j])
{
i++; j++; // 进位比较后续字符
}
else
{
j = Next[j]; // 直接为索引 j 赋新值,若赋值为0,则表明重头开始
}
}
if(j == T->Length+1) // 若T串完全匹配
{
return i;
}
else
{
return 0;
}
}