KMP算法是一个很精妙的字符串算法,个人认为这个算法十分符合编程美学:十分简洁,而又极难理解。笔者算法学的很烂,所以接触到这个算法的时候也是一头雾水,去网上看各种帖子,发现写着各种KMP算法详解的转载帖子上面基本都会附上一句:“我也看的头晕”——这种诉苦声一片的错觉仿佛人生苦旅中找到知音,让我几乎放弃了这个算法的理解,准备把它直接记在脑海里了事。
但是后来在背了忘忘了背的反复过程中发现一个真理:任何对于算法的直接记忆都是徒劳无功的,基本上忘得比记的要快。后来看到刘未鹏先生的这篇文章:知其所以然(三):为什么算法这么难?才知道不去理解,而硬生生的背诵算法是多么困难的一件事情。因此我尽可能的尝试理解KMP的算法,并用自己的语言描述一下这个优雅算法的思维过程。
1. 明确问题
我们首先要明确,我们要做的事情是什么:给定字符串M和N(M.length >= N.length),请找出N在M中出现的匹配位置。说白了,就是一个简单的字符串匹配。或许你会说这项工作没什么难度啊,其实只要从头开始比较两个字符串对应字符相等与否,不相等就再从M的下一位开始比较就好了么。是的,这就是一个传统的思路,总结起来其思想如下:
- 当
m[j] === n[i]
时,i与j同时+1; - 当
m[j] !== n[i]
时,j回溯到j-i+1,i回溯到0,然后回到第一步; - 当
i === len(n)
时,说明匹配完成,输出一个匹配位置,之后回到第二步,查找下一个匹配点。
我们举个例子来演示一下这个比较的方法,给定字串M - abcdabcdabcde,找出N - abcde这个字符串。传统思路解法如下:
i: 0 1 2 3 4 5 6 7 8 9 0 1 2
M: a b c d a b c d a b c d e
N: a b c d e // 匹配四位成功后发现a、e不匹配
i: 0 1 2 3 4 5 6 7 8 9 0 1 2
M: a b c d a b c d a b c d e
N: a b c d e // 发现 a、b不匹配
i: 0 1 2 3 4 5 6 7 8 9 0 1 2
M: a b c d a b c d a b c d e
N: a b c d e // 发现 a、c不匹配
i: 0 1 2 3 4 5 6 7 8 9 0 1 2
M: a b c d a b c d a b c d e
N: a b c d e // 发现 a、d不匹配
i: 0 1 2 3 4 5 6 7 8 9 0 1 2
M: a b c d a b c d a b c d e
N: a b c d e // 匹配四位成功后发现a、e不匹配
i: 0 1 2 3 4 5 6 7 8 9 0 1 2
M: a b c d a b c d a b c d e
N: a b c d e // 发现 a、b不匹配
i: 0 1 2 3 4 5 6 7 8 9 0 1 2
M: a b c d a b c d a b c d e
N: a b c d e // 发现 a、c不匹配
i: 0 1 2 3 4 5 6 7 8 9 0 1 2
M: a b c d a b c d a b c d e
N: a b c d e // 发现 a、d不匹配
i: 0 1 2 3 4 5 6 7 8 9 0 1 2
M: a b c d a b c d a b c d e
N: a b c d e // 匹配成功
嗯,看起来蛮不错,匹配出了正确的结果。但是我们可以从N的角度上来看待一下这个匹配的过程:N串发现第一次的匹配其实挺完美的,就差一步就可以匹配到位了——结果第4位的a、e不匹配。这种功亏一篑的挫败感深深的影响了字符串N,指向它的指针不得不回到它的头部,开始与M的下一个字符匹配。“b不匹配、c不匹配、d不匹配……”这种感觉简直糟糕透了,直到N又发现一个a,继而又发现了接下来的b、c、d——这让N仿佛找到了第一次的感觉。可当指针走到第四位时,悲剧还是发生了。懊恼的N再次将指针指向自己的头部,开始与M的下一个字符进行匹配。“b不匹配、c不匹配、d不匹配……” N嘟囔着这句仿佛说过一遍的话,直到遇见了下一个a。这次N一点欣喜都没有,尽管匹配获得了成功,但是它总觉得上两次对它的打击实在是太大了。
“有没有什么改进的办法呢?如果一开始就没有产生匹配成功,只能下移一位进行重新匹配,这一点毋庸置疑。但是产生了部分匹配之后再发现不匹配,还需要再从头回溯吗?前两次的匹配我已经很努力的得出了匹配结果,难道因为一位的不匹配便要抛弃一切从头再来吗?”N努力思考着这个问题,然后回顾了一下刚才的匹配过程,“刚才在每一次回溯匹配的过程中,我都经历了b、c、d的不匹配,这是重复的啊!等等,b、c、d这三个字符好像很面熟啊,这……不是我本身吗?噢噢对的,因为之前我已经部分匹配成功了么,所以M中的这些字符肯定就和我本身匹配成功的那一部分是一样的啊,也就是说,如果产生了部分匹配成功,那么再次回溯就会和我本身进行比较;如果产生了多次部分匹配成功的情况,那就要多次与自己本身进行比较。这明显产生了冗余吗!”
能不能解决这个冗余呢?N想了一会儿,然后笃定的得出了一个结论:既然要多次比较自身,那不如先将自身比较一遍,得出比较结果保存起来,下次使用时直接调用就好了啊!
如果有读者跟不上字符串N的思路看的云里雾里,那么我就直接给出一个不难记住的结论好了:减少匹配冗余步数的精髓在于对字符串N进行预处理,通常我们把处理结果保存在一个叫做模式值(如果你看过别的文章,里面可能会有一个奇怪的看不懂的数组,那就是这个模式值数组了,又称作backtracking、Next[n]、T[n]、失效函数等等)的数组中。
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。