KMP算法与Z函数
2025-10-29 upt: 更新了带注释板码。
2025-10-31 upt: 更新了 Z 函数部分讲解,并从 1-based 调整为 0-based。
前置知识
我们先来了解一下前缀函数这一定义,详细的解释一下。
给定一个长度为 \(n\) 的字符串 \(s\),其前缀函数被定义为一个长度为 \(n\) 的数组 \(\pi\)。
其中 \(\pi[i]\) 就是字符串 \(s\) 的字串 \(s[0...i]\) 最长的相等的真前缀与真后缀的长度。
以上部分借鉴OI wiki。
真前缀和真后缀是什么?我们类比数学中真子集的定义,就是不同于 \(s\) 本身,也不为空串的 \(s\) 的前后缀。(好像也有点不同,毕竟真子集包括空集)
前缀和后缀是什么???这不知道的话可以不用学习kmp了
我们不妨来举个例子:
对于一个字符串 wiwshwish:你问我为什么要选择一个这么神奇的字符串?
规定下标编号:肯定是有些特殊意义喽
| w | i | w | s | h | w | i | s | h |
|---|---|---|---|---|---|---|---|---|
| 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 |
\(\pi[0] = 0\)
真前缀和真后缀都为空,没有相等的。
\(\pi[1] = 0\)
真前缀:w
真后缀:i
没有相等的。
\(\pi[2] = 1\)
真前缀:w, wi
真后缀:w, iw
最长相等的是 w,长度为 \(1\)。
\(\pi[3] = 0\)
真前缀:w, wi, wiw
真后缀:s, ws, iws
没有相等的。
\(\pi[4] = 0\)
真前缀:w, wi, wiw, wiws
真后缀:h, sh, wsh, iwsh
没有相等的。
\(\pi[5] = 1\)
真前缀:w, wi, wiw, wiws, wiwsh
真后缀:w, hw, shw, wshw, iwshw
最长相等的是 w,长度为 \(1\)。
\(\pi[6] = 2\)
真前缀:w, wi, wiw, wiws, wiwsh, wiwshw
真后缀:i, wi, hwi, shwi, wshwi, iwshwi
最长相等的是 wi,长度为 \(2\)。
\(\pi[7] = 0\)
真前缀:w, wi, wiw, wiws, wiwsh, wiwshw, wiwshwi
真后缀:s, is, wis, hwis, shwis, wshwis, iwshwis
没有相等的。
\(\pi[8] = 0\)
真前缀:w, wi, wiw, wiws, wiwsh, wiwshw, wiwshwi, wiwshwis
真后缀:h, sh, ish, wish, hwish, shwish, wshwish, iwshwish
没有相等的。
所以最终\(\pi\)数组表示为:\(\{0, 0, 1, 0, 0, 1, 2, 0, 0\}\)。
那我们如何实现呢?这里不讲述原理了,直接给出 \(O(n)\) 时间复杂度代码(OI wiki):
vector<int> prefix_function(string s) {
int n = (int)s.length();
vector<int> pi(n);
for (int i = 1; i < n; i++) {
int j = pi[i - 1];
while (j > 0 && s[i] != s[j]) j = pi[j - 1];
if (s[i] == s[j]) j++;
pi[i] = j;
}
return pi;
}
KMP 算法
Knuth–Morris–Pratt 算法用于解决在字符串中查找子串的问题,该任务是前缀函数的一个典型应用。
实现流程
在 KMP 算法中,对于 \(s\) 中每个位置 \(i\),我们要找到最大的 \(j\) 满足 \(s[i-j+1]...s[i]\) 和 \(p[1]...p[j]\) 是相同的。
在寻找的时候:
我们每次把 \(j\) 向右移动一个个位置(也就是遍历 \(s[i]\))
- 如果 \(j ≠ m\) 并且 \(s[i + 1]\) 和 \(p[j + 1]\) 是一样的,\(j\) 也向右移动一位就好啦!
- 否则我们也并不需要暴力进行回退:
我们只要让 \(j\) 向前回到满足串 \(s[i - k + 1] … s[i]\) 和 \(p[1] … p[k]\) 完全相等,且 \(k\) 的值最大的位置,然后继续判断:如果 \(s[i + 1]\) 和 \(p[j + 1]\) 仍然不相同的话,我们不停向前回退,直到 \(s[i + 1]\) 和 \(p[j + 1]\) 相同或者 \(j = 0\) 为止!
问题来了,我们如何快速求出 \(k\) 呢?你猜我为什么要讲前置知识(没看的快回去看啊!!!)
在 KMP 匹配过程中,当我们发现:\(s[i + 1] ≠ p[j + 1]\)
我们不想从头开始匹配,而是希望尽可能少地回退 \(j\),同时保证 \(p[1..k]\) 仍然是 \(s\) 当前已匹配部分的后缀。
所以我们找的是:最大的 \(k < j\),使得 \(p[1..k] = s[i-k+1..i]\) 也就是 \(p[1..k] = p[j-k+1..j]\)。
那前缀函数的定义呢?\(π[j] = max(k)\),使得 \(p[1..k] = p[j-k+1..j]\)。
我们发现???一模一样哦,那这 KMP 算法你不就已经学会了?
代码实现
#include<bits/stdc++.h>
using namespace std;
#define endl '\n'
#define ll long long
#define dbug(x) (void)(cerr << #x << " = " << x << endl)
string s, p; // s : 文本串 p : 模式串
ll n, m;
ll border[(ll)1e6+86], f[(ll)1e6+86];
inline void kmp() {
border[1] = 0; // 初始化
// 循环从2 开始,处理前缀数组
for (ll i = 2, j = 0; i <= m; i++) {
while (j > 0 && p[j + 1] != p[i]) j = border[j]; // 如果不匹配就不断回退
if (p[j + 1] == p[i]) j++;
border[i] = j;
}
// 进行 KMP
for (ll i = 1, j = 0 ; i <= n; i++) {
// 与之前不同,这次如果j == m(即匹配完全)时也应该回退
while (j == m || (j > 0 && p[j + 1] != s[i])) j = border[j];
if (p[j + 1] == s[i]) j++;
f[i] = j;
if (f[i] == m) { // 注意此时的i是匹配结尾的位置
// 输出开头位置
cout << i - m + 1 << endl;
}
}
for (ll i = 1; i <= m; i++) {
cout << border[i] << " ";
}
}
int main() {
cin >> s >> p;
// 0-based -> 1-based
n = s.size();
m = p.size();
s = '.' + s;
p = '.' + p;
kmp();
return ~~ (0 ^ 0);
}
其他技巧及注意事项
字符串的周期
一个字符串是一个有限序列。特别地,它也可以是空序列(即长度为 \(0\) 的序列)。
如果字符串 \(A\) 是通过字符串 \(B\) 和 \(C\) 按顺序连接(中间没有任何间隔符号)得到的,我们表示为 \(A=BC\)。
如果存在一个字符串 \(B\) 使得 \(A=PB\),那么字符串 \(P\) 是字符串 \(A\) 的前缀。此外,如果 \(P \neq A\) 且 \(P\) 不是空字符串,我们称 \(P\) 是 \(A\) 的真前缀。
如果 \(Q\) 是 \(A\) 的真前缀,并且 \(A\) 是字符串 \(QQ\) 的前缀(不一定是真前缀),那么字符串 \(Q\) 是 \(A\) 的周期。例如,字符串 abab 和 ababab 都是 abababa 的周期。
字符串 \(A\) 的最小周期是其最短的周期,如果 \(A\) 没有周期,则为空字符串。例如,ababab 的最小周期是 ab;abc 的最小周期是空字符串。
字符串 \(A\) 的最大周期是其最长的周期,如果 \(A\) 没有周期,则为空字符串。例如,ababab 的最大周期是 abab;abc 的最大周期是空字符串。
我们先给出一个很有效的结论:
设字符串 \(A\) 长度为 \(n\),其前缀函数记为 \(\pi\)(每一个 \(\pi_i\) 也可以称为 border,其实就是一种别名,本质上其实约等于一个东西)。
如果 $ n \mod {(n - \pi[n])} = 0 $ 就说明有循环节,\(n-\pi[n]\) 的值,就是 \(A\) 的最小循环节的长度,而 \(n/(n-\pi[n])\) 就是最大循环次数!
是不是很神奇?证明就不证了,这篇题解写的很清楚
P4391 [BalticOI 2009] Radio Transmission 无线传输
//最小周期例题
#include<bits/stdc++.h>
using namespace std;
#define endl '\n'
#define ll long long
#define dbug(x) (void)(cerr << #x << " = " << x << endl)
const ll N = 1e6 + 6;
ll border[N] , ans;
int main() {
ll len;
cin >> len;
string p;
cin >> p;
p = '.' + p;
ll j = 0;
border[1] = 0;
for(ll i = 2;i <= len;i++){
while(j > 0 && p[j + 1] != p[i]){
j = border[j];
}
if(p[j+1] == p[i]){
j++;
}
border[i] = j;
}
cout << len - border[len];
return ~~ (0 ^ 0);
}
P3435 [POI 2006] OKR-Periods of Words
//最大周期例题,上方的定义就来自于本题面
#include<bits/stdc++.h>
using namespace std;
#define endl '\n'
#define ll long long
#define dbug(x) (void)(cerr << #x << " = " << x << endl)
const ll N = 1e6 + 6;
ll border[N] , ans;
ll sum;
int main() {
ll len;
cin >> len;
string p;
cin >> p;
p = '.' + p;
ll j = 0;
border[1] = 0;
for(ll i = 2;i <= len;i++){
while(j > 0 && p[j + 1] != p[i]){
j = border[j];
}
if(p[j+1] == p[i]){
j++;
}
border[i] = j;
//cout << i << " " <<border[i] << " " << i - border[i] << endl;
}
for(ll i = 2;i <= len;i++){
j = i;
while(border[j]) j = border[j];
if(border[i]) border[i] = j; // 优化
sum += i - j;
}
//cout << endl;
//cout << len - border[len] << " ";
cout << sum;
return ~~ (0 ^ 0);
}
最长公共前后中缀
这个标题也是我自己起的,其实就是说如果对于一个字符串 \(s\) 中的子串 \(p\),同时满足:
- \(p\) 是 \(s\) 的前缀
- \(p\) 是 \(s\) 的后缀
- \(p\) 在 \(s\) 中间也出现过(反正我叫它字符串中缀)
我们把这几类问题都讲一讲吧:
-
最长公共前缀
这就不用讲了,又叫 LCP 问题,往下滑,在 Z 函数章节。 -
最长公共后缀
倒过来的 LCP 问题,pass -
最长公共前后缀
有人可能对这个陌生一些,你肯定没好好看前缀知识!!!,这不就是前缀函数嘛,直接就解决了。
好了我们回归正轨:
对于我们找到的一个满足条件的字符串 \(P\),是 \(S\) 的公共前后中缀,(假设它在 \(S\) 中的起始和终点坐标分别为 \(l\),\(r\)),那么我们发现(设 \(∣S∣=n\)):
- \(P\) 既是 \(S[1...r]\) 的前缀,也是 \(S[1...r]\) 的后缀。
- \(P\) 既是 \(S[l...n]\) 的前缀,也是 \(S[l...n]\) 的后缀。
那我们只需要处理两遍正反的 \(\pi\) 数组,然后枚举 \(l\) 判断就可以了吧?
#include<bits/stdc++.h>
using namespace std;
#define endl '\n'
#define ll long long
#define dbug(x) (void)(cerr << #x << " = " << x << endl)
const int N = 1e6+5;
ll border1[N],border2[N];
int main() {
string s;
cin >> s;
ll n = s.size();
s = '.' + s;
// 要想找到最大公共前后中缀(是不是很奇怪的名字?)
// 我们求出正反双向的 border 数组
border1[1] = 0;
for(ll i = 2 , j = 0;i <= n;i++){
while(j > 0 && s[j+1] != s[i]) j = border1[j];
if(s[j+1] == s[i]) j++;
border1[i] = j;
//cout << border1[i] << " ";
}
//cout << endl;
s = s.substr(1,n);
reverse(s.begin() , s.end());
s = '.' + s;
border2[1] = 0;
for(ll i = 2 , j = 0;i <= n;i++){
while(j > 0 && s[j+1] != s[i]) j = border2[j];
if(s[j+1] == s[i]) j++;
border2[i] = j;
//cout << border2[i] << " ";
}
//cout << endl;
// 查找最大的 len
ll len = 0;
for(ll k = 2;k < n;k++){
if(border1[k] == border2[n - k + border1[k]]){
len = max(len,border1[k]);
}
}
if(len == 0) cout << "Just a legend";
else{
//cout << len << " ";
s = s.substr(1,n);
reverse(s.begin() , s.end());
s = '.' + s;
cout << s.substr(1 , len);
}
return ~~ (0 ^ 0);
}
其他注意
我们在处理字符串时,更习惯去处理 1-based 字符串(就是下标从 \(1\) 开始的字符串),所以一种简单的技巧就是在字符串前面拼接一个无用字符,比如 s = '.' + s 。
但请务必注意,下标从 \(1\) 开始,所有的代码语句都要与之适配(不然就可能像我一样一道题调一个小时最后发现只是数组下标忘记加一了)。
Z 函数
国外一般将该算法称为 Z Algorithm(Z 函数),而国内则称其为扩展 KMP(exKMP)。
好了相信聪明的你已经学会 KMP 了,我们趁热打铁直接学习 exKMP 吧。
与之前不同,Z 函数我们约定字符串 0-based,即下标从零开始。
Z 函数主要用于解决 LCP 问题,即最长公共前缀。
对于一个长度为 \(n\) 的字符串 \(s\),定义函数 \(z[i]\) 表示 \(s\) 和 \(s[i,n-1]\)(即以 \(s[i]\) 开头的后缀)的最长公共前缀(LCP)的长度,则 \(z\) 被称为 \(s\) 的 \(Z\) 函数。特别地,\(z[0] = 0\) 。
那我们发现这个算法和 KMP 的思路是差不多的,\(z\) 数组与 KMP 的 \(\pi\) 数组的区别我们要注意下,\(z\) 数组表示的量是以 \(s[i]\) 为开头,但 \(\pi\) 数组却是以 \(s[i]\) 为结尾。
好了,相信你学会了,我们直接看代码。
咳咳,其实你也不一定学会了。
我们来讲解以下,Z 函数处理过程中,我们维护一个匹配右边界 \(r\),表示先前过程中,最靠右的最长公共前缀右端点,此时前缀的左端点记录为匹配中心 \(c\),Z 函数维护的数组自然叫做 \(\{z_i\}\)。
Z 函数的过程,就是当来到的处理出发点 \(i\),如何利用 \(z,r,c\) 来进扩展过程的加速的过程。
对于这个维护过程,我们分情况进行处理:
- \(i\) 没有被 \(r\) 包住,那么以 \(i\) 为出发点直接扩展。
- \(i\) 被 \(r\) 包住:
- 但是前点 \(i-c\) 的扩展长度,对应在大扩展区域以内,直接确定 \(z_i = z_{i-c}\);
- 但是前点 \(i-c\) 的扩展长度,对应在大扩展区域以外,直接确定 \(z_i=r-i\);
- 但是前点 \(i-c\) 的扩展长度,对应在大扩展区域的边界,从 \(r\) 之外的位置进行扩展。
至于为什么分这四种情况,相信你可以通过自己的思考想出来的。我是不会告诉你其实是我懒得写了。
我们看代码吧,其实代码中不需要对这四种情况进行单独判断的,我们采取的思路就是先赋一个初值,然后不管属于哪种情况都进行暴力更新,这样就算是不用更新的情况,也只需要进行一次 \(O(1)\) 的判断就会弹出。
可以证明其时间复杂度是 \(O(n)\) 的,因为只要暴力更新,右界 \(r\) 一定会被扩大,而最大长度就是 \(n\),也就是说最坏情况扩大 \(n\) 次,所以时间复杂度就是线性的。
需要提一嘴的是,网上有很多讲解会涉及到 e 数组,这是因为大多时候题目中都有文本串和模式串的比较,并不是单纯找一个字符串中的最大公共前缀,但其实是根本没必要的,可以通过将模式串拼接在文本串之前,然后直接跑 Z 函数,就可以得到其最大公共前缀了。
需要注意的是,在洛谷的模板题目中,\(z[0] = n\),这与 OI wiki 上给出的定义不同,应该是源于不同的理解方式,从零开始的子串是原串,原串与原串的最大公共前缀可被理解为长度为 \(n\)。
#include<bits/stdc++.h>
using namespace std;
#define endl '\n'
#define ll long long
#define dbug(x) (void)(cerr << #x << " = " << x << endl)
const int N = 2e7+5;
inline ll exkmp(string s, ll x) {
ll n = s.size();// 0 - based
vector<ll> z(n + 5, 0);
z[0] = n;
for (ll i = 1, c = 0, r = 0, len; i < n; i++) {
if (i < r) len = min(z[i - c], r - i);
else len = 0;
while (i + len < n && s[len] == s[i + len]) {
len ++;
}
if (i + len > r) {
r = i + len;
c = i;
}
z[i] = len;
}
ll ans = 0;
for (ll i = x; i < s.size(); i++) {
ans ^= (i - x + 1) * (z[i] + 1);
}
return ans;
}
string s, p;
int main() {
ios::sync_with_stdio(0);
cin.tie(0), cout.tie(0);
cin >> s >> p;
cout << exkmp(p, 0) << endl;
//处理模式串与文本串的时候,只需要把p拼接到s的前面再处理一遍就可以了
cout << exkmp(p + s, p.size()) << endl;
return ~~ (0 ^ 0);
}
废弃1 - based 代码:
点击查看代码
#include<bits/stdc++.h>
using namespace std;
#define endl '\n'
#define ll long long
#define dbug(x) (void)(cerr << #x << " = " << x << endl)
const int N = 2e7+5;
string s,p;
inline ll exkmp(string s , ll x){
//cout << s << endl;
s = '.' + s;// 使其变成 base-1 字符串
ll n = s.size()-1;
vector<ll> z(n + 5 , 0);
// z[i] 表示 s 和 s[i,n-1](即以s[i] 开头的后缀)的最长公共前缀(LCP)的长度
z[1] = n;
//初始化z[1],且z数组处理应从2开始
for(ll i = 2 , l = 1 , r = 0; i <= n ; i++){
if(i <= r) z[i] = min(z[1 + i - l] , 1 + r - i);
while(i + z[i] <= n && s[1 + z[i]] == s[i + z[i]]) z[i]++;//暴力向后寻找
if(i + z[i] - 1 > r){//如果更新后的r比原来更大就更新
l = i;
r = i + z[i] - 1;
}
}
ll ans = 0;
for(ll i = x; i <= n; i++){
//cout << i << " " << z[i] << endl;
ans = ans ^ ((z[i] + 1) * (i - x + 1));
}
return ans;
}
int main() {
ios::sync_with_stdio(0);
cin.tie(0),cout.tie(0);
cin >> s >> p;
cout << exkmp(p , 1) << endl;
//处理模式串与文本串的时候,只需要把p拼接到s的前面再处理一遍就可以了
cout << exkmp(p + s , p.size() + 1) << endl;
return ~~ (0 ^ 0);
}

浙公网安备 33010602011771号