后缀自动机(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\) 作为节点,将子串的信息压缩到了极致。

定义 \({\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\)。

  1. 若不存在这样的 \(q\),则 \({\rm link}(p)=t_0\),结束;

  2. 若 \({\rm len}(q)+1={\rm len}(z)\),这说明 \(q\) 的下底边对应 \(z\) 的下底边,则令 \({\rm link}(p)=z\);

  3. 若 \({\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\):

  1. 添加字符,令 \(n\leftarrow n+1\),\(rem\leftarrow rem+1\);

  2. 若 \(rem\) 大于这条边长度,就一直向下走,保证一步就能找到 \(s[k,n]\);

  3. 若 \(s[k,n]\) 在树中存在,令 \({\rm link}(lst)\leftarrow now\),直接退出;

  4. 若 \(s[k,n-1]\) 对应一个结点,那么直接新建叶子结点 \(p\),\({\rm link}(lst)\leftarrow now\),然后 \(lst\leftarrow now\);

  5. 若 \(s[k,n-1]\) 对应一条边,就分裂这条边,新建中间节点 \(v\) 和叶子结点 \(p\),令 \({\rm link}(lst)\leftarrow v,lst\leftarrow v\)。

  6. 令 \(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;
}
posted @ 2025-07-01 15:12  XP3301_Pipi  阅读(52)  评论(0)    收藏  举报
Title