您的位置:首页 > 其它

KMP算法—转

2015-08-03 09:10 239 查看
字符串匹配是计算机的基本任务之一。

举例来说,有一个字符串"BBC ABCDAB ABCDABCDABDE",我想知道,里面是否包含另一个字符串"ABCDABD"?



许多算法可以完成这个任务,Knuth-Morris-Pratt算法(简称KMP)是最常用的之一。它以三个发明者命名,起头的那个K就是著名科学家Donald Knuth。



这种算法不太容易理解,网上有很多解释,但读起来都很费劲。直到读到Jake
Boxer的文章,我才真正理解这种算法。下面,我用自己的语言,试图写一篇比较好懂的KMP算法解释。

1.



首先,字符串"BBC ABCDAB ABCDABCDABDE"的第一个字符与搜索词"ABCDABD"的第一个字符,进行比较。因为B与A不匹配,所以搜索词后移一位。

2.



因为B与A不匹配,搜索词再往后移。

3.



就这样,直到字符串有一个字符,与搜索词的第一个字符相同为止。

4.



接着比较字符串和搜索词的下一个字符,还是相同。

5.



直到字符串有一个字符,与搜索词对应的字符不相同为止。

6.



这时,最自然的反应是,将搜索词整个后移一位,再从头逐个比较。这样做虽然可行,但是效率很差,因为你要把"搜索位置"移到已经比较过的位置,重比一遍。

7.



一个基本事实是,当空格与D不匹配时,你其实知道前面六个字符是"ABCDAB"。KMP算法的想法是,设法利用这个已知信息,不要把"搜索位置"移回已经比较过的位置,继续把它向后移,这样就提高了效率。

8.



怎么做到这一点呢?可以针对搜索词,算出一张《部分匹配表》(Partial Match Table)。这张表是如何产生的,后面再介绍,这里只要会用就可以了。

9.



已知空格与D不匹配时,前面六个字符"ABCDAB"是匹配的。查表可知,最后一个匹配字符B对应的"部分匹配值"为2,因此按照下面的公式算出向后移动的位数:

  移动位数 = 已匹配的字符数 - 对应的部分匹配值

因为 6 - 2 等于4,所以将搜索词向后移动4位。

10.



因为空格与C不匹配,搜索词还要继续往后移。这时,已匹配的字符数为2("AB"),对应的"部分匹配值"为0。所以,移动位数 = 2 - 0,结果为 2,于是将搜索词向后移2位。

11.



因为空格与A不匹配,继续后移一位。

12.



逐位比较,直到发现C与D不匹配。于是,移动位数 = 6 - 2,继续将搜索词向后移动4位。

13.



逐位比较,直到搜索词的最后一位,发现完全匹配,于是搜索完成。如果还要继续搜索(即找出全部匹配),移动位数 = 7 - 0,再将搜索词向后移动7位,这里就不再重复了。

14.



下面介绍《部分匹配表》是如何产生的。

首先,要了解两个概念:"前缀"和"后缀"。 "前缀"指除了最后一个字符以外,一个字符串的全部头部组合;"后缀"指除了第一个字符以外,一个字符串的全部尾部组合。

15.



"部分匹配值"就是"前缀"和"后缀"的最长的共有元素的长度。以"ABCDABD"为例,

  - "A"的前缀和后缀都为空集,共有元素的长度为0;

  - "AB"的前缀为[A],后缀为,共有元素的长度为0;

  - "ABC"的前缀为[A, AB],后缀为[BC, C],共有元素的长度0;

  - "ABCD"的前缀为[A, AB, ABC],后缀为[BCD, CD, D],共有元素的长度为0;

  - "ABCDA"的前缀为[A, AB, ABC, ABCD],后缀为[BCDA, CDA, DA, A],共有元素为"A",长度为1;

  - "ABCDAB"的前缀为[A, AB, ABC, ABCD, ABCDA],后缀为[BCDAB, CDAB, DAB, AB, B],共有元素为"AB",长度为2;

  - "ABCDABD"的前缀为[A, AB, ABC, ABCD, ABCDA, ABCDAB],后缀为[BCDABD, CDABD, DABD, ABD, BD, D],共有元素的长度为0。

16.



"部分匹配"的实质是,有时候,字符串头部和尾部会有重复。比如,"ABCDAB"之中有两个"AB",那么它的"部分匹配值"就是2("AB"的长度)。搜索词移动的时候,第一个"AB"向后移动4位(字符串长度-部分匹配值),就可以来到第二个"AB"的位置。

KMP算法是一个很精妙的字符串算法,个人认为这个算法十分符合编程美学:十分简洁,而又极难理解。


[b]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
、T
、失效函数等等)的数组中。


2. 模式值数组与最长首尾匹配

可能有读者因上一节的匹配太缭乱而直接跳到这里,那笔者再重复一遍已经得到的结论:我们需要对字符串N进行预处理,得到一个叫做模式值数组的东西。那么我们怎样处理字符串N呢?

这个东西如果我们能思考出来,那我们就可以在KMP算法后面多写一个字母了(KMP算法是以其发现者Knuth, Morris, Pratt三人的名字首字母命名的)。我们首先感谢这三位大拿不辞辛劳的研究,然后直接给出这个处理的方法:寻找最长首尾匹配位置

这是什么意思呢?首尾匹配位置就是说,给定一个字符串N(长度为n,即N由N[0]...N
组成),找出是否存在这样的i,使得N[0]=N[n-i],N1=N[n-i-1],……,N[i]=N
,不存在返回-1。如下图所示:



图中绿色的部分完全相等,满足首尾匹配。且不会找出一点k,k>i且满足N[0]=N[n-k],N1=N[n-k-1],……,N[k]=N
。我们假设确定最长首尾匹配的位置的函数为next,即 
next(N
)=i
 当在匹配的过程中,发现N的j+1位不匹配时,回溯到第 
next(N[j])+1
 位来进行查找是最优的,换言之,
next(N[j])+1
 位是最早可能产生匹配的位置,之前的位都不可能产生匹配。证明如下:
证明匹配:我们设 next(N[j]) = e,则满足N[0...e] = N[j-e...e]。当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)。
内容来自用户分享和网络整理,不保证内容的准确性,如有侵权内容,可联系管理员处理 点击这里给我发消息
标签: