学习笔记: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", 我们暴力匹配,过程会是怎样?

img

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

img

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

img

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

img

匹配失败,继续尝试。

img

下一位,匹配成功。

img

就这样一直匹配到结尾。

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

img

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

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


j 应该被赋值为多少,是只与模式串自身有关的。每个模式串,都对应着一张 PMT,比如"ababcabaa"对应的PMT如下:

img

这是什么意思呢?简单地说,pmt[i]就是,从p[0]往后数、同时从p[i]往前数相同的位数,在保证前后缀相同的情况下,最多能数多少位。(但要小于p的长度)

img

专业点说,它是真前缀真后缀的集合之交集中,最长元素的长度。(这里的“真”字与“真子集”中的“真”字类似)

为什么 PMT 可以用来确定j指针的位置呢?让我们先回到暴力匹配算法第一次失配时的情形:

img

这时,s中的'a'p中的'c'没有配上,我们计划保持i指针(上面的指针)不变,而把j指针左移。我们注意到,"abab"已经匹配成功了,它拥有一个前缀"ab",以及一个后缀"ab"(虚线部分),所以我们可以把这个"ab"利用起来,变成下面这样:

img

实际上这时我们正是在令j = pmt[j - 1]。再举一个例子:

img

发生失配,我们令j = pmt[j-1] = 3(也就是符合条件的最长前缀紧接着的下一位):

img

仍不匹配,我们继续:

img

img

这次取得了成功。 当然,我们并不总是能成功,有可能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指针,直到成功匹配或移到头为止
    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(m2),并不理想。

一种精妙的做法是,在错开一位后,让p自己匹配自己(这相当于是用前缀去匹配后缀)。我们知道pmt[0] = 0,而之后的每一位则可以通过在匹配过程中记录 j 值得到。

还是以刚刚的模式串为例:

img

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

img

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

img

继续匹配成功,j 指针右移,pmt[3] = 2

img

下一位失配,因为前面的pmt已经算出来了,我们可以像匹配文本串时那样地使用它。pmt[2 - 1]pmt[1] = 0,所以退回到开头。

img

j 指针已经到了开头,仍未匹配成功,所以不再移动,pmt[4] = j = 0

接下来也按这种方法操作:

img

最后一位出现失配,这次我们先令j = pmt[j - 1] = 1

img

img

再次匹配,匹配成功,j指针右移一位,pmt[i] = j = 1。自此,我们通过一趟自我匹配,求出了 PMT,代码如下:

// 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 字符串匹配

题目描述

给出两个字符串 s1s2,若 s1 的区间 [l,r] 子串与 s2 完全相同,则称 s2s1 中出现了,其出现位置为 l
现在请你求出 s2s1 中所有出现的位置。

定义一个字符串 s 的 border 为 s 的一个s 本身的子串 t,满足 t 既是 s 的前缀,又是 s 的后缀。
对于 s2,你还需要求出对于其每个前缀 s 的最长 border t 的长度。

输入格式

第一行为一个字符串,即为 s1
第二行为一个字符串,即为 s2

输出格式

首先输出若干行,每行一个整数,按从小到大的顺序输出 s2s1 中出现的位置。
最后一行输出 |s2| 个整数,第 i 个整数表示 s2 的长度为 i 的前缀的最长 border 长度。

样例 #1

样例输入 #1

ABABABC
ABA

样例输出 #1

1
3
0 0 1

提示

样例 1 解释

对于 s2 长度为 3 的前缀 ABA,字符串 A 既是其后缀也是其前缀,且是最长的,因此最长 border 长度为 1

数据规模与约定

本题采用多测试点捆绑测试,共有 3 个子任务

  • Subtask 1(30 points):|s1|15|s2|5
  • Subtask 2(40 points):|s1|104|s2|102
  • Subtask 3(30 points):无特殊约定。

对于全部的测试点,保证 1|s1|,|s2|106s1,s2 中均只含大写英文字母。

#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

其实中间这几次跳转毫无意义,我们明知道da是不能匹配的,却做了很多无用功。所以我们可以在计算pmt时做一些小改动来避免这种情况。

img

例如上图这里,按道理匹配到这一步我们应该令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的逻辑。所以整理一下逻辑可以得到下面的代码:

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

posted @   tsqtsqtsq  阅读(9)  评论(0编辑  收藏  举报  
相关博文:
阅读排行:
· 地球OL攻略 —— 某应届生求职总结
· 周边上新:园子的第一款马克杯温暖上架
· Open-Sora 2.0 重磅开源!
· 提示词工程——AI应用必不可少的技术
· .NET周刊【3月第1期 2025-03-02】
点击右上角即可分享
微信分享提示