「笔记」KMP 算法

写在前面

不是我吹,我是真的刚学会 KMP 啊(

引入

给定字符串 s1,s2(|s2||s1|),求 s2s1 中的所有出现位置。
1|s2||s1|5×106
1S,128MB。

朴素的想法是枚举 s2s1 中的开头位置,暴力枚举判断是否匹配。如果失配,则抛弃当前已匹配的部分,到下一位置再从开头匹配。时间复杂度为 O(|s1||s2|)
而 KMP 算法可以在 O(|s1|+|s2|) 的时空复杂度内解决上述问题,且常数较小。

定义

s[i:j]:字符串 s 的子串 sisj
真前/后缀:字符串 s 的真前缀定义为满足不等于它本身的 s 的前缀。同理就有了真后缀的定义:满足不等于它本身的 s 的后缀。

border:字符串 sborder 定义为,满足既是 s 的真前缀,又是 s 的真后缀的最长的字符串 t
aabaaborderaa

fail:字符串 sfail 是一个长度为 |s| 的整数数组,它又被称为 s失配指针faili 表示前缀 s[1:i]border 的长度,即:

faili=max{j},(j<i)(s[1,j]=s[ij+1,i])

特别的,若不存在这样的 j,则 faili=0。如 aabaafail={0,1,0,1,2}

原理

在朴素算法中,如果在某一位上失配,则会抛弃当前已匹配的部分,跳到下一个位置再从开头进行匹配。
而 KMP 利用了当前已匹配的部分,使得在下一个位置时不必从开头进行匹配,从而对朴素算法进行了加速。
举个例子,如下图所示:

看猫片

失配指针

s 的失配指针 fail 可以通过在 s 上按上述思想匹配自身求得。下述算法中枚举到第 i 位时即可求得 faili
首先显然有 fail1=0。设枚举到第 i 位,考虑已知 fail1faili1 的情况下如何求得 faili
设当前匹配部分为 s[il,i1],即有 s[il,i1]=s[1,l]。则显然有 faili1=l。接下来考察 si=sl+1 是否成立。

若成立,则有 s[il,i]=s[1,l+1],得 faili=l+1
若不成立,一种朴素的想法是减小已匹配长度 l 并暴力检查,直到找到最大的一个 l<l,满足 s[il,i1]=s[1,l]si=sl+1,此时 faili=l+1。考虑利用已匹配部分的 border 加速上述过程。

引理:满足 l<ls[il,i1]=s[1,l]l 的最大的 lfaill

证明:考虑反证法,设存在 j 满足 faill<j<l 是最大的满足条件的 l
根据条件,有 s[ij,i1]=s[1,j],又 j<l,则 s[ij,i1]s[il,i1] 的一段后缀, s[1,j]s[1,l] 的一段前缀。则有 s[1,j]=s[lj,l] 成立。
j>faill,根据 border 的定义,则 faill 应为 j,这与已知矛盾,反证原结论成立。

直观的理解如下所示:

s1 s2faill s3 s4l=faili1  si3 si2 si1 sifailll=faili1 si+1

l=faill 仍不满足 si=sl+1,则一直令 l=faill,直到满足条件或 l=0

模拟上述过程,可以得到下述代码:

复制复制
fail[1] = 0;
for (int i = 2, j = 0; i <= n2; ++ i) { //j 为匹配长度
while (j > 0 && s2[i] != s2[j + 1]) j = fail[j]; //找到满足条件的 border
if (s2[i] == s2[j + 1]) ++ j; //匹配成功
fail[i] = j;
}

匹配

按照上述过程实现即可,代码如下:

for (int i = 1, j = 0; i <= n1; ++ i) { //j 为匹配长度
while (j > 0 && (j == n2 || s1[i] != s2[j + 1])) j = fail[j]; //找到满足条件的 border,注意当整个串匹配成功的特判。
if (s1[i] == s2[j + 1]) ++ j; //第 j 位匹配成功
if (j == n2) printf("%d\n", i - n2 + 1); //整个串匹配成功
}

完整代码

P3375 【模板】KMP 字符串匹配

//知识点:KMP
/*
By:Luckyblock
*/
#include <algorithm>
#include <cctype>
#include <cstdio>
#include <cstring>
#define LL long long
const int kN = 1e6 + 10;
//=============================================================
char s1[kN], s2[kN];
int n1, n2;
int fail[kN];
//=============================================================
inline int read() {
int f = 1, w = 0;
char ch = getchar();
for (; !isdigit(ch); ch = getchar())
if (ch == '-') f = -1;
for (; isdigit(ch); ch = getchar()) w = (w << 3) + (w << 1) + (ch ^ '0');
return f * w;
}
void Chkmax(int &fir, int sec) {
if (sec > fir) fir = sec;
}
void Chkmin(int &fir, int sec) {
if (sec < fir) fir = sec;
}
//=============================================================
int main() {
scanf("%s", s1 + 1);
scanf("%s", s2 + 1);
n1 = strlen(s1 + 1), n2 = strlen(s2 + 1);
fail[1] = 0;
for (int i = 2, j = 0; i <= n2; ++ i) {
while (j > 0 && s2[i] != s2[j + 1]) j = fail[j];
if (s2[i] == s2[j + 1]) ++ j;
fail[i] = j;
}
for (int i = 1, j = 0; i <= n1; ++ i) {
while (j > 0 && (j == n2 || s1[i] != s2[j + 1])) j = fail[j];
if (s1[i] == s2[j + 1]) ++ j;
if (j == n2) printf("%d\n", i - n2 + 1);
}
for (int i = 1; i <= n2; ++ i) printf("%d ", fail[i]);
return 0;
}

复杂度

求失配指针与匹配两部分的代码类似,仅解释其中一部分。

for (int i = 2, j = 0; i <= n2; ++ i) {
while (j > 0 && s2[i] != s2[j + 1]) j = fail[j];
if (s2[i] == s2[j + 1]) ++ j;
fail[i] = j;
}

代码中仅有 while 的执行次数是不明确的。但可以发现,在 whilej 每次至少减少 1,每层循环中 j 每次至多增加 1。
又时刻保证 j0,则 j 的减少量不大于 j 的增加量,即 n2。故 while 最多执行 n2 次,则整个循环的复杂度为 O(n) 级别。

例题

CF126B Password

给定一字符串 s,求一个字符串 t,满足 t 既是 s 的前缀,又是 s 的后缀,同时 t 还在 s 中间出现过(即不作为 s 的前后缀出现)。
1|s|106
2S,256MB。

既是 s 的前缀,又是 s 的后缀的串可以通过枚举 failnfailfailn, 获得。
s 中间出现过的所有 s 的前缀为 s[1:fail2]s[1:failn1],用桶判断这两部分有无重复元素即可。
代码:A submission

P4391 [BOI2009]Radio Transmission 无线传输

给定一字符串 s1,已知它是由某个字符串 s2 不断自我连接形成的,即有:

s1=s2+s2++s2[1,|s1|mod|s2|]

求字符串 s2 的最短长度。
1|s1|106
1S,128MB。

考虑一个更简单的问题,如何判断 s1 的一个前缀 i 是否为 s1 的循环节?
考虑求 s1fail,显然当 i|s1|fail|s1|=|s1|ii 为循环节。
正确性显然,若该条件成立,则保证了 s[1:i]=s[i+1:2i],s[i+1:2i]=s[2i+1:3i], 如下所示:

s1=abababborder=     abab

发现呈现错位相等的关系,对应的,则有 s[1:i]=s[|s1|i+1,|s1|],可得 i 是一个循环节。


由上,可以得到两种做法。

第一种是暴力枚举前缀 i,判断 failn(nmodi) 是否等于 n(nmodi)i,且 failni
第一个条件保证了 is1[1:n(nmodi)] 部分的循环节,第二个条件保证了剩下的部分是 i 的一个前缀。

第二种是直接输出 nfailn。原理如下所示:

s1=abbabbabborder=       abbab

显然可知最后的不完整部分是 nfailn 的一个前缀。又保证了 failn 是最长的既是 s1 的前缀又是 s1 的后缀的字符串,则 nfailn 即为答案。

总复杂度均为 O(|s1|) 级别。

//知识点:KMP
/*
By:Luckyblock
*/
#include <algorithm>
#include <cctype>
#include <cstdio>
#include <cstring>
#define LL long long
const int kN = 1e6 + 10;
//=============================================================
char s[kN];
int n, fail[kN];
//=============================================================
inline int read() {
int f = 1, w = 0;
char ch = getchar();
for (; !isdigit(ch); ch = getchar())
if (ch == '-') f = -1;
for (; isdigit(ch); ch = getchar()) w = (w << 3) + (w << 1) + (ch ^ '0');
return f * w;
}
//=============================================================
int main() {
n = read();
scanf("%s", s + 1);
fail[1] = 0;
for (int i = 2, j = 0; i <= n; ++ i) {
while (j && s[i] != s[j + 1]) j = fail[j];
if (s[i] == s[j + 1]) ++ j;
fail[i] = j;
}
//Sol 2:
printf("%d\n", n - fail[n]);
return 0;
//Sol 1:
for (int i = 1; i <= n; ++ i) {
int lth = n - (n % i);
if (fail[lth] == lth - i && fail[n] >= n % i) {
printf("%d\n", i);
return 0;
}
}
return 0;
}

「NOI2014」动物园

n 组数据,每次给定一字符串 s
定义 numi 表示是 s[1:i] 的前后缀,且长度不大于 i2 的字符串的个数。
求:

i=1n(numi+1)(mod109+7)

1n51|s|106
1S,512MB。

做法是自己 YY 的,效率被爆踩但是能过(
B(i) 表示满足既是前缀 s[1:i] 的真前缀,又是其真后缀的字符串组成的集合。

先不考虑长度不大于 i2 这一限制,对于前缀 s[1:i],显然 numi 的值为 |B(i)|。则显然有 numi=numfaili+1,表示在 numi 的基础上计入 s[1:i]border 的贡献。num 可在 KMP 算法中顺便求得。
再考虑限制,若前缀 s[1:i]border 的长度大于 i2,则需要不断跳 fail,跳到第一个满足长度合法的位置 jB(i),再统计其贡献 numj
暴跳实现可以获得 50pts 的好成绩。

发现跳 fail 过程中对应的字符串长度会缩短(废话),考虑倒序枚举各位置 i,使得 i2 也呈现递减的状态。
考虑暴跳过程,显然是由于某些 fail 的转移被重复统计,导致暴跳效率较低。考虑并查集的思路,将重复的转移进行路径压缩。
posi 表示前缀 s[1:i] 在跳 fail 之后对应的最大的第一个满足长度合法的 B 中的元素,初始值为 posi=i。在暴力跳 fail 时,更新沿途遍历到的 pos 即可。
这个路径压缩的复杂度我并不会证,但是感觉跑的还蛮快的= =

//知识点:KMP
/*
By:Luckyblock
*/
#include <algorithm>
#include <cctype>
#include <cstdio>
#include <cstring>
#define LL long long
const int kN = 1e6 + 10;
const int mod = 1e9 + 7;
//=============================================================
int n, ans, next[kN], num[kN], pos[kN];
char s[kN];
//=============================================================
inline int read() {
int f = 1, w = 0;
char ch = getchar();
for (; !isdigit(ch); ch = getchar())
if (ch == '-') f = -1;
for (; isdigit(ch); ch = getchar()) w = (w << 3) + (w << 1) + (ch ^ '0');
return f * w;
}
void Init() {
ans = 1;
scanf("%s", s + 1);
n = strlen(s + 1);
}
void KMP() {
for (int i = 2, j = 0; i <= n; ++ i) {
while (j > 0 && s[i] != s[j + 1]) j = next[j];
if (s[i] == s[j + 1]) ++ j;
next[i] = j;
if (! j) continue ;
pos[j] = j; //初始化
num[j] = 1ll * (num[next[j]] + 1ll) % mod;
}
}
int Find(int x_, int lth_) {
if (pos[x_] <= lth_ / 2) return pos[x_];
return pos[x_] = Find(next[pos[x_]], lth_); //路径压缩
}
//=============================================================
int main() {
int t = read();
while (t --) {
Init(); KMP();
for (int i = n; i >= 2; -- i) {
pos[next[i]] = Find(next[i], i); //找到贡献位置
if (! pos[next[i]]) continue ; //特判无贡献情况
ans = 1ll * ans * (num[pos[next[i]]] + 1) % mod;
}
printf("%d\n", ans);
}
return 0;
}

写在最后

参考资料:

《算法竞赛进阶指南》-李煜东
OI-wiki

posted @   Luckyblock  阅读(339)  评论(8编辑  收藏  举报
编辑推荐:
· 开发者必知的日志记录最佳实践
· SQL Server 2025 AI相关能力初探
· Linux系列:如何用 C#调用 C方法造成内存泄露
· AI与.NET技术实操系列(二):开始使用ML.NET
· 记一次.NET内存居高不下排查解决与启示
阅读排行:
· Manus重磅发布:全球首款通用AI代理技术深度解析与实战指南
· 被坑几百块钱后,我竟然真的恢复了删除的微信聊天记录!
· 没有Manus邀请码?试试免邀请码的MGX或者开源的OpenManus吧
· 园子的第一款AI主题卫衣上架——"HELLO! HOW CAN I ASSIST YOU TODAY
· 【自荐】一款简洁、开源的在线白板工具 Drawnix
点击右上角即可分享
微信分享提示