道长的算法笔记:KMP算法及其各种变体
(一)如何优化暴力算法
Waiting...
(二)KMP模板
KMP 算法的精髓在于 数组, 代表 , 数值意义代表 所有前缀与后缀的最长公共部分,我们约定本文提到的前缀与后缀均为不包含原字符串 本身。
这个过程动手计算并不困难,但想理解代码为何如此实现倒并不简单。个人建议,自行动手画图计算 数组,以此体会这个过程,否则永远不可能理解哪怕一行代码。由于 可能会有命名与内置命名冲突的风险,因而我们的代码实现中写为 而非 ,下文我们都将使用 ,同时为使代码实现起来更加简洁,我们所有字符串的索引均从 开始计算。

说完约定之后,接着来看模式串 的最长前缀与后缀公共长度的计算过程,前缀字符最大可取到的范围是在 ,后缀最大可取到的范围是在 ,例如 前缀包括,后缀包括了,值得注意,后缀的字符出场顺序与原字符串 是一样的,都是从左往右。回忆一下 ,其含义是指 ,注意,此处使用方括号与逗号分隔的表示法是指左闭右闭区间,使用方括号与冒号分隔的切片表示法才是左闭右开区间。我们计算 数组的过程其实就是字符串 对其自身的匹配过程,我们错开一位再进行比较即可比对所有前缀与所有后缀之间,最大公共长度部分了。

再看上图的匹配过程,如果失配,指向模式串的指针 往左退,除非已经到达了退无可退的状态,方才跳出。在完成了向左移动退步的操作之后,再对当前文本串 位置与模式串进行对比,指针 从始至终都只向右移动。下列模板来自 ACwing831 ,若条件允许,强烈建议购买 ACwing 算法课程,其它模板题亦有 LeetCode28、洛谷3375等等。
#include<bits/stdc++.h>
using namespace std;
#define MAXN 1000010
char txt[MAXN], pat[MAXN];
int nxt[MAXN];
int main(){
int n, m;
scanf("%d %s", &n, pat + 1);
scanf("%d %s", &m, txt + 1);
// 显然i = 1不符合条件,此时即既没有非平凡前缀又没有非平凡后缀
for(int i = 2, j = 0; i <= n; i++){
while(j && pat[i] != pat[j + 1])
j = nxt[j];
if(pat[i] == pat[j + 1]) j++;
nxt[i] = j;
}
for(int i = 1, j = 0; i <= m; i++){
while(j && txt[i] != pat[j + 1])
j = nxt[j];
if(txt[i] == pat[j + 1]) j++;
if(j == n){
printf("%d ", i - n);
j = nxt[j];
}
}
printf("\n");
return 0;
}
KMP 算法的本质是利用字符串本身蕴含的冗余信息,通俗来说就是利用自身的相同部分来减少比对的次数,很直观的,当出现失配的时候,对于已经比过的、相同的部分,我们显然不需要重新再比对一次。
当一个字符串是由某个循环节,循环若干次构成的时候,此时字符串蕴含的冗余信息几乎是最理想的状态,KMP 认为所有的字符串均是通过某个循环节,进行若干次循环之后,再截取子串获得的,例如字符串 其实就是通过 循环四次之后所得的 文本串 截取 部分得到的。下文“KMP理解加深”章节中,我们会继续讨论这一点。
(三)KMP理解加深
(3.1)重复的子字符串问题
给定一个非空的字符串 ,检查 是否可以通过由其一个子串重复多次构成,假如 包含若干子串 ,不妨记 ,其中 ,那么 ,掐头去尾丢弃两个字符,也就是相当于破坏了头尾部分两个 子串,此番操作之后,剩余 ,由于 ,代入可知 至少会在 出现一次,因而只要对于切片 检查是否包含 即可知道 是否可以通过由其一个子串重复多次构成。
class Solution {
public:
bool repeatedSubstringPattern(string s) {
string cp = "#" + s;
string txt = "#" + s.substr(1) + s; txt.pop_back();
vector<int> nxt(cp.size() + 1, 0);
for(int i = 2, j = 0; i < cp.size(); i++){
while(j && cp[i] != cp[j + 1])
j = nxt[j];
if(cp[i] == cp[j + 1]) j++;
nxt[i] = j;
}
for(int i = 1, j = 0; i < txt.size(); i++){
while(j && txt[i] != cp[j + 1])
j = nxt[j];
if(txt[i] == cp[j + 1]) j++;
if(j == cp.size() - 1){
return true;
}
}
return false;
}
};
这道题是比较简单的面试题,检查字符串是否可由多个重复的子串构成,本题放在此处主要是为了下一题分析字符串子串循环节长度、循环次数做铺垫。
为了便于形式化表达,我们不妨使用记号 代表 ,以此类推,我们可以写出 , 其中 是整数,且不大于 ,这种形式化的表达会在下文帮助我们理解 KMP 算法的本质。
(3.2)串周期
给定一个字符串,其前缀是从第一个字符开始的连续若干个字符,在本例中,我们规定前缀包括字符串本身,例如 共有前缀 ,我们希望对每个前缀判断是否具有循环节,如果存在循环节,其长度是多少,循环次数是多少。
对于某一个字符串,在其众多的 候选项中,如果存在于一个使得 , 那么, 也即第二行图例中的灰色部分,即为 循环元,其循环次数 。
其实,只要 是由若干循环节构成的,那么我们要找的 其实就是 ,因为 代表着 “失配时指针向左移动最少的位置”,如果存在其它 同样满足 ,则其需要移动的位置一定多于 ,也就是说,此时的 并非最小单元长度。

举个例子,比如字符串 ,最小的循环节应为 ,长度,满足,但是 长度,也能构成一个循环节,同样满足 ,但它并非最小循环节。
#include <bits/stdc++.h>
#include <algorithm>
using namespace std;
char str[1000010];
int nxt[1000010];
int n;
int kase;
int main(){
while(scanf("%d", &n) != EOF){
if(n == 0) break;
scanf("%s", str + 1);
for(int i = 2, j =0; i <= n; i++){
while(j && str[i] != str[j + 1])
j = nxt[j];
if(str[i] == str[j + 1]){
j++;
}
nxt[i] = j;
}
printf("Test case #%d\n", ++kase);
for(int i = 2; i <= n; i++){
if(i % (i - nxt[i]) == 0 && nxt[i]){
printf("%d %d\n", i, i/(i - nxt[i]));
}
}
printf("\n");
}
return 0;
}
本题能够延伸得到一些其它结论,详见下列条目,其中 代表的含义与上文相同:
- 如果 能够整除 ,那么具有最小循环节,长度
- 如果,( 且 能够整除 ,那么 具有循环元,长度
- 其余候选项 均满足 ,其中
- 任意一个循环元的长度必然是最小循环元的整数倍
- 如果 无法整除 ,那么任意 均不可能作为 循环元
- 无论 可否整除 , 都等于若干倍 ,也即
对于最后一条可能的会使得感到抽象,我们举个例子。使用的 作为循环节反复拼接四次构成新字符串 ,然后截取其中一个片段 作为我们接下来分析的文本串,我们先算这个片段 数组。

显然文本串 没有循环节,但是 ,所得的数值 竟然就是我们最初用于构造的 循环节的长度,,所得数值恰好是循环节的两倍,符合我们上面所说的规律,这是巧合吗?我们接着往下迭代,由于 已经不在候选项中了,我们不再往下分析。其实,上述的过程其实并非巧合,相反其恰恰道出了 KMP 算法的本质,也即所有字符串均可通过循环节进行若干次循环之后截取子串得到。通常 即可推算得出用于构造 文本串的循环节长度。理解了这点,也就不难做出 AC4188连接字符串 、UVA10298这几道题了。
(3.3)匹配统计
Waiting...
(3.4)处理字符矩阵
Waiting...
(四)KMP算法变体
(4.1)构造Z函数求解LCP
Waiting...
(4.2)自动机模型
Waiting...
支持作者

【推荐】国内首个AI IDE,深度理解中文开发场景,立即下载体验Trae
【推荐】编程新体验,更懂你的AI,立即体验豆包MarsCode编程助手
【推荐】抖音旗下AI助手豆包,你的智能百科全书,全免费不限次数
【推荐】轻量又高性能的 SSH 工具 IShell:AI 加持,快人一步
· 地球OL攻略 —— 某应届生求职总结
· 周边上新:园子的第一款马克杯温暖上架
· Open-Sora 2.0 重磅开源!
· 提示词工程——AI应用必不可少的技术
· .NET周刊【3月第1期 2025-03-02】