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]比较,知道其相等为止。我们依然用一张图来说明这个问题:

[+]查看原图

假设上图中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算法解析(转自图灵社区)的更多相关文章

  1. KMP算法解析

    介绍一种高效的KMP算法:代码可以直接运行 #include <iostream> #include <iomanip> using namespace std; void p ...

  2. poj_3461 KMP算法解析

    A - Oulipo Time Limit:1000MS     Memory Limit:65536KB     64bit IO Format:%I64d & %I64u Submit S ...

  3. 字符串匹配的KMP算法详解及C#实现

    字符串匹配是计算机的基本任务之一. 举例来说,有一个字符串"BBC ABCDAB ABCDABCDABDE",我想知道,里面是否包含另一个字符串"ABCDABD" ...

  4. KMP串匹配算法解析与优化

    朴素串匹配算法说明 串匹配算法最常用的情形是从一篇文档中查找指定文本.需要查找的文本叫做模式串,需要从中查找模式串的串暂且叫做查找串吧. 为了更好理解KMP算法,我们先这样看待一下朴素匹配算法吧.朴素 ...

  5. KMP算法深入解析

    本文主要介绍KMP算法原理.KMP算法是一种高效的字符串匹配算法,通过对源串进行一次遍历即可完成对字符串的匹配. 1.基础知识的铺垫 字符串T的前k(0 =< k <=tlen)个连续的字 ...

  6. 经典串匹配算法(KMP)解析

    一.问题重述 现有字符串S1,求S1中与字符串S2完全匹配的部分,例如: S1 = "ababaababc" S2 = "ababc" 那么得到匹配的结果是5( ...

  7. 不能更通俗了!KMP算法实现解析

    我之前对于KMP算法理解的也不是很到位,如果很长时间不写KMP的话,代码就记不清了,今天刷leetcode的时候突然决定干脆把它彻底总结一下,这样即便以后忘记了也好查看.所以就有了这篇文章. 本文在于 ...

  8. 图灵社区 书单推荐:成为Java顶尖程序员 ,看这11本书就够了

    java书单推荐 转自 http://www.ituring.com.cn/article/211418 “学习的最好途径就是看书“,这是我自己学习并且小有了一定的积累之后的第一体会.个人认为看书有两 ...

  9. Java数据结构之字符串模式匹配算法---KMP算法

    本文主要的思路都是参考http://kb.cnblogs.com/page/176818/ 如有冒犯请告知,多谢. 一.KMP算法 KMP算法可以在O(n+m)的时间数量级上完成串的模式匹配操作,其基 ...

随机推荐

  1. 《Spark 官方文档》机器学习库(MLlib)指南

    spark-2.0.2 机器学习库(MLlib)指南 MLlib是Spark的机器学习(ML)库.旨在简化机器学习的工程实践工作,并方便扩展到更大规模.MLlib由一些通用的学习算法和工具组成,包括分 ...

  2. HttpClient 与 HtmlParser 简介 转载

    转载地址:https://www.ibm.com/developerworks/cn/opensource/os-cn-crawler/ 本小结简单的介绍一下 HttpClinet 和 HtmlPar ...

  3. iis发布后,未能找到编译器可执行文件 csc.exe

    iis 未能找到编译器可执行文件 csc.exe在一台新安装完的Windows Server 2003上,打上Framework 3.5,配置好WebService的IIS,结果浏览时出现:未找到编译 ...

  4. 市面上主流服务器简单介绍(apache、IIS、tomcat..)

    apache:apache(阿帕奇)的具体介绍可以参看apache的网站(http://www.apache.org/),或者在网上随便搜搜吧.apache是世界使用排名第一的web服务器软件:它可以 ...

  5. 简单讲解MVC(视图/模型/控制器)

    MVC全名是Model View Controller,是模型(model)-视图(view)-控制器(controller)的缩写,一种软件设计典范,用一种业务逻辑.数据.界面显示分离的方法组织代码 ...

  6. matlab basic operation command

    Matlab basic operation: >> 5+6 ans = 11 >> 3*4 ans = 12 >> 2^6 ans = 64 >> 1 ...

  7. 用:hover伪类代替js的hover事件

    制作二级菜单要实现鼠标移动上去显示子菜单,鼠标移出子菜单隐藏,或者其他类似需求的地方,首先我会想到用jquery的hover事件来实现,如: $(".nav").hover(fun ...

  8. 不再为Apache进程淤积、耗尽内存而困扰((转))

    本篇文章是为使用Apache+MySQL,并为Apache耗尽内存而困扰的系统管理员而写.如果您没有耐心读完本文,请参考以下步骤: 修改/etc/my.cnf,加上这样一行: log-slow-que ...

  9. VR外包 虚拟现实外包 北京软件公司

    我们制作各类型VR全景虚拟现实,增强现实视频制作.录制等项目.品质保证,售后完备,可签合同.contectus: 13911652504(技术经理tommy) 承揽VR外包 虚拟现实外包 U3D外包( ...

  10. Media Player插件

    <object classid="clsid:22D6F312-B0F6-11D0-94AB-0080C74C7E95" id="MediaPlayer1" ...