后缀数组
创建时间:2026-02-06
后缀数组的概念
对于一个长度为 \(n\) 的字符串 \(s\),取出其所有后缀,按字典序从小到大排序。
定义其后缀数组(Suffix Array, SA)\(sa_1, sa_2, sa_3, \cdots, sa_n\) 表示排名为 \(1, 2, 3, \cdots, n\) 的后缀的起始下标。定义排名数组 \(rk_i\) 为以 \(i\) 开头的后缀的排名。
根据定义显然有 \(rk_{sa_i} = i\)。
以 \(s = \textup{banana}\) 为例,我们有:
| 索引 | 后缀 | 排序后的后缀 | \(sa\) | \(rk\) |
|---|---|---|---|---|
| \(1\) | \(\text{banana}\) | \(\text{a}\) | \(6\) | \(4\) |
| \(2\) | \(\text{anana}\) | \(\text{ana}\) | \(4\) | \(3\) |
| \(3\) | \(\text{nana}\) | \(\text{anana}\) | \(2\) | \(6\) |
| \(4\) | \(\text{ana}\) | \(\text{banana}\) | \(1\) | \(2\) |
| \(5\) | \(\text{na}\) | \(\text{na}\) | \(5\) | \(5\) |
| \(6\) | \(\text{a}\) | \(\text{nana}\) | \(3\) | \(1\) |
后缀数组的求法
倍增求后缀数组
看到P3809 【模板】后缀排序,给定一个字符串 \(s\)(\(|s| \le 10^6\)),求其后缀数组。
记 \(n = |s|\)。
最简单的做法是取出所有后缀,暴力排序并比较,复杂度 \(O(n^2 \log n)\),太慢了。
容易发现,暴力做法慢的原因是每次比较比较两个后缀的字典序大小都要 \(O(n)\) 的时间,所以考虑利用已知信息加速比较。
尝试倍增,每次先比较所有后缀的前 \(k\) 个字符,求出其 \(sa\) 和 \(rk\),这样就可以通过以 \(rk_i\) 为第一关键字,\(rk_{i+k}\) 为第二关键字快速比较两个后缀的前 \(2k\) 个字符。
具体的,当且仅当 \(rk_i < rk_j\) 或 \(rk_i = rk_j\) 且 \(rk_{i+k} < rk_{j+k}\),以 \(i\) 开头的后缀的前 \(2k\) 个字符小于以 \(j\) 开头的后缀的前 \(2k\) 个字符(当 \(i+k>n\) 时认为 \(rk_{i+k} = 0\),即空字符串的字典序永远最小,\(j\) 同理)。
代码:
for (int i = 1; i <= n; i++)
sa[i] = i, rk[i] = s[i];
for (int k = 1; k <= n; k <<= 1) {
copy(rk + 1, rk + n + 1, oldrk + 1);
sort(sa + 1, sa + n + 1, [=](int x, int y) {
return oldrk[x] == oldrk[y] ?
oldrk[x + k] < rk[y + k] : oldrk[x] < oldrk[y];
});
for (int j = 1; j <= n; j++) {
rk[sa[j]] = rk[sa[j - 1]];
if (oldrk[sa[j]] != oldrk[sa[j - 1]] ||
oldrk[sa[j] + k] != oldrk[sa[j - 1] + k])
rk[sa[j]]++;
}
}
这样做的时间复杂度为 \(O(n \log^2 n)\),还不够优秀。
基数排序优化
回忆刚才的做法,算法瓶颈在按双关键字比较的快速排序,复杂度 \(O(n \log n)\)。众所周知,快速排序是基于比较的排序中最快的了,所以想优化必须使用非比较类排序算法。
考虑到在该问题中需要按双关键字排序,故自然想到使用基数排序(口胡一下):
先对所有可能的第二关键字排序,并 \(pos_i\) 为记录排名是 \(i\) 的第二关键字对应 \(s\) 中哪个后缀,这通过可以 \(sa\) 数组在 \(O(n)\) 的时间内快速实现。再计算第一关键字小于等于 \(i\) 的后缀数量 \(cnt_i\),即 \(\sum_{j=1}^n [rk_j \le i]\),得到排序后的 \(sa\) 数组。
具体实现见代码。
void suffix_sort(int m) {
auto radix_sort = [](int m) {
fill(cnt, cnt + m + 1, 0);
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[pos[i]]]--] = pos[i];
};
copy(s.begin() + 1, s.end(), rk + 1);
iota(pos + 1, pos + n + 1, 1);
radix_sort(m);
for (int k = 1, tot = 0; k < n && tot < n; k *= 2, m = tot) {
tot = k;
iota(pos + 1, pos + k + 1, n - k + 1);
for (int i = 1; i <= n; i++)
if (sa[i] > k)
pos[++tot] = sa[i] - k;
radix_sort(m);
copy(rk + 1, rk + n + 1, pos + 1);
rk[sa[1]] = tot = 1;
for (int i = 2; i <= n; i++)
rk[sa[i]] = pos[sa[i]] == pos[sa[i - 1]] && pos[sa[i] + k] == pos[sa[i - 1] + k] ? tot : ++tot;
}
}
熟练后可以写得更加简洁:
void suffix_sort(int m = 128) {
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 >= 1; i--)
sa[buc[rk[i]]--] = i;
for (int k = 1, tot = 0; k < n && tot < n; k *= 2, m = tot) {
tot = k;
iota(pos + 1, pos + k + 1, n - k + 1);
for (int i = 1; i <= n; i++)
if (sa[i] > k)
pos[++tot] = sa[i] - k;
fill(buc + 1, buc + m + 1, 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 >= 1; i--)
sa[buc[rk[pos[i]]]--] = pos[i];
copy(rk + 1, rk + n + 1, pos + 1);
rk[sa[1]] = tot = 1;
for (int i = 2; i <= n; i++)
rk[sa[i]] = pos[sa[i]] == pos[sa[i - 1]] && pos[sa[i] + k] == pos[sa[i - 1] + k] ? tot : ++tot;
}
}
后缀数组与 LCP 问题
我们把两个字符串的最长公共前缀(Longest Common Prefix)叫做它们的 LCP。
定义 \(height_i\) 为以 \(sa_i\) 开头的后缀和以 \(sa_{i-1}\) 开头的后缀的 LCP 的长度。画图易证,\(height_{rk_i+1} \ge height_{rk_i} - 1\)。
故可以在 \(O(n)\) 的时间内求出 \(height\) 数组。
void build_height() {
for (int i = 1, k = 0; i <= n; i++) {
if (rk[i] == 1)
continue;
if (k > 0) k--;
int j = sa[rk[i] - 1];
while (i + k <= n && j + k <= n && s[i + k] == s[j + k])
k++;
height[rk[i]] = k;
}
}
同样可以证明,\(\forall 1 \le i < j \le n\),\(sa_i\) 和 \(sa_j\) 的 LCP 长度等于 \(\min_{i < k \le j} \{height_k\}\)。
这条性质,使得后缀数组(或 \(height\) 数组)可以解决很多和与 LCP 有关的问题,且常常与以 ST 表、单调队列为代表的和 RMQ 有关的数据结构,又或者是二分、单调栈等与单调性有关的算法结合。
习题
P4051 [JSOI2007] 字符加密(板)
P2463 [SDOI2008] Sandy 的卡片(后缀数组 + 单调队列)
P2336 [SCOI2012] 喵星球上的点名(后缀数组 + ST表 + 二分 + 树状数组/莫队)
P4248 [AHOI2013] 差异(后缀数组 + 单调栈)
P2178 [NOI2015] 品酒大会(后缀数组 + 并查集)
P5341 [TJOI2019] 甲苯先生和大中锋的字符串(后缀数组 + 单调队列)
P4094 [HEOI2016/TJOI2016] 字符串(后缀数组 + 二分答案 + ST表 + 二分 + 主席树)

浙公网安备 33010602011771号