常见字符串算法
关于本博客的修订版,见 字符串基础
一些基本定义:
表示两个字符串 的最长公共前缀 longest common prefix。类似的, 表示 的最长公共后缀 longest common suffix。 和 表示字符串 位置 上的字符连接而成的子串。若 或 则有时省略,即 表示 长度为 的前缀, 表示长度为 的后缀, 表示字符串 的长度。- 真前缀表示非原串的前缀。真后缀同理。
Change log
- 2021.12.12. 新增 KMP 算法与 Z 算法。
- 2021.12.13. 修改部分笔误。
- 2021.12.23. 新增前言。
- 2021.12.24. 新增 SA 应用部分。
- 2022.1.10 新增几个 SA 应用与例题。
0. 前言
几乎所有字符串算法都存在一个共同特性:基于所求信息的特殊性质与已经求出的信息,使用增量法均摊复杂度求得所有信息。这是动态规划算法的又一体现。
Manacher 很好地印证了这一点:它以所求得的最右回文子串的回文中心
接下来的后缀数组 SA,KMP 算法,Z 算法与后缀自动机等常见字符串结构无不遵循这一规律。读者在阅读时可以着重体会该技巧,个人认为这种思想对于提升解决问题的能力有极大帮助。
1. Manacher 算法
1.1. 算法简介
首先将字符串所有字符之间(包括头尾)插入相同分隔符,因为 Manacher 仅能找到长度为奇数的回文串。并在整个字符串最前方额外插入另一种分隔符,防止越界。
设以字符
显然,若
Manacher 算法:记录在所有遍历过的位置
对于当前位置
const int N = 2.2e7 + 5;
int n, m, ans, p[N]; char s[N >> 1], t[N];
int main(){
scanf("%s", s + 1), n = strlen(s + 1), t[0] = '#', t[m = 1] = '@';
for(int i = 1; i <= n; i++) t[++m] = s[i], t[++m] = '@'; t[++m] = '!';
for(int i = 1, r = 0, d = 0; i < m; i++) {
if(i > r) p[i] = 1; else p[i] = min(p[2 * d - i], r - i + 1);
while(t[i - p[i]] == t[i + p[i]]) p[i]++;
if(i + p[i] - 1 > r) d = i, r = i + p[i] - 1; cmax(ans, p[i] - 1);
} cout << ans << endl;
return 0;
}
1.2. 应用
利用 Manacher 算法,我们可以求出以每个字符开头或结尾的最长回文子串:考虑一个位置
1.3. 例题
I. P4555 [国家集训队]最长双回文串
对每个位置求出以该字符开头和结尾的最长回文子串
const int N = 2e5 + 5;
int n, m, ans, p[N], x[N], y[N];
char s[N], t[N];
int main(){
scanf("%s", s + 1), n = strlen(s + 1), t[0] = '!', t[m = 1] = '@';
for(int i = 1; i <= n; i++) t[++m] = s[i], t[++m] = '@'; t[++m] = '#';
for(int i = 1, r = 0, d = 0; i < m; i++) {
p[i] = r < i ? 1 : min(r - i + 1, p[2 * d - i]);
while(t[i - p[i]] == t[i + p[i]]) p[i]++;
if(i + p[i] - 1 > r) {
for(int j = r + 1; j <= i + p[i] - 1; j++)
if(j % 2 == 0) x[j >> 1] = j - i + 1;
r = i + p[i] - 1, d = i;
}
} for(int i = m - 1, r = m, d = 0; i; i--) {
p[i] = i < r ? 1 : min(i - r + 1, p[2 * d - i]);
while(t[i - p[i]] == t[i + p[i]]) p[i]++;
if(i - p[i] + 1 < r) {
for(int j = i - p[i] + 1; j < r; j++)
if(j % 2 == 0) y[j >> 1] = i - j + 1;
r = i - p[i] + 1, d = i;
}
} for(int i = 1; i < n; i++) ans = max(ans, x[i] + y[i + 1]);
cout << ans << endl;
return 0;
}
II. P1659 [国家集训队]拉拉队排练
以
III. P5446 [THUPC2018]绿绿和串串
转化一下题意,任何回文中心
IV. P6216 回文匹配
首先 KMP 求出
使用一个被用烂掉的技巧:把形如
V. UVA11475 Extend to Palindrome
和例题 III 差不多。
2. Suffix Array 后缀数组
作为复习写下后缀数组相关博客。前置知识:桶排序。
2.1. 基本定义
- 设
表示字符串 以 为开头的后缀,称为 后缀,即 。 - 定义
表示 在所有后缀中的字典序排名。由于任意后缀长度不同,故排名唯一。 - 定义
表示排名为 的后缀的开始位置,它与 互逆: 。这就是我们要求的后缀数组,简称 SA。 - 简记
后缀与 后缀的最长公共前缀为 。 - 定义
表示 与 的最长公共前缀长度,即 。特殊的, 。
2.2. 后缀排序
后缀排序算法能够通过一系列排序操作得到一个字符串的后缀数组。它主要运用倍增的思想。
假设我们知道了所有
在实现中,为了追求更小的常数,我们可以直接优化掉首先对第二关键字的排序:若
此外,由于
char s[N];
int n, sa[N], rk[N], ork[N << 1]; // 由于统计 rk 的时候需要用到原来的 rk, 故复制一份并开两倍空间 (i + 2 ^ {w - 1} - 1 可能超出 n 的范围)
int buc[N], id[N], pid[N];
bool cmp(int a, int b, int w) {return ork[a] == ork[b] && ork[a + w] == ork[b + w];}
void build() {
int m = 1 << 7, p = 0;
for(int i = 1; i <= n; i++) buc[rk[i] = s[i]]++;
for(int i = 1; i <= m; i++) buc[i] += buc[i - 1];
for(int i = n; i; i--) sa[buc[s[i]]--] = i;
for(int w = 1; ; w <<= 1, m = p, p = 0) {
for(int i = n; i > n - w; i--) id[++p] = i; // 循环顺序无关
for(int i = 1; i <= n; i++) if(sa[i] > w) id[++p] = sa[i] - w;
mem(buc, 0, m + 1); // 注意清空数组
for(int i = 1; i <= n; i++) buc[pid[i] = rk[id[i]]]++; // pid[i] 记录 rk[id[i]] 使访问连续
for(int i = 1; i <= m; i++) buc[i] += buc[i - 1];
for(int i = n; i; i--) sa[buc[pid[i]]--] = id[i];
cpy(ork, rk, n + 1), p = 0;
for(int i = 1; i <= n; i++) rk[sa[i]] = cmp(sa[i - 1], sa[i], w) ? p : ++p; // 原排名相同则新排名相同,否则排名 + 1
if(p == n) break; // n 个排名互不相同则排序完成
}
}
2.3. height 数组
极大多数关于 SA 的应用都需要
如上图,我们定义
下方求
for(int i = 1, k = 0; i <= n; i++) {
if(k) k--;
while(s[i + k] == s[sa[rk[i] - 1] + k]) k++;
ht[rk[i]] = k;
}
2.4. 应用
2.4.1. 求任意两个后缀的 LCP
有了
接下来我们给出一个关键性质:若
仍然是这个例子:两个
上图为对
如果将整张图逆时针旋转 height
这一名称的来源吧。也正因如此,SA 可与单调栈相结合(众所周知,单调栈可以求出柱状图中面积最大的矩形)。
由于我们需要查询区间最值,使用倍增数组维护即可做到
注意点:查询时范围是
2.4.2 求本质不同子串个数
考虑每次添加一个后缀,并删去这个后缀与已经添加的后缀的所有重复子串,即
2.4.3 应用:与单调栈结合
2.4.4. 应用:多个串的最长公共子串
给定
考虑用双指针维护这个过程,因为若
2.5. 例题
*I. CF822E Liar
使用贪心的思想可知在一轮匹配中,我们能匹配尽量匹配,即若从
考虑到
求一个字符串某两个后缀的
*II. P1117 [NOI2016] 优秀的拆分
本题巧妙的地方有两点,一是通过乘法原理将
求出
求任意两个前缀的 LCS 或任意两个后缀的 LCP 用 SA 实现,时间复杂度线性对数,包括建出 SA,建出
*III. P7361 「JZOI-1」拜神
还算不错的题目。考虑建出
显然答案满足可二分性,因此着眼于判断一个长度
考虑如何更新 set
lower_bound
查询
*IV. P2178 [NOI2015] 品酒大会
由于
这启发我们求出
进一步地,如果只记录四个极值并使用线性方法求 SA,可以做到
V. P4248 [AHOI2013]差异
SA 与单调栈结合应用例题,时间复杂度线性对数。这里给出代码。
ll solve() {
static ll stc[N], w[N], top = 0, area = 0, ans = 0;
for(int i = 2; i <= n; i++) {
ll width = 1;
while(top && stc[top] >= ht[i])
width += w[top], area -= w[top] * stc[top], top--;
area += ht[i] * width, stc[++top] = ht[i], w[top] = width, ans += area;
} return ans << 1;
}
int main() {
cin >> s + 1, n = strlen(s + 1), build();
cout << 1ll * (n - 1) * n * (n + 1) / 2 - solve() << endl;
return 0;
}
VI. P7409 SvT
双倍经验。
VII. CF1073G Yet Another LCP Problem
三倍经验。注意区分
VIII. P3763 [TJOI2017]DNA
枚举开始位置,使用 SA 加速匹配即可。时间复杂度线性对数。
IX. P7769 丑国传说 · 大师选徒(Selecting Apprentices)
考虑
这启发我们把
对于限制 1 和 2,开个桶
X. P5028 Annihilate
考虑枚举每个字符串
XI. P2852 [USACO06DEC]Milk Patterns G
考虑从大到小添加每个
XII. P2463 [SDOI2008] Sandy 的卡片
将
const int N = 1.1e5 + 5;
int n, L, ans, a[N], bel[N], s[N];
int sa[N], ht[N], rk[N], ork[N << 1];
int buc[N], id[N], pid[N];
bool cmp(int a, int b, int w) {return ork[a] == ork[b] && ork[a + w] == ork[b + w];}
void build() {
int m = N - 5, p = 0;
for(int i = 1; i <= L; i++) buc[rk[i] = s[i]]++;
for(int i = 1; i <= m; i++) buc[i] += buc[i - 1];
for(int i = L; i; i--) sa[buc[rk[i]]--] = i;
for(int w = 1; ; w <<= 1, m = p, p = 0) {
for(int i = L; i > L - w; i--) id[++p] = i;
for(int i = 1; i <= L; i++) if(sa[i] > w) id[++p] = sa[i] - w;
cpy(ork, rk, L + 1), mem(buc, 0, m + 1);
for(int i = 1; i <= L; i++) buc[pid[i] = rk[id[i]]]++;
for(int i = 1; i <= m; i++) buc[i] += buc[i - 1];
for(int i = L; i; i--) sa[buc[pid[i]]--] = id[i]; p = 0;
for(int i = 1; i <= L; i++) rk[sa[i]] = cmp(sa[i - 1], sa[i], w) ? p : ++p;
if(p == L) break;
}
for(int i = 1, k = 1; i <= L; i++) {
if(k) k--;
while(s[i + k] == s[sa[rk[i] - 1] + k]) k++;
ht[rk[i]] = k;
}
}
int main() {
cin >> n;
if(n == 1) cout << read() << endl, exit(0);
for(int i = 1, m; i <= n; i++) {
cin >> m;
for(int j = 1; j <= m; j++) a[j] = read();
for(int j = 2; j <= m; j++) s[++L] = a[j] - a[j - 1] + 2e3, bel[L] = i;
s[++L] = 1e5 + i;
} build(), mem(buc, 0, N);
static int d[N], hd = 1, tl = 0;
for(int i = 1, l = 1, cnt = 0; i <= L && s[sa[i]] <= 1e5; i++) {
cnt += !buc[bel[sa[i]]], buc[bel[sa[i]]]++;
while(cnt == n && buc[bel[sa[l]]] > 1) buc[bel[sa[l]]]--, l++;
while(hd <= tl && d[hd] <= l) hd++;
if(i > 1) {
while(hd <= tl && ht[d[tl]] >= ht[i]) tl--;
d[++tl] = i;
} if(cnt == n) cmax(ans, ht[d[hd]]);
} cout << ans + 1 << endl;
return flush(), 0;
}
XIII. P6095 [JSOI2015]串分割
显然的贪心是让最大位数最小,即答案串长度
同时答案 在后缀数组中的排名 满足可二分性。我们破环成链,枚举
正确性证明:若可匹配
3. KMP 算法
重新复习基础算法 ing……
3.1. 算法简介
KMP 算法可以在
维护两个指针
此时,三位大神横空出世(大雾),提出了这样一个解决方法:因为我们知道
例如
给出结论:记
对于
求出
- 首先令
,这表示 的最长相等前缀后缀一定由 的相等前缀后缀(不一定最长,因为可能不匹配)扩展而来,我们先尝试最长的那个相等前缀后缀。 - 尝试匹配
和 ,若匹配,则得到 。 - 否则我们要找到最大的
使得 且 存在长度为 的相等前缀后缀。注意到因为 存在长度为 的相等前缀后缀,故 长度不大于 的后缀也一定是 的后缀。这表明我们要求的 和 的定义本质相同。因此令 。 - 不断重复上述过程直到找到一个
使得 与 成功匹配( )或者 ,此时直接判断是否有 即可,若是,则 否则 。
KMP 非常好写,模板题 P3375 【模板】KMP 字符串匹配 代码:
const int N = 1e6 + 5;
int n, m, nxt[N]; char s1[N], s2[N];
int main(){
scanf("%s %s", s1 + 1, s2 + 1), n = strlen(s1 + 1), m = strlen(s2 + 1);
for(int i = 2, p = 0; i <= m; i++) {
while(p && s2[p + 1] != s2[i]) p = nxt[p];
p = nxt[i] = p + (s2[p + 1] == s2[i]);
} for(int i = 1, p = 0; i <= n; i++) {
while(p && s1[i] != s2[p + 1]) p = nxt[p];
if((p += s1[i] == s2[p + 1]) == m) printf("%d\n", i - m + 1), p = nxt[p];
} for(int i = 1; i <= m; i++) printf("%d ", nxt[i]);
return 0;
}
相较于字符串哈希,KMP 算法为我们提供了非常有用的
注意点:很多时候题目会给
3.2. 扩展:KMP 自动机
KMP 自动机是一种确定有限状态自动机。
对一个长度为
很好理解:若当前字符匹配,则匹配长度
int tr[N][26];
for(int i = 0; i <= n; i++) {
for(int j = 'a'; j <= 'z'; j++)
if(i < n && s[i + 1] == j) tr[i][j] = i + 1;
else if(!i) tr[i][j] = 0;
else tr[i][j] = tr[nxt[i]][j];
}
3.3. 应用:失配树与 Border 理论
见 Part 5. border 理论部分。
3.4. 应用:AC 自动机
3.5. 例题
I. P3193 [HNOI2008]GT考试
KMP 自动机。设
设
*II. P2375 [NOI2014] 动物园
相当于对每个前缀求其最长的长度不超过串长一半的 border 在失配树上的深度。为此,我们首先求一遍
为什么不能在一开始就限制
III. UVA11022 String Factoring
经 典 老 题。设
预处理
*IV. P3449 [POI2006]PAL-Palindromes
神仙题!看到题目,我的想法是如果
注意到我们根本没有用到
充分性(
在证明必要性之前,我们给出一个引理:若长度为
-
当
时,显然成立。 -
设串
, ,即 。设 ,显然 。设 表示 翻转后得到的串。由于
都是回文串,故 。因为 是 的前缀, 也是 的前缀,所以 即 回文。故 。因为
是 的前缀,所以 。同理, 。这说明 是 的回文周期。 -
这是一个递归式的子命题:若长度为
的回文串 存在回文周期 ,则存在长为 的回文整周期。若子命题成立,则原命题成立。 -
由于
,类似辗转相除法,因此必然出现 的情况,此时 。故原命题成立。
数学归纳法真好用。
必要性(
-
首先,
不可能是 某个整周期回文串,否则 最短回文周期不大于 ,显然矛盾。 -
当
时,因为 ,而 和 是回文串,所以 。故 ,即 是 的回文周期。根据引理,这说明
存在回文整周期 ,从而有 的最短回文周期不大于 即小于 ,与 的定义矛盾。 -
当
时,因为 和 是回文串,所以 。这说明 是 的回文周期。根据引理,
存在回文整周期 ,从而有 的最短回文周期不大于 即小于 ,与 的定义矛盾。
本文用了多少遍反证法(大雾)?
综上,我们只需 KMP 求出每个字符串的
每个最短回文周期字符串对答案的贡献为
*V. P3546 [POI2012]PRE-Prefixuffix
好题!不难发现若
根据这一性质,我们从大到小枚举所有
判断字符串是否相等使用哈希,自然溢出哈希会被卡。求
4. Z Algorithrm
别称 Z 算法,扩展 KMP 算法。
4.1. 算法简介
我们定义一个长度为
称
- 若
,直接暴力匹配。 - 若
,因为 ,所以 。故首先令 ,然后暴力匹配。
看完我直呼:这也太像 Manacher 了!
时间复杂度分析:当
for(int i = 2, l = 0, r = 0; i <= n; i++) {
z[i] = i > r ? 0 : min(r - i + 1, z[i - l + 1]);
while(t[1 + z[i]] == t[i + z[i]]) z[i]++;
if(i + z[i] - 1 > r) l = i, r = i + z[i] - 1;
}
4.2. 应用:字符串匹配
求字符串
- 解法 1:令
,其中 是任意分隔符,对 求 z 函数。 - 解法 2:类似求 z 函数的方法,我们维护最右匹配段
表示 ,若 则暴力匹配,否则令 。
两种解法本质相同,因为 Z Algorithm 就相当于用
4.3. 例题
I. P5410 【模板】扩展 KMP(Z 函数)
const int N = 2e7 + 5;
int n, m, z[N], p[N]; ll ans;
char s[N], t[N];
int main(){
scanf("%s %s", s + 1, t + 1), n = strlen(s + 1), z[1] = m = strlen(t + 1);
for(int i = 2, l = 0, r = 0; i <= m; i++) {
z[i] = i > r ? 0 : min(r - i + 1, z[i - l + 1]);
while(t[1 + z[i]] == t[i + z[i]]) z[i]++;
if(i + z[i] - 1 > r) l = i, r = i + z[i] - 1;
} for(int i = 1; i <= m; i++) ans ^= 1ll * i * (z[i] + 1);
cout << ans << endl, ans = 0;
for(int i = 1, l = 0, r = 0; i <= n; i++) {
p[i] = i > r ? 0 : min(r - i + 1, z[i - l + 1]);
while(p[i] < m && t[1 + p[i]] == s[i + p[i]]) p[i]++; // 注意这里应该判断 p[i] 小于模式串长度而非匹配串长度
if(i + p[i] - 1 > r) l = i, r = i + p[i] - 1;
} for(int i = 1; i <= n; i++) ans ^= 1ll * i * (p[i] + 1);
cout << ans << endl, ans = 0;
return 0;
}
*II. CF526D Om Nom and Necklace
重新表述题意:若
此时仅需考虑可能的
III. CF432D Prefixes and Suffixes
找到前缀后缀可用 KMP 求得
5. Border 理论
5.1. 基础定义
定义长度为
定义
定义
5.2. Border 的性质
-
性质 1:若
为 的周期,则 为 的 border。证明:因为
,所以 。 -
性质 2:若
存在 border,则其最短 border 长度不超过字符串长度的一半。证明:设
的最短 border 长度为 ,那么 。因为 ,所以 且 。因此 也是 的 border,这与 的定义矛盾,如下图。 -
性质 3:
5.3. 失配树
在 KMP 算法中注意到
失配树有很好的性质:对于树上任意两个具有祖先 - 后代关系的节点
const int N = 1e6 + 5;
const int K = 20;
int n, m, lg, dep[N], fa[K][N]; char s[N];
int main(){
scanf("%s %d", s + 1, &m), n = strlen(s + 1), lg = log2(n), dep[0] = 1, dep[1] = 2;
for(int i = 2, p = 0; i <= n; i++) {
while(p && s[p + 1] != s[i]) p = fa[0][p];
dep[i] = dep[p = fa[0][i] = p + (s[p + 1] == s[i])] + 1;
} for(int i = 1; i <= lg; i++) for(int j = 2; j <= n; j++) fa[i][j] = fa[i - 1][fa[i - 1][j]];
while(m--) {
int u, v; scanf("%d %d", &u, &v), u = fa[0][u], v = fa[0][v];
if(dep[u] < dep[v]) swap(u, v);
for(int i = lg; ~i; i--) if(dep[fa[i][u]] >= dep[v]) u = fa[i][u];
for(int i = lg; ~i; i--) if(fa[i][u] != fa[i][v]) u = fa[i][u], v = fa[i][v];
cout << (u == v ? u : fa[0][u]) << "\n";
} return 0;
}
暂时没见到失配树有什么应用。
I. P4391 [BOI2009]Radio Transmission 无线传输
来一道基础题:根据性质 1,要求
*II. P3435 [POI2006]OKR-Periods of Words
若
*III. P3426 [POI2005]SZA-Template
POI 的题目质量总是这么高,很有启发性,好评!
转化题意,相当于求长度最小的 border
翻看了一遍题解区,发现一个惊为天人的 DP 做法。它用到了这样一个性质:设
定义
IV. P3538 [POI2012]OKR-A Horrible Poem
由于若
【推荐】国内首个AI IDE,深度理解中文开发场景,立即下载体验Trae
【推荐】编程新体验,更懂你的AI,立即体验豆包MarsCode编程助手
【推荐】抖音旗下AI助手豆包,你的智能百科全书,全免费不限次数
【推荐】轻量又高性能的 SSH 工具 IShell:AI 加持,快人一步
· 地球OL攻略 —— 某应届生求职总结
· 周边上新:园子的第一款马克杯温暖上架
· Open-Sora 2.0 重磅开源!
· 提示词工程——AI应用必不可少的技术
· .NET周刊【3月第1期 2025-03-02】