【学习笔记】字符串后缀算法
后缀数组 Suffix Array#
后缀排序#
使用一种基数排序结合倍增的方法,将一个字符串的所有后缀排序。
定义 为排名为 的后缀起始位置, 为起始位置为 的后缀排名。
假设已然排出长度为 的全部子串,那么长度为 的只需要按照前半排名为第一关键字,后半排名的第二关键字。
于是以长度为 的排名为参照,可以先按照后半段的排名得到一个结果,在此基础上按照前半段排序。
点击查看代码
int n;
char s[maxn];
int sa[maxn],rk[maxn<<1],cnt[maxn],oldsa[maxn],oldrk[maxn<<1],tmp[maxn];
inline void get_sa(){
int m=max(n,127);
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 l=1,k;l<n;l<<=1){
//以上一次排序的rk作为参考
//先按照后半段排序
memset(cnt,0,sizeof(cnt));
for(int i=1;i<=n;++i) oldsa[i]=sa[i];
for(int i=1;i<=n;++i) ++cnt[rk[oldsa[i]+l]];
for(int i=1;i<=m;++i) cnt[i]+=cnt[i-1];
//基数排序要保留原有的关键字,因此从后向前枚举排名
for(int i=n;i>=1;--i) sa[cnt[rk[oldsa[i]+l]]--]=oldsa[i];
//同理按照前半段排序
memset(cnt,0,sizeof(cnt));
for(int i=1;i<=n;++i) oldsa[i]=sa[i];
for(int i=1;i<=n;++i) ++cnt[rk[oldsa[i]]];
for(int i=1;i<=m;++i) cnt[i]+=cnt[i-1];
for(int i=n;i>=1;--i) sa[cnt[rk[oldsa[i]]]--]=oldsa[i];
for(int i=1;i<=n;++i) oldrk[i]=rk[i];
//现在可以得到基本的顺序,大致知道排名,同时要按照这个排名为另一个数组赋值
k=0;
for(int i=1;i<=n;++i){
//如果两个关键字完全相同排名应当一致
if(oldrk[sa[i]]==oldrk[sa[i-1]]&&oldrk[sa[i]+l]==oldrk[sa[i-1]+l]) rk[sa[i]]=k;
else rk[sa[i]]=++k;
if(k==n) return;
}
m=k;
}
}
这里每次倍增都使用的两次基数排序,然而按照后半段时已经没有必要保留原来的顺序,因此分两部分排序:后半段为空的直接放在排名最靠前的位置,剩下的根据后半段的排名依次放进去。
在此基础上进行第二次的基数排序,相当于常数优化,复杂度仍是 。
点击查看代码
int n;
char s[maxn];
int sa[maxn],rk[maxn<<1],cnt[maxn],oldsa[maxn],oldrk[maxn<<1],tmp[maxn];
inline bool cmp(int x,int y,int l){
return oldrk[x]==oldrk[y]&&oldrk[x+l]==oldrk[y+l];
}
inline void get_sa(){
int m=max(n,127);
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 l=1,k;;l<<=1){
k=0;
//后半部分为空直接加入
for(int i=n;i+l>n;--i) oldsa[++k]=i;
//剩下的按排名从小到大枚举起始位置超过l的加入
for(int i=1;i<=n;++i) if(sa[i]>l) oldsa[++k]=sa[i]-l;
//正常的基数排序
memset(cnt,0,sizeof(cnt));
for(int i=1;i<=n;++i) ++cnt[tmp[i]=rk[oldsa[i]]];
for(int i=1;i<=m;++i) cnt[i]+=cnt[i-1];
for(int i=n;i>=1;--i) sa[cnt[tmp[i]]--]=oldsa[i];
for(int i=1;i<=n;++i) oldrk[i]=rk[i];
k=0;
for(int i=1;i<=n;++i) rk[sa[i]]=cmp(sa[i],sa[i-1],l)?k:++k;
if(k==n) return;
m=k;
}
}
求 #
定义为后缀排序之后,排名为 的后缀与排名为 的后缀的最长公共前缀。
显然后缀排序后,排名越靠近的越相像,可以理性理解即得到:
也就是在已知排名相邻的 后,可以借助数据结构快速求出任意的 ,目前预处理复杂度已经降到了 。
这里定义相邻的 为 ,辅助引入一个 ,接下来证明:。
考虑后缀 实际是后缀 去掉第一个字符,假设后缀 排名前一位后缀是 ,那么 ,考虑 与 都去掉开头字符后,得到的两个后缀的排名距离不会缩小。
也就是说, 应当是一个多个相邻 取 的结果。而在这其中,就包括了 ,即证:。
由于只回退 次,只增加 次,求 数组就是 的。
点击查看代码
for(int i=1,k=0;i<=n;++i){
if(k) --k;
while(s[i+k]==s[sa[rk[i]-1]+k]) ++k;
height[rk[i]]=k;
}
一些技巧#
-
求出 后基本上可以把 转化成与区间 有关的问题。
最基本的是 查询的 ST 表,可以快速求得 。
其次统计所有 时,一共 对后缀只有 个答案,于是枚举这些答案的影响范围,实际就是单调栈求作为最小值的区间。
还有一种操作是考虑对排名相邻的连边权为 的边,任意 都是树上路径的 ,类似瓶颈路问题。求与 值对应方案数,考虑从大到小连边,两棵树彼此之间的 都是当前连边的边权。同时可以考虑重构树,新建节点的权值代替边权。
一些题#
CodeForces-822E Liar *2400#
贪心的去想,且找到一个符合 DP 的状态设计。
很小,可以枚举。显然 中同一段前缀,能分段匹配 的前缀越多越好,于是可以 DP 的目标就是分段匹配到的最大前缀。
找到 次分段后 匹配到的位置 ,从 开始分出一段,需要求两个串从固定位置开始的最大公共子串,加一个分隔符求一下 就好了。
由于是分段匹配, 中并不要求 是最后一段的结尾,而转移到的 与之相反,解决方法是取前缀 。
Luogu-P1117 NOI 2016 优秀的拆分#
神仙题。
第一步转化是 AABB
的形式相当于两个 AA
拼接,只需求出每个位置作为 AA
的开头或结尾的方案数,答案就是 。
之后是非常强大的做法:枚举长度设置关键点。假设当前的 A
长度为 ,当我们每 个位置设置一个关键点时,满足 AA
的串一定包含两个关键点,考虑求出这两个关键点 以及 ,当二者的公共部分区间有交集时,说明至少存在一个 AA
,简单计算可以确定开头的取值区间和结尾的取值区间。
求 以及 可以正反 SA,区间加最后单点查询直接差分即可。
Luogu-P2178 NOI 2015 品酒大会#
结合图论去思考, 取 的性质不仅适合 ST 表、单调栈等等数据结构,也可以考虑树上瓶颈路的问题。
即对于将 值作为边权,从大到小连边,每次连边的两个连通块之间的 就是当前枚举边的边权,合并过程中即可维护方案数以及最大乘积。
Luogu-P4248 AHOI 2013 差异#
的来源是某个 , 的贡献是作为最小值的区间,单调栈解决问题。
Luogu-P7409 SvT#
多测且选取的范围为集合,对直接查询和单调栈并不友好,考虑使用与瓶颈路有关的做法。
建出重构树后,对于非叶子节点,其权值与左右子树大小之积就是贡献。由于只需要在有左右儿子的节点贡献答案,建虚树即可。
CodeForces-1073G Yet Another LCP Problem *2600#
同上,建出虚树后改变一下求解的式子即可。
Luogu-P5028 Annihilate#
先插分隔符求出做总的后缀排序。
对于每个位置,钦定其最小值来源的串,那么一定是找到排名的前驱后继求解,使用 set
以及 ST 表。这样的复杂度是 。
这样的做法不够优秀,主要是分开枚举两个后缀的来源限制了复杂度。假定一个后缀的来源,对应的后缀在排名中打上标记,那么每个没有被打上标记的(即来自别的串的后缀),与当前串所有后缀的最大 同样也是在找前驱后继,于是直接正反各扫一遍,每个位置的答案更新为到上一个标记点的答案,时间复杂度是 。
分隔符不能使用同样的。
Luogu-P2852 USACO 2006 DEC Milk Patterns G#
同样处理瓶颈路的套路,出现次数等价于连通块的大小。
后缀自动机 Suffix Automaton#
后缀自动机与 树的构建#
简单定义#
-
:子串 在原串 中所有出现位置(最后一个字符位置)的集合。
-
等价类: 相同的所有子串构成一个等价类。
-
后缀自动机:使得所有子串都能从初始状态出发得到,并通过等价类压缩状态的自动机。
-
:一个等价类中的最大子串长。
-
:一个等价类中的最小子串长。
-
:后缀链接,定义为所有 等价类中,规模最小且是 超集的状态,或者表述为将 对应子串去掉第一个字符得到的子串所在等价类。
-
树:后缀链接 构成的一棵树。
一些定理#
-
处于同一 等价类的子串两两为后缀关系。
-
处于同一 等价类的子串长度恰好覆盖区间 。
-
在 树上,父子关系节点的 等价类为包含关系,兄弟关系节点的 等价类没有交,这构成了一棵划分集合的树。
-
树的叶子节点都是原串一个前缀。
-
反串建出的 树等价于正串的后缀树,也就是正串所有后缀构成的 。
如何构建#
按照以下算法流程:
-
记上次新建的节点 ,当前新建的节点 ,增加字符 ,令 ,接下来要处理 。
-
从 开始跳后缀链接,直到初始状态或找到一个有 转移的状态。
-
若到初始状态,说明这个字符之前没有出现,。
-
另一种情况,设找到状态 ,转移 到达 ,分情况讨论。若 ,说明 代表的等价类只包含一个子串,此时可以直接修改 ,令 ,即增加了当前字符的位置;反之则新建节点 ,继承 的全部信息,同时使 。
-
将 修改为 。
对新建 节点的补充:
假设当时的位置 ,跳后缀链接时,我们只关心 出现的等价类集合,而添加一个字符时,对应的子串应当为上文中的 ,当这个子串并不单独存在一个状态中,就需要新建 节点。
对新建 节点的再补充:
考虑字符串 ,加入第 个字符时, 的后缀链接为 ,其字符 的转移为 ,此时属于第一种情况,可以直接增加;加入第 个字符时, 的后缀链接为 ,再跳到 才存在 的转移 ,而实际上我们是由 转移到 ,而 并不是最长的后缀,这使得并不是整个状态的出现次数都有所增加,因此要拆点。
对新建 节点的再再补充:
跳后缀链接至 前经过的节点为 ,则 到 有连边,由于所有到 有转移的状态子串长度应当恰好填满 ,且 子串是最小的( 之上不再有连到 的节点),因此 ,当 时,就有 ,这符合定义,反之不符合定义需要拆点。
点击查看代码
struct SuffixAutomaton{
int ch[maxn<<1][26],tot,last;
int len[maxn<<1],link[maxn<<1];
SuffixAutomaton(){
tot=0,last=0;
len[0]=0,link[0]=-1;
}
vector<int> E[maxn<<1];
inline void extend(int c){
int cur=++tot;
len[cur]=len[last]+1;
int p=last;
while(p!=-1&&!ch[p][c]){
ch[p][c]=cur;
p=link[p];
}
if(p==-1) link[cur]=0;
else{
int q=ch[p][c];
if(len[p]+1==len[q]) link[cur]=q;
else{
int clone=++tot;
len[clone]=len[p]+1,link[clone]=link[q];
for(int i=0;i<26;++i) ch[clone][i]=ch[q][i];
while(p!=-1&&ch[p][c]==q){
ch[p][c]=clone;
p=link[p];
}
link[cur]=link[q]=clone;
}
}
last=cur;
}
}SAM;
解决各类问题#
由于后缀自动机在线性复杂度内表示出一个字符串的所有子串,可以解决很多字符串问题,一些复杂度比 SA 优秀。
-
求本质不同子串个数:自动机就是关于本质不同子串建的,对于每一个等价类集合,其包含子串个数为 ,求和即可(若在线查询则每次都增量在新建的节点 )处。
-
求子串出现次数:找到子串所在等价类集合,相当于求这个集合的等价类规模。不难发现所有的新建的节点 都是一个前缀,在 树上,子树内前缀个数也就是等价类规模,建出 树遍历一遍即可。
例题:Luogu-P5341 TJOI 2019 甲苯先生和大中锋的字符串、SPOJ-NSUBSTR Substrings
-
字典序为 的子串:按照正常 Trie 树遍历即可
-
区间单模式串匹配:对于查询串,区间左端点右移即考虑仍在当前状态或是跳 ,区间右端点右移则是直接转移
-
线段树合并维护 集合:暴力向上维护,时间空间复杂度是都是 ,时间复杂度的证明与正常线段树合并相同,线段树的节点数为 ,每次合并时,重复节点个数不超过小的部分,因此类比启发式,得到 的时间复杂度,空间复杂度同理,每次遇到重复节点才会新开节点。
广义后缀自动机 General Suffix Automaton#
对多个串处理,需要建广义后缀自动机。
建出之后处理问题与单串没有太多的区别。
离线构建方法#
要求保证每个串之间的独立性以及串内字符的连续性,简单的离线做法是建出 Trie 树,记录下树上节点对应自动机上编号,BFS 建树即可。
点击查看代码
struct SuffixAutomaton{
int ch[maxn<<1][26],tot;
int len[maxn<<1],link[maxn<<1];
SuffixAutomaton(){
tot=0;
len[0]=0,link[0]=-1;
}
inline int extend(int last,int c){
int cur=++tot;
len[cur]=len[last]+1;
int p=last;
while(p!=-1&&!ch[p][c]){
ch[p][c]=cur;
p=link[p];
}
if(p==-1) link[cur]=0;
else{
int q=ch[p][c];
if(len[p]+1==len[q]) link[cur]=q;
else{
int clone=++tot;
len[clone]=len[p]+1,link[clone]=link[q];
for(int i=0;i<26;++i) ch[clone][i]=ch[q][i];
while(p!=-1&&ch[p][c]==q){
ch[p][c]=clone;
p=link[p];
}
link[cur]=link[q]=clone;
}
}
return cur;
}
}SAM;
struct Trie{
int tr[maxn][26],tot;
int fa[maxn],last[maxn],C[maxn];
inline void insert(){
int len=strlen(s+1);
int u=0;
for(int i=1;i<=len;++i){
int c=s[i]-'a';
if(!tr[u][c]) tr[u][c]=++tot;
fa[tr[u][c]]=u,C[tr[u][c]]=c;
u=tr[u][c];
}
}
inline void build(){
queue<int> q;
for(int i=0;i<26;++i){
if(tr[0][i]) q.push(tr[0][i]);
}
while(!q.empty()){
int u=q.front();
q.pop();
last[u]=SAM.extend(last[fa[u]],C[u]);
for(int i=0;i<26;++i){
if(tr[u][i]) q.push(tr[u][i]);
}
}
}
}T;
参考资料#
后缀数组 Suffix Array#
后缀自动机 Suffix Automaton#
作者:SoyTony
出处:https://www.cnblogs.com/SoyTony/p/Learning_Notes_about_String_Suffix_Algorithms.html
版权:本作品采用「署名-非商业性使用-相同方式共享 4.0 国际」许可协议进行许可。
【推荐】国内首个AI IDE,深度理解中文开发场景,立即下载体验Trae
【推荐】编程新体验,更懂你的AI,立即体验豆包MarsCode编程助手
【推荐】抖音旗下AI助手豆包,你的智能百科全书,全免费不限次数
【推荐】轻量又高性能的 SSH 工具 IShell:AI 加持,快人一步
· 单线程的Redis速度为什么快?
· 展开说说关于C#中ORM框架的用法!
· Pantheons:用 TypeScript 打造主流大模型对话的一站式集成库
· SQL Server 2025 AI相关能力初探
· 为什么 退出登录 或 修改密码 无法使 token 失效