KMP 回炉重造

前言

模拟赛的时候 T2 正解要用 KMP,然而本咕在学长讲 KMP 时没有认真听…… (STO @int_R 学长)

回炉重造 复习了。

引入

我们经常会遇上诸如这一类题:

现在给定一个字符串 \(S\),判断字符串 \(P\) 是否是字符串 \(S\) 的一个子串。我们可以称 \(P\) 为模板串,\(S\) 为文本串。

好的先打超级大暴力!

暴力做法

直接遍历每一个起点,检查是否能匹配模板串。

Code:

#include<bits/stdc++.h>
using namespace std;
string s,p;

int main(){
    cin>>s>>p;
    for(int i=0; i+p.size()-1<s.size(); i++){
        if(s.substr(i,p.size())==p) exit(puts("Yes")*0);
    }
    else cout<<"No";
    return 0;
}

由于 string 的内置 substr 函数也是 \(\mathcal{O}(n)\) 的复杂度,因此这种做法就是稳定的 \(\mathcal{O}(nm)\) 的,其中,\(n\) 表示文本串长度,\(m\) 表示模板串长度。

显然只要字符串长度略大一点就会直接炸掉呢……

然后我们还有一种看起来不那么暴力的暴力:

#include<bits/stdc++.h>
using namespace std;

string s,p;

int main(){
    cin>>s>>p;
    int n=s.size(), m=p.size();
    int i=0,j=0;
    while(i<n && j<m){
        if(s[i]==p[j]) i++,j++;//匹配上了,同时后移
        else{
            i-=j-1;
            j=0;//没匹配上,之前的都要重新算
        }
    } 
    cout<<i-j<<endl;
    return 0;
}

实际上没有什么区别,复杂度都是 \(\mathcal{O}(nm)\) 的。

然后就有人发明了 KMP 算法!

正文

首先还是这个问题,我们能优化显然是因为暴力有能优化的地方。 废话

所以到底是哪里出了问题才导致这个暴力如此 TLE 呢?

我们考虑极端情况:

S="010101010100";
P="0100";

那么按照上面的程序,我们就会让模板串每次右移一个字符找匹配,然后发现只有到了最后一个字符才会出现不匹配,这样的话前面的所有匹配就要全部扔掉……

但是实际上前面这些已经匹配上的 0 实际上还是有用的,我们不应该直接扔掉啊,

要是能有什么办法把那些还能用的已匹配字符存下来就好了……

思路

很好你已经找到 KMP 的大致方向了!我们就是要找一个指针 \(next_j\) 使得在模板串上某一位置 \(j\) 匹配失败时,应该回退到哪一个合法匹配位置,使得能够充分利用之前已经匹配上的值。

怎么理解呢?我们考虑上面那个例子:

要不你看文字?

咕咕咕,十分生动形象的例子呢!

等会我要说啥来着

然后我们该怎么算 \(next_j\) 捏……

关于 \(next\)

在算 \(next\) 指针之前,先来认真想一想他的本质是什么。什么情况下我们会用到 \(next\) 指针?以及它让我们干了什么?

事实上,由于之前的文本串的某一段已经完美匹配模板串 \([0,j)\) 了,那么也就是说 这一段和模板串一模一样,也就可以当做模板串。

要把前面已经匹配上的区间再次利用,那么这一段就必须 和之前匹配上的 后面的一部分匹配。以上图为例,我们在第四位 \(0\) 失配后,应该跳转到 \(next_j=1\) 表示由第四位失配后直接可以沿用 \(\left[0,1 \right)\),再在第一位的后面继续接。

换句话说,现在已经匹配到第 \(j\) 位且第一次失配,那么显然前 \([0,j-1)\) 是已经完美匹配的了。但是第 \(j\) 位失配了,所以现在要把模板串右移一些,让之前的 \([0,next_j)\) 重新匹配上 \([j-next_j,j)\)

于是,问题就变成了:

在模板串的一段区间 \([0,j)\) 内,找到一段区间的 真前缀 \([0,next_j)\),使得它与这段区间的 真后缀 \([j-next_j,j)\) 完全相同,而且尽可能长。

也许不是很好理解?我们还是以图为例:

Image

好懂多了吧

然后这个 \(next\) 要肿么求嘞……

是啊怎么求啊

\(nexy\) 指针

首先我们钦定我们已经知道前 \([0,k)\) 位的 \(next\) 指针了,现在要求 \(next_j\)

那么一共两种情况:

  1. \(P_k=P_{next_k+1}\),也即这一位能够直接接在上一位后面且仍然满足要求。显然直接接上就行。
  2. \(P_k\ne P_{next_k+1}\),不能直接接上,这时候怎么办?

对于第二种情况,考虑现在已有了 \(k-1\) 位的最长前后同缀,那么现在我们希望 这个 前后同缀一段后缀 和 他的一段前缀仍然完全相同,试试在这个 前后同缀 的 前后同缀 后面接上第 \(k\) 位。

哦啊,套娃好难懂,怎么理解呢?依旧上图:

?hyw?

啊啊啊画图好累

总结

很好,看到这我钦定你已经会求 \(next\) 了(?那么现在你可以自己 发明 写出 KMP 了!

来回顾一下:

  1. 第一步,求出模板串所有位置的 \(next\) 指针;

\[next_k=\begin{cases} next_{k-1}+1 &(P_k=P_{next_k+1})\\ next_{next_{k-1}} &(P_k\ne P_{next_k+1}) \end{cases}\]

  1. 第二步,依次遍历文本串每一位寻找匹配。
  2. 第三步,若不匹配,无需回退文本串,将模板串右移使得 \(next_j\) 与现在的 \(j\) 对其即可。
  3. 依次遍历求解。

完结~

时间复杂度

分析一下时间复杂度:

  1. 我们会从左至右遍历模板串,\(\mathcal{O}(m)\) 预处理出每一个 \(next\) 指针;
  2. 然后从右向左遍历一遍文本串,无需回退和重载,直接算,\(\mathcal{O}(n)\)

总复杂度 \(\mathcal{O}(n+m)\),十分优秀!

Code

#include<bits/stdc++.h>
using namespace std;
const int N=1e5+7;
string s,p;
int nxt[N];
void build(){
    int len=s.size();
    int i=0,j=-1;
    nxt[0]=-1;//小细节:处理归零匹配
    while(i<len){
        if(j<0 || p[j]==p[j]){
            i++,j++;//匹配上了
            nxt[i]=j;
        }
        else j=nxt[j];//沿着 next 找
    }
}
int main(){
    cin>>s>>p;
    build();
    int i=0, j=0;
    int len=s.size(), lenp=p.size();
    while(i<len && j<lenp){
        if(j==-1 || s[i]==p[j]) i++,j++;
        else j=nxt[j];
    }//比较
    if(j<lenp) cout<<"NO";
    else cout<<"YES";
    return 0;
}

引用

参考资料:

推荐习题:

画图真的很累的求求你给一个推荐叭……


\[\color{green}{The\;End} \]

posted @ 2026-08-26 10:02  听凭风引adj  阅读(6)  评论(0)    收藏  举报