后缀自动机(SAM)与后缀树
后缀自动机(SAM)与后缀树
Part I. 后缀自动机
1. 定义
字符串 \(s\) 的 SAM 是只接受 \(s\) 的后缀的最小 DFA,它包含了 \(s\) 所有子串的信息。
设 \(t_0\) 为起始状态,即空串对应的状态。SAM 是个 DAG。
2. endpos
设 \(s\) 的子串 \(t\) 的 \({\rm endpos}(t)\) 为 \(t\) 在 \(s\) 中终止位置的集合。
引理 1:若 \({\rm endpos}(u)={\rm endpos}(v)\land |u|<|v|\),则 \(u\) 是 \(v\) 的后缀。
引理 2:对于子串 \(u,v\) 且 \(|u|<|v|\),如果 \(u\) 是 \(v\) 的后缀,则 \({\rm endpos}(v)\subseteq {\rm endpos}(u)\);否则 \({\rm endpos}(u)\cap {\rm endpos}(v)=\varnothing\)。
定义一个等价类 \(a\),其包含的所有子串的 \({\rm endpos}(t)={\rm endpos}(a)\)。
这也就是说,一个等价类包含的所有子串在 \(s\) 内 “如影随形” 地同时出现。
定义 \({\rm long}(a)\) 为 \(a\) 中最长的子串,\({\rm len}(a)\) 为其长度;\({\rm short}(a)\) 为 \(a\) 中最短的子串,\({\rm minlen(a)}\) 为其长度。
引理 3:对于一个等价类 \(a\),其包含的所有子串的长度对应一个区间 \([{\rm minlen}(a),{\rm len}(a)]\) 内的所有整数。
那么对于一个等价类 \(a\),其包含的所有子串构成了以 \({\rm long}(a)\) 为下底,\({\rm short}(a)\) 为上底的 右侧直角梯形。长度较短的子串一定是长度较长的子串的后缀。
SAM 以等价类 \(a\) 作为节点,将子串的信息压缩到了极致。
3. 后缀链接 link
定义 \({\rm link}(a)\) 为 \({\rm long}(a)\) 最长的不在 \(a\) 内的后缀对应的等价类。即 \(a\) 对应的 "直角梯形" 的上一层子串的等价类。
容易得到 \({\rm minlen}(a)-1={\rm len}({\rm link}(a))\)。
引理 4:将 \(a\) 向 \({\rm link}(a)\) 连边,得到一棵以 \(t_0\) 为根节点的根向树。
这棵树称为 后缀链接树,也称 parent 树。后缀树描述了等价类的分裂。
从任意一个结点 \(a\) 开始,向上遍历后缀树,遍历到的子串的长度构成一个区间 \([0,{\rm len}(a)]\)。
还可以得到:等价类 \(a\) 的 \({\rm endpos}\) 大小即为其在后缀树上所有儿子的 \({\rm endpos}\) 大小之和。
4. trans
定义 \({\rm trans}(a,c)\) 为在 \(a\) 之后加上一个字符 \(c\) 后到达的节点。
引理 5:若 \({\rm trans}(a,c)\) 存在,则其唯一。
设 \(s\in a\),\(s+c\in b\)。那么由于 \(a\) 中子串都 “如影随形” 地出现,\(\forall s\in a\),\(a+c\) 也 “如影随形” 地出现。所有的 \(s+c\) 是 \(b\) 的一个子集,也就是一个子梯形。
推论:\({\rm trans}(a,c)\) 描述了等价类的合并关系。
\(c\) 固定时,\(b\) 是由几个子梯形拼起来的。
引理 6:若 \(a\) 被多个节点的 \({\rm trans}\) 指向,则这些节点对应着后缀树上的一条祖先后代链。
所有 \({\rm trans}(a,c)=b\) 的 \(a\) 拼起来了 \(b\) 砍掉最右边一列的子梯形。这些子梯形的关系显然满足上面 link 的定义。
5. 增量构建法
在 \(s\) 后加上一个字符 \(c\),维护 SAM 的变化。
首先 \(s+c\) 一定在一个新等价类中,所以我们先新建一个节点 \(p\)。
设当前 \(s\) 所在的节点为 \(lst\),从 \(lst\) 向上遍历后缀树。
由引理 6,更新 trans 只需要考虑 \(lst\) 的祖先。向上遍历,若 \({\rm trans}(q,c)\) 不存在就将其设为 \(p\)。
找到第一个存在 \({\rm trans}(q,c)\) 的 \(q\)。设 \({\rm trans}(q,c)=z\)。
-
若不存在这样的 \(q\),则 \({\rm link}(p)=t_0\),结束;
-
若 \({\rm len}(q)+1={\rm len}(z)\),这说明 \(q\) 的下底边对应 \(z\) 的下底边,则令 \({\rm link}(p)=z\);
-
若 \({\rm len}(q)+1<{\rm len}(z)\),那么 \(z\) 上面的一部分的 \({\rm endpos}\) 多了 \(|s+c|\),而下面的部分没有。
于是 \(z\) 需要分裂为 \(up,dn\) 两个节点。令 \(dn=z\),新建一个 \(up\),将 \(dn\) 的所有信息赋值给 \(up\)。
令 \({\rm link}(p)=up,{\rm link}(dn)=up\)。然后继续从 \(q\) 向上遍历后缀树,若 \({\rm trans}(q',c)=z\),那么修改其为 \(up\)。
SAM 的节点数量不多于 \(2n-1\),转移数不多于 \(3n-4\)。
节点数量的证明:
从构建过程证明:每次最多新建一个、分裂一个,第一个不需要分裂。
从 endpos 证明:parent 树的叶节点的 endpos 集合不交,最多 \(n\) 个,而 \(n\) 个叶节点的树最多有 \(2n-1\) 个节点。
构建 SAM 的时间复杂度为 \(O(n)\),空间复杂度为 \(O(n|\Sigma |)\)。若 \({\rm trans}\) 用平衡树实现,则时间复杂度为 \(O(n\log |\Sigma|)\),空间复杂度为 \(O(n)\)。
void Insert(int c){
int p=++tot,q=lst;
sam[p].len=sam[q].len+1;
for(;q&&!sam[q].tr[c];q=sam[q].lnk) sam[q].tr[c]=p;
if(!q) sam[p].lnk=1;
else{
int z=sam[q].tr[c];
if(sam[q].len+1==sam[z].len) sam[p].lnk=z;
else{
int up=++tot,dn=z;
sam[up]=sam[dn],sam[up].len=sam[q].len+1;
for(;q&&sam[q].tr[c]==dn;q=sam[q].lnk) sam[q].tr[c]=up;
sam[dn].lnk=sam[p].lnk=up;
}
}
lst=p;
}
Part II. 后缀树
1. 定义
包含文本串 \(s\) 所有后缀的 trie 树称为后缀 trie。只保留分叉节点和后缀节点的后缀 trie 称为后缀树。只保留分叉节点和叶子结点的后缀树称为隐式后缀树。
2. SAM 与后缀树
对反串建 SAM 求 parent 树,即为后缀树。
树上的祖先后代关系描述了子串的前缀关系。树边对应的字符可以通过给每个节点记录一个 endpos 里的任意一个元素得到。
这个算法可以支持在前端动态添加字符。
3. Ukkonen 算法
Ukkonen 算法可以支持在后端动态添加字符,求出隐式后缀树。而在 \(s\) 末尾添加一个特殊字符就可以得到后缀树。
称一个后缀是 隐式后缀,当且仅当其没有对应的叶子结点。它可能结束于一个分叉节点,也可能结束于一条边的中间。
引理 1:设最长隐式后缀为 \(s[k,n]\),那么 \(\forall l>k\),\(s[l,n]\) 都是隐式后缀,\(\forall p<k\),\(s[p,n]\) 都是显式后缀。
\(s[p,n]\) 显然为显式后缀。
\(s[k,n]\) 为隐式后缀,意味着存在 \(c\) 使得 \(s[k,n]+c\) 为 \(s\) 的子串。这对于 \(l>k\) 也成立。
我们需要记录每条边的在 \(s\) 中的起始位置和这条边的长度。每次插入新叶子节点时,由于叶子结点对应串的结尾,我们把叶节点的父边的长度设为 \(+\infty\),这样添加字符时叶节点就会自动延长。
记录最长隐式后缀 \(s[k,n]\)。每次插入时,不需要管比 \(k\) 长的后缀,因为它们已经是显式的,会自动延长。
添加字符,令 \(n\leftarrow n+1\)。从根开始寻找 \(s[k,n]\)。
-
若已经在树里说明 \(s[k,n]\) 为隐式后缀,由引理 1 不需要再管其他后缀,直接退出;
-
否则,若 \(s[k,n-1]\) 终止于边内部,需要拆边,然后新建叶子,否则直接新建叶子即可。然后令 \(k\leftarrow k+1\),考虑更短的后缀,重复上述过程。
这个过程是 \(O(n^2)\) 的。若每次都能快速找到某个隐式后缀的位置就能做到 \(O(n)\)。
首先 \(s[k,n]\) 可以不用暴力找。可以记录一个 \((now,rem)\),表示从 \(now\) 出发沿着 \(s_k\) 出边走 \(rem\) 格到达 \(s[k,n]\)。\(k\) 增加时,需要能够直接找到 \(s[k+1,n]\) 对应的节点。
称 后缀链接 \({\rm link}(p)\) 表示 \(p\) 的 最长真后缀 对应的节点。这相当于 ACAM 中的 fail 指针。
引理 2:对于所有非叶子节点,\({\rm link}(p)\) 都存在。
一个节点是非叶子节点,意味着存在 \(c_1,c_2\) 满足 \(t+c_1,t+c_2\) 都是 \(s\) 的子串。这对于 \(t\) 的所有后缀都成立。
只需动态维护非叶子节点的 \({\rm link}\)。插入字符时,只需要在分裂边时求出中间节点 \(v\) 对应的 \({\rm link}\) 就行了。\({\rm link}(v)\) 可以放到插入 \(s[k+1,n]\) 的过程中求,分类讨论:
-
下一轮仍需要分裂边,设新分裂出来的中间节点为 \(v'\),则 \({\rm link}(v)\leftarrow v'\);
-
只需新建叶子或已经存在 \(s[k+1,n]\),则令 \({\rm link}(v)\leftarrow {\rm link}(now)\).
边插入边维护,记上一轮需要求 \({\rm link}\) 的节点为 \(lst\):
-
添加字符,令 \(n\leftarrow n+1\),\(rem\leftarrow rem+1\);
-
若 \(rem\) 大于这条边长度,就一直向下走,保证一步就能找到 \(s[k,n]\);
-
若 \(s[k,n]\) 在树中存在,令 \({\rm link}(lst)\leftarrow now\),直接退出;
-
若 \(s[k,n-1]\) 对应一个结点,那么直接新建叶子结点 \(p\),\({\rm link}(lst)\leftarrow now\),然后 \(lst\leftarrow now\);
-
若 \(s[k,n-1]\) 对应一条边,就分裂这条边,新建中间节点 \(v\) 和叶子结点 \(p\),令 \({\rm link}(lst)\leftarrow v,lst\leftarrow v\)。
-
令 \(k\leftarrow k+1\)。此时需要找到 \(s[k+1,n]\) 对应的 \(now'\)。若 \(now\ne 1\),那么直接令 \(now\leftarrow {\rm link}(now)\),否则令 \(rem\leftarrow rem-1\)。
\(rem\) 只会增加 \(n\) 次,减小时每次只花费 \(O(1)\) 的代价,所以复杂度线性。
int n,m;
char a[N];
int tot=1,now=1,rem=0;
int dep[M],siz[M];
int trie[M][27],len[M],st[M],lnk[M];
ll ans;
int Create(int s,int l){
++tot;
st[tot]=s,len[tot]=l;
return tot;
}
void Ukkonen(int x){
// printf("Ukkonen(%d)\n",x);
++rem; ++m;
for(int lst=1;rem;){
while(len[trie[now][a[m-rem+1]-'a']]<rem){
rem-=len[now=trie[now][a[m-rem+1]-'a']];
}
int &u=trie[now][a[m-rem+1]-'a'];
int c=a[st[u]+rem-1]-'a';
if(!u){
u=Create(m,IINF);
lst=lnk[lst]=now;
}
else if(c==x){
lst=lnk[lst]=now;
break;
}
else{
int v=Create(st[u],rem-1);
trie[v][c]=u,trie[v][x]=Create(m,IINF);
st[u]+=rem-1,len[u]-=rem-1;
lst=lnk[lst]=u=v;
}
if(now==1) --rem;
else now=lnk[now];
}
}
void dfs(int x){
int cs=0;
for(int i=0;i<=26;i++)
if(trie[x][i]){
++cs;
int y=trie[x][i];
dep[y]=dep[x]+len[y];
dfs(y); siz[x]+=siz[y];
}
if(!cs) siz[x]=1;
if(siz[x]>1) Ckmax(ans,1ll*siz[x]*dep[x]);
}
signed main(){
scanf("%s",a+1);
n=strlen(a+1);
len[0]=IINF; a[n+1]='z'+1;
for(int i=1;i<=n+1;i++) Ukkonen(a[i]-'a');
dfs(1);
printf("%lld\n",ans);
return 0;
}

浙公网安备 33010602011771号