【学习笔记】后缀数组(SA)
前言
先把 SA 给写出来,SAM 暂时还没学会,学会了应该也不会写,因为构造过程过于繁琐。
本文可能对 SA 的算法流程和代码实现上的介绍相对简略,主要介绍一些常见用途。
约定
- 无特殊说明字符串的下标从
开始。 - 无特殊说明
表示字符串, 表示字符串长度。 - “后缀
”或“第 个”后缀指字符串从第 个字符开始的后缀。 表示字符串 第 到第 个字符构成的子串。
用途
后缀数组最主要的用途就是进行后缀排序,也就是将一个字符串的
:后缀排序后排名第 的后缀的编号。 :第 个后缀进行排序后的排名。 :排第 的后缀与排第 名的后缀的 (最长公共前缀)长度,特别地 。
利用排序后后缀间的一些性质,我们可以解决许多字符串相关的问题。
算法流程
首先考虑最暴力的想法:直接将字符串的 sort
。
由于字符串比较字段序是
由于太唐了,这里不多叙述。
倍增#
考虑优化这个过程,这里需要用到一个倍增的思想。
我们先不着急对后缀进行排序,而是先对
- 最开始时,我们令
,即对所有大小为 的子串排序,记 为此轮排序的结果。 - 随后,令
,现在要对所有长为 的子串排序,此时可以直接采用双关键字排序:对每个长度为 的子串分别设定 和 为其第一二关键字。然后再直接跑sort
,就可以得到所有长度为 的子串排序的结果,同样记 为本轮排序结果。 - 再令
,现在对所有长度为 的子串排序,同样直接以 和 为关键字sort
即可,本轮排序结果为 。 - 继续重复上面的过程,直到
算法结束。
对于
这样我们 sort
排序的复杂度为
(图片来源于 OI-Wiki,仅帮助理解使用)
由于这里懒得自己写一份,同样给一个 OI-Wiki 上的代码实现:
char s[N];
int n, w, sa[N], rk[N << 1], oldrk[N << 1];
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];
});
memcpy(oldrk, rk, sizeof(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;
} // 若两个子串相同,它们对应的 rk 也需要相同,所以要去重
}
}
for (i = 1; i <= n; ++i) printf("%d ", sa[i]);
return 0;
}
基数排序#
其实还可以在进一步,如果有
有没有呢?当然有,直接使用基数排序就可以把每轮的排序过程优化到线性。
当然这里是双关键字排序,可以先按照第二关键字做一遍排序,然后在此基础上按第一关键字排序,就可以达到想要的双关键字排序的效果了。
基数排序这里不多讲了,感觉能理解桶排肯定就能懂基排吧……
实现细节#
相信读者完全理解上文后其实已经能够自己实现出
首先直接给出来我自己的后缀排序代码:
#include<bits/stdc++.h>
#define For(i, a, b) for(int i = (a); i <= (b); i++)
#define Rof(i, a, b) for(int i = (a); i >= (b); i--)
using namespace std;
int n, m, sa[1000005], cnt[1000005], rk[1000005], tmp[1000005];
char s[1000005];
void get_SA(){
n = strlen(s + 1); m = 122;
For(i, 1, n) cnt[rk[i] = s[i]]++;
For(i, 1, m) cnt[i] += cnt[i - 1];
Rof(i, n, 1) sa[cnt[rk[i]]--] = i;
for(int k = 1; k <= n; k <<= 1){
int tot = 0;
For(i, n - k + 1, n) tmp[++tot] = i;
For(i, 1, n) if(sa[i] > k) tmp[++tot] = sa[i] - k;
For(i, 1, m) cnt[i] = 0;
For(i, 1, n) cnt[rk[tmp[i]]]++;
For(i, 1, m) cnt[i] += cnt[i - 1];
Rof(i, n, 1) sa[cnt[rk[tmp[i]]]--] = tmp[i];
swap(rk, tmp); rk[sa[1]] = tot = 1;
auto check = [&](int x, int y, int w){
return tmp[sa[x]] == tmp[sa[y]] && tmp[sa[x] + k] == tmp[sa[y] + k];
};
For(i, 2, n) rk[sa[i]] = check(i, i - 1, k) ? tot : ++tot;
if(n == tot) break; m = tot;
}
}
void Solve(){
cin >> (s + 1); get_SA();
For(i, 1, n) cout << sa[i] << ' ';
}
优化 1:第二关键字的基数排序可以省去#
仔细思考对第二关键字排序的过程,不难发现实质上是将长度在
体现在代码里的是这两行:
For(i, n - k + 1, n) tmp[++tot] = i; //不存在当然是最小
For(i, 1, n) if(sa[i] > k) tmp[++tot] = sa[i] - k; //其余顺次放入,注意这里有个 -k,因为再往前移 k 位的那个后缀是把自己当第二关键字的
优化 2:实时更新桶的值域上限#
代码中的
优化 3:所有子串排名不同时及时结束#
注意到字典序是从前往后比的,如果出现某一轮要排序的
在代码中也就是检查
height 数组#
注意到我们开局提到的三个数组目前只求了两个,还有剩下
记
引理:
具体证明我不太会,但是感性理解是容易的。
有了上面这个引理可以直接暴力求,因为每次只减
void get_height(){
for(int i = 1, k = 0; i <= n; i++){
if(rk[i] == 1) continue;
int j = sa[rk[i] - 1];
if(k) k--;
while(s[i + k] == s[j + k] && max(i + k, j + k) <= n) k++;
height[rk[i]] = k;
}
}
经典应用
讲完了整个后缀数组的构建过程,我们就要介绍一些经典用法了。
求任意两个后缀 #
对于任意两个后缀
显然,如果
可以使用 ST 表做到
比较任意两个子串字典序#
假如我们要比较
- 如果
,等价于比较两者的长度。 - 否则等价于比较
和 。
求不同子串数目#
不同指长得不同。
考虑用子串个数减去重复个数,后缀排序后相邻两个后缀之间会贡献
求任意子串出现次数#
假设我们要查询
一个在字符串问题中的常用思考方式:子串是后缀的前缀。
可以使用 ST 表维护,每次查询二分,做到
给一份示范代码:
void init(){
get_SA(); get_Height();
For(i, 2, n) lg[i] = lg[i >> 1] + 1;
For(i, 1, n) st[0][i] = height[i];
For(i, 1, 19) For(j, 1, n)
st[i][j] = min(st[i - 1][j], st[i - 1][j + (1 << i - 1)]);
}
int query(int l, int r){
int k = lg[r - l + 1];
return min(st[k][l], st[k][r - (1 << k) + 1]);
}
int get_cnt(int L, int R){ //s[l...r] 出现次数
if(!(1 <= L && L <= R && R <= n)) return 0;
int len = R - L + 1, pos = rk[L], res = 1;
int l = 1, r = pos - 1, ans = pos;
while(l <= r){
int mid = (l + r) >> 1;
if(query(mid + 1, pos) >= len) r = mid - 1, ans = mid;
else l = mid + 1;
}
res += pos - ans;
l = pos + 1, r = n, ans = pos;
while(l <= r){
int mid = (l + r) >> 1;
if(query(pos + 1, mid) >= len) l = mid + 1, ans = mid;
else r = mid - 1;
}
res += ans - pos;
return res * (R - L + 1);
}
未完待续……
【推荐】国内首个AI IDE,深度理解中文开发场景,立即下载体验Trae
【推荐】编程新体验,更懂你的AI,立即体验豆包MarsCode编程助手
【推荐】抖音旗下AI助手豆包,你的智能百科全书,全免费不限次数
【推荐】轻量又高性能的 SSH 工具 IShell:AI 加持,快人一步
· winform 绘制太阳,地球,月球 运作规律
· TypeScript + Deepseek 打造卜卦网站:技术与玄学的结合
· AI 智能体引爆开源社区「GitHub 热点速览」
· Manus的开源复刻OpenManus初探
· 写一个简单的SQL生成工具