KMP算法与Z函数
前置知识
我们先来了解一下前缀函数这一定义,详细的解释一下。
给定一个长度为 \(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)
const int N = 1e6 + 86;
string s, p;
ll border[N];
ll f[N];
inline void kmp(){
ll n = s.size()-1 , m = p.size()-1;
ll j = 0;
for(ll i = 2;i <= m;i++){ // 处理border数组,从 2 开始
while(j > 0 && p[j+1] != p[i]){
j = border[j];
}
if(p[j+1] == p[i]){
j++;
}
border[i] = j;
}
j = 0;
for(ll i = 1 ;i <= n;i++){
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) cout << i-m+1 << endl;
}
for(ll i = 1;i <= m;i++){
cout << border[i] << " ";
}
}
int main() {
cin >> s >> p;
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);
}
其他注意
我们在处理字符串时,更习惯去处理 base-1 字符串(就是下标从 \(1\) 开始的字符串),所以一种简单的技巧就是在字符串前面拼接一个无用字符,比如 s = '.' + s
。
但请务必注意,下标从 \(1\) 开始,所有的代码语句都要与之适配(不然就可能像我一样一道题调一个小时最后发现只是数组下标忘记加一了)。
Z 函数
国外一般将该算法称为 Z Algorithm(Z 函数),而国内则称其为扩展 KMP(exKMP)。
好了相信聪明的你已经学会 KMP 了,我们趁热打铁直接学习 exKMP 吧。
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]\) 为结尾。
好了,相信你学会了,我们直接看代码。
咳咳,其实你也不一定学会了,但由于思路大致相同,代码也比较简洁,我就直接把带注释源码贴在这里吧。我是不会告诉你其实是我懒得写了
#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);
}