KMP算法

KMP算法是一个很精妙的字符串算法,个人认为这个算法十分符合编程美学:十分简洁,而又极难理解。笔者算法学的很烂,所以接触到这个算法的时候也是一头雾水,去网上看各种帖子,发现写着各种KMP算法详解的转载帖子上面基本都会附上一句:“我也看的头晕”——这种诉苦声一片的错觉仿佛人生苦旅中找到知音,让我几乎放弃了这个算法的理解,准备把它直接记在脑海里了事。

但是后来在背了忘忘了背的反复过程中发现一个真理:任何对于算法的直接记忆都是徒劳无功的,基本上忘得比记的要快。后来看到刘未鹏先生的这篇文章:知其所以然(三):为什么算法这么难?才知道不去理解,而硬生生的背诵算法是多么困难的一件事情。因此我尽可能的尝试理解KMP的算法,并用自己的语言描述一下这个优雅算法的思维过程。

1. 明确问题

我们首先要明确,我们要做的事情是什么:给定字符串M和N(M.length >= N.length),请找出N在M中出现的匹配位置。说白了,就是一个简单的字符串匹配。或许你会说这项工作没什么难度啊,其实只要从头开始比较两个字符串对应字符相等与否,不相等就再从M的下一位开始比较就好了么。是的,这就是一个传统的思路,总结起来其思想如下:

  1. m[j] === n[i] 时,i与j同时+1;
  2. m[j] !== n[i] 时,j回溯到j-i+1,i回溯到0,然后回到第一步;
  3. 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]比较,知道其相等为止。我们依然用一张图来说明这个问题:

循环求取next(i)[+]查看原图

假设上图中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算法究竟是个什么东西了。

参考资料:

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值