KMP模式匹配
Knuth-Morris-Pratt三位学者发现的.
2. 模式值数组与最长首尾匹配
可能有读者因上一节的匹配太缭乱而直接跳到这里,那笔者再重复一遍已经得到的结论:我们需要对字符串N进行预处理,得到一个叫做模式值数组的东西。那么我们怎样处理字符串N呢?
这个东西如果我们能思考出来,那我们就可以在KMP算法后面多写一个字母了(KMP算法是以其发现者Knuth, Morris, Pratt三人的名字首字母命名的)。我们首先感谢这三位大拿不辞辛劳的研究,然后直接给出这个处理的方法:寻找最长首尾匹配位置。
这是什么意思呢?首尾匹配位置就是说,给定一个字符串N(长度为n,即N由N[0]...N[n]组成),找出是否存在这样的i,使得N[0]=N[n-i],N1=N[n-i-1],……,N[i]=N[n],不存在返回-1。如下图所示:
图中绿色的部分完全相等,满足首尾匹配。且不会找出一点k,k>i且满足N[0]=N[n-k],N1=N[n-k-1],……,N[k]=N[n]。我们假设确定最长首尾匹配的位置的函数为next,即 next(N[n])=i
当在匹配的过程中,发现N的j+1位不匹配时,回溯到第 next(N[j])+1
位来进行查找是最优的,换言之,next(N[j])+1
位是最早可能产生匹配的位置,之前的位都不可能产生匹配。证明如下:
- 证明匹配:我们设 next(N[j]) = e,则满足N[0...e] = N[j-e...j]。当N[j+1] != M[y+1]时,可知已经完成匹配:M[y-j...y] = N[0...j],则M[y-e...y] = N[j-e...j]。由此可以推知N[0...e] = M[y-e...y],即将N后移至首尾相等位置,仍然可以满足匹配,接下来只需要查看N[e+1]与M[y+1]是否相等即可。
- 证明最优:依然用反证法,假设存在f,f>e,满足N[0...f] = M[y-f...y],即其匹配位置出现在更早的位置,则由于M[y-j...y] = N[0...j],则M[y-f...y] = N[j-f...j],则满足N[j-f...j] = N[0...f],则e就不是最长的首尾匹配点,与原假设不符。因此e点时最早可能产生匹配的位置。如图所示:
经过以上重重繁琐证明,我们终于得出了这样的结论——当部分匹配成功N[0...j],发现不匹配N[j+1]要进行回溯时,回溯到next(N[j])是最优的。而next()就是求取字符串N[0...j]中最长首尾匹配位置的函数。如果你把这一系列的值求取出来,保存到一个数组里,如next[j] = next(N[j]),那么这个数组就是所谓的模式值数组。
3. 模式值数组的求取
我知道又有读者会直接跳到这一段——没关系,我们复述一下我们前两节得到的结论:一切的问题都归结于如何进行最长首尾匹配。我们把问题简化如下:对于给定的字符串N,如何返回其最长首尾匹配位置?如abca,返回0,表示第0位与最后一位匹配;abcab,返回1,表示N[0,1]=N[n-1,n];abc,返回-1,表示没有首尾匹配,等等。
简单的想一下这个问题,发现用递归求取是一个不错的办法。首先我们假设N[j]已经求出了next(next(N[0...j]) = i),那么对于N[j+1]的next应该怎么求呢?
三种情况:
-
N[j+1] == N[i+1]
:这个情况十分的乐观,我们可以直接说next(N[0...j+1]) = i+1。至于证明则依然用反证法,可以很容易的得出这个结论。 -
N[j+1] != N[i+1]
:这个情况就比较复杂,我们就需要循环查找i的next,即i = next(N[0...i]),之后再用N[j+1]与N[i+1]比较,知道其相等为止。我们依然用一张图来说明这个问题:
假设上图中k = next(i),那么我们说如果N[k+1] == N[j+1],那么k+1就是最长的首尾匹配位置,即next(N[j+1]) = k+1。你很快会发现这个证明模式与刚才的证明模式非常相同:首先我们证明其匹配,对于N[0...k]来说,其与N[i-k...i]匹配,同时由于N[0...i]与N[j-i...j]匹配,则N[i-k...i]与N[j-k...j]匹配,则N[0...k]与N[j-k...j]匹配。则如果N[k+1] == N[j+1],我们就可以说k+1是一个首尾匹配位置。如果要证明其实最长,那么可以依然用反证法,得出这个结论。
- 最后,如果未能发现相等,返回-1。证明新的字符串N[0...j+1]无法产生首尾匹配。
我们用js代码实现以下这个算法,这里我们规定如果字符串只有一位,如a,其返回值也是-1,作为递归的终止条件。代码如下所示:
function next(N, j) {
if (j == 0) return -1 // 递归终止条件
var i = next(N, j-1) // 获取上一位next
if (N[i+1] == N[j]) return i+1 // 情况1
else {
while (N[i+1] != N[j] && i >= 0) i = next(N, i)
if (N[i+1] == N[j]) return i+1 // 情况2
else return -1 // 情况3
}
}
我们来看一下这段代码有没有可以精简之处,情况1实际上与情况2是重复的,我们在while循环里已经做了这样的判断,所以我们可以将这个if-else分支剪掉合并成一个,如下所示:
function next(N, j) {
if (j == 0) return -1 // 递归终止条件
var i = next(N, j-1) // 获取上一位next
while (N[i+1] != N[j] && i >= 0) i = next(N, i)
if (N[i+1] == N[j]) return i+1 // 情况1、2
else return -1 // 情况3
}
好的,我们已经有了求取next数组的函数,接下来我们就可以进行next[i] = next(i)的赋值操啦~等一下,既然我们本来的目的就是要保存一个next数组,而在递归期间也会重复用到前面保存的内容(next(N, i))那我们为什么还要用递归啊,直接从头保存不就好了么!
于是我们直接修改递归函数如下,开辟一个数组保存递归的结果:
function getnext(N) {
var next = [-1]
, n = N.length
, j = 1 // 从第二位开始保存
, i
for (; j < n; j++) {
i = next[j-1]
while (N[i+1] != N[j] && i >= 0) i = next[i]
if (N[i+1] == N[j]) next[j] = i+1 // 情况1、2
else next[j] = -1 // 情况3
}
return next
}
我们再来看一下这个程序的 i = next[j-1]
的这个赋值。其实在每次循环结束后,i的值都有两种可能:
- 情况1、2:则i = next[j]-1,当j++时,i == next[j-1]-1
- 情况3:情况3是因为i < 0而跳出while循环,所以i的值为-1,而next[j]=-1,也就是说j++时,i ==next[j-1]
所以我们可以把循环改成这样:
var i = -1
for (; j < n; j++) {
while (N[i+1] != N[j] && i >= 0) i = next[i]
if (N[i+1] == N[j]) i++ // 情况1、2
next[j] = i // 情况3
}
大功告成!这样我们就得出了可以求取模式值数组next的函数,那么在具体的匹配过程中怎样进行呢?
4. KMP匹配
经过上面的努力我们求取了next数组——next[i]保存的是N[0...i]的最长首尾匹配位置。在进行字符串匹配的时候,我们在N[j+1]位不匹配时,只需要回溯到N[next[j]+1]位进行匹配即可。这里的证明我们已经在第二节中给出,所以这里直接按照证明写出程序:
function kmp(M, N) {
var next = getnext(N)
, match = []
, m = M.length
, n = N.length
, j = 0
, i = -1
for (; j < m; j++) {
while (N[i+1] != M[j] && i >= 0) i = next[i] // 2. 否则回溯到next点继续匹配
if (N[i+1] == M[j]) i++ // 1. 如果相等继续匹配
if (i == n-1) {match.push(j-i); i = next[i]} // 如果发现匹配完成输出成功匹配位置
// 否则返回i=-1,继续从头匹配
}
return match
}
这里的kmp程序是缩减过的,其逻辑与 getnext()
函数相同,因为都是在进行字符串匹配,只不过一个是匹配自身,一个是两个对比而已。我们来分析一下这段代码的时间复杂度,其中有一个for循环和一个while循环,对于整个循环中的while来说,其每次回溯最多回溯i步(因为当i < 0时停止回溯),而i在整个循环中的递增量最多为m(当匹配相等时递增)故while循环最多执行m次;按照平摊分析的说法,摊还到每一个for循环中时间复杂度为O(1),总共的时间复杂度即为O(m)。同理可知,getnext()
函数的时间复杂度为O(n),所以整个KMP算法的时间复杂度即为O(m+n)。
笔者认为写完这篇文章以后,笔者再也不会忘记KMP算法究竟是个什么东西了。
参考资料:
- KMP算法详解:据称是最容易理解的一篇文章;
- Matrix67: KMP算法详解:笔者认为是代码最简洁的一片文章;
- 从头到尾理解KMP算法:认为是图表最多比较清晰的一篇文章;
- Knuth–Morris–Pratt algorithm:KMP英文wiki。
-----------------------------------------------------------------------------------------------------------------
文章作者:姜南(Slyar) 文章来源:Slyar Home (www.slyar.com) 转载请注明,谢谢合作。
KMP 算法我们有写好的函数帮我们计算 Next 数组的值和 Nextval 数组的值,但是如果是考试,那就只能自己来手算这两个数组了,这里分享一下我的计算方法吧。
计算前缀 Next[i] 的值:
我们令 next[0] = -1 。从 next[1] 开始,每求一个字符的 next 值,就看它前面是否有一个最长的"字符串"和从第一个字符开始的"字符串"相等(需要注意的是,这2个"字符串"不能是同一个"字符串")。如果一个都没有,这个字符的 next 值就是0;如果有,就看它有多长,这个字符的 next 值就是它的长度。
计算修正后的 Nextval[i] 值:
我们令 nextval[0] = -1。从 nextval[1] 开始,如果某位(字符)与它 next 值指向的位(字符)相同,则该位的 nextval 值就是指向位的 nextval 值(nextval[i] = nextval[ next[i] ]);如果不同,则该位的 nextval 值就是它自己的 next 值(nextvalue[i] = next[i])。
举个例子:
计算前缀 Next[i] 的值:
next[0] = -1;定值。
next[1] = 0;s[1]前面没有重复子串。
next[2] = 0;s[2]前面没有重复子串。
next[3] = 0;s[3]前面没有重复子串。
next[4] = 1;s[4]前面有重复子串s[0] = 'a'和s[3] = 'a'。
next[5] = 2;s[5]前面有重复子串s[01] = 'ab'和s[34] = 'ab'。
next[6] = 3;s[6]前面有重复子串s[012] = 'abc'和s[345] = 'abc'。
next[7] = 4;s[7]前面有重复子串s[0123] = 'abca'和s[3456] = 'abca'。
计算修正后的 Nextval[i] 值:
nextval[0] = -1;定值。
nextval[1] = 0;s[1] != s[0],nextval[1] = next[1] = 0。
nextval[2] = 0;s[2] != s[0],nextval[2] = next[2] = 0。
nextval[3] = -1;s[3] == s[0],nextval[3] = nextval[0] = -1。
nextval[4] = 0;s[4] == s[1],nextval[4] = nextval[1] = 0。
nextval[5] = 0;s[5] == s[2],nextval[5] = nextval[2] = 0。
nextval[6] = -1;s[6] == s[3],nextval[6] = nextval[3] = -1。
nextval[7] = 4;s[7] != s[4],nextval[7] = next[7] = 4。