(笔记)后缀树 后缀数组 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)\) 的。

正确性证明呢?具体地,我们需要证明:

\[\forall i,(rk[i],rk[i+1])\in [2,n] \implies \mathrm{LCP}'(sa[rk[i]-1],sa[rk[i]])-1\le \mathrm{LCP}'(sa[rk[i+1]-1],sa[rk[i+1]]) \]

即对于相邻后缀其 \(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. 同一个等价类中较短子串是较长子串的后缀。

  2. 同一个等价类中子串长度不等,且依次递增 \(1\)
    同时由这一点可以类比,在 Parent Tree 上存在连边的两个节点其字串长度一定是连续的。这是由于 Parent Tree 上从上至下分裂的过程同时也是给字符串不断加前缀字符的过程,因此每加一个字符,要么转移到新的等价类节点,要么留在原来的。

  3. 如果一个子串 \(v\) 是另一个子串 \(u\) 的后缀,则 $endpos(u)\subseteq endpos(v) $。
    这比较显然吧,因为取的字符越多限制越紧。

  4. 一个长度为 \(n\) 的字符串 \(S\) 的等价类数量不超过 \(2n\)
    这个是由 \(\text{SAM}\)增量法构造过程推导出来的。具体来说,在 Parent Tree 上,从上至下分裂的过程就是等价类构造的过程。

构造后缀自动机

母树 Parent Tree

image

(上图是 \(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,这也便于我们描述与应用。

具体过程

首先明确一下定义。

  1. 对每个节点记 \(len\),表示其承载的 \(endpos\) 等价类中长度的 \(\max\),记为 \(\text{maxlen}\)。同理的,下文长度 \(\min\) 也会记为 \(\text{minlen}\)

  2. 对于每个节点记 \(26\) 个儿子,表示其向其他点以字符为边权的有向边。

  3. 对每个节点记 \(fa\),表示其代表的等价类在 Parent Tree 上的父亲。

对于一个 \(\text{SAM}\),我们需要其满足如下条件:

  1. 所有点都与 Parent Tree 上 \(endpos\) 等价类意义对应。

  2. 有向边边权为字符,每个子串都可以表示为一条根到某个点的路径(DFA)。

  3. 不希望点/边太多,都在 \(O(n)\) 级别,且点表示的子串集合两两无交。

  4. 对于一个 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\) 种情况:

  1. 集中在链的下部,访问到的节点不存在连向其他点边权为 \(S_{i+1}\) 的边,直接连到 \(cur\),可以做到新增后缀 \([j,i+1]\),满足目标 \(2\)

  2. 如果整条链都是 1 类点,说明所有新增后缀 \([j,i+1]\) 之前都没有出现过,决定其 .fa 即其 Parent Tree 上父亲时直接连向根,满足目标 \(1\)

  3. 集中在链上部,访问到的节点已经存在连向其他点边权为 \(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\),尽管我们没有在代码中体现)。以下是具象化的过程:

image

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;
}
posted @ 2025-04-24 14:46  TBSF_0207  阅读(66)  评论(1)    收藏  举报