学习笔记:KMP

· · 算法·理论

KMP

引入

KMP 算法(全称 Knuth-Morris-Pratt 字符串查找算法,由三位发明者的姓氏命名)是可以在文本串中快速查找模式串的一种算法。

实现

要想知道 KMP 算法是如何减少字符串查找的时间复杂度的,我们不如来看暴力匹配方法是如何浪费时间的。

所谓暴力匹配,就是逐字符逐字符地进行匹配,如果当前字符匹配成功,就匹配下一个字符,如果失配,i回溯,j置为0。代码如下:

// 暴力匹配
int i = 0, j = 0;
while(i < s.length()){
    if(s[i] == p[j])i++,j++;
    else i = i - j + 1,j = 0;
    if(j == p.length()){ // 匹配成功
        // 对s[i - j .. i - 1]进行一些操作
        cout << i - j << endl;
        i = i - j + 1;j = 0;
    }
}

举例来说,假如s="abababcabaa", 我们暴力匹配,过程会是怎样?

从头开始匹配,第一个字符是 a,匹配成功。

第 2~4 个字符也匹配成功,继续。

下一位,匹配失败,回溯。

匹配失败,继续尝试。

下一位,匹配成功。

就这样一直匹配到结尾。

设两个字符串的长度分别为 n 和 m ,则暴力匹配的最坏时间复杂度是 O(nm)。究其原因,在于 i 进行回溯浪费了时间。能不能让 i 不走回头路呢?然而,如果 i 不回溯,同时又把 j 置为 0,很可能会出现缺漏,如下图。

这样配下去会漏掉一个匹配,从而得到错解。

于是为了让 j 被赋为一个合适的值,我们引入了 PMT(Partial Match Table,部分匹配表)。

![img](https://pic3.zhimg.com/80/v2-b5eda0e3764d25f39b0675c3056b5c9e_720w.webp) 这是什么意思呢?简单地说,`pmt[i]`就是,从`p[0]`往后数、同时从`p[i]`往前数相同的位数,在保证前后缀相同的情况下,最多能数多少位。(但要小于`p`的长度) ![img](https://pic4.zhimg.com/80/v2-747f4be1f49f4fdc5e4fc3e80594bb3b_720w.webp) 专业点说,它是**真前缀**与**真后缀**的集合之交集中,最长元素的长度。(这里的“真”字与“真子集”中的“真”字类似) 为什么 PMT 可以用来确定`j`指针的位置呢?让我们先回到暴力匹配算法第一次失配时的情形: ![img](https://pic2.zhimg.com/80/v2-9d1c23a01a1f37510fda43475a8ed969_720w.webp) 这时,`s`中的`'a'`与`p`中的`'c'`没有配上,我们计划保持i指针(上面的指针)不变,而把j指针左移。我们注意到,`"abab"`已经匹配成功了,它拥有一个前缀`"ab"`,以及一个后缀`"ab"`(虚线部分),所以我们可以把这个`"ab"`利用起来,变成下面这样: ![img](https://pic4.zhimg.com/80/v2-91d8b7d6082d758ebac1ab378717270f_720w.webp) 实际上这时我们正是在令`j = pmt[j - 1]`。再举一个例子: ![img](https://pic4.zhimg.com/80/v2-bcb634c9d7b2eeb2d4886bf549f0acdb_720w.webp) 发生失配,我们令`j = pmt[j-1] = 3`(也就是符合条件的**最长前缀**所**紧接**着的下一位): ![img](https://pic1.zhimg.com/80/v2-1fe634b938413fb16e7872bd0fad4174_720w.webp) 仍不匹配,我们继续: ![img](https://pic4.zhimg.com/80/v2-5769d470eda95bc7a8bbf60b31ba3f8b_720w.webp) ![img](https://pic1.zhimg.com/80/v2-5f591a07ff5f071351337006bc842f80_720w.webp) 这次取得了成功。 当然,我们并不总是能成功,有可能j指针一路减到了0,但`s[i]`仍然不等于`p[j]`,这时我们不再移动j指针。 以上这些过程转换为代码是这样的: ```cpp for(int i = 0, j = 0 ; i < s.length() ; i ++){ while(j && s[i] != p[j]) j = pmt[j - 1]; // 不断前移j指针,直到成功匹配或移到头为止 if(s[i] == p[j]) j++; // 当前位匹配成功,j指针右移 if(j == p.length()){ // 对s[i - j + 1 .. i]进行一些操作 j = pmt[j - 1]; } } ``` 很多文章中会使用`next`数组,即把 PMT 整体向右移一位(特别地,令`next[0] = -1`),表示在每一位失配时应跳转到的索引。也就是说,失配时,按照`i -> next[i] -> next[next[i]] -> ...`的顺序跳转。其实原理和实现都是差不多的。 ------ 现在问题来了,PMT 怎么求?如果暴力求的话,时间复杂度是 $O(m^2)$,并不理想。 一种精妙的做法是,在**错开一位**后,让`p`**自己匹配自己**(这相当于是用**前缀**去匹配**后缀**)。我们知道`pmt[0] = 0`,而之后的每一位则可以通过在匹配过程中记录 $j$ 值得到。 还是以刚刚的模式串为例: ![img](https://pic1.zhimg.com/80/v2-7715906edbd181b2def0b8f1883b0d9c_720w.webp) 匹配失败,则`pmt[1] = -1 + 1 = 0`,$i$ 指针后移。 ![img](https://pic1.zhimg.com/80/v2-756dfd984818b1cfca7420b64cab847c_720w.webp) 接下来匹配成功,$j$ 指针右移,可知`pmt[2] = 1`,然后将两个指针都右移。 ![img](https://pic3.zhimg.com/80/v2-d10fec4b1e91dec7e1b189cb295a13ae_720w.webp) 继续匹配成功,$j$ 指针右移,`pmt[3] = 2`。 ![img](https://pic2.zhimg.com/80/v2-c4769fb332833124b50057505dfb5d95_720w.webp) 下一位失配,因为前面的`pmt`已经算出来了,我们可以像匹配文本串时那样地使用它。`pmt[2 - 1]`即`pmt[1] = 0`,所以退回到开头。 ![img](https://pic2.zhimg.com/80/v2-7ae53ad16124c052441568f2df895b81_720w.webp) $j$ 指针已经到了开头,仍未匹配成功,所以不再移动,`pmt[4] = j = 0`。 接下来也按这种方法操作: ![img](https://pic3.zhimg.com/80/v2-2bfca9e441f95030be16b74c0b9fbb2a_720w.webp) 最后一位出现失配,这次我们先令`j = pmt[j - 1] = 1`: ![img](https://pic1.zhimg.com/80/v2-e3a3860d27d3cb45ffb7e884d64d246c_720w.webp) ![img](https://pic2.zhimg.com/80/v2-49a44760e3e8ec5ec9665dfeed67e2e9_720w.webp) 再次匹配,匹配成功,j指针右移一位,`pmt[i] = j = 1`。自此,我们通过一趟**自我匹配**,求出了 PMT,代码如下: ```cpp // pmt[0] = 0; for (int i = 1, j = 0 ; i < plen ; i ++){ while(j != 0 && p[i] != p[j])j = pmt[j - 1]; if(p[i] == p[j])j++; pmt[i] = j; } ``` 现在已经可以解决洛谷模板题了: > # [【模板】KMP 字符串匹配](https://www.luogu.com.cn/problem/P3375) > > ## 题目描述 > > 给出两个字符串 $s_1$ 和 $s_2$,若 $s_1$ 的区间 $[l, r]$ 子串与 $s_2$ 完全相同,则称 $s_2$ 在 $s_1$ 中出现了,其出现位置为 $l$。 > 现在请你求出 $s_2$ 在 $s_1$ 中所有出现的位置。 > > 定义一个字符串 $s$ 的 border 为 $s$ 的一个**非 $s$ 本身**的子串 $t$,满足 $t$ 既是 $s$ 的前缀,又是 $s$ 的后缀。 > 对于 $s_2$,你还需要求出对于其每个前缀 $s'$ 的最长 border $t'$ 的长度。 > > ## 输入格式 > > 第一行为一个字符串,即为 $s_1$。 > 第二行为一个字符串,即为 $s_2$。 > > ## 输出格式 > > 首先输出若干行,每行一个整数,**按从小到大的顺序**输出 $s_2$ 在 $s_1$ 中出现的位置。 > 最后一行输出 $|s_2|$ 个整数,第 $i$ 个整数表示 $s_2$ 的长度为 $i$ 的前缀的最长 border 长度。 > > ## 样例 #1 > > ### 样例输入 #1 > > ``` > ABABABC > ABA > ``` > > ### 样例输出 #1 > > ``` > 1 > 3 > 0 0 1 > ``` > > ## 提示 > > ### 样例 1 解释 > > ![](https://cdn.luogu.com.cn/upload/pic/2257.png)。 > > 对于 $s_2$ 长度为 $3$ 的前缀 `ABA`,字符串 `A` 既是其后缀也是其前缀,且是最长的,因此最长 border 长度为 $1$。 > > > ### 数据规模与约定 > > **本题采用多测试点捆绑测试,共有 3 个子任务**。 > > - Subtask 1(30 points):$|s_1| \leq 15$,$|s_2| \leq 5$。 > - Subtask 2(40 points):$|s_1| \leq 10^4$,$|s_2| \leq 10^2$。 > - Subtask 3(30 points):无特殊约定。 > > 对于全部的测试点,保证 $1 \leq |s_1|,|s_2| \leq 10^6$,$s_1, s_2$ 中均只含大写英文字母。 ```cpp #include <iostream> #include <cstring> #define MAXL 1000005 using namespace std; char a[MAXL], b[MAXL]; int lena, lenb, nxt[MAXL]; int main(){ ios::sync_with_stdio(false); cin >> a + 1 >> b + 1; lena = strlen(a + 1);lenb = strlen(b + 1); int j = 0; for(int i = 2 ; i <= lenb ; i ++){ while(j != 0 && b[j + 1] != b[i])j = nxt[j]; if(b[j + 1] == b[i])j++; nxt[i] = j; } j = 0; for(int i = 1 ; i <= lena ; i ++){ while(j != 0 && b[j + 1] != a[i])j = nxt[j]; if(b[j + 1] == a[i])j++; if(j == lenb)cout << i - lenb + 1 << endl; } for(int i = 1 ; i <= lenb ; i ++){ if(i != 1)cout << " "; cout << nxt[i]; } cout << endl; return 0; } ``` ------ 绝大多数情况下,上面的算法都够用了,所以很多人就管它叫 KMP 算法。但实际上,它只能称作 MP 算法,因为真正的 KMP 算法还有一个 Knuth 提出的常数优化。其实,这个《真**KMP算法》反而一般用不上,但这里也介绍一下。 例如对于`"abababc"`这个模式串,如果我们用它来匹配`"abababd"`,在最后处要跳转3次才能发现匹配失败: ![img](https://pic4.zhimg.com/80/v2-1f91cd1d0572e35ed2daad1d15497d2b_720w.webp) 其实中间这几次跳转毫无意义,我们明知道`d`和`a`是不能匹配的,却做了很多无用功。所以我们可以在计算`pmt`时做一些小改动来避免这种情况。 ![img](https://pic1.zhimg.com/80/v2-36a6f749bb625c4703eb5bfb84137734_720w.webp) 例如上图这里,按道理匹配到这一步我们应该令`pmt[i] = ++j (=2)`。但是我们发现,`p[i + 1]`与`p[j + 1]`是相等的`'a'`。也就是说稍后在匹配时,假如j指针为4时失配(说明`"ababa"`无法匹配),那在j指针为2时肯定也会失配(因为`"aba"`也无法匹配)。所以这时我们不如把路径压缩一下——直接让`pmt[i] = pmt[j] (=pmt[2-1])`而不是`++j (=2)` ,跳过j指针为2的情况。 所以当`p[i] == p[j]`且`p[i + 1] == p[j + 1]`时,我们让`pmt[i] = pmt[j]`,其他情况维持MP的逻辑。所以整理一下逻辑可以得到下面的代码: ```cpp void get_pmt(const string& p){ for(int i = 1, j = 0; i < p.length(); ++i){ while(j != 0 && s[i] != s[j]) j = pmt[j - 1]; bool b = p[i] == p[j], c = p[i + 1] == p[j + 1]; if(b != 0) pmt[i] = pmt[j++]; if(b == 0 || c == 0) pmt[i] = j; } } ``` 其实这样得到的`pmt`数组已经不符合我们定义的 PMT 的性质了,如果较真的话可以换一个名字。 无论是 MP 算法还是 《真**KMP算法》,其总时间复杂度都是 $O(n+m)$,这是因为`++i`和`++j`都只进行了 $n+m$ 次,虽然`j`在过程中有减小,但`j`在任何时刻不可能小于 −1 ,所以`j`减小的次数也不可能超过 $n+m+1$。