[学习笔记] SAM——后缀自动机

[学习笔记] SAM——后缀自动机

零.前言

真是给我整的有够难受的,这个SAM,也不算搞懂了。只是粗浅的理解了一下,且在这里试图将它写下来。

​ 上面是这个笔记的初稿,现在做了一些题,感觉自己不说懂完了,但是还是有一点点点点东西的。/cy

一.概念

1.自动机

​ 感兴趣的可以自主找资料看一下,很有趣的一个概念,能够帮助你理解各路自动机,由于不是重点,所以这里不加以阐释。

2.后缀自动机(Suffix automaton,SAM)

​ 为对应字符串的所有子串的压缩形式,节点数最多为 (2n-1),转移边最多有 (3n-4) 条。这些性质在后面会给出论证。此处不做讲解

​ 组成部分分有两个,一个是它的自动机部分,为一个有向无环图,图上的每一个点被称为是一个“状态”,每一个边称为“转移”,图上的源点称为初始状态。另外一个是一棵树,多被称为 (Parent~tree) ,每一个点也是状态,但是边为 "后缀链接",初始状态为这棵树的根。此处也暂时不做证明。

​ 接下来给出上面所有加粗术语的内涵。

3.状态

​ 又被称为一个 “(Endpos) 等价类”,或者叫 "(right) 集合"。

​ 对于子串 s,它的所有出现位置(以 "right" ,即最后一个字母(end) 出现的位置( position )为代表),可以构成一个集合 S。那么集合相同的所有串被同一个状态所代表。

​ 取这些串中的最长的串为 (s_{max}) 那么状态上会储存它的长度 (Len)

4.转移

​ 每个转移上有一个字符 c ,那么经过这条转移相当于在串的末尾添加一个字符 c。每一个状态所对应的所有转移互不相同。且这个状态所代表的所有串都可以走这个转移到一个相同的状态中。

5.后缀链接

​ 表示状态 (endpos) 包含关系的连边。若是等价类 (S subset 等价类A) 那么从 S 对应的状态向 A 对应的状态连边。

6.论证与清新理解

​ 这里就是我得私货了,是我自己关于以上概念的一些不成熟的想法,跟算法没有什么关系,只是我得一些小想法。不想看可以不看。

trie与 parent tree 与后缀链接 与 状态 和后缀树

​ 首先每一个子串都会有一个与之相对应的状态,虽然并不一一对应。那么作为一个及其特殊的子串——空串,存在于所有位置,他会恰好对应一个最大的集合,也就是初始状态,parent tree 的树根,因为所有的状态上的 endpos 集合都被初始状态的集合所包含。

​ 那么考虑一个向前“扩展”的过程,对于一个子串(A),我们通过在其前面添加一个字符,变成一个更长的合法子串(B=cA)。容易想出,B 对应的集合是包含于A的。一方面可以考虑 A 是 B 的真后缀,所以所有 B 出现的 endpos ,A 也一定出现。另一方面可以认为我们使 B 变得更为特殊,出现条件更为苛刻,所以满足条件的位置更少。

​ 然后将扩展想象成伸出一条带字母的边,可以认为是在不断的向下“生长”成为一颗树。

​ 那么如此想像,可以得到一颗树,很有趣的是,它和将所有子串倒着插成一个 (trie) 的结果相同。很可悲的是,他跟SAM中的树还暂时没有什么关系。这颗 tire 上的每一个点到根代表着一个子串,对于每一个子串,也只对应一个点,然后不妨将这个子串在原串中的出现次数标到点上。

​ 若是存在连续一段中间没有分叉且出现次数相同的部分,我们就可以将它压缩成同一个点,实际上是后缀树的定义,这样压缩下来就成为了SAM对应的 parent tree,而其中剩下的边即为后缀链接。且这个时候也能说明为什么 (endpos) 等价类之间形成一个树关系。

​ 可能听着觉得很奇怪。但这个想法实际上反映了 (endpos) 等价类的许多内涵。记这个段上某一个点为 (A) 它的父亲为 (B) .没有分叉,证明了所有的子串 B 向前 “扩展” 只能成为 A,同时树的性质也决定了所有的 A 也只由 B 扩展而来。再换句话说,有 A 的地方就有 B ,有 B 的地方就有 A,且 A 为 B 的后缀。这满足了 (endpos) 等价类的意义。需要注意的是,当 B 为 'a' ,A 为 'aa' ,不满足意义,因为出现次数不同。

​ 同时原来 tire 中的边就是反映的一个子串间的后缀关系,而后缀链接作为边的一部分同样也可以反映,所以叫 “后缀” 链接,更具体的,记后缀链接 ((p,q)),所以 q 中的串为 p 中的串的后缀。(由于压缩的意义,一个状态已经包含很多个串了)

​ 所以可以快速证明以下几个引理。

  • 字符串 S 的两个非空子串 u 和 w (假设 |u|<=|w| ,可以得到在 tire 中 u 为 w 的祖先)的 相同,当且仅当字符串 u 在 s 中的每次出现,都是以 w 后缀的形式存在的。

    很显然,endpos等价类来源于压缩的过程,而压缩前的tire 保证了后缀关系。

  • 同样条件下,(endpos(u))(endpos(w)) 不交叉,即交集要么为空要么为 (endpos(w))

    同样很好证,tire树的形态保证了节点要么包含要么不交。

  • 考虑一个 (endpos) 等价类,将类中的所有子串按长度非递增的顺序排序。每个子串都不会比它前一个子串长,与此同时每个子串也是它前一个子串的后缀。换句话说,对于同一等价类的任一两子串,较短者为较长者的后缀,且该等价类中的子串长度恰好覆盖整个区间 。

    易证,本来在tire上就是后缀关系,合在一起并不破坏关系。

  • 对于原串 S 的任一前缀,均为其所在的状态中最长的串。

    易证,由于是前缀,所以在原 trie 中到达叶子节点,能和他合并的并没有比他长的。同时这个在增量构建的方式中也看得出来。

  • 所有在 parent tree 中的叶子节点均为前缀,但是前缀并不为叶子,为叶子是充分条件。

    自证不难。不想写了。

状态与转移

​ 与“扩展”在前面加一个字符的形式不同,转移时在后面添加一个字符。于是连边就连向对应的 (endpos) 等价类就好。然后这个自动机是一个有向无环图也很好证,因为每走一个转移长度会加1,所以不可能走到他自己,所以无环。

状态数的证明

​ 从之前的理解来看,容易发现叶子节点均为一个前缀,于是可以知道 parent tree 是一个内部节点除了部分特殊点(前文提到的 'aa' 与 'a',而且这种东西只会在一个前缀均为同一字母的时候才为链,可以思考为什么,或者直接画图检验)都有着不止一个儿子,而叶子节点最多只有前缀个数即 |S| 个(顶不到的情况是特殊点,会消耗叶子节点数,状态数更小),于是 “任意内部节点读书至少为2,叶子节点最多为 n”的树,点数最多为 (2n-1)

​ 当然也可以从构造算法看。

转移数的证明

​ 咕咕咕。

二.构造

​ 扯了一堆没什么卵用的。

​ 现在来说SAM的构造,首先这是一个在线的构造,对于每次新增的一个字符 c ,插入一个可以代表他的状态 cur 。要维护他在 parent tree 上的位置以及转移。不妨记上一次插入的状态为 last 。那么很显然的 last 为上一次插入的 cur,且每一次插入的都是代表整个串,即集合{end}.

​ 先考虑维护它的转移。

​ 由于转移是相当于在后面加一个合法的字符,所以理论上来讲所有的原后缀都可以增添一个转移到 cur 。由于 last 代表整个原串,所以顺着 last 在 parent tree 上到根的路径就可以遍历出所有的后缀,并且试图在其后添加一个到 cur 的转移。

​ 但是当某一个代表原后缀的状态 p ,拥有一个为 c 的转移的时候,就会产生“冲突”,这和我们概念所保证的每一个状态中转移互不相同不一样。于是尝试去解决这个问题。

​ 开始观察 p 经过 c 转移所到达的状态 q。首先设想一个非常简单的情况,q 中最长的串为 (xc) ,那么很幸运地是此时可以直接将 cur 的后缀链接连到 q 上去,因为满足在上面所提到的“后缀链接 ((a,b)), b 中最长的串为 a 中最短的串的后缀”。当最长的串为 (dxc) 的时候,情况变得了复杂起来。因为并不满足性质。

​ 这种情况之所以会出现,是因为之前压缩的太过度了。这种情况下最憨的想法是将那个状态拆回 tire 上的形式,然后在压缩。经过手玩之后,我们发现这个过程产生了一个新的状态,且这个状态恰好代表 (xc) ,而它的儿子最短的串都比(xc) 在前面多一个字符 。此时就可以将 cur 接到新状态上去了。并且发现拆的前后的信息十分相似,唯一的差别就是最长串的长度,信息的相似读者自证奥。

​ 最后一步是处理转移,因为有且只有 p 的后缀可以走 c 的转移到 q,转移出来的串长度可能是原来的 q 中长度大于 (xc) 的那一部分,所以有一个转移分流。

​ 给出构造的代码。

CODE

struct node{
	int len,link,siz;//最大串长度,后缀链接,出现次数(后文会讲)
	map<char , int > tran;//转移
}alf[MAXN];
inline void SAM_insert(char c){
	int cur=++tot,now=last;
	alf[cur].len=alf[last].len+1;
	alf[cur].siz=1;
	while(now!=-1&&!alf[now].tran[c])alf[now].tran[c]=cur,now=alf[now].link;//顺着跳,加转移
	if(now==-1)alf[cur].link=0;//它的合法真后缀就只有空串了
	else{
		int bet=alf[now].tran[c];
		if(alf[bet].len==alf[now].len+1)alf[cur].link=bet;//简易情况
		else {
			int clone=++tot;//拆点
			alf[clone]=alf[bet];//复制
			alf[clone].len=alf[now].len+1,alf[clone].siz=0;//更新串长与出现次数
			while(now!=-1&&alf[now].tran[c]==bet)alf[now].tran[c]=clone,now=alf[now].link;//转移分流
			alf[cur].link=alf[bet].link=clone;//链起来
		}
	} 
	last=cur;
}

​ 同时很有趣的是,我们拆出一个新点,恰好给他了两个儿子,并不破环 parent tree 的性质,并且每一次操作只会产生一个新点,所以总状态数是 (2n) 级别的。

​ 总共构建的复杂度不会算。

​ 并且由于是增量构建的,所以每插入一个完成后,都可以识别当前所有的后缀,而当前是一个前缀,之后也并不删除信息,故能识别所有前缀的所有后缀,即所有子串。

三.应用选讲

​ 此处总结了一些见过和想到的东西。还是题做少了。

1.本质不同子串数

​ 将所有状态上的 len 和父亲的 len 做差即可。

2.第 k 小子串

​ 可以知道一条转移序列和一个子串一一对应,等于是在DAG上面先DP出每一个状态走任意转移能得到的子串数,然后贪心即可,有点类似于平衡树上第 k 小。

3.某子串在文本串中的出现次数

​ 直接拿文本串 S 大力跑,能转移就转移,不能转移就跳 link 直到能转移。这一步其实很贪心,因为跳 link 相当于在前面删去尽可能少的字符,然后看是否够转移。

​ 同时注意到由于相当于删去,最多跳 |S| 次,最多转移 |S| 次,所以线性。

​ 最后统计答案的时候需要将子树内答案累加,因为后缀链接代表后缀关系,那么一个串出现即代表它的所有后缀都出现。

4.两个字符串最长公共子串

​ 还是拿一个建,另一个大力跑,然后记录一下当前匹配到的长度 (Len(i)) 表示在 i 位置匹配到了一个长度为 (Len(i)) 的串,取 Len 的最大值即可。

​ 具体的计算,在走转移时 (Len(i)) 就+1,跳 link 的时候就置为状态上的 len,感觉不太需要解释,毕竟贪心。

例题

5.多个字符串最长公共子串

​ 方法同上,只是单次要在子树内取最大,全局要取每一次最小。

例题

CODE

#define fe(i,a,b) for(int i=a;i<=b;++i)
const int MAXN=1e5+5;
struct node{
	int trans[30],link,len,ans,nowans;
	vector<int> ch;
	inline void upd(int x){nowans=min(len,max(nowans,x));}
}alf[MAXN*2-1];
int tot,last,ans;
inline void SAM_insert(int c){
	int cur=++tot,now=last;
	alf[cur].len=alf[last].len+1,alf[cur].ans=1e9;
	while(now!=-1&&!alf[now].trans[c])alf[now].trans[c]=cur,now=alf[now].link;
	if(now==-1)alf[cur].link=0;
	else{
		int bet=alf[now].trans[c];
		if(alf[bet].len==alf[now].len+1)alf[cur].link=bet;
		else{
			int clone=++tot;
			alf[clone]=alf[bet],alf[clone].len=alf[now].len+1;
			while(now!=-1&&alf[now].trans[c]==bet)alf[now].trans[c]=clone,now=alf[now].link;
			alf[cur].link=alf[bet].link=clone;
		}
	}
	last=cur;
}
char s[MAXN];
inline void solve(int x){
	int siz=alf[x].ch.size();
	fe(i,0,siz-1)solve(alf[x].ch[i]);
	alf[x].ans=min(alf[x].ans,alf[x].nowans);
	if(x)alf[alf[x].link].upd(alf[x].nowans);
}
int main(){
	scanf("%s",s+1);
	alf[0].link=-1;
	int len=strlen(s+1);
	fe(i,1,len)SAM_insert(s[i]-'a');
	fe(i,1,tot)alf[alf[i].link].ch.push_back(i);
	while(scanf("%s",s+1)!=EOF){
		fe(i,0,tot)alf[i].nowans=0;
		len=strlen(s+1);
		int now=0,cnt=0;
		fe(i,1,len){
			if(alf[now].trans[s[i]-'a'])now=alf[now].trans[s[i]-'a'],cnt++;
			else {
				while(now!=-1&&!alf[now].trans[s[i]-'a'])now=alf[now].link;
				if(now!=-1)cnt=alf[now].len+1,now=alf[now].trans[s[i]-'a'];
				else now=cnt=0;
			}
			alf[now].upd(cnt);
		}
		solve(0);
	}
	fe(i,1,tot)ans=max(ans,alf[i].ans);
	printf("%d",ans);
	return 0;
}

6.一个串和另一个串的某一区间的最长公共子串

例题

​ 这是一个很典型的 (i-Len(i)) 的模型,原题对于每一个询问 ([l,r]),相当于求 (max_{lleq ileq r}{min{Len_i,i-l+1}})。考虑 min 号里面两项做差,抛开常数即为 (i-Len(i)),容易发现这个东西单调不降。因为向后走 i 肯定能增大,而 Len 最多增大1.所以有单调性。直接二分看什么时候取那一边就完事了。

CODE

#define fe(i,a,b) for(int i=a;i<=b;++i)
const int MAXN=2e5+5;
struct node{
	int trans[30],link,len;
}alf[MAXN*2-1];
int tot,last;
inline void SAM_insert(int c){
	...
}
char s[MAXN],t[MAXN];
int bet[MAXN],Q,RMQ[MAXN][20],lg2[MAXN],lent,len;
inline int ST_query(int l,int r){
	int k=lg2[r-l+1];
	return max(RMQ[l][k],RMQ[r-(1<<k)+1][k]);
}
inline void ST_init(){
	fe(i,1,len)RMQ[i][0]=bet[i],bet[i]=i-bet[i]+1;
	fe(j,1,19)for(int i=1;i+(1<<(j-1))-1<=len;++i)RMQ[i][j]=max(RMQ[i][j-1],RMQ[i+(1<<(j-1))][j-1]);
	lg2[1]=0;
	fe(i,2,len)lg2[i]=lg2[i>>1]+1;
}
int main(){
	alf[0].link=-1;
	scanf("%s",s+1),len=strlen(s+1);
	scanf("%s",t+1),lent=strlen(t+1);
	fe(i,1,lent)SAM_insert(t[i]-'a');
	int now=0,cnt=0;
	fe(i,1,len){
		if(alf[now].trans[s[i]-'a'])now=alf[now].trans[s[i]-'a'],cnt++;
		else {
			while(now!=-1&&!alf[now].trans[s[i]-'a'])now=alf[now].link;
			if(now!=-1)cnt=alf[now].len+1,now=alf[now].trans[s[i]-'a'];
			else now=cnt=0;
		}bet[i]=cnt;
	}
	ST_init();
	Q=read();
	fe(i,1,Q){
		int l=read(),r=read();
		if(bet[r]<l)printf("%d
",r-l+1);
		else if(bet[l]>=l)printf("%d
",ST_query(l,r));
		else {
			int mid=lower_bound(bet+1,bet+len+1,l)-bet;
			printf("%d
",max(mid-l,ST_query(mid,r)));
		}
	}
	return 0;
}

7.Len(i) 套DP

例题

还是先求一个 (Len(i)) ,然后可以一通乱搞写一个非常经典的枚举断点的DP式 (f[i]=maxlimits_{jin[i-len[i],i-1)}{f[j]+i-j}),发现可以单调队列优化,这个水题就结束了/cy,太水了就不贴代码了。

8.线段树合并维护 Endpos

​ 你要先会线段树合并,不会的话大概花半个小时先学会吧。

​ 然后这题就是大力跑T,然后看每一位是不是可以歪出去,然后挑最远的歪就好了。中间大力转移的时候需要注意走合法的位置,因为要在区间 ([l,r]) 中,然后可以清晰的知道估计的转移后长度 (i) ,于是要求转移到的集合中与区间([l+i-1,r])存在交集。

code

#define fe(i,a,b) for(int i=a;i<=b;++i)
const int MAXN=2e5+5;
int lc[MAXN*20],rc[MAXN*20],siz[MAXN*20],tot_Seg;
inline void upd(int x){siz[x]=siz[lc[x]]+siz[rc[x]];}
int Seg_insert(int l,int r,int g){
	if(l>r)return 0;
	int res=++tot_Seg,mid=(l+r)>>1;
	if(l==r)return siz[res]++,res;
	if(mid>=g)lc[res]=Seg_insert(l,mid,g);
	else rc[res]=Seg_insert(mid+1,r,g);
	return upd(res),res;
}
int merge(int x,int y){
	if(!x||!y)return x+y;
	int res=++tot_Seg;
	lc[res]=merge(lc[x],lc[y]);
	rc[res]=merge(rc[x],rc[y]);
	return upd(res),res;
}
bool query(int x,int l,int r,int L,int R){
	if(l>=L&&r<=R)return siz[x]!=0;
	if(r<L||l>R||!x)return 0;
	int mid=(l+r)>>1;
	return query(lc[x],l,mid,L,R)|query(rc[x],mid+1,r,L,R);
}
char s[MAXN];
struct node{
	int link,len,trans[30];
	int rt,in;//rt代表对应的线段树的根节点,in是入度用来排序
}alf[MAXN*2+1];
int tot_SAM,last,len;
inline void SAM_insert(int c){
	int cur=++tot_SAM,now=last;
	alf[cur].len=alf[last].len+1,alf[cur].rt=Seg_insert(1,len,alf[cur].len);//有所改变的地方
	while(now!=-1&&!alf[now].trans[c])alf[now].trans[c]=cur,now=alf[now].link;
	if(now==-1)alf[cur].link=0;
	else{
		int bet=alf[now].trans[c];
		if(alf[bet].len==alf[now].len+1)alf[cur].link=bet;
		else{
			int clone=++tot_SAM;
			alf[clone]=alf[bet],alf[clone].len=alf[now].len+1,alf[clone].rt=0;
			while(now!=-1&&alf[now].trans[c]==bet)alf[now].trans[c]=clone,now=alf[now].link;
			alf[cur].link=alf[bet].link=clone;
		}
	}
	last=cur;
}
int que[MAXN*2+1],fr,ed;
inline void make_endpos(){
	fe(i,1,tot_SAM)++alf[alf[i].link].in;
	fe(i,1,tot_SAM)if(!alf[i].in)que[ed++]=i;
	while(fr<ed){
		int now=que[fr++];
		if(!now)continue;
		alf[alf[now].link].rt=merge(alf[alf[now].link].rt,alf[now].rt);
		alf[alf[now].link].in--;
		if(!alf[alf[now].link].in)que[ed++]=alf[now].link;
	}
}
int nxt[MAXN];
int main(){
	scanf("%s",s+1);
	len=strlen(s+1);
	alf[0].link=-1;
	fe(i,1,len)SAM_insert(s[i]-'a');
	make_endpos();
	int Q=read();
	while(Q--){
		int l=read(),r=read(),now=0,del,i;
		scanf("%s",s+1),del=strlen(s+1);
		for(i=1;;i++){
			nxt[i]=-1;
			fe(j,max(s[i]-'a'+1,0),'z'-'a')if(alf[now].trans[j]&&query(alf[alf[now].trans[j]].rt,1,len,l+i-1,r)){nxt[i]=j;break;}
			if(i==del+1||!alf[now].trans[s[i]-'a']||!query(alf[alf[now].trans[s[i]-'a']].rt,1,len,l+i-1,r))break;
			now=alf[now].trans[s[i]-'a'];
		}
		while(i&&nxt[i]==-1)i--;
		if(!i)printf("-1
");
		else {
			fe(j,1,i-1)printf("%c",s[j]);
			printf("%c
",nxt[i]+'a');
		}
	}
	return 0;
}

四.一些扩展——广义SAM

​ 既然学了SAM就要会广义,不然很亏的cy

​ 这个东西,网上有很多假写法,主要假在复杂度,要是真的想学可以上OIwiki或者模板题的题解里面看晨星凌写的东西,在线离线都有。顺带一提我现在还只会写离线的,在线的懂了没写。

​ 用我的话来讲,就是先插入一个 tire 树中,然后大力拿 trie 树上面按深度 FIFO 的顺序建SAM,需要注意的是复制节点的地方需要特判一下。复杂度一类的就咕咕了,反正是线性(有些带字符集,想了解可以看15年论文集第一篇,就是在那里提出的)。

CODE

#include<iostream>
#include<cstdio>
#include<algorithm>
#include<cstring>
#include<cmath>
#include<queue>
using namespace std;
#define fe(i,a,b) for(int i=a;i<=b;++i)
inline int read(){
	char ch=getchar();
	int res=0,f=1;
	for(;ch<'0'||ch>'9';ch=getchar())if(ch=='-')f=-1;
	for(;ch>='0'&&ch<='9';ch=getchar())res=(res<<3)+(res<<1)+(ch-'0');
	return res*f;
}
const int MAXN=1e6,CSIZ=30;
struct EX_SAM{
	#define pii pair<int,int> 
	#define mp(a,b) make_pair(a,b)
	int tot;
	struct node{int trans[CSIZ+1],link,len;}alf[MAXN*2+1];
	inline void strins(string s){
		int len=s.length(),now=0;
		fe(i,0,len-1)now=alf[now].trans[s[i]-'a']?alf[now].trans[s[i]-'a']:(alf[now].trans[s[i]-'a']=++tot);
	}
	inline long long solve(){
		long long res=0;
		fe(i,1,tot)res+=alf[i].len-alf[alf[i].link].len;
		return res;
	}
	inline void SAM_insert(int last,int c){
		int cur=alf[last].trans[c],now=alf[last].link;
	//	if(alf[cur].len)return ;我觉得这句很没有意义,但是看OIwiki上面有就写在这里以表尊敬,可能是我复杂度假了,反正加不加都能过模板。
		alf[cur].len=alf[last].len+1;
		while(now!=-1&&!alf[now].trans[c])alf[now].trans[c]=cur,now=alf[now].link;
		if(now==-1)alf[cur].link=0;
		else {
			int bet=alf[now].trans[c];
			if(alf[bet].len==alf[now].len+1)alf[cur].link=bet;
			else {
				int clone=++tot;
				alf[clone].link=alf[bet].link,alf[clone].len=alf[now].len+1;
				fe(i,0,CSIZ)if(alf[alf[bet].trans[i]].len)alf[clone].trans[i]=alf[bet].trans[i];
				while(now!=-1&&alf[now].trans[c]==bet)alf[now].trans[c]=clone,now=alf[now].link;
				alf[cur].link=alf[bet].link=clone;
			}
		}
	}
	inline void build(){
		queue<pii> q;
		alf[0].link=-1;
		fe(i,0,CSIZ)if(alf[0].trans[i])q.push(mp(0,i));
		while(!q.empty()){
			int u=alf[q.front().first].trans[q.front().second];
			SAM_insert(q.front().first,q.front().second);
			fe(i,0,CSIZ)if(alf[u].trans[i])q.push(mp(u,i));
			q.pop();
		}
	}
}S;
string s;
int n;
int main(){
	n=read();
	fe(i,1,n){
		cin>>s;
		S.strins(s);
	}
	S.build();
	printf("%lld",S.solve());
	return 0;
}

与 AC自动机

​ 噢,终于来到了我最想写的部分。

​ 在15年论文里面,就提出了 AC自动机可以被 SAM 所代替。但是我没怎么看到实现,于是我自己写了一下,已经把三个luogu的模板题过了。

​ 先来想 AC自动机 和广义 SAM 的关系吧,有好几个相同点。

  • 都是以 trie 树作为基础搭建的(这里我说的是离线构造SAM,按晨星凌题解里面说的正确的在线节点数跟离线是一样的,但是我没时间写所以还不得而知)
  • 都有一个指向后缀关系的链接/指针(link和fail)

但是还是各有不同点。

​ AC自动机不破坏原来 trie树的结构,但是只能识别一整个串。

​ 广义 SAM 破坏了原来 tire 树的结构,但是可以识别所有的串的所有子串,识别本串当然不在话下。

于是也不是每一个AC自动机的题都可以简单的去替换,比如像阿狸的打字机这种题,既运用了trie树的形态,又运用了fail树的性质的题,虽然我yy了一下可以拿广义 SAM 去写,但是会很烦和恶心,因为原来的结构烂掉了。所以没有必要舍近求远,恶心自己的事情还是少做。

​ 然后具体说说怎么去实现。首先在插入 trie 后,给末尾的状态打上对应的标记。然后就能知道整串在哪里出现。然后对于一整个文本串,直接大力跑,给贪心经过的状态打上标记。

​ 为了避免整串识别到别的串某一后缀去或者说保持逻辑上的合理性,又或是和AC自动机相互照应,反正是需要统计整个子树内的标记情况。然后比AC自动机稍微复杂一点的是当前状态实在囊括了太多信息,所幸模式串串所在的状态的最长串一定是模式串(我 yy 的,感觉上和实践上都是对的,自证感觉不难),所以更关心一个状态的最长串是否被经过。于是需要判断当前贪心得到的最长长度和 (maxlen) 的关系。

​ 给出AC自动机模板(二次加强版)的代码。

CODE

#include<iostream>
#include<cstdio>
#include<algorithm>
#include<cstring>
#include<cmath>
#include<queue>
#include<vector>
using namespace std;
#define fe(i,a,b) for(int i=a;i<=b;++i)
inline int read(){
	char ch=getchar();
	int res=0,f=1;
	for(;ch<'0'||ch>'9';ch=getchar())if(ch=='-')f=-1;
	for(;ch>='0'&&ch<='9';ch=getchar())res=(res<<3)+(res<<1)+(ch-'0');
	return res*f;
}
const int CHIZ=30,MAXN=4e5;
int ans[MAXN];
struct EX_SAM{
	int tot;
	struct node{
		int trans[CHIZ+1],link,len,vis;
		vector<int>ch,num;
	}alf[MAXN*2+1];
	inline void str_insert(string s,int num){
		int len=s.length(),now=0;
		fe(i,0,len-1)now=alf[now].trans[s[i]-'a']?alf[now].trans[s[i]-'a']:(alf[now].trans[s[i]-'a']=++tot);
		alf[now].num.push_back(num);
	}
	inline void SAM_insert(int last,int c){
		int cur=alf[last].trans[c],now=alf[last].link;
		alf[cur].len=alf[last].len+1;
		while(now!=-1&&!alf[now].trans[c])alf[now].trans[c]=cur,now=alf[now].link;
		if(now==-1)alf[cur].link=0;
		else{
			int bet=alf[now].trans[c];
			if(alf[bet].len==alf[now].len+1)alf[cur].link=bet;
			else{
				int clone=++tot;
				alf[clone].link=alf[bet].link,alf[clone].len=alf[now].len+1;
				fe(i,0,CHIZ)if(alf[alf[bet].trans[i]].len)alf[clone].trans[i]=alf[bet].trans[i];
				while(now!=-1&&alf[now].trans[c]==bet)alf[now].trans[c]=clone,now=alf[now].link;
				alf[cur].link=alf[bet].link=clone;
			}
		}
	}
	inline void build(){
		alf[0].link=-1;
		#define pii pair<int,int>
		#define mp(a,b) make_pair(a,b)
		queue<pii> q;
		fe(i,0,CHIZ)if(alf[0].trans[i])q.push(mp(0,i));
		while(!q.empty()){
			int u=alf[q.front().first].trans[q.front().second];
			SAM_insert(q.front().first,q.front().second);
			q.pop();
			fe(i,0,CHIZ)if(alf[u].trans[i])q.push(mp(u,i));
		}
		fe(i,1,tot)alf[alf[i].link].ch.push_back(i);
	}
	void dfs(int x){
		int siz=alf[x].ch.size();
		fe(i,0,siz-1)dfs(alf[x].ch[i]),alf[x].vis+=alf[alf[x].ch[i]].vis;
		if(alf[x].num.size())fe(i,0,alf[x].num.size()-1)ans[alf[x].num[i]]+=alf[x].vis;
	}
}S;
string s[MAXN];
int main(){
	int n=read();
	fe(i,1,n){
		cin>>s[i];
		S.str_insert(s[i],i);
	}
	S.build();
	cin>>s[0];
	int len=s[0].length(),now=0,cnt=0;
	fe(i,0,len-1){
		if(S.alf[now].trans[s[0][i]-'a'])now=S.alf[now].trans[s[0][i]-'a'],cnt++;
		else{
			while(now!=-1&&!S.alf[now].trans[s[0][i]-'a'])now=S.alf[now].link;
			if(now!=-1)cnt=S.alf[now].len+1,now=S.alf[now].trans[s[0][i]-'a'];
			else now=0,cnt=0;
		}
		if(cnt==S.alf[now].len)S.alf[now].vis++;
		else S.alf[S.alf[now].link].vis++;
	}
	S.dfs(0);
	fe(i,1,n)printf("%d
",ans[i]);
	return 0;
}

(u1s1,前两个模板是真的水,我拿假写法都写过了,理论上这一个模板比较强,但要是有锅欢迎激情指出,感激不尽)

​ 看到这里可能有人会想,AC自动机这么好写,何必作贱去写 SAM 呢?但是根据15年的论文,广义SAM是可以在线增量构建的。也就是说我可以做一个强制在线的AC自动机,其他的自动机做得到吗?做得到吗?(夜神月基本手势)

​ 只是我暂时没时间写增量构建,但是理论指导实践,我觉得多半是可行的。

​ 希望有一一天能把这个坑填了( 于2021.2.22 夜 )

原文地址:https://www.cnblogs.com/clockwhite/p/14432696.html