KMP算法 (Knute-Morris-Pratt Algorithm) 是一种字符串匹配算法,由D.E.Knuth,J.H.Morris和V.R.Pratt提出,是一种可以在文本串 $s$ 中快速查找模式串 $p$ 的一种算法
暴力查找
要想知道KMP算法是如何减少字符串查找的复杂度,让我们先看基础的暴力方法是如何大量进行重复操作的。所谓暴力查找,就是逐个字符的进行匹配(比较 $s[i]$ 和 $p[j]$),如果当前字符匹配成功,就继续判断下一个字符
($ ++i,++j$);如果匹配失败,就将 $i$ 回溯,$j$ 改为 0($ i = i - j + 1,j = 0$)。代码如下:
// 暴力匹配
int i = 0, j = 0;
while(i < s.length())
{
if(s[i] == p[i])
++i, ++j;
else
i = i - j + 1, j = 0;
if(j == p.length())
{
cout << i - j << endl;
i = i - j + 1;
j = 0;
}
}
假如我们有字符串 s = "acacaba",模式串 p = "acaba",进行暴力匹配的过程就是如下:

从头开始匹配,第一位都是 $a$,匹配成功

第 2~3 个字符也匹配成功,继续下一步

下一位,匹配失败,回溯($ i = 3 - 3 + 1 = 1,j = 0$)

匹配失败,继续尝试

下一位,匹配成功

直到结尾都匹配成功
设两个字符串的长度分别为 $n$,$m$。则暴力方法的时间复杂度最坏为 $O(nm)$,因为 $i$ 的回溯花去了太多时间,但如果不进行回溯,又将 $i$ 置为 0 ,很可能会缺漏
为了能让 $j$ 每次被赋予一个合适的值,我们引入 PMT(Partical Match Table,部分匹配表)
部分匹配表 PMT
$j$ 应该被赋予的值,只和模式串自身有关。每个模式串都对应一张 PMT,比如,"acacaba" 对应的 PMT 如下:
0
1
2
3
4
5
6
p
a
c
a
c
a
b
a
PMT
0
0
1
2
3
0
1
简单来说,PMT$[i]$ 就是从 $p[0]$ 开始往后数、同时从 $p[i]$ 往前数的相同的位数,也就是 $p$ 中真前缀和真后缀的交集最长能有多少位(必然少于 $p$ 的长度)

为什么 PMT 能用来确定 $j$ 指针的位置呢?让我们回到上面暴力算法第一次失去匹配时候的情形:

这时,虽然 'c' 和 'b' 没有匹配上,但我们可以保持 $i$ 指针不变,而将 $j$ 指针左移。因为我们注意到,"aca" 已经匹配成功了,它拥有一个前缀 "a",以及一个后缀 "a" ,所以我们可以将画线部分利用起来,变成下面这样:

实际上我们这时候正是令 j = PMT[j - 1],我们再看下面的这个例子:

发生失配,我们令 j = PMT[j - 1] (=3)

这次仍然不匹配,我们继续进行操作:


这次成功匹配,当然,我们并不是总是可以成功进行匹配,有可能 $j$ 指针一路减到 0 的时候,$s[i]$ 仍然不等于 $p[j]$,这时候我们不再移动 $j$ 指针
用代码实现以上过程:
for(int i = 0,j = 0; i < s.length(); ++i)
{
while(j && s[i] != p[j]) // 当不匹配时,将 j 指针的位置改为 pmt[j - 1]
j = pmt[j - 1];
if(s[i] == p[i]) // 匹配时自增
++j;
if(j == p.length()) // 如果走到了模式串 p 的最后一位,证明匹配成功
{
// some operations
j = pmt[j - 1];
}
}
在许多文章中也会使用到 next 数组,即将 PMT 数组整体向右移一位(特别的,令 next[0] = -1),表示在那一位失配时应跳转到的索引。也就是按照 i -> next[i] -> next[next[i]] -> ... 的顺序跳转,原理和实现其实相差不多
计算PMT
所以,我们还要解决最后的一个问题,如何求出 PMT ?如果用暴力方法直接进行求解,时间复杂度是 $O(m^{2})$
一种简单且优雅的做法是,让模式串 $p$ 在 错开一位 后,自己和自己进行匹配(也就是用前缀去匹配后缀)
由于我们易得 pmt[0] = 0,而之后的每一位都将在匹配过程中记录 $j$ 得到
我们以模式串 ”ababcabaa” 为例

匹配失败,所以 pmt[1] = -1 + 1 = 0,i 指针后移

接下来匹配成功,j 指针右移,可知 pmt[2] = 1,然后将两个指针都右移

依然匹配成功,$j$ 指针右移,pmt[3] = 2

下一位失配,因为前面我们已经算出来 pmt[2 - 1] 的值,所以我们也可以像匹配字符串的时候一样使用pmt[2 - 1] = pmt[1] = 0,所以退回开头的位置

$j$ 指针已经到了开头,仍未匹配成功,所以不再移动,pmt[4] = j = 0
接下来也按这种方法操作:

在最后一位的时候失配,这次我们先令 j = pmt[j - 1] = 1 :


再次进行匹配,匹配成功,pmt[i] = j = 1
自此,我们通过一趟自我匹配,求出了 PMT,代码如下:
// pmt[0] = 0;
for(int i = 1, j = 0; i < p.length(); ++i)
{
while(j && p[i] != p[j])
j = pmt[i];
if(p[i] == p[j])
j++;
pmt[i] = j;
}
Knuth常数优化
以上的算法被称为 MP 算法,在一般的题目中都足以使用,而 KMP 算法还有一个由 Knuth 提出的常数优化,不过一般不太用得上,在这也介绍一下
我们可以看出,中间的几次跳转毫无意义,我们明知道 d 和 a 是不能匹配的,但还是做了三次无用的操作。我们可以在计算 pmt 的时候做一些小改动来避免这样的情况

在上图这种情况下,我们匹配到这一步的时候应该令 pmt[i] = ++j = 2,但是可以发现,p[i + 1] 和 p[j + 1] 是同样的字符 'a' 。也就是说,在稍后进行匹配的时候,如果指针 $j = 4$ 的时候失配( "ababa" 无法匹配),那么在指针 $j = 2$ 的时候也会失配( "aba" 也无法匹配,因为跳转过去后,还是使用 ‘a’ 做匹配)。
所以我们可以直接将路径压缩,让 pmt[i] = pmt[j] (pmt[2 - 1]) ,而不是 ++j,从而直接跳过指针 $j = 2$ 的情况
不过这样求出的数组已经不符合 PMT 的性质,所以我在实现中使用 nextval 代替
不论是 MP 算法还是 KMP 算法,其时间复杂度都为 $O(n + m)$,这是因为在这两个算法中,无论是 ++i 还是 ++j 操作,最多都只进行了 $n + m$ 次,虽然 $j$ 在该过程中有所减小,但 $j$ 在任何时刻都不可能减到 $-1$,所以减小的次数也不可能超过 $n + m + 1$
代码实现
const int N = 1e6 + 10;
string s, p;
int pmt[N];
int nextval[N];
void GetPMT()
{
for(int i = 1, j = 0; i < p.length(); ++i)
{
while(j && p[i] != p[j])
j = pmt[j - 1];
if(p[i] == p[j])
++j;
pmt[i] = j;
// pmt[i] = p[i] == p[j] ? ++j : j;
}
}
void GetNextval()
{
for(int i = 1, j = 0; i < p.length(); ++i)
{
while(j && p[i] != p[j])
j = nextval[j - 1];
if(p[i] == p[j])
if(p[i + 1] == p[j + 1])
nextval[i] = nextval[j++];
else
nextval[i] = ++j;
else
nextval[i] = j;
// nextval[i] = p[i] == p[j] ? (p[i + 1] == p[j + 1] ? nextval[j++] : ++j) : j;
}
}
void KMP()
{
for(int i = 0, j = 0; i < s.length(); ++i)
{
while(j && s[i] != p[j])
j = nextval[j - 1];
if(s[i] == p[j])
++j;
if(j == p.length())
{
cout << i - j + 2 << endl;
j = nextval[j - 1];
}
}
}
int main()
{
cin >> s >> p;
GetNextval();
KMP();
return 0;
}



