考研中,复习KMP算法的时候,觉得KMP虽然写起来很简单,但是理解起来有些难度,属于那种看一遍觉得自己懂了,过一会儿再去想的时候又想不清楚了。所以写篇总结性的文章,以自己的理解解读一下KMP算法,加深理解的同时,也希望读者有所收获。
名称来由
KMP算法在网上有个俗称叫“看毛片”算法,为了给KMP正名,我特地查了一下,KMP之所以叫KMP,是因为这种算法是D.E. Knuth 与 V.R. Pratt 和 J.H. Morris 三人同时发现的,人们便以三人名字的首字母KMP命名了这个算法。😄
KMP算法是为了解决什么问题
KMP算法是一种改进的串模式匹配算法,就是在一个字符串中找到子串的位置。传统的做法很简单,类似于暴力求解。比如 主串是ababc
, 模式串是abc
的传统做法中,是这样匹配的
可以看到,传统做法的方式是模式串与主串一一比较,如果其中一个匹配失败,则从主串上次开始比较的下一个位置在来一遍。如果用i
表示主串的位置,用j
表示模式串的位置,则不难看出在传统做法下,i
和j
都有回溯,所以KMP改进传统算法的关键点就是如何减少i
和j
的回溯。
KMP算法的核心思想
紧接上文,KMP是如何减少i
和j
的回溯的呢?我们一点点来分析
关键状态
这里,设主串为S1S2S3...Sn ,模式串为P1P2P3...Pm ,在上节传统算法的匹配过程中有一个关键状态
table-1
为什么这里是关键状态?因为我们可以看到,传统算法中每次遇到这个状态时,就会开始回溯i
和j
进行下一轮的匹配了,所以改进算法的关键就从这里开始。
减少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处的不匹配问题解决了,此时
i
和j
都加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模式串匹配的核心算法思想,巧妙使用模式串中的重复项来减少i
和j
的回溯,提高算法效率。但是这个算法不是稳定的,因为性能的好坏其实重度依赖于模式串,如果模式串中几乎没有重复项,那算法就会退化为和传统算法差不多的性能
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
有问题,请评论区指正!