浅析KMP算法
前言和导引
今天数据结构课学了字符串的相关内容,其中着重介绍了KMP算法。
KMP是一种非常好用的字符串匹配算法。对于给定的长度为 m m m的字符串 s ( S t r i n g ) s(String) s(String),和需要匹配的长度为 n n n的模式串 p ( P a t t e r n ) p(Pattern) p(Pattern),KMP能够实现 O ( m + n ) O(m+n) O(m+n)时间复杂度级别的匹配。相比于传统的 O ( m ∗ n ) O(m*n) O(m∗n)的暴力匹配,是非常大的提升。
高中学竞赛的时候只是接触过KMP,而且第一次看没有什么深刻的理解,更多的只是背一下模板,考试的时候能用上。现在回过头来再次思考KMP算法的实现过程,会发现这个算法思路是非常简单清晰的的。同样,算法的代码也很短。
个人感觉,KMP算法的产生是很自然的。当我们发现暴力匹配会损失掉一些字符串信息的时候,当我们发现暴力匹配做了很多次不必要的检测的时候,KMP算法的思路就出来了:那些已经匹配成功的字符能不能派上用场呢?
算法剖析
要理解KMP算法的重点,我们先回顾一下暴力匹配的过程。假设在字符串 s s s的位置 i i i,字符串 p p p的位置 j j j,发生了字符失配(匹配不成功),那么 i i i会跳转到原来 p [ 1 ] p[1] p[1]对应的位置, j j j则会变成初始位置 0 0 0,以便继续下一次的匹配。注意到,字符串 p p p仅仅滑动了一格。
例如,我们有:
s
:
a
b
a
a
b
a
a
b
c
a
b
a
a
b
c
s:abaabaabcabaabc
s:abaabaabcabaabc
p
:
a
b
a
a
b
c
p:abaabc
p:abaabc
第一次匹配直到 p p p的最后一位 p [ 5 ] = = c p[5]==c p[5]==c才会停止。这之后, p p p向后滑动了一格,变成:
a
b
a
a
b
a
a
b
c
a
b
a
a
b
c
abaabaabcabaabc
abaabaabcabaabc
a
b
a
a
b
c
\ \ abaabc
abaabc
但是,如果细心一些,你会发现, p p p完全可以再往后多移动一些,成为:
a
b
a
a
b
a
a
b
c
a
b
a
a
b
c
abaabaabcabaabc
abaabaabcabaabc
a
b
a
a
b
c
\quad \ \ abaabc
abaabc
这样做移动,不仅 p p p滑动的距离长了,而且我们不用再从 p p p的开头开始匹配。
以上是我们用肉眼观察到的现象。对此,我们不禁要问:为什么可以这么做?要怎样才能把这种机制程序化表达出来?
让我们回到失配的字符, p [ 5 ] = = c p[5]==c p[5]==c上。稍加观察就会发现, p [ 5 ] p[5] p[5]前面的两个字符和 p p p开头的两个字符按从左到右的顺序看起来一样,都是 a b ab ab。于是,我们便把 p p p直接拉过来,让 p [ 2 ] p[2] p[2]移动到原来 p [ 5 ] p[5] p[5]的位置。这样, p [ 0 ] , p [ 1 ] p[0],p[1] p[0],p[1]就和原来的 p [ 3 ] , p [ 4 ] p[3],p[4] p[3],p[4]对齐了,而且我们的匹配也从 p [ 2 ] p[2] p[2]开始,而不是从 p [ 0 ] p[0] p[0]重新匹配。
到这里,我们已经发现了KMP的小秘密:利用一定长度的前缀和后缀相同的特点,拉长模式串移动的距离,并且减少冗余匹配的次数。具体来说,模式串 p p p移动的距离变成了 j − l j-l j−l,并且会从 p [ l ] p[l] p[l]开始匹配。其中 l l l是失配字符前面的字符串中,前缀和后缀能相等的最长长度。例如,对于 p [ 5 ] p[5] p[5]来说, l = = 2 l==2 l==2。
你可能还会问:中间略过去的那些字符怎么办?难道没有可能匹配成功的吗?要回答这个问题,让我们回头再看:
p : a b a a b c p:abaabc p:abaabc
p [ 5 ] p[5] p[5]前面的字符串 a b a a b abaab abaab,其前缀和后缀最长的能相等的长度就是 2 2 2。对于长度为 3 3 3和 4 4 4的前缀后缀,它们是不相等的。如果你让模式串移动比 j − l j-l j−l更少的距离,也就是说,考虑了那些本该被略过的字符,那么你会发现,他们一定是不能匹配成功的。因为比 2 2 2更长的前缀和后缀注定无法相等,而此时的后缀与字符串 s s s中的对应字符串相等,从而前缀就不可能与 s s s中的对应字符串相等了。
所以,我们要想加快字符串的匹配,重点就是找出 p p p的每个字符 p [ i ] p[i] p[i]前面的字符串 p [ 0 t o i − 1 ] p[0\ to\ i-1] p[0 to i−1]中,前缀和后缀能相等的最长长度。在KMP中,这些数据用一个叫做 n e x t next next的数组存储。 n e x t [ i ] next[i] next[i]就表示位置 i i i前面的字符串 p [ 0 t o i − 1 ] p[0\ to\ i-1] p[0 to i−1]中,前缀和后缀能相等的最长长度。
具体实现——next数组
要求 n e x t next next数组,我们假设位置 i i i前面的 n e x t next next都已经求出,然后考虑 n e x t [ i ] next[i] next[i]怎么求。
因为 n e x t [ i − 1 ] next[i-1] next[i−1]已经求好,我们记 k = n e x t [ i − 1 ] k=next[i-1] k=next[i−1]。这就意味着, p [ 0 ] p[0] p[0]到 p [ k − 1 ] p[k-1] p[k−1]与 p [ i − k − 1 ] p[i-k-1] p[i−k−1]到 p [ i − 2 ] p[i-2] p[i−2]对应相等。如果双方后面再接上一个相等的字符,也就是 p [ k ] = = p [ i − 1 ] p[k]==p[i-1] p[k]==p[i−1],那么 n e x t [ i ] next[i] next[i]的值就可以直接知道了,等于 n e x t [ i − 1 ] + 1 next[i-1]+1 next[i−1]+1。
上面是简单的情况。如果 p [ k ] ! = p [ i − 1 ] p[k] \ !=\ p[i-1] p[k] != p[i−1]怎么办呢?这时候,你可能会想到:能不能把相等的前缀和后缀缩短一些,好让他们后面的一个字符能相等?如果能,缩短多少好呢?
这里我们有一个操作: k = n e x t [ k ] k=next[k] k=next[k]。想一想,为什么这么做?
别忘了,我们的 k k k也是有 n e x t next next值的。并且,回顾我们的过程: k k k前面的字符串和 i − 1 i-1 i−1前面的字符串是相同的。如果你调取 n e x t [ k ] next[k] next[k],你就获得了一段前缀,它既等于 k k k前面的一顿后缀,又等于 i − 1 i-1 i−1前面的一段后缀。巧妙之处就在于此:相等的对称性。我们有相等的大前缀和大后缀,而从大前缀中取出的相等的小前缀和小后缀,可以平移到大后缀上面去。
如此,我们只要递归到 p [ k ] = = p [ i − 1 ] p[k]==p[i-1] p[k]==p[i−1],或者不再有相等的前后缀为止。
总结一下,就是:模式串 p p p移动的距离变成了 j − n e x t [ j ] j-next[j] j−next[j],并且会从 p [ n e x t [ j ] ] p[next[j]] p[next[j]]开始匹配。
既然大体上我们已经明白 n e x t next next数组怎么求,我就贴一波代码:
void Get_Next(string p){
int len = p.size() ;
int j=0, k=-1 ; //j,k分别代表当前比较的两个字符的位置
Next[0] = -1 ;
while(j < len-1 ){
if(k == -1 || p[k] == p[j]){
j++ ; k++ ;
if(p[k] != p[j]){
Next[j] = k ;
}
else {
Next[j] = Next[k] ;
//本来是要递归直到p[k] != p[j]的,但是发现Next[j]之前已经求过了
//也就意味着p[j] == p[k] != p[Next[k]]
//也就是k = Next[k], Next[j] = k
//简化成Next[j] = Next[k]
//但是其实不能写成第一种
//第二种写法没有改变k,因为下一组还要用p[j] == p[k]
//如果改变了k,那么Next[j+1]就有可能不对了.p[j+1] 不一定等于 p[k+1]
}
}
else {
k = Next[k] ;
}
}
}
这里, n e x t next next数组的求法和上文所说有一些不同。
- 在 n e x t next next数组中设为 − 1 -1 −1的位置,说明不仅 p [ j ] p[j] p[j]前面没有相等的前后缀,并且就连 p [ 0 ] p[0] p[0]也和要比较的 s [ i ] s[i] s[i]不相等。那么我们只好在 j = − 1 j=-1 j=−1时把 i i i向后移动,同时令 j + 1 j+1 j+1,这样从下一位重新开始匹配。
- 按照上文的思路,代码应该是这样的:
if(k == -1 || p[k] == p[j]){
j++ ; k++ ;
Next[j] = k ;
但是这样会有问题。不是思路上的错误,而是会造成时间浪费。
思考:匹配时遇到了
p
[
j
]
!
=
s
[
i
]
p[j]!=s[i]
p[j]!=s[i]。如果
p
[
j
]
=
=
p
[
n
e
x
t
[
j
]
]
p[j]==p[next[j]]
p[j]==p[next[j]],那么
p
p
p移动过来之后,匹配照样会失败。必须换一个字符,匹配才有成功的可能。也就是说,我们在选定
n
e
x
t
next
next的时候,不能让
p
[
j
]
=
=
p
[
n
e
x
t
[
j
]
]
p[j]==p[next[j]]
p[j]==p[next[j]]。在这里,又要使用我们的递归方法了,直到找到
p
[
k
]
!
=
p
[
j
]
p[k]!=p[j]
p[k]!=p[j]的k为止,然后设定
n
e
x
t
[
j
]
=
n
e
x
t
[
k
]
next[j]=next[k]
next[j]=next[k]。具体在写代码的时候,并没有写成递归,原因在上面的注释中可以看到。
具体实现——模式串匹配
有了 n e x t next next数组,匹配就变得简单快捷了。
void KMP_Match(string s, string p){
int i=0, j=0 ;
int len_s = s.size(), len_p = p.size() ;
while(i < len_s ){
if(j == -1 || s[i] == p[j]){
i++ ; j++ ;
}
else {
j = Next[j] ;
}
if(j == len_p ){
j = 0 ;
i -= len_p-1 ;
//出现了匹配成功的例子
//j回到开头0,i则跳转到这次匹配开始的后一个位置
printf("%d\n", i-1 ) ;
}
}
}
这就是全部的匹配代码,比暴力匹配也长不了多少,但是在时间上却实现了巨大的突破。