【学习笔记】字符串后缀算法
后缀数组 Suffix Array
后缀排序
使用一种基数排序结合倍增的方法,将一个字符串的所有后缀排序。
定义 \(sa_i\) 为排名为 \(i\) 的后缀起始位置,\(rk_i\) 为起始位置为 \(i\) 的后缀排名。
假设已然排出长度为 \(l\) 的全部子串,那么长度为 \(2\times l\) 的只需要按照前半排名为第一关键字,后半排名的第二关键字。
于是以长度为 \(l\) 的排名为参照,可以先按照后半段的排名得到一个结果,在此基础上按照前半段排序。
点击查看代码
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;
}
}
这里每次倍增都使用的两次基数排序,然而按照后半段时已经没有必要保留原来的顺序,因此分两部分排序:后半段为空的直接放在排名最靠前的位置,剩下的根据后半段的排名依次放进去。
在此基础上进行第二次的基数排序,相当于常数优化,复杂度仍是 \(O(n\log n)\)。
点击查看代码
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;
}
}
求 \(\mathrm{lcp}\)
\(\mathrm{lcp}(i,j)\) 定义为后缀排序之后,排名为 \(i\) 的后缀与排名为 \(j\) 的后缀的最长公共前缀。
显然后缀排序后,排名越靠近的越相像,可以理性理解即得到:
也就是在已知排名相邻的 \(\mathrm{lcp}\) 后,可以借助数据结构快速求出任意的 \(\mathrm{lcp}\),目前预处理复杂度已经降到了 \(O(n^2)\)。
这里定义相邻的 \(\mathrm{lcp}\) 为 \(height_i=\mathrm{lcp}(i-1,i)\),辅助引入一个 \(h_i=height_{rk_i}\),接下来证明:\(h_i\ge h_{i-1}-1\)。
考虑后缀 \(i\) 实际是后缀 \(i-1\) 去掉第一个字符,假设后缀 \(i-1\) 排名前一位后缀是 \(j-1\),那么 \(h_{i-1}-1=\mathrm{lcp}(rk_i,rk_j)\),考虑 \(i-1\) 与 \(j-1\) 都去掉开头字符后,得到的两个后缀的排名距离不会缩小。
也就是说,\(\mathrm{lcp}(rk_i,rk_j)\) 应当是一个多个相邻 \(\mathrm{lcp}\) 取 \(\min\) 的结果。而在这其中,就包括了 \(\mathrm{lcp}(rk_i-1,rk_i)=h_i\),即证:\(h_i\ge h_{i-1}-1\)。
由于只回退 \(O(n)\) 次,只增加 \(O(n)\) 次,求 \(height\) 数组就是 \(O(n)\) 的。
点击查看代码
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;
}
一些技巧
-
求出 \(heihgt\) 后基本上可以把 \(\mathrm{lcp}\) 转化成与区间 \(\min\) 有关的问题。
最基本的是 \(O(1)\) 查询的 ST 表,可以快速求得 \(\mathrm{lcp}\)。
其次统计所有 \(\mathrm{lcp}\) 时,一共 \(O(n^2)\) 对后缀只有 \(O(n)\) 个答案,于是枚举这些答案的影响范围,实际就是单调栈求作为最小值的区间。
还有一种操作是考虑对排名相邻的连边权为 \(height\) 的边,任意 \(\mathrm{lcp}\) 都是树上路径的 \(\min\),类似瓶颈路问题。求与 \(\mathrm{lcp}\) 值对应方案数,考虑从大到小连边,两棵树彼此之间的 \(\mathrm{lcp}\) 都是当前连边的边权。同时可以考虑重构树,新建节点的权值代替边权。
一些题
CodeForces-822E Liar *2400
贪心的去想,且找到一个符合 DP 的状态设计。
\(k\) 很小,可以枚举。显然 \(s\) 中同一段前缀,能分段匹配 \(t\) 的前缀越多越好,于是可以 DP 的目标就是分段匹配到的最大前缀。
找到 \(j-1\) 次分段后 \([1,i]\) 匹配到的位置 \(p\),从 \(i+1\) 开始分出一段,需要求两个串从固定位置开始的最大公共子串,加一个分隔符求一下 \(\mathrm{lcp}\) 就好了。
由于是分段匹配,\(dp_{i,j-1}\) 中并不要求 \(i\) 是最后一段的结尾,而转移到的 \(dp_{i+\mathrm{lcp},j}\) 与之相反,解决方法是取前缀 \(\max\)。
Luogu-P1117 NOI 2016 优秀的拆分
神仙题。
第一步转化是 AABB
的形式相当于两个 AA
拼接,只需求出每个位置作为 AA
的开头或结尾的方案数,答案就是 \(\sum_{i=1}^{n-1} f_ig_{i+1}\)。
之后是非常强大的做法:枚举长度设置关键点。假设当前的 A
长度为 \(L\),当我们每 \(L\) 个位置设置一个关键点时,满足 AA
的串一定包含两个关键点,考虑求出这两个关键点 \(\mathrm{lcs}\) 以及 \(\mathrm{lcp}\),当二者的公共部分区间有交集时,说明至少存在一个 AA
,简单计算可以确定开头的取值区间和结尾的取值区间。
求 \(\mathrm{lcs}\) 以及 \(\mathrm{lcp}\) 可以正反 SA,区间加最后单点查询直接差分即可。
Luogu-P2178 NOI 2015 品酒大会
结合图论去思考,\(\mathrm{lcp}\) 取 \(\min\) 的性质不仅适合 ST 表、单调栈等等数据结构,也可以考虑树上瓶颈路的问题。
即对于将 \(height\) 值作为边权,从大到小连边,每次连边的两个连通块之间的 \(\mathrm{lcp}\) 就是当前枚举边的边权,合并过程中即可维护方案数以及最大乘积。
Luogu-P4248 AHOI 2013 差异
\(\mathrm{lcp}\) 的来源是某个 \(height\),\(height\) 的贡献是作为最小值的区间,单调栈解决问题。
Luogu-P7409 SvT
多测且选取的范围为集合,对直接查询和单调栈并不友好,考虑使用与瓶颈路有关的做法。
建出重构树后,对于非叶子节点,其权值与左右子树大小之积就是贡献。由于只需要在有左右儿子的节点贡献答案,建虚树即可。
CodeForces-1073G Yet Another LCP Problem *2600
同上,建出虚树后改变一下求解的式子即可。
Luogu-P5028 Annihilate
先插分隔符求出做总的后缀排序。
对于每个位置,钦定其最小值来源的串,那么一定是找到排名的前驱后继求解,使用 set
以及 ST 表。这样的复杂度是 \(O(n|s|\log|s|)\)。
这样的做法不够优秀,主要是分开枚举两个后缀的来源限制了复杂度。假定一个后缀的来源,对应的后缀在排名中打上标记,那么每个没有被打上标记的(即来自别的串的后缀),与当前串所有后缀的最大 \(\mathrm{lcp}\) 同样也是在找前驱后继,于是直接正反各扫一遍,每个位置的答案更新为到上一个标记点的答案,时间复杂度是 \(O(|s|\log|s|+n|s|)\)。
分隔符不能使用同样的。
Luogu-P2852 USACO 2006 DEC Milk Patterns G
同样处理瓶颈路的套路,出现次数等价于连通块的大小。
后缀自动机 Suffix Automaton
后缀自动机与 \(\mathrm{Parent}\) 树的构建
简单定义
-
\(\mathrm{endpos}(t)\):子串 \(t\) 在原串 \(s\) 中所有出现位置(最后一个字符位置)的集合。
-
等价类:\(\mathrm{endpos}\) 相同的所有子串构成一个等价类。
-
后缀自动机:使得所有子串都能从初始状态出发得到,并通过等价类压缩状态的自动机。
-
\(\mathrm{len}(u)\):一个等价类中的最大子串长。
-
\(\mathrm{minlen}(u)\):一个等价类中的最小子串长。
-
\(\mathrm{link}(u)\):后缀链接,定义为所有 \(\mathrm{endpos}\) 等价类中,规模最小且是 \(\mathrm{endpos}(u)\) 超集的状态,或者表述为将 \(\mathrm{minlen}(u)\) 对应子串去掉第一个字符得到的子串所在等价类。
-
\(\mathrm{Parent}\) 树:后缀链接 \(\mathrm{link}\) 构成的一棵树。
一些定理
-
处于同一 \(\mathrm{endpos}\) 等价类的子串两两为后缀关系。
-
处于同一 \(\mathrm{endpos}\) 等价类的子串长度恰好覆盖区间 \([\mathrm{minlen}(u),\mathrm{len}(u)]\)。
-
在 \(\mathrm{Parent}\) 树上,父子关系节点的 \(\mathrm{endpos}\) 等价类为包含关系,兄弟关系节点的 \(\mathrm{endpos}\) 等价类没有交,这构成了一棵划分集合的树。
-
\(\mathrm{Parent}\) 树的叶子节点都是原串一个前缀。
-
反串建出的 \(\mathrm{Parent}\) 树等价于正串的后缀树,也就是正串所有后缀构成的 \(\mathrm{Trie}\)。
如何构建
按照以下算法流程:
-
记上次新建的节点 \(last\),当前新建的节点 \(cur\),增加字符 \(c\),令 \(\mathrm{len}(cur)=\mathrm{len}(last)+1\),接下来要处理 \(\mathrm{link}(cur)\)。
-
从 \(last\) 开始跳后缀链接,直到初始状态或找到一个有 \(c\) 转移的状态。
-
若到初始状态,说明这个字符之前没有出现,\(\mathrm{link}(cur)=0\)。
-
另一种情况,设找到状态 \(p\),转移 \(c\) 到达 \(q\),分情况讨论。若 \(\mathrm{len}(p)+1=\mathrm{len}(q)\),说明 \(q\) 代表的等价类只包含一个子串,此时可以直接修改 \(\mathrm{endpos}(q)\),令 \(\mathrm{link}(cur)=q\),即增加了当前字符的位置;反之则新建节点 \(clone\),继承 \(q\) 的全部信息,同时使 \(\mathrm{link}(cur)=\mathrm{link}(q)=clone\)。
-
将 \(last\) 修改为 \(cur\)。
对新建 \(clone\) 节点的补充:
假设当时的位置 \(i\),跳后缀链接时,我们只关心 \(i-1\) 出现的等价类集合,而添加一个字符时,对应的子串应当为上文中的 \(\mathrm{len}(p)+1\),当这个子串并不单独存在一个状态中,就需要新建 \(clone\) 节点。
对新建 \(clone\) 节点的再补充:
考虑字符串 \(\texttt{aabaabab}\),加入第 \(6\) 个字符时,\(\texttt{aabaa}\) 的后缀链接为 \(\texttt{aa}\),其字符 \(\texttt{b}\) 的转移为 \(\texttt{aab}\),此时属于第一种情况,可以直接增加;加入第 \(8\) 个字符时,\(\texttt{aabaaba}\) 的后缀链接为 \(\texttt{aaba}\),再跳到 \(\texttt{a}\) 才存在 \(\texttt{b}\) 的转移 \(\texttt{aab}\),而实际上我们是由 \(\texttt{a}\) 转移到 \(\texttt{ab}\),而 \(\texttt{ab}\) 并不是最长的后缀,这使得并不是整个状态的出现次数都有所增加,因此要拆点。
对新建 \(clone\) 节点的再再补充:
跳后缀链接至 \(p\) 前经过的节点为 \(p'\),则 \(p'\) 到 \(cur\) 有连边,由于所有到 \(cur\) 有转移的状态子串长度应当恰好填满 \([\mathrm{minlen}(cur)-1,\mathrm{len}(cur)-1]\),且 \(p'\) 子串是最小的(\(p'\) 之上不再有连到 \(cur\) 的节点),因此 \(\mathrm{minlen}(cur)=\mathrm{minlen}(p')+1=(\mathrm{len}(p)+1)+1\),当 \(\mathrm{len}(p)+1=\mathrm{len}(q)\) 时,就有 \(\mathrm{minlen}(cur)=\mathrm{len}(q)+1\),这符合定义,反之不符合定义需要拆点。
点击查看代码
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 优秀。
-
求本质不同子串个数:自动机就是关于本质不同子串建的,对于每一个等价类集合,其包含子串个数为 \(\mathrm{len}(u)-\mathrm{minlen}(u)+1=\mathrm{len}(u)-\mathrm{len}(\mathrm{link}(u))\),求和即可(若在线查询则每次都增量在新建的节点 \(cur\))处。
-
求子串出现次数:找到子串所在等价类集合,相当于求这个集合的等价类规模。不难发现所有的新建的节点 \(cur\) 都是一个前缀,在 \(\mathrm{Parent}\) 树上,子树内前缀个数也就是等价类规模,建出 \(\mathrm{Parent}\) 树遍历一遍即可。
例题:Luogu-P5341 TJOI 2019 甲苯先生和大中锋的字符串、SPOJ-NSUBSTR Substrings
-
字典序为 \(k\) 的子串:按照正常 Trie 树遍历即可
-
区间单模式串匹配:对于查询串,区间左端点右移即考虑仍在当前状态或是跳 \(\mathrm{link}\),区间右端点右移则是直接转移
-
线段树合并维护 \(\mathrm{endpos}\) 集合:暴力向上维护,时间空间复杂度是都是 \(O(n\log n)\),时间复杂度的证明与正常线段树合并相同,线段树的节点数为 \(O(n)\),每次合并时,重复节点个数不超过小的部分,因此类比启发式,得到 \(O(n\log n)\) 的时间复杂度,空间复杂度同理,每次遇到重复节点才会新开节点。
广义后缀自动机 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;