学习笔记:后缀数据结构
学习笔记:后缀数据结构
后缀数组(SA)
相关定义
以下讨论的对象为一个字符串 \(s\),长度为 \(n\)。
定义 \(rk_i\) 表示后缀 \(s[i..n]\) 在所有后缀中的字典序排名。由于任意后缀长度不同,故不存在两个后缀排名相同。
定义 \(sa_k\) 表示排名为 \(k\) 的后缀的开始位置。
由定义,\(rk\) 与 \(sa\) 互逆,即 \(rk(sa_i)=sa(rk_i)=\mathrm{Id}(i)=i\)。
后缀排序
我们定义二元组的偏序关系:\((x_1,y_1)\le(x_2,y_2)\) 当且仅当 \(x_1<x_2\) 或 \(x_1=x_2\) 且 \(y_1\le y_2\)。这与 C++ 中 pair 的比较逻辑相同。
对于两个长度相等且为偶数的字符串 \(s\) 和 \(t\),我们总可以把它们分成两半 \(s_1,s_2\) 和 \(t_1,t_2\),进而通过比较二元组 \((s_1,s_2)\) 和 \((t_1,t_2)\) 的大小,可以得到 \(s\) 和 \(t\) 的大小关系。
因此考虑倍增,设 \(s(i,w)=s[i..i+2^w-1]\),则 \(s(i,w)=s(i,w-1)+s(i+2^{w-1},w-1)\)(\(w\ge1\))。如果 \(i+2^w-1>n\),超出部分用空字符补齐。
设当前 \(rk_i\) 表示所有 \(s(i,w-1)\)(\(i=1,\cdots,n\))的排名,目标是求出 \(s(i,w)\)(\(i=1,\cdots,n\))对应的新排名数组 \(rk'_i\)。
当 \(2^w\ge n\) 时,就求出了所有 \(s[i..n]\) 的排名,排序完毕。
由于 \(s(i,w)\) 可以拆成两部分,所以 \(rk'_i<rk'_j\) 当且仅当二元组 \((rk_i,rk_{i+2^{w-1}})<(rk_j,rk_{j+2^{w-1}})\)。因此,对二元组序列 \((rk_i,rk_{i+2^{w-1}})\) 进行排序即可得到新的 \(rk'_i\)。
借用国家集训队论文的一张图:

两个关键字值域大小为 \(n\),故使用内部为计数排序的基数排序,即可做到单次倍增中排序复杂度 \(O(n)\),总复杂度 \(O(n\log n)\)。
计数排序:设 \((a_i)\) 为待排序的序列,\(c_k\) 为 \((a_i)\) 中 \(k\) 的出现次数,\((s_k)\) 为 \((c_k)\) 的前缀和序列,则排序过程如下:
算出前缀和序列之后,键值为 \(k\) 的元素应放在 \([s_{k-1}+1,s_k]\) 的下标区间内。从 \(i=n\) 到 \(1\) 枚举,将 \(a_i\) 放到下标为 \(s_{a_i}\) 的位置,同时 \(s_{a_i}\) 减一。如图所示。
时间复杂度 \(O(n+m)\),其中 \(m\) 为值域大小。计数排序是稳定排序(上图紫色数字为第二键值,其顺序不变)。
基数排序:如果每个元素有 \(k\) 个关键字,先对所有元素按第 \(k\) 个关键字进行稳定排序,再对所有元素按第 \(k-1\) 个关键字进行稳定排序……最后对所有元素按第 \(1\) 个关键字进行稳定排序。这样实现的 LSD 基数排序是稳定排序。
如果单个关键字值域不大,对每个关键字可以使用计数排序,时间复杂度 \(O(kn+\sum_{i=1}^km_i)\)。
常数优化
-
不需要对第二关键字进行排序:设当前轮的串长为 \(2w\),借助上一轮的 \(sa\) 数组,我们可以求出按第二关键字排序后的 \(sa'\)。
- 如果 \(i+w>n\),由 \(s[i..i+2w-1]=s[i..w-1]+s[i+w..i+2w-1]\) 可知后半部分为空串,因此第二关键字排序后在最前面。
- 如果 \(i+w\le n\),我们希望按 \(rk(i+w)\) 从小到大对 \(i\) 进行排序。考虑 \(rk(w+1)\) 到 \(rk(n)\) 是怎么来的,就是由满足 \(sa_i=w+1,\cdots,n\) 的 \(i\) 得来的,并且这些 \(i\) 对应的 \(rk_i\) 一定是非严格递增的,因此枚举 \(i=1,\cdots,n\),如果 \(sa_i>w\),则加入 \(sa_i-w\)。这样就得到了按第二关键字排序后的 \(sa'\)。
(
我也不知道我上面在叽里咕噜说啥。手模一下可能会感受到这样做的原因所在。) -
注意到在中间过程中,\(rk\) 的最大值很可能不为 \(n\),因为排序时有重复元素的存在。此时,如果设当前串长为 \(w\),则 \(rk_i\) 的意义为:在所有长度为 \(w\) 的子串中,小于等于 \(s[i..i+w-1]\) 的子串数量。因此,在做计数排序时,桶的下标最多枚举到上一轮 \(rk\) 的最大值,即 \(rk(sa_n)\);如果 \(rk(sa_n)\),说明所有子串都已不同,继续往后加后缀也不会改变相对大小,因此直接退出算法,无需 \(w\ge n\)。
模板题代码
#include <bits/stdc++.h>
using namespace std;
int const N = 1e6 + 10, M = 1 << 7;
int n;
string s;
int sa[N], rk[N], ork[N], buc[N], id[N]; // ork: old rank; id: sorted by second key
void get_SA() {
int p = 0, m = M - 1;
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[rk[i]]--] = i;
for (int w = 1; ; m = p, p = 0, w <<= 1) { // m: max rank (max index of buckets)
for (int i = n - w + 1; i <= n; i++) id[++p] = i;
for (int i = 1; i <= n; i++) if (sa[i] > w) id[++p] = sa[i] - w;
memset(buc, 0, sizeof(int) * (m + 1));
p = 0;
for (int i = 1; i <= n; i++) buc[rk[i]]++;
for (int i = 1; i <= m; i++) buc[i] += buc[i - 1];
for (int i = n; i; i--) sa[buc[rk[id[i]]]--] = id[i]; // sa: sorted by first key (rk[i])
memcpy(ork, rk, sizeof(int) * (n + 1));
for (int i = 1; i <= n; i++)
if (ork[sa[i]] == ork[sa[i - 1]] && ork[sa[i] + w] == ork[sa[i - 1] + w])
rk[sa[i]] = p;
else rk[sa[i]] = ++p;
if (p == n) break;
}
return;
}
signed main() {
cin.tie(0)->sync_with_stdio(false);
cin >> s;
n = s.size();
s = ' ' + s;
get_SA();
for (int i = 1; i <= n; i++) cout << sa[i] << ' ';
cout << '\n';
return 0;
}
height 数组
下称后缀 \(s[i..n]\) 为「后缀 \(i\)」。后缀 \(i\) 和后缀 \(j\) 的最长公共前缀(LCP)记为 \(\mathrm{lcp}(i,j)\)。
定义 \(ht_i\) 表示排名为 \(i-1\) 和 \(i\) 的后缀的 LCP 长度,即 \(|\mathrm{lcp}(sa_{i-1},sa_i)|\)。定义 \(ht_1=0\)。
关于 \(ht\) 数组,有两个结论:
- 结论 1:若 \(rk_i<rk_j<rk_k\),则 \(|\mathrm{lcp}(i,k)|\le\min\{|\mathrm{lcp}(i,j)|,|\mathrm{lcp}(j,k)|\}\),即 \(\mathrm{lcp}(i,k)\) 比 \(\mathrm{lcp}(i,j)\) 和 \(\mathrm{lcp}(j,k)\) 都要短。
- 结论 2:\(ht(rk_u)\ge ht(rk_{u-1})-1\),其中 \(u>1\)。
结论 1 的证明:显然字典序离得越近,LCP 越长。若结论不成立,例如 \(|\mathrm{lcp}(i,k)|>|\mathrm{lcp}(i,j)|\),那么 \(k\) 的排名应该在 \(j\) 前面,矛盾。
结论 2 的证明:若 \(ht(rk_{u-1})\le1\),右式小于等于零,显然成立。下面设 \(ht(rk_{u-1})>1\)。设后缀 \(sa(rk_{u-1})=u-1\) 和后缀 \(sa(rk_{u-1}-1)\) 分别为 \(aAD\) 和 \(aAB\),其中 \(a\) 为字符,\(A,B,D\) 为字符串,\(B<D\),\(|aA|=ht(rk_{u-1})\),那么 \(|A|=ht(rk_{u-1})-1\)。考虑后缀 \(u\) 和后缀 \(sa(rk_{u-1}-1)+1\),二者分别为 \(AD\) 和 \(AB\),那么在二者之间一定存在排名为 \(rk_u-1\) 的后缀,根据结论 1,有 \(|\mathrm{lcp}(sa(rk_u-1),u)|\ge|\mathrm{lcp}(u,sa(rk_{u-1}-1)+1)|\),即 \(ht(rk_u)\ge|A|=ht(rk_{u-1})-1\)。
根据该性质,我们可以按 \(i\) 递增的顺序求 \(ht(rk_i)\) 数组。考虑如何由 \(ht(rk_{i-1})\) 即排名为 \(rk_{i-1}\) 和排名为 \(rk_{i-1}+1\) 后缀的 LCP 长度,求 \(ht(rk_i)\) 即排名为 \(rk_i\) 和排名为 \(rk_i+1\) 的后缀的 LCP 长度。由性质可知,值最多减少 \(1\),设这个值为 \(k\),那么每次求值先令 \(k\) 减一,然后暴力向后试图增加 \(k\),直到无法匹配即可,时间复杂度为 \(O(n)\)。代码如下:
int ht[N];
void get_height() {
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;
}
return;
}
例题
洛谷-P4248 [AHOI2013] 差异(单调栈)
给定一个长度为 \(n\) 的字符串 \(S\),令 \(T_i\) 表示它从第 \(i\) 个字符开始的后缀。求
\[\displaystyle \sum_{1\leqslant i<j\leqslant n}\operatorname{len}(T_i)+\operatorname{len}(T_j)-2\cdot\operatorname{len}(\operatorname{lcp}(T_i,T_j)), \]其中 \(\text{len}(a)\) 表示字符串 \(a\) 的长度,\(\text{lcp}(a,b)\) 表示字符串 \(a\) 和字符串 \(b\) 的最长公共前缀。
\(2\le n\le 500000\),且 \(S\) 中均为小写字母。
首先可以把式子拆开,前两项和即为 \(\sum_{i<j}(i+j)\),由于每个 \(k=1,\cdots,n\) 作为 \(i\) 或 \(j\) 被计算 \(n-1\) 次,所以即为 \((n-1)\sum_{k=1}^nk=\frac12(n-1)n(n+1)\)。\(O(1)\) 算出即可。
下面求 \(\sum_{i<j}|\mathrm{lcp}(T_i,T_j)|\)。显然对后缀排序不影响答案。
后缀排序后,相邻排名的后缀的 LCP 长度为 \(ht(i)\),则排名 \(i\) 和 \(j\) 的后缀的 LCP 长度为 \(\min\{ht(i+1),ht(i+2),\cdots,ht(j)\}\)。
设 \(F(i)=\sum_{j=1}^{i-1}|\mathrm{lcp}(sa_j,sa_i)|\),即 \(F(i)\) 为排名在 \(i\) 前面的所有后缀与排名为 \(i\) 的后缀的 LCP 长度之和。
将 \(ht(i)\) 看做数轴上宽度为 \(1\)、高度为 \(ht(i)\) 的矩形,则利用单调栈即可求解 \(F(2),\cdots,F(n)\),答案减去 \(2\sum_{i=2}^nF(i)\) 即可。
洛谷-P3975 [TJOI2015] 弦论
给定一个长度为 \(n\) 的字符串 \(S\),求第 \(k\) 小子串。若 \(t=0\) 表示不同位置相同子串算作一个,\(t=1\) 表示不同位置相同子串算两个。
若不同子串(或本质不同子串)个数不足 \(k\) 个,输出 \(-1\)。\(1\le n\le5\cdot10^5\),\(1\le k\le10^9\)。


浙公网安备 33010602011771号