关于KMP算法的一点思考
引子
KMP算法是解决找出字符串中第一个匹配项的下标问题的算法,首先要明确的一点是,KMP算法核心是求字符匹配出错时的跳转数组next,而next数组又和最长相等前后缀子串息息相关。本文重点放在next数组的求取上,以及对算法合理性的一点深入思考,也是对个人学习过程的记录。
最长相等前后缀子串
这里给出相等前后缀子串的定义,对于字符串s,如果存在j满足0<j<L(L表示字符串s的长度),使得s[0~j-1]==s[L-j~L-1](此处为了方便描述,两端都是闭合区间,下同),则称子串s[0~j-1]是s的相等前后缀子串,在以上定义中,很显然j就是相等前后缀子串的长度。而最长相等前后缀子串,顾名思义就是长度最大的相等前后缀子串,即j为可能值的最大值。
一个例子,比如对字符串aaaa,如下图所示,a、aa、aaa都是它的相等前后缀子串,而aaa即为aaaa的最长相等前后缀子串。

next数组
由于next元素含义的定义不同,求取next代码会存在差异,因此这里统一定义next[i]为KMP匹配过程中当子串下标i处匹配出错时子串指针跳转的位置,个人认为是比较符合常规逻辑的定义。
那么当子串下标i处匹配出错时,子串指针应该如何跳转呢?传统的BF算法(Brute Force,暴力匹配)会将字符串整体右移一位,相当于主串指针不变,子串指针i跳转到i-1。但是这样效率太低了,没有充分利用到已匹配信息,即主串haystack的一部分可以和子串的一部分———needle[0~i-1]完全匹配(此处使用LeetCode上题目的字符串主串及子串变量名,下同)。
结合以上最长相等前后缀子串的概念,显然如果能找到needle[0~i-1]的长度为j的最长相等前后缀子串needle[0~j-1],子串指针就可以从i直接跳转到j(j
<
\lt
<i),相当于字符串前进了i-j位,而不用每次只前进一位。
以子串ababcd的匹配为例,如下图所示,在子串串needle下标5处匹配出错。显然needle的指针从5跳转到4(needle前进一位)、跳转到3(needle前进二位)都是不可能匹配成功的,这一点是由子串needle本身的构成决定,与匹配的主串haystack无关。而如果我们已经知道子串needle已匹配成功部分(abcab)的最长相等前后缀子串为ab(对应下标为0、1),长度为2,那么主串**haystack匹配成功部分也有同样的最长相等前后缀子串**,将当前子串needle指针跳转到2无疑是最佳选择。当然此例中跳转到下标2处仍然匹配出错,需要进一步匹配。

通过以上的例子我们可以知道,子串needle在下标i处匹配出错时,如果i之前的子串needle[0~i-1]存在长度为j的最长相等前后缀子串,那么就由i跳转到j处。那么很显然needle的转移数组满足next[i]==j。
此时问题转化为求下标i对应的子串needle[0~i-1]的最长相等前后缀子串的长度j。不过,在i=0时,如果在此处匹配出错,因为前面已匹配成功部分,无法进行跳转,只能将子串needle后移一位,等价于跳转到“下标”-1处,即next[0]始终为-1。由此可以写出以下代码。
public int[] getNext(String s) {
// next[i]表示 第 i 字符匹配出错时, 跳转的位置
int[] next = new int[s.length()];
char[] needle = s.toCharArray();
next[0] = -1;
int j = -1;
for (int i = 1; i < needle.length; i++) {
// 每次循环 j 初始为 i-1 之前 (needle[0~i-2]) 的最长相等前后缀子串长度
while (j >= 0 && needle[i - 1] != needle[j]) {
j = next[j];
}
next[i] = ++j;
}
return next;
}
以上代码中,每次进入循环i开始时,j都表示next[i-1],即needle[0~i-2]的最长相等前后缀子串长度。此时我们希望在j的基础上延长一位,获得next[i],即needle[0~i-1]的最长相等前后缀子串长度。直觉上我们会比较needle[i-1]==needle[j],如果相等就可以获得next[i]。而如果不等,则需要进行上面匹配时类似的跳转。因为我们已经已知needle[0~i-2]的最长相等前后缀子串为needle[0~j-1],那么我们就要在此范围里寻找更短的相等前后缀子串,即通过j=next[j]跳转,再试探是否可以延长,如此循环,直到j为-1或者找到可以延长的地方。而两种终止情况下j的终值都为needle[0~i-1]的最长相等前后缀子串长度,为下一次循环i+1服务。
以字符串abcababcaae为例,当求next[11]时,寻找next[0~10]的最长相等前后缀子串长度过程如下图所示。

改进next数组
最开始我们将next[i]定义为为匹配过程中当子串needle下标i处匹配出错时子串指针跳转的位置,而上面的求法将这个跳转位置与needle[0~i-1]的最长相等前后缀子串长度画上了等号,但是这真的是最佳的跳转位置吗?
以子串abcabce的匹配过程为例,如下图所示。
当匹配到needle[5]时匹配错误(这里用?表示主串haystack中匹配错误的字符,它可以是任意不为c的字符),原始版本的KMP算法希望needle指针跳转到下标2。但是在判断c==?时,我们已经获得了c
≠
\neq
=?的信息,跳转到下标2处岂不是要又要判断一次c==?,很显然这是一次冗余判断。而这也是由needle自身的结构决定的,与haystack无关,在求next数组时我们就可以得到c
≠
\neq
=?这一信息。

所以我们对KMP算法作出改进,next[i]不再单纯是needle[0~i-1]的最长相等前后缀子串长度,而是在此基础之上要再加上一个约束前提needle[i]!=needle[next[i]]。所以在以上代码的基础上,我们在循环中增加一个判断,代码如下。
这里要注意,进入循环i时,j仍然是needle[0~i-2]的最长相等前后缀子串长度,但未必是next[i-1]。
public int[] getNext(String s) {
// next[i]表示 第 i 字符匹配出错时, 跳转的位置
int[] next = new int[s.length()];
char[] needle = s.toCharArray();
next[0] = -1;
int j = -1;
for (int i = 1; i < needle.length; i++) {
// 每次循环 j 初始为 i-1 之前 (needle[0~i-2]) 的最长相等前后缀子串长度
// i-1 前最长相等前后缀子串长度可能不等于 next[i-1]
// needle[j] 对应 needle[i-1]
while (j >= 0 && needle[i - 1] != needle[j]) {
j = next[j];
}
// 无论 j == -1 还是 j != -1 (needle[i - 1] == needle[j]) 的情况,
// 都需要考虑是否 needle[i] == needle[j + 1]
if (needle[i] == needle[j + 1]) {
next[i] = next[++j];
} else {
next[i] = ++j;
}
}
return next;
}
关于改进算法的思考
以上改进的代码可以确定是完全正确的,但是不知大伙是否注意到如下问题:
进入循环i时,j是needle[0~i-2]的最长相等前后缀子串长度,但**next[1~i-1]不再是对应最长相等前后缀子串长度**。这时如果进入while循环发生了跳转1j = next[j],而恰好此前求next[j]时发生了跳转二(它使得next[j]不再是needle[0:j-1]的最长相等前后缀子串长度)。那么我们还能保证后续求next[i+1]、next[i+2]·····的循环中,刚进入循环时j是needle[0~i-1]、needle[0~i]······的最长相等前后缀子串长度吗?
while (j >= 0 && needle[i - 1] != needle[j]) {
// 跳转一
j = next[j];
}
if (needle[i] == needle[j + 1]) {
// 跳转二
next[i] = next[++j];
} else {
next[i] = ++j;
}
答案是肯定的,如下图所示,除黑色外同色直线表示相等的字符串部分。

在求next[j](假设此前从未发生跳转二,和原版KMP算法等价)过程中,尽管needle[0~j-1]的最长相等前后缀子串长为k,但是因为needle[j]==needle[k],所以跳到更前面needle[m]处,且needle[j]!=needle[m]满足条件。后来,求next[i]时,尝试拓展相等前后缀子串时,由于needle[i-1]!=needle[j],发生了跳转一到达m(m=next[j])处,似乎略过了“最佳选择”k。但是很显然,有needle[j]==needle[k]!=needle[i-1],即便尝试在k处拓展仍然要发生跳转一。
综上,改进版本KMP增设的跳转二处判断不会影响j在进入循环i时,始终为needle[0~i-2]的最长相等前后缀子串长度。
相反,如果只想和原始版本一样,求字符串needle的最长相等前后缀子串长度,或许按照改进版本添加约束判断比较好,这样可以减少冗余判断,不过此时需要用另一个数组来存储每轮循环j的终值。
题外话
KMP算法是比BF时间复杂度更优的寻找子串方法,在很长的一段时间里,我都以为一般编程语言中内置的该功能函数(比如Java的indexOf)底层使用的是类似于KMP这种取巧的方法,但没想到事实是底层真的是用的是暴力法。上Google搜了下,一种解释是在子串不太长的情况下,KMP和BF基本没差,而对于长子串,计算next数组的开销会很大,所以库函数一般还是用BF。
的indexOf)底层使用的是类似于KMP这种取巧的方法,但没想到事实是底层真的是用的是暴力法。上Google搜了下,一种解释是在子串不太长的情况下,KMP和BF基本没差,而对于长子串,计算next数组的开销会很大,所以库函数一般还是用BF。
207

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



