后缀数组学习笔记
后缀数组挺好玩的,于是来写后缀数组学习笔记了。
一些规定:
字符串为
后缀
什么是后缀数组?
后缀数组主要关系到 2 个数组:
-
表示将所有后缀按照字典序从小到大排序,排名第 的后缀为后缀 。 -
表示将所有后缀按照字典序从小到大排序,后缀 的排名为 。
这两个数组满足一个性质:
后缀数组的求法:
的求法:
很显然可以将所有后缀取出来然后排序,然后容易求得
的求法:
这个做法要用到倍增的思想。
我们设
我们可以先取出所有长度为
接下来假设我们已经求出了
对于长度为
代码如下:
#include <bits/stdc++.h>
using namespace std;
const int kMaxN = 1e6 + 5;
int n, sa[kMaxN], rk[kMaxN * 2], oldrk[kMaxN * 2]; // 注意这里的 rk 要开 2 倍空间,否则在双关键字比较是会出问题
string s;
void SA(int n, string s, int sa[], int rk[]) {
for (int i = 1; i <= n; i++) {
sa[i] = i, rk[i] = s[i]; // 由于 sa_0[i] 无需用到,可以直接赋为 i。这里的 rk[i] 可以直接等于 s[i],自行体会
}
for (int w = 1; w < n; w <<= 1) {
sort(sa + 1, sa + 1 + n, [&](int i, int j) { return rk[i] == rk[j] ? rk[i + w] < rk[j + w] : rk[i] < rk[j]; }); // 双关键字比较
copy(rk, rk + 1 + n + n, oldrk); // 复制一遍原来的 rk 数组
for (int i = 1, p = 0; i <= n; i++) { // 排名可能会相同
rk[sa[i]] = p += !(oldrk[sa[i]] == oldrk[sa[i - 1]] && oldrk[sa[i] + w] == oldrk[sa[i - 1] + w]); // 判断排名相邻的两个字符串是否相同
}
}
}
int main() {
ios::sync_with_stdio(0), cin.tie(0);
cin >> s, n = s.size(), s = ' ' + s, SA(n, s, sa, rk);
for (int i = 1; i <= n; i++) {
cout << sa[i] << ' ';
}
return 0;
}
其实这种做法就够用了,简单好写,但前提是出题人不卡你。(
的求法:
容易发现用 sort
排序是很慢的,由于
代码如下:
#include <bits/stdc++.h>
using namespace std;
const int kMaxN = 1e6 + 5;
int n, V, sa[kMaxN], rk[kMaxN * 2], oldrk[kMaxN * 2], oldsa[kMaxN], cnt[kMaxN];
string s;
void SA(int n, string s, int sa[], int rk[]) {
V = 128;
for (int i = 1; i <= n; i++) {
cnt[rk[i] = s[i]]++;
}
for (int i = 1; i <= V; i++) {
cnt[i] += cnt[i - 1];
}
for (int i = n; i; i--) {
sa[cnt[rk[i]]--] = i;
} // 由于要用到 sa_0[i],必须再排序一遍。
copy(rk, rk + 1 + n + n, oldrk);
for (int i = 1, p = 0; i <= n; i++) {
rk[sa[i]] = p += !(oldrk[sa[i]] == oldrk[sa[i - 1]]);
}
for (int w = 1; w < n; w <<= 1, V = n) {
fill(cnt, cnt + V + 1, 0), copy(sa, sa + 1 + n, oldsa); // 将 sa 数组复制一遍
for (int i = 1; i <= n; i++) { // 第二关键字排序
cnt[rk[oldsa[i] + w]]++;
}
for (int i = 1; i <= V; i++) {
cnt[i] += cnt[i - 1];
}
for (int i = n; i; i--) {
sa[cnt[rk[oldsa[i] + w]]--] = oldsa[i];
}
fill(cnt, cnt + V + 1, 0), copy(sa, sa + 1 + n, oldsa); // 将 sa 数组复制一遍
for (int i = 1; i <= n; i++) { // 第一关键字排序
cnt[rk[oldsa[i]]]++;
}
for (int i = 1; i <= V; i++) {
cnt[i] += cnt[i - 1];
}
for (int i = n; i; i--) {
sa[cnt[rk[oldsa[i]]]--] = oldsa[i];
}
copy(rk, rk + 1 + n + n, oldrk);
for (int i = 1, p = 0; i <= n; i++) {
rk[sa[i]] = p += !(oldrk[sa[i]] == oldrk[sa[i - 1]] && oldrk[sa[i] + w] == oldrk[sa[i - 1] + w]);
}
}
}
int main() {
ios::sync_with_stdio(0), cin.tie(0);
cin >> s, n = s.size(), s = ' ' + s, SA(n, s, sa, rk);
for (int i = 1; i <= n; i++) {
cout << sa[i] << ' ';
}
return 0;
}
一些常数优化:
第二关键字无需排序:
因为第二关键字排序的本质是把超出字符串范围的
优化基数排序的值域:
显然值域并不是每次都是
若排名都不相同可以直接退出循环:
若排名都不相同说明已经后缀排序完毕,可以退出循环。
代码如下:
#include <bits/stdc++.h>
using namespace std;
const int kMaxN = 1e6 + 5;
int n, V, sa[kMaxN], oldsa[kMaxN], rk[kMaxN * 2], oldrk[kMaxN * 2], cnt[kMaxN];
string s;
void SA(int n, string s, int sa[], int rk[]) {
V = 256, fill(cnt, cnt + 1 + V, 0), fill(rk, rk + n + n + 1, -1);
for (int i = 1; i <= n; i++) {
cnt[rk[i] = s[i]]++;
}
for (int i = 1; i <= V; i++) {
cnt[i] += cnt[i - 1];
}
for (int i = n; i; i--) {
sa[cnt[rk[i]]--] = i;
}
for (int w = 1, p = 0, tot = 0; p != n /* 若排名都不相同直接退出循环 */; w <<= 1, tot = 0, V = p /* 优化值域 */) {
for (int i = n - w + 1; i <= n; i++) {
oldsa[++tot] = i;
} // 第二关键字无需排序
for (int i = 1; i <= n; i++) {
sa[i] > w && (oldsa[++tot] = sa[i] - w);
}
fill(cnt, cnt + 1 + V, 0), copy(rk, rk + 1 + n + n, oldrk), p = 0;
for (int i = 1; i <= n; i++) {
cnt[rk[i]]++;
}
for (int i = 1; i <= V; i++) {
cnt[i] += cnt[i - 1];
}
for (int i = n; i; i--) {
sa[cnt[rk[oldsa[i]]]--] = oldsa[i];
}
for (int i = 1; i <= n; i++) {
rk[sa[i]] = p += !(oldrk[sa[i]] == oldrk[sa[i - 1]] && oldrk[sa[i] + w] == oldrk[sa[i - 1] + w]);
}
}
}
int main() {
ios::sync_with_stdio(0), cin.tie(0);
cin >> s, n = s.size(), s = ' ' + s, SA(n, s, sa, rk);
for (int i = 1; i <= n; i++) {
cout << sa[i] << ' ';
}
return 0;
}
上述代码均为 Luogu - P3809【模板】后缀排序 的代码。
的求法:
有两种方法:DC3 和 SA-IS,但是本文不在此赘述,如有需要请查看其他资料。
后缀数组的应用:
Luogu - P4051 [JSOI2007] 字符加密
题目链接:https://www.luogu.com.cn/problem/P4051
题意:
给定一个长度为
思路:
字符串是环形的,考虑将字符串重复拼接一次,将题目变成子串排序,这些子串长度相同所以可以比较以其开头的为开头的后缀的字典序大小,若后缀小,那么其子串一定小于或等于另一个子串,否则是大于等于,但是在这里等于不重要,所以可以直接后缀排序,然后输出末尾字符即可。
代码:
#include <bits/stdc++.h>
using namespace std;
const int kMaxN = 2e5 + 5;
int n, V, sa[kMaxN], oldsa[kMaxN], rk[kMaxN * 2], oldrk[kMaxN * 2], cnt[kMaxN];
string s, t;
void SA(int n, string s, int sa[], int rk[]) {
V = 256, fill(cnt, cnt + 1 + V, 0), fill(rk, rk + n + n + 1, -1);
for (int i = 1; i <= n; i++) {
cnt[rk[i] = s[i]]++;
}
for (int i = 1; i <= V; i++) {
cnt[i] += cnt[i - 1];
}
for (int i = n; i; i--) {
sa[cnt[rk[i]]--] = i;
}
for (int w = 1, p = 0, tot = 0; p != n; w <<= 1, tot = 0, V = p) {
for (int i = n - w + 1; i <= n; i++) {
oldsa[++tot] = i;
}
for (int i = 1; i <= n; i++) {
sa[i] > w && (oldsa[++tot] = sa[i] - w);
}
fill(cnt, cnt + 1 + V, 0), copy(rk, rk + 1 + n + n, oldrk), p = 0;
for (int i = 1; i <= n; i++) {
cnt[rk[i]]++;
}
for (int i = 1; i <= V; i++) {
cnt[i] += cnt[i - 1];
}
for (int i = n; i; i--) {
sa[cnt[rk[oldsa[i]]]--] = oldsa[i];
}
for (int i = 1; i <= n; i++) {
rk[sa[i]] = p += !(oldrk[sa[i]] == oldrk[sa[i - 1]] && oldrk[sa[i] + w] == oldrk[sa[i - 1] + w]);
}
}
}
int main() {
ios::sync_with_stdio(0), cin.tie(0);
cin >> s, n = s.size() * 2, s = ' ' + s + s, t.resize(n); // 复制一遍拼在后面
SA(n, s, sa, rk);
for (int i = 1; i <= n / 2; i++) {
t[rk[i] - 1] = s[i == 1 ? n / 2 : i - 1];
}
for (int i = 0; i < n; i++) {
t[i] && (cout << t[i]);
}
return 0;
}
这里可以引申一下,若要比较两个子串
Luogu - P1368 【模板】最小表示法
题目链接:https://www.luogu.com.cn/problem/P1368
题意:
给定一个长度为
思路:
同样的将序列复制一遍放到后面,然后后缀排序,找到排名最靠前的且后缀长度大于
代码:
#include <bits/stdc++.h>
using namespace std;
const int kMaxN = 6e5 + 5;
int n, V, s[kMaxN], p, sa[kMaxN], oldsa[kMaxN], rk[kMaxN * 2], oldrk[kMaxN * 2], cnt[kMaxN];
void SA(int n, int s[], int sa[], int rk[]) {
V = 256, fill(cnt, cnt + 1 + V, 0), fill(rk, rk + n + n + 1, -1);
for (int i = 1; i <= n; i++) {
cnt[rk[i] = s[i]]++;
}
for (int i = 1; i <= V; i++) {
cnt[i] += cnt[i - 1];
}
for (int i = n; i; i--) {
sa[cnt[rk[i]]--] = i;
}
for (int w = 1, p = 0, tot = 0; p != n; w <<= 1, tot = 0, V = p) {
for (int i = n - w + 1; i <= n; i++) {
oldsa[++tot] = i;
}
for (int i = 1; i <= n; i++) {
sa[i] > w && (oldsa[++tot] = sa[i] - w);
}
fill(cnt, cnt + 1 + V, 0), copy(rk, rk + 1 + n + n, oldrk), p = 0;
for (int i = 1; i <= n; i++) {
cnt[rk[i]]++;
}
for (int i = 1; i <= V; i++) {
cnt[i] += cnt[i - 1];
}
for (int i = n; i; i--) {
sa[cnt[rk[oldsa[i]]]--] = oldsa[i];
}
for (int i = 1; i <= n; i++) {
rk[sa[i]] = p += !(oldrk[sa[i]] == oldrk[sa[i - 1]] && oldrk[sa[i] + w] == oldrk[sa[i - 1] + w]);
}
}
}
int main() {
ios::sync_with_stdio(0), cin.tie(0);
cin >> n;
for (int i = 1; i <= n; i++) {
cin >> s[i], s[n + i] = s[i]; // 复制一遍拼在后面
}
SA(n + n, s, sa, rk);
for (p = 1; sa[p] > n; p++) { // 找到符合条件的第一名
}
for (int i = sa[p]; i <= sa[p] + n - 1; i++) {
cout << s[i] << ' ';
}
return 0;
}
后缀数组还有一个用处就是查找子串。
Luogu - P3808 AC 自动机(简单版):
题目链接:https://www.luogu.com.cn/problem/P3808
题意:
给定
两个模式串不同当且仅当他们编号不同。
思路:
这里我们不用 AC 自动机做,用 SA 做。
问题的本质就是查找
代码:
#include <bits/stdc++.h>
using namespace std;
const int kMaxN = 1e6 + 5;
int n, m, ans, V, sa[kMaxN], oldsa[kMaxN], rk[kMaxN * 2], oldrk[kMaxN * 2], cnt[kMaxN];
string s[kMaxN], S, t;
void SA(int n, string s, int sa[], int rk[]) {
V = 256, fill(cnt, cnt + 1 + V, 0), fill(rk, rk + n + n + 1, -1);
for (int i = 1; i <= n; i++) {
cnt[rk[i] = s[i]]++;
}
for (int i = 1; i <= V; i++) {
cnt[i] += cnt[i - 1];
}
for (int i = n; i; i--) {
sa[cnt[rk[i]]--] = i;
}
for (int w = 1, p = 0, tot = 0; p != n; w <<= 1, tot = 0, V = p) {
for (int i = n - w + 1; i <= n; i++) {
oldsa[++tot] = i;
}
for (int i = 1; i <= n; i++) {
sa[i] > w && (oldsa[++tot] = sa[i] - w);
}
fill(cnt, cnt + 1 + V, 0), copy(rk, rk + 1 + n + n, oldrk), p = 0;
for (int i = 1; i <= n; i++) {
cnt[rk[i]]++;
}
for (int i = 1; i <= V; i++) {
cnt[i] += cnt[i - 1];
}
for (int i = n; i; i--) {
sa[cnt[rk[oldsa[i]]]--] = oldsa[i];
}
for (int i = 1; i <= n; i++) {
rk[sa[i]] = p += !(oldrk[sa[i]] == oldrk[sa[i - 1]] && oldrk[sa[i] + w] == oldrk[sa[i - 1] + w]);
}
}
}
bool C(int m, int n) {
int L = 1, R = m, M = L + R >> 1;
for (; L < R; M = L + R >> 1) {
t.substr(sa[M], min(n, m - sa[M] + 1)) >= S ? R = M : L = M + 1;
}
return t.substr(sa[M], min(n, m - sa[M] + 1)) == S;
}
int main() {
ios::sync_with_stdio(0), cin.tie(0);
cin >> n;
for (int i = 1; i <= n; i++) {
cin >> s[i];
}
cin >> t, m = t.size(), t = ' ' + t, SA(m, t, sa, rk);
for (int i = 1; i <= n; i++) {
S = s[i], ans += C(m, s[i].size());
}
cout << ans;
return 0;
}
height 数组的定义:
height 数组的求法:
下面需要证一个引理:
证明:
若
若
接下来我们将
有了这个引理,求解 height 数组就非常方便,可以直接暴力算。
void Geth(int n, int sa[], int rk[], int h[]) {
for (int i = 1, k = 0; i <= n; i++) {
for (k -= !!k; i + k <= n && sa[rk[i] - 1] + k <= n && s[i + k] == s[sa[rk[i] - 1] + k]; k++) {
} // 暴力计算
h[rk[i]] = k; // 算出结果
}
}
height 数组的应用:
现在我们可以比较子串字典序大小了。在前面我们知道了:若后缀
height 数组其实还有特别重要一个性质
证明:
先证一个引理:
设后缀
若
那么性质的证明就很显然了:
有了这个性质,那么我们就可以把后缀的最长公共前缀转化为 RMQ 问题。
这样我们也可以算出两个子串的最长公共前缀了,要求
接下来看一些例题,先是一个字符串的问题。
UVA1223 Editor
题目链接:https://www.luogu.com.cn/problem/UVA1223
题意:
给定一个长度为
思路:
子串就是后缀的前缀,不同的后缀的前缀对应的区间一定不同,所以两个后缀的公共前缀就是重复出现的子串,要求子串长度最大,那么就是最长公共前缀,所以我们要求的答案就是:
代码:
#include <bits/stdc++.h>
using namespace std;
const int kMaxN = 5005;
int T, n, sa[kMaxN], rk[kMaxN * 2], oldrk[kMaxN * 2], h[kMaxN];
string s;
void SA(int n, string s, int sa[], int rk[]) {
fill(rk, rk + 1 + n + n, -1);
for (int i = 1; i <= n; i++) {
sa[i] = i, rk[i] = s[i];
}
for (int w = 1, p = 0; w < n && p != n; w <<= 1) {
sort(sa + 1, sa + 1 + n, [&](int i, int j) { return rk[i] == rk[j] ? rk[i + w] < rk[j + w] : rk[i] < rk[j]; });
copy(rk, rk + 1 + n + n, oldrk), p = 0;
for (int i = 1; i <= n; i++) {
rk[sa[i]] = p += !(oldrk[sa[i]] == oldrk[sa[i - 1]] && oldrk[sa[i] + w] == oldrk[sa[i - 1] + w]);
}
}
}
void Geth(int n, string s, int sa[], int rk[], int h[]) {
for (int i = 1, k = 0; i <= n; i++) {
for (k -= !!k; i + k <= n && sa[rk[i] - 1] + k <= n && s[i + k] == s[sa[rk[i] - 1] + k]; k++) {
}
h[rk[i]] = k;
}
}
int main() {
ios::sync_with_stdio(0), cin.tie(0);
for (cin >> T; T; T--) {
cin >> s, n = s.size(), s = ' ' + s;
SA(n, s, sa, rk), Geth(n, s, sa, rk, h);
cout << *max_element(h + 2, h + 1 + n) << '\n';
}
return 0;
}
Editor 2
作者暂时还没找到原题……
题意:
给定一个长度为
思路:
由于不能重叠不能再像原来那样直接求最大值,观察到答案具有单调性,考虑二分答案将题目转化为判定性问题。设此次要判定的长度为
代码:
#include <bits/stdc++.h>
using namespace std;
const int kMaxN = 1e6 + 5;
int T, n, L, R, M, sa[kMaxN], rk[kMaxN * 2], oldrk[kMaxN * 2], h[kMaxN];
string s;
void SA(int n, string s, int sa[], int rk[]) {
fill(rk, rk + 1 + n + n, -1);
for (int i = 1; i <= n; i++) {
sa[i] = i, rk[i] = s[i];
}
for (int w = 1, p = 0; w < n && p != n; w <<= 1) {
sort(sa + 1, sa + 1 + n, [&](int i, int j) { return rk[i] == rk[j] ? rk[i + w] < rk[j + w] : rk[i] < rk[j]; });
copy(rk, rk + 1 + n + n, oldrk), p = 0;
for (int i = 1; i <= n; i++) {
rk[sa[i]] = p += !(oldrk[sa[i]] == oldrk[sa[i - 1]] && oldrk[sa[i] + w] == oldrk[sa[i - 1] + w]);
}
}
}
void Geth(int n, string s, int sa[], int rk[], int h[]) {
for (int i = 1, k = 0; i <= n; i++) {
for (k -= !!k; i + k <= n && sa[rk[i] - 1] + k <= n && s[i + k] == s[sa[rk[i] - 1] + k]; k++) {
}
h[rk[i]] = k;
}
}
bool C(int k) {
for (int i = 1, j = 1, maxl = n - sa[1] + 1, minl = n - sa[1] + 1, lcp = 1e9; j <= n; maxl = minl = n - sa[i = ++j] + 1) {
for (; j + 1 <= n && min(lcp, h[j + 1]) >= k; j++, maxl = max(maxl, n - sa[j] + 1), minl = min(minl, n - sa[j] + 1)) {
}
if (maxl - minl >= k) {
return 1;
}
}
return 0;
}
int main() {
ios::sync_with_stdio(0), cin.tie(0);
for (cin >> T; T; T--) {
cin >> s, n = s.size(), s = ' ' + s;
SA(n, s, sa, rk), Geth(n, s, sa, rk, h);
L = 0, R = n, M = L + R + 1 >> 1;
for (; L < R; M = L + R + 1 >> 1) {
C(M) ? L = M : R = M - 1;
}
cout << L << '\n';
}
return 0;
}
Luogu - P2408 不同子串个数
题目链接:https://www.luogu.com.cn/problem/P2408
题意:
给定一个长度为
思路:
先将字符串后缀排序,对于每一个后缀会产生
代码:
#include <bits/stdc++.h>
using namespace std;
const int kMaxN = 1e5 + 5;
int n, sa[kMaxN], rk[kMaxN * 2], oldrk[kMaxN * 2], h[kMaxN];
string s;
long long ans;
void SA(int n, string s, int sa[], int rk[]) {
fill(rk, rk + 1 + n + n, -1);
for (int i = 1; i <= n; i++) {
sa[i] = i, rk[i] = s[i];
}
for (int w = 1, p = 0; w < n && p != n; w <<= 1) {
sort(sa + 1, sa + 1 + n, [&](int i, int j) { return rk[i] == rk[j] ? rk[i + w] < rk[j + w] : rk[i] < rk[j]; });
copy(rk, rk + 1 + n + n, oldrk), p = 0;
for (int i = 1; i <= n; i++) {
rk[sa[i]] = p += !(oldrk[sa[i]] == oldrk[sa[i - 1]] && oldrk[sa[i] + w] == oldrk[sa[i - 1] + w]);
}
}
}
void Geth(int n, string s, int sa[], int rk[], int h[]) {
for (int i = 1, k = 0; i <= n; i++) {
for (k -= !!k; i + k <= n && sa[rk[i] - 1] + k <= n && s[i + k] == s[sa[rk[i] - 1] + k]; k++) {
}
h[rk[i]] = k;
}
}
int main() {
ios::sync_with_stdio(0), cin.tie(0);
cin >> n >> s, s = ' ' + s;
SA(n, s, sa, rk), Geth(n, s, sa, rk, h);
for (int i = 1; i <= n; i++) {
ans += n - sa[i] + 1 - h[i];
}
cout << ans;
return 0;
}
SP32951 ADASTRNG - Ada and Substring
题目链接:https://www.luogu.com.cn/problem/SP32951
题意:
给定一个长度为 a
到 z
开头的本质不同的子串个数。
思路:
其实和前一题很像,首先可以将字符串后缀排序,对于
代码:
#include <bits/stdc++.h>
using namespace std;
const int kMaxN = 1e6 + 5;
int n, sa[kMaxN], rk[kMaxN * 2], oldrk[kMaxN * 2], h[kMaxN];
string s;
long long ans[26];
void SA(int n, string s, int sa[], int rk[]) {
fill(rk, rk + 1 + n + n, -1);
for (int i = 1; i <= n; i++) {
sa[i] = i, rk[i] = s[i];
}
for (int w = 1, p = 0; w < n && p != n; w <<= 1) {
sort(sa + 1, sa + 1 + n, [&](int i, int j) { return rk[i] == rk[j] ? rk[i + w] < rk[j + w] : rk[i] < rk[j]; });
copy(rk, rk + 1 + n + n, oldrk), p = 0;
for (int i = 1; i <= n; i++) {
rk[sa[i]] = p += !(oldrk[sa[i]] == oldrk[sa[i - 1]] && oldrk[sa[i] + w] == oldrk[sa[i - 1] + w]);
}
}
}
void Geth(int n, string s, int sa[], int rk[], int h[]) {
for (int i = 1, k = 0; i <= n; i++) {
for (k -= !!k; i + k <= n && sa[rk[i] - 1] + k <= n && s[i + k] == s[sa[rk[i] - 1] + k]; k++) {
}
h[rk[i]] = k;
}
}
int main() {
ios::sync_with_stdio(0), cin.tie(0);
cin >> s, n = s.size(), s = ' ' + s;
SA(n, s, sa, rk), Geth(n, s, sa, rk, h);
for (int i = 1; i <= n; i++) {
ans[s[sa[i]] - 'a'] += n - sa[i] + 1 - h[i];
}
for (int i = 0; i < 26; i++) {
cout << ans[i] << ' ';
}
return 0;
}
【推荐】国内首个AI IDE,深度理解中文开发场景,立即下载体验Trae
【推荐】编程新体验,更懂你的AI,立即体验豆包MarsCode编程助手
【推荐】抖音旗下AI助手豆包,你的智能百科全书,全免费不限次数
【推荐】轻量又高性能的 SSH 工具 IShell:AI 加持,快人一步
· 地球OL攻略 —— 某应届生求职总结
· 周边上新:园子的第一款马克杯温暖上架
· Open-Sora 2.0 重磅开源!
· 提示词工程——AI应用必不可少的技术
· .NET周刊【3月第1期 2025-03-02】