(笔记)后缀树 后缀数组 SA 后缀自动机 SAM
后缀树 Suffix Tree
实际上是把字符串 \(S\) 的所有后缀 \(\mathrm{suffix}(i)\) 以 \(O(n^2)\) 的空间用字典树存储下来的树形结构,费时费力费空间,非常不好用。作为一个启发点,我们把所有后缀从第一个字符到最后一个字符接到 Trie 上,那么对于任意一个节点和根节点组成的路径,都可以视作一个原串 \(S\) 的合法子串。且由于 \(\text{子串}=\text{原串后缀的前缀}\),所有的子串都可以通过这种方式表示。
后缀数组 Suffix Array
主要应用是通过 SA 还原出后缀树,并将其操作压缩至可以接受的时间复杂度内。
对于 Suffix Tree,我们想了一个办法来维护它。如果将 Suffix Tree 的 Trie 结构每个节点儿子从左到右分别为字符字典序从小到大排序,那么通过后缀数组我们可以做到对每个后缀按字典序排序,即求出在后缀树上终止节点(存在以此节点为终结的后缀)的先序遍历的结果。通过这个数组,我们可以解决许多子串问题。
因为 \(\text{子串}=\text{原串后缀的前缀}\),我们想到对于后缀求 \(\mathrm{LCP}\),这样可以描述原串的子串,同时也代表了后缀树上节点 DFS 序相邻的两个节点的 \(\text{LCA}\) 的深度,这个记在 \(height\) 数组中。经过后缀数组的排序,我们可以做到使用 ST 表做到 \(O(n\log n)\) 处理,\(O(1)\) 查区间最小值,作为任意后缀两两 \(\text{LCA}\) 深度。
我们同时希望通过 SA 还原出后缀树 Suffix Tree,或者说,其需要被答案统计的信息。对于这一点,我们可以按照 \(height\) 从大到小扫描线,这其实就是 Suffix Tree 子树从下至上合并的过程(可以使用并查集维护,合并次数 \(O(n)\)),因为字典树 Trie 的特质决定了具有相同前缀的字符串,其有一段以根开始的连续节点是重合的。然后我们就可以在合并过程中统计许多原来需要建出 \(O(n^2)\) 的 Suffix Tree 才能统计的东西。
默认以下所有值取 \(\text{integer}\)。记两个数组 \(rk[i],sa[i]\) ,字符串 \(S\) 字符下标从 \(1\) 到 \(|S|\),同时为了方便令 \(n=|S|\)。
sa & rk
定义:
- \(\mathrm{\mathrm{suffix}}(i)\) 表示从 \([i,n]\) 的字串,即字符串 \(S\) 从下标 \(i\) 开始的后缀,其值为字符串。
- \(rk[i]\) 表示将所有 \(\mathrm{\mathrm{suffix}}(i)\) 按照字典序排序后,该字符串的排名,显然 \(rk[i]\in [1,n]\)。
- \(sa[i]\) 表示排名第 \(i\) 的后缀 \(\mathrm{suffix}(j)\) 中的 \(j\) 的值,\(sa[i]\in [1,n]\)。
\(sa\) 与 \(rk\) 的关系:
Attention:显然两数组有互推关系,具体地,\(rk[sa[i]]=i,sa[rk[i]]=i\)。
后缀数组即为 \(sa\) 数组。该数组有几种常用求法,包括 \(O(n\log^2 n)\) 的倍增做法与近似 \(O(n\log n)\) 的基数排序做法。
height
定义:
- \(\mathrm{LCP}(i,j)\) 指 \(\mathrm{suffix}(i)\) 和 \(\mathrm{suffix}(j)\) 的 \(\text{Longest Common Prefix(最长公共前缀)}\)。
- \(\mathrm{LCP}'(i,j)\) 指 \(\mathrm{LCP}(i,j)\) 的长度。
- \(height[i]\) 指 \(\mathrm{LCP}'(i,sa[rk[i]-1])\),即 \(\mathrm{suffix}(i)\) 和排名为 \(rk[i]-1\) 的后缀的 \(\mathrm{LCP}'\)。
- \(pre_s[i]\) 指字符串 \(S\) 从下标 \(i\) 结尾的前缀,其值为字符串。
\(height\) 数组有许多妙用,包括但不限于连续段 \(\mathrm{LCP}\) 问题(可以 ST 表 \(O(1)\) 得到 \(\mathrm{LCP}'(i,j)(rk[i]<rk[j])\),即为 \(\min_{k=rk[i]+1}^{rk[j]} height[sa[k]]\)),最长重复子串问题与最长公共子序列问题。
线性递推 \(height\) 数组中的一些证明是一个难点。我们的递推思路是:依次找 \(i=1\dots n\) 的所有 后缀 \(\mathrm{suffix}(i)\)(相当于以后缀长度降序递推),然后利用上一次找到的 \(height\) 得到本次的 \(height\),本次 \(height\) 最少只能是上次 \(height\) 的值 \(-1\),然后如果增加了就暴力加。
时间证明是 \(h\) 最多做 \(n\) 次 \(-1\),相应地又由于 \(h\le n\),\(h\) 最多可以做 \(2n\) 次 \(+1\),所以递推复杂度就是 \(O(n)\) 的。
正确性证明呢?具体地,我们需要证明:
即对于相邻后缀其 \(height\)(在后缀排列中与前相邻串的 \(\mathrm{LCP}'\))最多 \(-1\)。
一句话证明:\(i\to i+1\) 在后缀 \([i,n]\) 中抠掉了一个字符 \(S_i\),在后缀 \([i+1,n]\) 中必定至少存在扣掉一个字符 \(S_i\) 的对应 \(\mathrm{LCP}\)。
可以看看下面的详细证明。
Proof1
可以把 \(height\) 理解为一个类似 \(\texttt{Fail}\) 指针的东西,转移就像 AC 自动机一样。
考虑 \(\mathrm{suffix}(i)\) 到 \(\mathrm{suffix}(i+1)\) 的递推,如果对于后缀 \(\mathrm{suffix}(i)\) 存在一个排名 \(rk[i]-1\) 的串和 \(i\) 的 \(\mathrm{LCP}'\) 为 \(height\)(\(height\) 数组的定义),那么下一个递推的后缀 \(\mathrm{suffix}(i+1)\) 只是相当于去掉了 \(i\) 的第一个字符,那么最劣情况下也可以从之前排名为 \(rk[i]-1\) 的后缀上扣下来第一个字符作为一个匹配的后缀。
由于需要考虑 \(S\) 的所有后缀,所以这个后缀一定是存在的(除非为空,这时候 \(height-1\) 后就得到了 \(0\),同样是合法的)。
如果不存在,肯定有一个排名在它们中间的串,使得它们的 \(\mathrm{LCP}'\) 比原来还要大(参见 Proof2 Lemma1 的证明),这样对于 \(\mathrm{suffix}(i)\) 到 \(\mathrm{suffix}(i+1)\) 的递推仍然是正确的。
那么对于 \(i\in[1,n-1]\) 这些情况都是成立的。
Proof2(严谨但较复杂,仅留档)
Proof. 注意到左式右式都包含排名相邻的两个后缀的 \(\mathrm{LCP}\)。考虑证明引理1:
Lemma1. 当若干字符串按字典序排序后,相邻两个后缀 \(\mathrm{suffix}(i),\mathrm{suffix}(i+1)\) 在有序前提下 \(\mathrm{LCP}'(i,i+1)\) 最大化。
-
Proof. 命题意为意图证明若有字符串 \(a,b,c\) 满足字典序偏序关系 \(d(a)<d(b)<d(c)\) ,必定有 \(\mathrm{LCP}'(a,c)\le \mathrm{LCP}'(a,b)\)。
-
这其实是个蛮显然的东西,但是不介意的话可以看个严谨证明。
点击查看证明
-
考虑字典序比较定义,对于字符串 \(S_1,S_2\) 与其对应的字典树权值 \(d(S_1),d(S_2)\) ,有 \(d(S_1)<d(S_2)\) 当且仅当 \(\exist i,pre_{S_1}[i]=pre_{S_2}[i],S_1[i+1]<S_2[i+1]\)。
-
转化为形式,可以得到 \(i=\mathrm{LCP}'(a,b),pre_a[i]=pre_b[i],a[i+1]<b[i+1]\) 与 \(j=\mathrm{LCP}'(b,c),pre_b[i]=pre_c[i],b[i+1]<c[i+1]\)。
-
若 \(i=j\),\(a[i+1]<b[i+1]<c[i+1],pre_a[i]=pre_b[i]=pre_c[i],\mathrm{LCP}'(a,c)=\mathrm{LCP}'(a,b)\)。
-
若 \(i<j\),\(a[i+1]<b[i+1]=c[i+1],\mathrm{LCP}'(a,c)=i=\mathrm{LCP}'(a,b)\)。
-
若 \(i>j\),\(a[j+1]=b[j+1]<c[j+1],\mathrm{LCP}'(a,c)=j<i=\mathrm{LCP}'(a,b)\)。
-
综上,命题得证,\(\mathrm{LCP}'(a,c)\le \mathrm{LCP}'(a,b)\)。
定义 \(S_1,S_2\) 相似度为 \(\mathrm{LCP}'(i,j)\)。
和 Proof1 思路相同,我们考虑 \(\mathrm{suffix}(i)\) 到 \(\mathrm{suffix}(i+1)\) 转移的正确性。
原命题中,令 \(S_1\) 为左式的任意 \(pre\),\(S_2\) 为右式的 \(pre\),其中 \(S_2\) 是 \(S_1\) 去掉第一个字符得到的字符串。
则 \(\mathrm{suffix}(sa[rk[i]-1])\) 可写作 \(\overline{S_1A}\) ,\(\mathrm{suffix}(sa[rk[i]])\) 可写作 \(\overline{S_1B}\) ,其中 \(d(A)<d(B)\),且 \(S_1\) 的长度最大化。
同样地,\(\mathrm{suffix}(sa[rk[i+1]])\) 可写作 \(\overline{S_2B}\) ,\(\mathrm{suffix}(sa[rk[i+1]-1])\) 可写作 \(\overline{S_2C}\) ,其中 \(d(C)<d(B)\)。
根据后缀定义,如果存在 \(\overline{S_1A},\overline{S_1B}\) 则必定至少会存在 \(\overline{S_2A},\overline{S_2B}\) 使得两串在字典序排序中相邻。
如果实际相邻,那么就有 \(\mathrm{LCP}'(\overline{S_2A},\overline{S_2B})=\mathrm{LCP}'(\overline{S_1A},\overline{S_1B})-1\)。如果不是实际相邻,由 Lemma1 ,必定有相似度更大的两个后缀是相邻的(存在一个 \(\overline{S_2C}\),且 \(A\neq C\)),那么只有可能是 \(\mathrm{LCP}'(\overline{S_2A},\overline{S_2B})\geq \mathrm{LCP}'(\overline{S_1A},\overline{S_1B})-1\)。
那么对于 \(i\in[1,n-1]\) 这些情况都是成立的。
综上,命题得证。
Code
\(sa\) 的倍增求法以及 \(height\) 的线性求法。
注意到对于所有后缀数组排序的处理我们都在外层套一个倍增,逐位往后扩展直到完整的后缀,并在此过程中不断加入优先级更低的关键字排序(不打乱先前排好的优先级较高的关键字)。如下给出的实现是内部 sort,瓶颈倍增内排序,\(O(n\log^2 n)\)。
点击查看代码
#include<bits/stdc++.h>
using namespace std;
typedef long long LL;
const int N=3e5+5;
char s[N];
int sa[N],rk[N],tmp[N];
int n,height[N],k;
inline bool cmp(int i,int j){
if(rk[i]!=rk[j])return rk[i]<rk[j];
int ai=(i+k<=n?rk[i+k]:-1);
int aj=(j+k<=n?rk[j+k]:-1);
return ai<aj;
}
void calc(){
for(int i=1;i<=n;i++){
rk[i]=s[i];
sa[i]=i;
}
for(k=1;k<=n;k<<=1){
sort(sa+1,sa+1+n,cmp);
tmp[sa[1]]=1;
for(int i=2;i<=n;i++)
tmp[sa[i]]=tmp[sa[i-1]]+cmp(sa[i-1],sa[i]);
for(int i=1;i<=n;i++)
rk[i]=tmp[i];
}
k=0;//getheight
for(int i=1;i<=n;i++){
if(k)k--;
if(!(rk[i]-1)){height[rk[i]]=0;continue;}
while(s[sa[rk[i]-1]+k]==s[i+k])k++;
height[rk[i]]=k;
}
}
int main(){
scanf("%s",s+1);
n=strlen(s+1);calc();
for(int i=1;i<=n;i++)printf("%d ",sa[i]-1);
printf("\n");
for(int i=1;i<=n;i++)printf("%d ",height[i]);
return 0;
}
倍增内常数较大的桶排序,理论接近 \(O(n\log n)\),实际人傻常数大。这个方法基于基数排序与高关键字桶排序。在 tmp[] 数组中记录低关键字的顺序(可相同),然后在 Rsort 实现高关键字(先前的排名,可能有相同)排序。由于每个后缀长度都不同,最终排名结果一定是 \([1,n]\) 的排列。同时每次排序我们先求出合法的 \(sa\) 然后再利用其求 \(rk\)。其原因是部分后缀可能并列,\(sa\) 对于排名为 \(i\) 的固定有一个后缀,而 \(rk\) 需要用准确的 \(<\text{它的数}+1\) 描述并列排名。
点击查看代码
#include<bits/stdc++.h>
using namespace std;
typedef long long LL;
const int N=1e6+5;
int lim=(max((int)'Z',max((int)'z',(int)'9')));
char s[N];
int sa[N],rk[N],tmp[N];
int buk[N];
int n,k;
inline bool cmp(int &i,int &j){
if(rk[i]!=rk[j])return rk[i]<rk[j];
int ai=(i+k<=n?rk[i+k]:0);
int aj=(j+k<=n?rk[j+k]:0);
return ai<aj;
}
inline void Rsort(int &lima){
for(int i=0;i<=lima;i++)
buk[i]=0;
for(int i=1;i<=n;i++)
buk[rk[tmp[i]]]++;
for(int i=1;i<=lima;i++)
buk[i]+=buk[i-1];
for(int i=n;i>=1;i--)
sa[buk[rk[tmp[i]]]--]=tmp[i];
}
void calc(){
for(int i=1;i<=n;i++){
rk[i]=s[i];
tmp[i]=sa[i]=i;
}
Rsort(lim);
for(k=1;k<=n;k<<=1){
int siz=0;
for(int i=n-k+1;i<=n;i++)
tmp[++siz]=i;//没有低位,顺序默认最前
for(int i=1;i<=n;i++){
if(sa[i]>k)tmp[++siz]=sa[i]-k;
if(siz==n)break;//有低位,按低位原大小顺序便利低位,如果合法加入原位
}//这两个循环相当于低位排序部分
Rsort(n);//高位排序
tmp[sa[1]]=1;
for(int i=2;i<=n;i++)
tmp[sa[i]]=tmp[sa[i-1]]+cmp(sa[i-1],sa[i]);
for(int i=1;i<=n;i++)
rk[i]=tmp[i];
if(tmp[sa[n]]==n)break;
}
}
int main(){
scanf("%s",s+1);
n=strlen(s+1);calc();
for(int i=1;i<=n;i++)printf("%d ",sa[i]);
return 0;
}
帮助调试:
点击查看代码
printf("rk:");
for(int i=1;i<=n;i++)
printf("%d ",rk[i]);
printf("\n");
printf("sa:");
for(int i=1;i<=n;i++)
printf("%d ",sa[i]);
printf("\n");
Rsort(n);
printf("newsa:");
for(int i=1;i<=n;i++)
printf("%d ",sa[i]);
printf("\n");
例题
不想找很多,这里做到什么放什么。
未命名 1
Statement:给定长为 \(n(n\le 10^5)\) 的字符串 \(S\),对其每个非空子串求其所有 \(\text{Border}\) 的长度和(其本身不算做 \(\text{Border}\))。
已严肃复习一晚上 SA。注意到问题可以转化为 \(\text{Border}\) 对串的贡献,即找到 \(\text{Border}\) \(A\) 的 \(cnt\) 个出现位置,可以贡献给 \(\begin{pmatrix}cnt\\2\end{pmatrix}\) 个子串。
考虑到对于所有相同长度的 \(A\) 快速找出其出现位置集合并计算,使用后缀数组描述后缀树从下至上的合并,对 \(height\) 从大到小做扫描线的过程相当于从下到上合并子树的过程。这是一个树形结构(后缀树),其中其每个根走到叶子的路径代表一个后缀。我们需要快速处理出对于这个树形结构的每个节点及其贡献。
注意到我们显然不能暴力处理 \(O(n^2)\) 个节点,但由于只有 \(O(n)\) 次合并,我们只需要在每次合并时计算最新贡献。对一个子树 \(v\) 记其上一次合并的深度 \(dep_v\),假设扫到 \(i\),可以贡献的长度就是 \(\sum_{j\in(i,dep_v]}j\)。每次合并统计其贡献,用并查集维护子树合并,一个子树的 \(siz\) 就是其出现位置个数。时间复杂度 \(O(n\log n+n\alpha(n))\),瓶颈在 SA。
点击查看代码
#include<bits/stdc++.h>
using namespace std;
typedef long long LL;
const int N=1e5+5;
const int lim=max(int('Z'),max(int('z'),int('9')));
char s[N];int n,k,sa[N],rk[N];
int tmp[N],buk[N],height[N],dep[N];
LL pre[N],ans;
vector<int>rbuk[N];
bool cmp(int x,int y){
if(rk[x]!=rk[y])return rk[x]<rk[y];
int xa=(x+k<=n?rk[x+k]:0);
int ya=(y+k<=n?rk[y+k]:0);
return xa<ya;
}
void Rsort(int lima){
for(int i=0;i<=lima;i++)
buk[i]=0;
for(int i=1;i<=n;i++)
buk[rk[i]]++;
for(int i=1;i<=lima;i++)
buk[i]+=buk[i-1];
for(int i=n;i>=1;i--)
sa[buk[rk[tmp[i]]]--]=tmp[i];
}
void getSA(){
for(int i=1;i<=n;i++)
rk[i]=s[i],tmp[i]=sa[i]=i;
Rsort(lim);
for(k=1;k<=n;k<<=1){
int cnt=0;
for(int i=n;n-k+1<=i;i--)
tmp[++cnt]=i;
for(int i=1;i<=n;i++){
if(sa[i]>k)tmp[++cnt]=sa[i]-k;
if(cnt==n)break;
}
Rsort(n);
tmp[sa[1]]=1;
for(int i=2;i<=n;i++)
tmp[sa[i]]=tmp[sa[i-1]]+cmp(sa[i-1],sa[i]);
for(int i=1;i<=n;i++)
rk[i]=tmp[i];
if(tmp[sa[n]]==n)break;
}
int now=0;
for(int i=1;i<=n;i++){
if(!(rk[i]-1)){height[rk[i]]=0;continue;}
if(now)now--;
while(s[sa[rk[i]-1]+now]==s[i+now])now++;
height[rk[i]]=now;
}
}
int fa[N],siz[N],vis[N],mvis[N];
inline int fr(int x){return fa[x]==x?x:fa[x]=fr(fa[x]);}
inline LL C(LL x){return x*(x-1)/2;}
void work(int i,int x){
mvis[x]=i;
ans+=C(siz[x])*(pre[dep[x]]-pre[i]);
dep[x]=i;
}
int main(){
//freopen("border.in","r",stdin);
//freopen("border.out","w",stdout);
scanf("%d",&n);
scanf("%s",s+1);
getSA();
for(int i=1;i<=n;i++)
pre[i]=pre[i-1]+i,
dep[i]=n-i+1,fa[i]=i,siz[i]=1;
for(int i=2;i<=n;i++)
rbuk[height[i]].emplace_back(i);
for(int i=n;i>=0;i--){
if(rbuk[i].empty())continue;
for(int v:rbuk[i]){
int frp=fr(rk[v-1]),frv=fr(rk[v]);
work(i,frp);work(i,frv);
fa[frp]=frv;siz[frv]+=siz[frp];
}
}
int findr=fr(1);
work(0,findr);
printf("%lld\n",ans);
return 0;
}
P2178 [NOI2015] 品酒大会
和上一题并查集维护子树合并,对 \(height\) 做扫描线的思路大差不差。每次合并时记录当前连通块的最大值,次大值,最小值,次小值(因为可能负负相乘),然后求取乘积最大值。时间复杂度 \(O(n\log n+n\alpha(n))\)。
点击查看代码
#include<bits/stdc++.h>
using namespace std;
typedef long long LL;
const int N=1e6+5,INF=1e9+1;
const LL LINF=1.1e18;
const int lim=max(int('Z'),max(int('z'),int('9')));
char s[N];int n,k,sa[N],rk[N],tmp[N],buk[N],height[N];
bool cmp(int x,int y){
if(rk[x]!=rk[y])return rk[x]<rk[y];
int xa=(x+k<=n?rk[x+k]:0);
int ya=(y+k<=n?rk[y+k]:0);
return xa<ya;
}
void Rsort(int lima){
for(int i=0;i<=lima;i++)
buk[i]=0;
for(int i=1;i<=n;i++)
buk[rk[i]]++;
for(int i=1;i<=lima;i++)
buk[i]+=buk[i-1];
for(int i=n;i>=1;i--)
sa[buk[rk[tmp[i]]]--]=tmp[i];
}
void getSA(){
for(int i=1;i<=n;i++)
rk[i]=s[i],tmp[i]=sa[i]=i;
Rsort(lim);
for(k=1;k<=n;k<<=1){
int cnt=0;
for(int i=n;n-k+1<=i;i--)
tmp[++cnt]=i;
for(int i=1;i<=n;i++){
if(sa[i]>k)tmp[++cnt]=sa[i]-k;
if(cnt==n)break;
}
Rsort(n);
tmp[sa[1]]=1;
for(int i=2;i<=n;i++)
tmp[sa[i]]=tmp[sa[i-1]]+cmp(sa[i-1],sa[i]);
for(int i=1;i<=n;i++)
rk[i]=tmp[i];
if(tmp[sa[n]]==n)break;
}
int now=0;
for(int i=1;i<=n;i++){
if(!(rk[i]-1)){height[rk[i]]=0;continue;}
if(now)now--;
while(s[sa[rk[i]-1]+now]==s[i+now])now++;
height[rk[i]]=now;
}
}
vector<int>rbuk[N];
LL Cnt[N],Ans[N];
int mx[N],cmx[N];
int mn[N],cmn[N];
int fa[N],siz[N];
inline int fr(int x){return fa[x]==x?x:fa[x]=fr(fa[x]);}
int main(){
//freopen(".in","r",stdin);
//freopen(".out","w",stdout);
scanf("%d",&n);
scanf("%s",s+1);
for(int i=1;i<=n;i++)
scanf("%d",&mx[i]),fa[i]=i,siz[i]=1,mn[i]=mx[i],cmn[i]=INF,cmx[i]=-INF;
getSA();
for(int i=2;i<=n;i++)
rbuk[height[i]].emplace_back(i);
for(int i=n-1;i>=0;i--){
Ans[i]=-LINF;
for(int v:rbuk[i]){
int frp=fr(sa[v-1]),frv=fr(sa[v]);
Cnt[i]+=1ll*siz[frv]*siz[frp];
fa[frp]=frv;siz[frv]+=siz[frp];
if(mx[frp]>=mx[frv]){
cmx[frv]=mx[frv];
mx[frv]=mx[frp];
if(cmx[frp]>cmx[frv])
cmx[frv]=cmx[frp];
}
else if(mx[frp]>cmx[frv])
cmx[frv]=mx[frp];
if(mn[frp]<=mn[frv]){
cmn[frv]=mn[frv];
mn[frv]=mn[frp];
if(cmn[frp]<cmn[frv])
cmn[frv]=cmn[frp];
}
else if(mn[frp]<cmn[frv])
cmn[frv]=mn[frp];
Ans[i]=max(Ans[i],
max((cmx[frv]!=-INF?1ll*mx[frv]*cmx[frv]:-LINF),
(cmn[frv]!=INF?1ll*mn[frv]*cmn[frv]:-LINF)));
}
Cnt[i]+=Cnt[i+1];
if(i!=n-1)Ans[i]=max(Ans[i],Ans[i+1]);
}
for(int i=0;i<n;i++)
printf("%lld %lld\n",Cnt[i],Ans[i]==-LINF?0:Ans[i]);
return 0;
}
后缀自动机 Suffix Automaton
这里有一个待填的大坑。
概念
后缀自动机 Suffix Automaton 是能存储和识别一个字符串 \(S\) 的所有后缀的自动机(DFA)。
后缀自动机是对后缀存图的优化。如果对一个字符串 \(S\) 的后缀依次插入字典树,那么空间为 \(O(n^2)\),非常浪费。考虑优化建图,暴力建图浪费空间的原因是做了重复存储,而后缀自动机就是将重复部分链起来,大量节省了空间。
为什么要叫 \(\text{SAM}\)?其原因是建出该结构时,根节点到每个节点的路径都代表对于某个 \(i\) 的 \([1,i]\) 前缀串中的后缀,即对应的,每个字串都可以通过一段根到任意节点的路径表示出来。这和后缀树的逻辑是类似的。我们采用了增量法构造,每次 \(i\to i+1\),相当于前缀串扩展了一位,因此对于原来所有的以 \(i\) 为结尾的后缀 \([?,i]\) 也需要扩展多一个字符。
或者说,根据 DFA 的原理,允许它扩展多一个字符,表现为给所有这些后缀 \([?,i]\) 的节点连一条它们向新点的边,边权为新字符 \(S_{i+1}\)。在接下来的过程中我们会讲到,这些新点可能有至多 \(2\) 个,这是由 \(\text{SAM}\) 的节点意义决定的:\(\text{SAM}\) 上的每个节点等价于 Parent Tree 上节点,即根据等价类建出节点。
如何建立后缀自动机?
endpos 和等价类
定义 \(endpos(T)\) 为子串 \(T\) 在 \(S\) 中出现的位置的右端点的集合,下面是一个例子:
\(S="aababa"\)
对于它的每个后缀的 \(endpos(T)\) 如下:
| 子串 \(T:\) | \(a\) | \(aa\) | \(ba,aba\) | \(aaba\) | \(baba,ababa,aababa\) | \(b,ab\) | \(aab\) | \(bab,abab,aabab\) | |
|---|---|---|---|---|---|---|---|---|---|
| \(endpos:\) | \(\{1,2,3,4,5,6\}\) | \(\{1,2,4,6\}\) | \(\{2\}\) | \(\{4,6\}\) | \(\{4\}\) | \(\{6\}\) | \(\{3,5\}\) | \(\{3\}\) | \(\{5\}\) |
可以参照下图 Parent Tree 理解。我们将 \(endpos\) 相同的子串成为等价类。
容易观察到几个性质:
-
同一个等价类中较短子串是较长子串的后缀。
-
同一个等价类中子串长度不等,且依次递增 \(1\)。
同时由这一点可以类比,在 Parent Tree 上存在连边的两个节点其字串长度一定是连续的。这是由于 Parent Tree 上从上至下分裂的过程同时也是给字符串不断加前缀字符的过程,因此每加一个字符,要么转移到新的等价类节点,要么留在原来的。 -
如果一个子串 \(v\) 是另一个子串 \(u\) 的后缀,则 $endpos(u)\subseteq endpos(v) $。
这比较显然吧,因为取的字符越多限制越紧。 -
一个长度为 \(n\) 的字符串 \(S\) 的等价类数量不超过 \(2n\)。
这个是由 \(\text{SAM}\) 的增量法构造过程推导出来的。具体来说,在 Parent Tree 上,从上至下分裂的过程就是等价类构造的过程。
构造后缀自动机
母树 Parent Tree

(上图是 \(aababa\) 的一个 Parent Tree)
对于每个等价类,新建一个节点,节点的若干儿子即为被包含于该节点 \(endpos\) 集合的子集,且该树的根节点为空串,不妨认为其 \(endpos\) 是全集,该树称为母树。其根的儿子都是 \(|endpos|=1\) 的节点。我们在 Parent Tree 上从上往下走的过程就是在不断分裂 \(endpos\) 的过程,也是不断向当前节点代表的最长的字串前面增加若干个字符的过程。分裂最多进行 \(O(n)\) 次,所以节点数量也是 \(O(n)\) 级别的。
在实际构造过程中,我们采用增量法,每次在 \([1,i]\) 中加入 \(S_{i+1}\)。因而需要记录终止节点及其组成的终止链,即 \(endpos\) 包含 \(i\) 的节点。在上图中,\(i=n\) 时终止节点已标为 \(\color{green}\text{绿色}\)。
后缀链
\(\text{SAM}\) 中原来在母树里连接父亲和儿子的有向边,在构建出的 \(\text{SAM}\) 中表现为节点的 .fa。在 \(\text{SAM}\) 的 \(\text{DAG}\) 结构中,由实边构成的图仅表示根节点 \(rt\) 到该节点 \(v\) 所有路径可以表示 \(endpos\) 等价类 \(v\) 中的所有字串。
而由后缀链组成的树形成的母树才是本质。在一个 \(\text{SAM}\) 中,由于我们无法接受存储 \(O(n^2)\) 的节点,于是对于一些可以压缩的节点。在最终形态的 \(\text{SAM}\) 中,其每个节点都与母树上一一对应,大量减少所需节点数,并将所有字串放在 \(\text{SAM}\) 有向边根 \(rt\) 到任意节点的路径上,因此每条边对应一个字符,类似一个 Trie。作为一个 DFA,这也便于我们描述与应用。
具体过程
首先明确一下定义。
-
对每个节点记 \(len\),表示其承载的 \(endpos\) 等价类中长度的 \(\max\),记为 \(\text{maxlen}\)。同理的,下文长度 \(\min\) 也会记为 \(\text{minlen}\)。
-
对于每个节点记 \(26\) 个儿子,表示其向其他点以字符为边权的有向边。
-
对每个节点记 \(fa\),表示其代表的等价类在 Parent Tree 上的父亲。
对于一个 \(\text{SAM}\),我们需要其满足如下条件:
-
所有点都与 Parent Tree 上 \(endpos\) 等价类意义对应。
-
有向边边权为字符,每个子串都可以表示为一条根到某个点的路径(DFA)。
-
不希望点/边太多,都在 \(O(n)\) 级别,且点表示的子串集合两两无交。
-
对于一个 Parent Tree 上父亲,其儿子 \(endpos\) 两两无交。
由于要同时满足 \(1,2\) 你可以想象到一个等价类需要有多个等价类向其连边,使得这个等价类为结尾的路径上可以代表等价类内的所有子串。
在实际构建中,我们在上文多次提到增量法,其意义在于记录一系列终止节点作为终止链,它们所代表的等价类的并集组成了所有 \([1,i]\sim [i,i]\) 的,以 \(i\) 为结尾的后缀。以这些点为结尾,已经可以分别跑出所有这些后缀(路径)。我们要做的是看看 \(i\to i+1\) 时会发生什么。
具体来说,先新建一个节点 \(cur\) 代表子串 \([1,i+1]\)(或可理解为等价类 \(\{i+1\}\),后缀 \([1,i+1]\))。我们的构建过程是在终止链从下往上跳,要做的事情有两个:
-
找到 \(cur\) 的
.fa(Parent Tree 上父亲) -
使得新等价类 \(cur\)(必包含 \([1,i+1]\))上记录所有字符串能被路径表示(DFA)。
依此分 \(3\) 种情况:
-
集中在链的下部,访问到的节点不存在连向其他点边权为 \(S_{i+1}\) 的边,直接连到 \(cur\),可以做到新增后缀 \([j,i+1]\),满足目标 \(2\)。
-
如果整条链都是 1 类点,说明所有新增后缀 \([j,i+1]\) 之前都没有出现过,决定其
.fa即其 Parent Tree 上父亲时直接连向根,满足目标 \(1\)。 -
集中在链上部,访问到的节点已经存在连向其他点边权为 \(S_{i+1}\) 的边。注意到我们顺着链往上走实际上是使得后缀不断缩短的过程,一个节点 Parent Tree 祖先链上所有串(等价类)都是它(所代表等价类及其中的任意串)的后缀。我们每次需要解决一个 \([j\sim i+1,i+1]\) 其中 \(j\) 不断增加的若干后缀的表示问题,称其为未解决后缀。
找到这些点中最深的点 \(u\),及其之前连向的边权为 \(S_{i+1}\) 的 \(v\)。这里同样需要分类讨论:
-
如果 \(len[v]=len[u]+1\),说明 \(v\) 等价类中不含比 \(u\) 更长的后缀(即仅覆盖未解决后缀,这些新增后缀已经在链下部处理过)。作为 \(cur\) 的
.fa即可。这一操作也就默认了上面未处理的终止链,它们的等价类内都加入了 \(\{i+1\}\),直接满足目标 \(1\)。 -
否则这样做会与 Parent Tree 性质产生冲突(父亲 \(endpos\) 包含儿子 \(endpos\),即 \(v\) 包含了已解决后缀,无法有效满足目标 \(1\))。考虑分裂这个等价类,将其 \(len[u]+1\) 的部分强行分离建一个新点 \(v'\),将其作为 \(cur\) 的
.fa。发现分裂出 \(v'\) 后,我们基本上什么都不用干,只需要把 \(v'\) 变成 \(v\) 的.fa,然后把原来连向 \(v\) 的所有边都抢过来,变成连向 \(v'\) 的边(其原因是分离后 \(v\) 中 \(\text{minlen}\) 已经变成了 \(len[u]+2\),尽管我们没有在代码中体现)。以下是具象化的过程:

Code
点击查看代码
#include<bits/stdc++.h>
#define LL long long
using namespace std;
const int N=1e6+5;
string s;
int idx,last;
struct Node{
int son[26];
int fa,len;
}t[N<<1];
int newNode(int len){
t[++idx].len=len;
t[idx].fa=-1;
for(int i=0;i<26;i++)
t[idx].son[i]=0;
return idx;
}
void init(){
idx=-1;
last=newNode(0);
}
int siz[N<<1];
void ins(int c){
int p=last,cur=newNode(t[last].len+1);
siz[cur]=1;
while(p!=-1&&!t[p].son[c])
t[p].son[c]=cur,p=t[p].fa;
if(p==-1)
t[cur].fa=0;
else {
int q=t[p].son[c];
if(t[q].len==t[p].len+1)
t[cur].fa=q;
else {
int nq=newNode(t[p].len+1);
memcpy(t[nq].son,t[q].son,sizeof(t[q].son));
t[nq].fa=t[q].fa;
t[cur].fa=t[q].fa=nq;
while(p!=-1&&t[p].son[c]==q){
t[p].son[c]=nq;
p=t[p].fa;
}
}
}
last=cur;
}
int head[N<<2],id;
struct Edge{
int v,next;
}adj[N<<1];
void ins(int x,int y){
adj[++id].v=y;
adj[id].next=head[x];
head[x]=id;
}
void build(){
for(int i=1;i<=idx;i++)ins(t[i].fa,i);
}
LL ans;
void dfs(int u){
for(int i=head[u];i;i=adj[i].next){
int v=adj[i].v;
dfs(v);
siz[u]+=siz[v];
}
if(siz[u]!=1)ans=max(ans,(LL)t[u].len*siz[u]);
}
signed main(){
ios::sync_with_stdio(0);
cin.tie(0);cout.tie(0);
cin>>s;
int n=s.size();
init();
for(int i=0;i<n;i++)
ins(s[i]-'a');
build();
dfs(0);
cout<<ans;
return 0;
}

浙公网安备 33010602011771号