后缀数组简介
一些约定
字符串相关的定义请参考 字符串基础。
字符串下标从
字符串
" 后缀
后缀数组是什么?
后缀数组(Suffix Array)主要关系到两个数组:
其中,
这两个数组满足性质:
解释
后缀数组示例:
后缀数组怎么求?
O(n^2logn) 做法
相信这个做法大家还是能自己想到的:将盛有全部后缀字符串的数组进行 sort
排序,由于排序进行
O(nlog^2n) 做法
这个做法要用到倍增的思想。
首先对字符串
倍增过程:
-
用两个长度为
的子串的排名,即 和 ,作为排序的第一第二关键字,就可以对字符串 的每个长度为 的子串: 进行排序,得到 和 ; -
之后用两个长度为
的子串的排名,即 和 ,作为排序的第一第二关键字,就可以对字符串 的每个长度为 的子串: 进行排序,得到 和 ; -
以此倍增,用长度为
的子串的排名,即 和 ,作为排序的第一第二关键字,就可以对字符串 的每个长度为 的子串 进行排序,得到 和 。其中,类似字母序排序规则,当 时, 视为无穷小; -
即是子串 的排名,这样当 时,得到的编号数组 ,也就是我们需要的后缀数组。
过程
倍增排序示意图:
显然倍增的过程是 sort
对子串进行排序是
除此之外,每次倍增在 sort
排序完后,还有额外的
所以这个算法的时间复杂度就是
实现
#include <algorithm>
#include <cstdio>
#include <cstring>
#include <iostream>
using namespace std;
constexpr int N = 1000010;
char s[N];
int n, w, sa[N], rk[N << 1], oldrk[N << 1];
// 为了防止访问 rk[i+w] 导致数组越界,开两倍数组。
// 当然也可以在访问前判断是否越界,但直接开两倍数组方便一些。
int main() {
int i, p;
scanf("%s", s + 1);
n = strlen(s + 1);
for (i = 1; i <= n; ++i) sa[i] = i, rk[i] = s[i];
for (w = 1; w < n; w <<= 1) {
sort(sa + 1, sa + n + 1, [](int x, int y) {
return rk[x] == rk[y] ? rk[x + w] < rk[y + w] : rk[x] < rk[y];
}); // 这里用到了 lambda
memcpy(oldrk, rk, sizeof(rk));
// 由于计算 rk 的时候原来的 rk 会被覆盖,要先复制一份
// 若两个子串相同,它们对应的 rk 也需要相同,所以要去重
for (p = 0, i = 1; i <= n; ++i) {
if (oldrk[sa[i]] == oldrk[sa[i - 1]] &&
oldrk[sa[i] + w] == oldrk[sa[i - 1] + w]) {
rk[sa[i]] = p;
} else {
rk[sa[i]] = ++p;
}
}
}
for (i = 1; i <= n; ++i) printf("%d ", sa[i]);
return 0;
}
O(nlogn) 做法
在刚刚的
由于计算后缀数组的过程中排序的关键字是排名,值域为
实现
#include <algorithm>
#include <cstdio>
#include <cstring>
#include <iostream>
using namespace std;
constexpr int N = 1000010;
char s[N];
int n, sa[N], rk[N << 1], oldrk[N << 1], id[N], cnt[N];
int main() {
int i, m, p, w;
scanf("%s", s + 1);
n = strlen(s + 1);
m = 127;
for (i = 1; i <= n; ++i) ++cnt[rk[i] = s[i]];
for (i = 1; i <= m; ++i) cnt[i] += cnt[i - 1];
for (i = n; i >= 1; --i) sa[cnt[rk[i]]--] = i;
memcpy(oldrk + 1, rk + 1, n * sizeof(int));
for (p = 0, i = 1; i <= n; ++i) {
if (oldrk[sa[i]] == oldrk[sa[i - 1]]) {
rk[sa[i]] = p;
} else {
rk[sa[i]] = ++p;
}
}
for (w = 1; w < n; w <<= 1, m = n) {
// 对第二关键字:id[i] + w进行计数排序
memset(cnt, 0, sizeof(cnt));
memcpy(id + 1, sa + 1,
n * sizeof(int)); // id保存一份儿sa的拷贝,实质上就相当于oldsa
for (i = 1; i <= n; ++i) ++cnt[rk[id[i] + w]];
for (i = 1; i <= m; ++i) cnt[i] += cnt[i - 1];
for (i = n; i >= 1; --i) sa[cnt[rk[id[i] + w]]--] = id[i];
// 对第一关键字:id[i]进行计数排序
memset(cnt, 0, sizeof(cnt));
memcpy(id + 1, sa + 1, n * sizeof(int));
for (i = 1; i <= n; ++i) ++cnt[rk[id[i]]];
for (i = 1; i <= m; ++i) cnt[i] += cnt[i - 1];
for (i = n; i >= 1; --i) sa[cnt[rk[id[i]]]--] = id[i];
memcpy(oldrk + 1, rk + 1, n * sizeof(int));
for (p = 0, i = 1; i <= n; ++i) {
if (oldrk[sa[i]] == oldrk[sa[i - 1]] &&
oldrk[sa[i] + w] == oldrk[sa[i - 1] + w]) {
rk[sa[i]] = p;
} else {
rk[sa[i]] = ++p;
}
}
}
for (i = 1; i <= n; ++i) printf("%d ", sa[i]);
return 0;
}
一些常数优化
如果你把上面那份代码交到 LOJ #111: 后缀排序 上:
这是因为,上面那份代码的常数的确很大。
第二关键字无需计数排序
思考一下第二关键字排序的实质,其实就是把超出字符串范围(即
int cur = 0;
for (int i = n - w + 1; i <= n; i++) id[++cur] = i;
for (int i = 1; i <= n; i++)
if (sa[i] > w) id[++cur] = sa[i] - w;
优化计数排序的值域
每次对
若排名都不相同可直接生成后缀数组
考虑新的
实现
#include <algorithm>
#include <cstdio>
#include <cstring>
#include <iostream>
using namespace std;
constexpr int N = 1000010;
char s[N];
int n;
int m, p, rk[N * 2], oldrk[N], sa[N * 2], id[N], cnt[N];
int main() {
scanf("%s", s + 1);
n = strlen(s + 1);
m = 128;
for (int i = 1; i <= n; i++) cnt[rk[i] = s[i]]++;
for (int i = 1; i <= m; i++) cnt[i] += cnt[i - 1];
for (int i = n; i >= 1; i--) sa[cnt[rk[i]]--] = i;
for (int w = 1;; w <<= 1, m = p) { // m = p 即为值域优化
int cur = 0;
for (int i = n - w + 1; i <= n; i++) id[++cur] = i;
for (int i = 1; i <= n; i++)
if (sa[i] > w) id[++cur] = sa[i] - w;
memset(cnt, 0, sizeof(cnt));
for (int i = 1; i <= n; i++) cnt[rk[i]]++;
for (int i = 1; i <= m; i++) cnt[i] += cnt[i - 1];
for (int i = n; i >= 1; i--) sa[cnt[rk[id[i]]]--] = id[i];
p = 0;
memcpy(oldrk, rk, sizeof(oldrk));
for (int i = 1; i <= n; i++) {
if (oldrk[sa[i]] == oldrk[sa[i - 1]] &&
oldrk[sa[i] + w] == oldrk[sa[i - 1] + w])
rk[sa[i]] = p;
else
rk[sa[i]] = ++p;
}
if (p == n) break; // p = n 时无需再排序
}
for (int i = 1; i <= n; i++) printf("%d ", sa[i]);
return 0;
}
O(n) 做法
在一般的题目中,常数较小的倍增求后缀数组是完全够用的,求后缀数组以外的部分也经常有
但如果遇到特殊题目、时限较紧的题目,或者是你想追求更短的用时,就需要学习
SA-IS
可以参考 诱导排序与 SA-IS 算法,另外它的 评论页面 也有参考价值。
DC3
可以参考[2009] 后缀数组——处理字符串的有力工具 by. 罗穗骞。
后缀数组的应用
寻找最小的循环移动位置
将字符串
例题:「JSOI2007」字符加密。
在字符串中找子串
任务是在线地在主串
从字符串首尾取字符最小化字典序
题意:给你一个字符串,每次从首或尾取一个字符组成字符串,问所有能够组成的字符串中字典序最小的一个。
题解
暴力做法就是每次最坏
由于需要在原串后缀与反串后缀构成的集合内比较大小,可以将反串拼接在原串后,并在中间加上一个没出现过的字符(如 #
,代码中可以直接使用空字符),求后缀数组,即可
参考代码
#include <cctype>
#include <cstring>
#include <iostream>
using namespace std;
constexpr int N = 1000010;
char s[N];
int n, sa[N], id[N], oldrk[N * 2], rk[N * 2], px[N], cnt[N];
bool cmp(int x, int y, int w) {
return oldrk[x] == oldrk[y] && oldrk[x + w] == oldrk[y + w];
}
int main() {
int i, w, m = 200, p, l = 1, r, tot = 0;
cin >> n;
r = n;
for (i = 1; i <= n; ++i)
while (cin >> s[i], !isalpha(s[i]));
for (i = 1; i <= n; ++i)
rk[i] = rk[2 * n + 2 - i] = s[i]; // 拼接正反两个字符串,中间空出一个字符
n = 2 * n + 1;
// 求后缀数组
for (i = 1; i <= n; ++i) ++cnt[rk[i]];
for (i = 1; i <= m; ++i) cnt[i] += cnt[i - 1];
for (i = n; i >= 1; --i) sa[cnt[rk[i]]--] = i;
for (w = 1; w < n; w *= 2, m = p) { // m=p 就是优化计数排序值域
for (p = 0, i = n; i > n - w; --i) id[++p] = i;
for (i = 1; i <= n; ++i)
if (sa[i] > w) id[++p] = sa[i] - w;
memset(cnt, 0, sizeof(cnt));
for (i = 1; i <= n; ++i) ++cnt[px[i] = rk[id[i]]];
for (i = 1; i <= m; ++i) cnt[i] += cnt[i - 1];
for (i = n; i >= 1; --i) sa[cnt[px[i]]--] = id[i];
memcpy(oldrk, rk, sizeof(rk));
for (p = 0, i = 1; i <= n; ++i)
rk[sa[i]] = cmp(sa[i], sa[i - 1], w) ? p : ++p;
}
// 利用后缀数组O(1)进行判断
while (l <= r) {
cout << (rk[l] < rk[n + 1 - r] ? s[l++] : s[r--]);
if ((++tot) % 80 == 0) cout << '\n'; // 回车
}
return 0;
}
height 数组
LCP(最长公共前缀)
两个字符串
下文中以
height 数组的定义
O(n) 求 height 数组需要的一个引理
证明
当
当
根据
既然后缀
那么不妨用
那么后缀
进一步地,后缀
因为后缀
所以
于是就可以得出
O(n) 求 height 数组的代码实现
利用上面这个引理暴力求即可:
for (i = 1, k = 0; i <= n; ++i) {
if (rk[i] == 0) continue;
if (k) --k;
while (s[i + k] == s[sa[rk[i] - 1] + k]) ++k;
height[rk[i]] = k;
}
height 数组的应用
两子串最长公共前缀
感性理解:如果
严格证明可以参考[2004] 后缀数组 by. 许智磊。
有了这个定理,求两子串最长公共前缀就转化为了 RMQ 问题。
比较一个字符串的两个子串的大小关系
假设需要比较的是
若
否则,
不同子串的数目
子串就是后缀的前缀,所以可以枚举每个后缀,计算前缀总数,再减掉重复。
「前缀总数」其实就是子串个数,为
如果按后缀排序的顺序枚举后缀,每次新增的子串就是除了与上一个后缀的 LCP 剩下的前缀。这些前缀一定是新增的,否则会破坏
所以答案为:
出现至少 k 次的子串的最大长度
题解
出现至少
所以,求出每相邻
可以使用单调队列
参考代码
#include <cstring>
#include <iostream>
#include <set>
using namespace std;
constexpr int N = 40010;
int n, k, a[N], sa[N], rk[N], oldrk[N], id[N], px[N], cnt[1000010], ht[N], ans;
multiset<int> t; // multiset 是最好写的实现方式
bool cmp(int x, int y, int w) {
return oldrk[x] == oldrk[y] && oldrk[x + w] == oldrk[y + w];
}
int main() {
cin.tie(nullptr)->sync_with_stdio(false);
int i, j, w, p, m = 1000000;
cin >> n >> k;
--k;
for (i = 1; i <= n; ++i) cin >> a[i]; // 求后缀数组
for (i = 1; i <= n; ++i) ++cnt[rk[i] = a[i]];
for (i = 1; i <= m; ++i) cnt[i] += cnt[i - 1];
for (i = n; i >= 1; --i) sa[cnt[rk[i]]--] = i;
for (w = 1; w < n; w <<= 1, m = p) {
for (p = 0, i = n; i > n - w; --i) id[++p] = i;
for (i = 1; i <= n; ++i)
if (sa[i] > w) id[++p] = sa[i] - w;
memset(cnt, 0, sizeof(cnt));
for (i = 1; i <= n; ++i) ++cnt[px[i] = rk[id[i]]];
for (i = 1; i <= m; ++i) cnt[i] += cnt[i - 1];
for (i = n; i >= 1; --i) sa[cnt[px[i]]--] = id[i];
memcpy(oldrk, rk, sizeof(rk));
for (p = 0, i = 1; i <= n; ++i)
rk[sa[i]] = cmp(sa[i], sa[i - 1], w) ? p : ++p;
}
for (i = 1, j = 0; i <= n; ++i) { // 求 height
if (j) --j;
while (a[i + j] == a[sa[rk[i] - 1] + j]) ++j;
ht[rk[i]] = j;
}
for (i = 1; i <= n; ++i) { // 求所有最小值的最大值
t.insert(ht[i]);
if (i > k) t.erase(t.find(ht[i - k]));
ans = max(ans, *t.begin());
}
cout << ans;
return 0;
}
是否有某字符串在文本串中至少不重叠地出现了两次
可以二分目标串的长度
连续的若干个相同子串
我们可以枚举连续串的长度
例题:「NOI2016」优秀的拆分。
结合并查集
某些题目求解时要求你将后缀数组划分成若干个连续 LCP 长度大于等于某一值的段,亦即将
经典题目:「NOI2015」品酒大会
结合线段树
某些题目让你求满足条件的前若干个数,而这些数又在后缀排序中的一个区间内。这时我们可以用归并排序的性质来合并两个结点的信息,利用线段树维护和查询区间答案。
结合单调栈
例题:「AHOI2013」差异
题解
被加数的前两项很好处理,为
我们知道
考虑每个位置对答案的贡献是哪些后缀的 LCP,其实就是从它开始向左若干个连续的
单调栈部分类似于 Luogu P2659 美丽的序列 以及 悬线法。
参考代码
#include <cstring>
#include <iostream>
#include <string>
using namespace std;
constexpr int N = 500010;
string s;
int n, sa[N], rk[N << 1], oldrk[N << 1], id[N], px[N], cnt[N], ht[N], sta[N],
top, l[N];
long long ans;
bool cmp(int x, int y, int w) {
return oldrk[x] == oldrk[y] && oldrk[x + w] == oldrk[y + w];
}
int main() {
int i, k, w, p, m = 300;
cin >> s;
n = s.size();
s = " " + s;
ans = 1ll * n * (n - 1) * (n + 1) / 2;
// 求后缀数组
for (i = 1; i <= n; ++i) ++cnt[rk[i] = s[i]];
for (i = 1; i <= m; ++i) cnt[i] += cnt[i - 1];
for (i = n; i >= 1; --i) sa[cnt[rk[i]]--] = i;
for (w = 1; w < n; w <<= 1, m = p) {
for (p = 0, i = n; i > n - w; --i) id[++p] = i;
for (i = 1; i <= n; ++i)
if (sa[i] > w) id[++p] = sa[i] - w;
memset(cnt, 0, sizeof(cnt));
for (i = 1; i <= n; ++i) ++cnt[px[i] = rk[id[i]]];
for (i = 1; i <= m; ++i) cnt[i] += cnt[i - 1];
for (i = n; i >= 1; --i) sa[cnt[px[i]]--] = id[i];
memcpy(oldrk, rk, sizeof(rk));
for (p = 0, i = 1; i <= n; ++i)
rk[sa[i]] = cmp(sa[i], sa[i - 1], w) ? p : ++p;
}
// 求 height
for (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;
}
// 维护单调栈
for (i = 1; i <= n; ++i) {
while (ht[sta[top]] > ht[i]) --top; // top类似于一个指针
l[i] = i - sta[top];
sta[++top] = i;
}
// 最后利用单调栈算 ans
sta[++top] = n + 1;
ht[n + 1] = -1;
for (i = n; i >= 1; --i) {
while (ht[sta[top]] >= ht[i]) --top;
ans -= 2ll * ht[i] * l[i] * (sta[top] - i);
sta[++top] = i;
}
cout << ans;
return 0;
}
类似的题目:「HAOI2016」找相同字符。
习题
- UVa 760 - DNA Sequencing
- UVa 1223 - Editor
- Codechef - Tandem
- Codechef - Substrings and Repetitions
- Codechef - Entangled Strings
- Codeforces - Martian Strings
- Codeforces - Little Elephant and Strings
- SPOJ - Ada and Terramorphing
- SPOJ - Ada and Substring
- UVa - 1227 - The longest constant gene
- SPOJ - Longest Common Substring
- UVa 11512 - GATTACA
- LA 7502 - Suffixes and Palindromes
- GYM - Por Costel and the Censorship Committee
- UVa 1254 - Top 10
- UVa 12191 - File Recover
- UVa 12206 - Stammering Aliens
- Codechef - Jarvis and LCP
- LA 3943 - Liking's Letter
- UVa 11107 - Life Forms
- UVa 12974 - Exquisite Strings
- UVa 10526 - Intellectual Property
- UVa 12338 - Anti-Rhyme Pairs
- DevSkills Reconstructing Blue Print of Life
- UVa 12191 - File Recover
- SPOJ - Suffix Array
- LA 4513 - Stammering Aliens
- SPOJ - LCS2
- Codeforces - Fake News (hard)
- SPOJ - Longest Commong Substring
- SPOJ - Lexicographical Substring Search
- Codeforces - Forbidden Indices
- Codeforces - Tricky and Clever Password
- LA 6856 - Circle of digits
参考资料
本页面中(4070a9b 引入的部分)主要译自博文 Суффиксный массив 与其英文翻译版 Suffix Array。其中俄文版版权协议为 Public Domain + Leave a Link;英文版版权协议为 CC-BY-SA 4.0。
论文:
创建日期: 2018年7月11日