KMP 学习笔记

KMP 学习笔记

高中的时候只是迷迷糊糊地理解了一点吧,停留在了背板的层面,后来甚至连板都背不利索了。

从现在的视角来看KMP,又有新的收获。

Intro

去理解一个算法一个比较好的方式其实是建立实际意义。

为什么打游戏的时候发不出藏话?试想一下,如果你在聊天框里面输入一个 \(114514\) 长度的文本,里面的所有脏字依然能够在很短的时间里面被检测出来并屏蔽,这是怎么做到的?

解决

以下都记文本串为 \(S\) ,模式串为 \(T\)

暴力

有一个很 naive 的想法就是,暴力地去枚举文本串的每一个开头,然后尽可能多地与模式串(藏话串)向后匹配,这样的时间复杂度是 \(O(n^2)\) 的。显然不太能够接受。

优化暴力

假设对于某个开头,已经匹配完了一定长度的串,但是在接下来的一个位置里面失配了,这里举个例子,比如 \(S=ABABABC\) ,\(T=ABABC\) 。以第 \(1\) 个位置为开头的时候,会在第 \(5\) 个位置失配,那么暴力来说,就会从第 \(2\) 个位置又从头再来,但这样有意义吗?

在这个例子中,我们实际上已经把 \([1,4]\) 位的匹配都做完了,如果第五位失配了,那你肯定会想,下一个从哪里开始匹配,可以保证不会遗漏答案的同时,又能够skip掉没有意义的环节。

显然对于 \(ABABABC\) 这个串,我们在第五位如果失配了,就会想着把已经匹配成功的 \(ABAB\) 缩短成 \(AB\) ,然后再看看后面能不能把 \(S[5]\) 拼接进来。再举一个例子,对于 \(CBCBC\) 在第 \(6\) 位失配,我们会把它缩短成 \(CBC\) ,尝试把 \(S[6]\) 拼进来。如果说拼不进来,那我们就不断重复上述缩短过程,直到剩下的串为空。

可以观察到,这样其实是截取了 **模式串 **的一个前缀串 \(S'\) 中,其前缀和后缀的最大相同部分,如果我们通过某种方式把 模式串 中所有 **前缀串 ** 的 前后最大相同部分 求出来了,那么这个匹配的过程就可以得到优化。

KMP

记对于一个前缀 \(S'=S[1]+S[2]+..+S[i]\) ,上述我们想要求得的东西是 \(nex[i]\) ,那么文本串和模式串的匹配就可以简化成如下过程:

枚举当前应该加入 \(S\) 中的哪个字符,然后不断地尝试匹配,成功之后输出能够匹配的开始位置。

    int j=0;
    for(int i=1;i<=len1;++i)
    {
        while(j&&t[j+1]!=s[i])j=nex[j];
        if(t[j+1]==s[i])j++;
        if(j==len2)
            cout<<i-j+1<<'\n';
    }

更形式化的,\(nex[i]\) 被定义为 :前缀 \(S'\) 中最长的非空相同前后缀。

不难发现,其实这个 \(nex\) 数组的求解过程,就如同把 \(T\) 与自身进行一个匹配。

如果已经成功求出了 \(nex[i-1]\) ,那么现在加入 \(T[i]\) 这个字符,就是看能不能接上去,如果不能接上去,我们就进行 缩短 操作,利用已经求得的 \(nex\) 数组迭代,直到能够成功匹配或者变为空串为止,这就可以用如下十分相似的代码来表达:

    int j=0;nex[1]=0;
    for(int i=2;i<=len2;++i)
    {
        while(j&&t[j+1]!=t[i])j=nex[j];
        if(t[j+1]==t[i])j++;
        nex[i]=j;
    }

图示

有一张比较抽象的解释 \(nex\) 的图,但是还是能够凑合着理解。
img

正确性 & 复杂度

正确性

为什么这样是不漏的?因为 \(nex\) 本身的定义就包含了一个 最长 ,那么我们失配之后(这时候还没有完成匹配)跳到的位置,一定是距离当前 最近的可能成为答案 的位置。

复杂度

这个复杂度其实也不太好分析,但洛谷第一篇题解写的很有道理:
“每次位置指针 \(i++\) 时,失配指针 \(j\) 最多增加一次,所以 \(j\) 至多增加 \(len\) 次,从而至多减少 \(len\) 次,因此是 \(O(n+m)\) 的。”

ExKMP(Z函数)

挖个坑寒假或者是之后来做这个板块,毕竟除了数据结构和字符串之外的基础内容也得学一学。

25.4.12 终于来填坑了。

定义

Z函数即是:\(z_i\) 表示字符串 \(s\)\(i\) 下标为开头的后缀,和整个串 \(s\) 的最长公共前缀。

求解

暴力 \(O(n^2)\),显然不太可以接受。我们仍思考如何通过自动机的思想来从已经计算过的答案向现在需要计算的答案进行转移。
写在前面,感觉 exkmp 的求解过程实际上和 manacher 的相似程度比较高。
由于开头可能并不同,所以 \(z_i\)\(z_{i-1}\) 并没有继承性,考虑记录一下每个前缀所能扩展的最大长度,在循环过程中将右端点最靠右的一个记下来,如果当前的 \(i\) 落在 \(MaxR\) 之内,那么就可以和 manacher 相似地继承,具体的,从 \(z_{i-l+1}\) 继承,不过要记得不能超出当前的 \(MaxR\)
然后仍然是和 Manacher 一样,我们尝试去扩展当前的 \(i\) 对应的 \(r\)

Code

#include<bits/stdc++.h>
using namespace std;
const int N=2e7+10;
int z[N<<1];

inline void calc_z(string s)
{
    int n=s.length();
    s=" "+s;
    int l=0;
    z[1]=n;
    for(int i=2;i<=n;++i)
    {
        if(l+z[l]-1>=i)z[i]=min(z[i-l+1],l+z[l]-i);
        while(i+z[i]<=n&&s[z[i]+1]==s[i+z[i]])z[i]++;
        if(i+z[i]>l+z[l])l=i;
    }
    // for(int i=1;i<=n;++i)cout<<z[i]<<" "; 
}
int main()
{
    ios::sync_with_stdio(0);
    cin.tie(0),cout.tie(0);
    string s,t;
    cin>>s>>t;
    int n=t.length(),m=s.length();
    calc_z(t+s);
    long long ans1=0,ans2=0;
    for(int i=1;i<=n;++i)ans1^=1ll*(min(n-i+1,z[i])+1)*i;
    for(int i=n+1;i<=n+m;++i)ans2^=1ll*(z[i]+1)*(i-n);
    cout<<ans1<<'\n'<<ans2<<'\n';
    return 0;
}
//aaaaa aaaabaa
posted @ 2024-12-18 11:36  Hanggoash  阅读(25)  评论(0)    收藏  举报
动态线条
动态线条end