【数据结构与算法】KMP算法解读与实现

338 阅读8分钟

考研中,复习KMP算法的时候,觉得KMP虽然写起来很简单,但是理解起来有些难度,属于那种看一遍觉得自己懂了,过一会儿再去想的时候又想不清楚了。所以写篇总结性的文章,以自己的理解解读一下KMP算法,加深理解的同时,也希望读者有所收获。

名称来由

KMP算法在网上有个俗称叫“看毛片”算法,为了给KMP正名,我特地查了一下,KMP之所以叫KMP,是因为这种算法是D.E. Knuth 与 V.R. Pratt 和 J.H. Morris 三人同时发现的,人们便以三人名字的首字母KMP命名了这个算法。😄

KMP算法是为了解决什么问题

KMP算法是一种改进的串模式匹配算法,就是在一个字符串中找到子串的位置。传统的做法很简单,类似于暴力求解。比如 主串是ababc , 模式串是abc的传统做法中,是这样匹配的

可以看到,传统做法的方式是模式串与主串一一比较,如果其中一个匹配失败,则从主串上次开始比较的下一个位置在来一遍。如果用i表示主串的位置,用j表示模式串的位置,则不难看出在传统做法下,ij都有回溯,所以KMP改进传统算法的关键点就是如何减少ij的回溯

KMP算法的核心思想

紧接上文,KMP是如何减少ij的回溯的呢?我们一点点来分析

关键状态

这里,设主串为S1S2S3...Sn ,模式串为P1P2P3...Pm ,在上节传统算法的匹配过程中有一个关键状态

table-1

为什么这里是关键状态?因为我们可以看到,传统算法中每次遇到这个状态时,就会开始回溯ij进行下一轮的匹配了,所以改进算法的关键就从这里开始。

减少i的回溯

传统做法中下一个状态i会回溯到i-j+1,而j会回溯到1。现在我们不要这么做,替代的做法是,不要回溯i,把P1P2...Pm向后移动,来试图消除Si处的不匹配,进而开始S(i+1)及其以后字符的比较,使得整个过程继续推进下去。

我们假设上面这个状态为 Status(k) ,而在P1P2...Pm向后移动过程中某处到达Status(k+1)

table-2

到达Status(k+1)状态时,有两种情况可能发生:

  • 其一,Si=Pt , 则Si处的不匹配问题解决了,此时ij都加1,向后继续比较
  • 其二,Si≠Pt , 则Si处的不匹配问题未解决,此时模式串继续后移,为Si的匹配寻找解决状态

减少j的回溯

我们可以看到,P1P2...P(t-1)与P(j-t+1)..P(j-1)重合,这是很常见的,模式串中难免会有一些重复子串,比如abcabcb中,abc就出现了2次。上面说,通过移动模式串而不变主串的方式避免了i的回溯,那这里的重复项就是有效减少j回溯的关键所在。

在传统做法中,当Si≠Pj时,j就回溯至1,但其实所有中间的比较都是多余的,因为只有在Status(k+1)状态时,P1...P(t-1)才能与主串中字符相对应,而此时只需要比较Si与Pt是否相等,也就是说,j只需要回溯到t,前面t-1项是重复的无需比较【因为P1...P(t-1)与P(j-t+1)...P(j-1)是相等的,而P(j-t+1)...P(j-1)在Status(k)状态已经比较过了】。从这里可以看出,j回溯多少,回溯到哪里,取决于模式串本身的重复项

因为P1...P(j-1)与S(i-j+1)...S(i-1)是相等的,我们大可去掉,只关注于模式串本身的移动

table-3

我们假设P1...P(j-1)为F,P(j-t+1)...P(j-1)为FR , P1...P(t-1)为FL ,则边框内部就是F串中前后相互重合的部分。当Pj处发生不匹配时,我们下一个j的位置就恰好是FL.length+1或者FR.length+1,也即Pt的位置。所以我们只要求出模式串从P1到Pm位置发生不匹配时下一个j的位置,就可以知道下一个状态Status应该从哪里开始比较。

求解Next

在KMP算法中,把模式串中每一个字符的下一个回溯位置用next[j]表示,我们接下来聚焦于如何求解next数组

以上面的table-3为例,此状态下,next[j]已经求得,注意next数组求解的是当前字符匹配失败时的下一个j的位置,所以这里当Pj匹配失败时,next[j]=t 。 那么求解next[j+1]分为两种情况:

  • 1)Pj=Pt时,相当于j=j+1/t=t+1时的情况,所以 next[j+1]=t+1
  • 2)Pj≠Pt时,这里可以把Status(k)对应的串看作主串,把Status(k+1)对应的串看作模式串,这样相当于是回到了由Status(k)找Status(k+1)的过程 , 所以只需要将t赋值为next[t]继续比较Pj与Pt

这里有一个特殊情况需要另外说明,就是next[0]=-1,什么意思呢?就是模式串第一个字符发生不匹配时下一个位置为 -1 。这里你或许奇怪,为啥不是 0 ,而是 -1 ,-1是不存在的位置啊?这是因为如果next[0] = 0 ,则一旦第一个字符真的不匹配就会无限死循环。根据我们上面说的 2)的处理,t=next[t],0位置的下一个回溯位置永远是 0 ,会死循环的。所以这里用 -1 作为一个标识,表示是第一个字符不匹配的情况。

KMP串匹配

Next数组求解出来后,就比较简单了,只要对照着Next数组来“跳”值,就可以在减少j的回溯情况下与主串匹配。

模式串依据Next数组与主串匹配的过程,与Next数组求解的过程非常相似,唯一的区别是:求解Next数组时是模式串模式串匹配的过程,且要记录Next数组 ; 而KMP串匹配时是主串模式串的匹配过程,但需要使用Next数组作为指导。

以上就是KMP模式串匹配的核心算法思想,巧妙使用模式串中的重复项来减少ij的回溯,提高算法效率。但是这个算法不是稳定的,因为性能的好坏其实重度依赖于模式串,如果模式串中几乎没有重复项,那算法就会退化为和传统算法差不多的性能

JS实现一下KMP算法

KMP算法主要是思想比较难理解,很绕,不画例子想一想Si,Pj,Pt之间的关系,很难真正搞懂为什么要这样去设计算法。但是明白原理后,实现起来是真的很简单。这里我用自己擅长的语言Javascript写了一个KMP,仅供参考

function getNext(pattern){
    let i = 0 , j = -1
    let next = [-1] //first pos
    while(i<pattern.length-1){
        if(j==-1 || pattern[i] == pattern[j]){
            ++i
            ++j
            next[i] = j
        }else{
            j = next[j]
        }
    }
    
    return next
}
const kmp = function(main , pattern){
    let next = getNext(pattern) //get Next Array
    let i = 0 , j = 0 
    while(i<main.length && j<pattern.length){
        if(j==-1 || main[i] == pattern[j]){
            ++i
            ++j
        }else{
            j = next[j]
        }
    }
    return i-pattern.length
}
module.exports = kmp

改进的KMP算法

上面我提到如果模式串几乎没有重复项的话,算法会退化。但是模式串中重复项过多的话,也是存在一些问题的。我们举个极端的例子来说明,比如对于模式串aaaaab,它的next数组如下:

我们看,当 b 不匹配时,

-> j回溯到4

-> a≠b , j回溯到3

-> ...

-> j == -1 , i 越界,退出循环,匹配失败

我们可以看到,其实在b不匹配时完全可以直接跳到 j=-1 ,而不用从j=4,j=3...j=-1,因为这些位置上对应的字符都是a,完全只需要比较1次就足够了。这就是KMP算法改进的关键点。

因为next数组是由前至后逐步建立的,就是说求next[j]时,前j-1项next是已知的。所以我们只需没走一步往前看一个就可以了。比如,求next[j]时,

  • Pj = P(next[j]) , 也即是Pj下一个位置对应的字符和自己一样时,则 next[j] = next[next[j]]
  • Pj ≠ P(next[j]) , 则next[j] = next[j] , 和之前KMP算法中的值一样,保持不变

这里改进不难, 就是在构造next数组时做一些小改动,下面是改进后的代码示例:

function getNext(pattern){
    let i = 0 , j = -1
    let next = [-1] //first pos
    while(i<pattern.length-1){
        if(j==-1 || pattern[i] == pattern[j]){
            ++i
            ++j
            if(pattern[i]!=pattern[j])    //改进求Next数组
            next[i] = j                   //
            else                          //
            next[i] = next[j]             //
        }else{
            j = next[j]
        }
    }
    return next
}
const kmp = function(main , pattern){
    let next = getNext(pattern) //get Next Array
    let i = 0 , j = 0 
    while(i<main.length && j<pattern.length){
        if(j==-1 || main[i] == pattern[j]){
            ++i
            ++j
        }else{
            j = next[j]
        }
    }
    return i-pattern.length
}
module.exports = kmp




有问题,请评论区指正!