后缀自动机是个好东西,代码短还很快。
这是因为根到一个节点的不同路径表示所有有某种相同性质的不同字符串。
假设对于字符串S建后缀自动机,以下名词的意思是:
right(S的子串s):s在S中出现的位置的右端点的集合;
状态:有同样right集合的子串,某状态的right集合是指该状态的所有字符串的right集合;
ancestor(状态k):表示一个集合,状态k的right集合是此集合中所有状态的right集合的真子集;
parent(状态k):ancestor(k)中right集合最小的那一个;
字符边:就是和trie树中的边一样的那种边,别人都叫它状态的转移;
dis(状态k):根到某个状态的最长路径。
这样会使得它们有一些神奇的性质:
1.对于某个状态,其中所有字符串的长度在一个区间内。
想象一下,对于字符串S,right(s)={x1,x2,x3}。
那么随着s的长度增加,三个位置前面可能会出现不一样的,right(s)就不是x1,x2,x3了;
随着s的长度减小,可能会在除了x1,x2,x3以外的地方有s出现,同上。
根据这条性质,就可以用根到状态的不同字符边路径表示状态中包含的不同字符串,dis(x)则表示该状态长度区间的上边界。那么下边界该怎么得来呢?刚刚说过,随着长度减少,right集合会更大。因为ancestor(x)的right集合都大于x,所以它们的字符串长度取值范围都小于x。那么其中最长的一个的长度应该刚好是x的下边界-1。因为对应的字符串长度最长,所以right集合最小,那么就是parent(x)了。也就是说,点x的下边界是dis(parent(x))+1,可以直接求出,没必要额外存一个。
2.对于任意两个状态,要么它们的right集合没有交集,要么一个是另一个的真子集。
考虑反证法。
假设存在两个状态k1,k2的right集合有交集但其中一个不是另一个的真子集。
从k1,k2中分别取s1,s2。
因为s1,s2的right集合有交集,所以必然有一个是另一个的后缀。假设s1是s2的后缀。
对于right(s2),也就是s2出现的所有位置,它的后缀必然都出现了,所以right(s2)是right(s1)的真子集,假设不成立。
根据这条性质,对于所有状态A的right集合是状态B的right集合的真子集,且不存在状态C,使得状态A的right集合是状态C的right集合的真子集,状态C的right集合是状态B的right集合的真子集,都可以得出parent(A)=B,也就形成了传说中的parent树。
3.parent(k)中的所有子串都是k的所有子串的后缀。
这是性质2的推论。
根据这条性质,parent边可以当AC自动机的失配边来用。
4.parent树的根对应的状态是空串。
因为每个位置都有空串。
根据这条性质,可以把起点和parent树的根直接称为“根”。
5.并没有第五条。
根据这条性质,就会发现这并不是对劲的博客。
那么该如何构建呢?考虑每次加一个字符s[x],新建节点np,大概想一想方法。
新串肯定包含原串的所有子串。相比于原串,新串多出的是新串的所有后缀。
那么将原串的所有后缀(包括空串)对应的状态都连一条值为s[x]的字符边到np上。
原串的后缀很好找,从上一次加入的点开始,沿着的parent边走到根就行了。
“大概”想完后,似乎一切都解决了,然而并不。
如果原串的所有后缀的状态都没有连过值为s[x]的字符边,那么s[x]这个字符在字符串中第一次出现。这时先照着“大概”做,再将np的parent连向根。因为s[x]这个字符从未出现过,只有空串是它的后缀。
如果在沿着parent边走的过程中走到一个状态p已经连了一条值为s[x]的字符边到q,也就是说s[x]这个字符不是第一次出现,该怎么办?
为了保证时间复杂度,不能再连一条值为s[x]的字符边。为了保证正确性,也不能将p到q的字符边直接接到np上。
情况一:如果dis(q)==dis(p)+1,直接令parent(np)=q就行了。能走到p说明p中所有字符串是旧串的后缀。dis(q)==dis(p)+1,说明p与q的差距就在于q中所有字符串是p中所有字符串末尾加s[x],q中所有字符串是新串的后缀。至于为什么ancestor(p)不用向np连字符边,是因为q和ancestor(q)中所有字符串已经包括了长度不超过dis(q)的所有的新串的后缀(←听上去好绕)。
情况二:如果dis(q)!=dis(p)+1,也就是说q中的字符串不全是新串的后缀,而是新串的后缀前面再加上些什么东西,情况就有些复杂了。这时考虑再新建一个节点nq,将q的所有信息(包括parent)都复制过去,将ancestor(p)中所有连向q的字符边都改为连向nq,再将q,np的parent都改为nq。这是因为q中有的字符串是新串的后缀前面再加上些什么东西,它不在新串的末尾出现。这就相当于q不仅仅有来自ancestor(p)的连边。这时就需要新建一个nq节点使得nq中所有字符串是新串的后缀且是q的后缀(←这就是将q,np的parent设为nq的原因),也就是将p的祖先的s[x]边接到nq上。
由于每次在末尾加一个字符,至多新增两个节点,所以空间复杂度是o(n)的。至于为什么时间复杂度是均摊o(n),并不对劲的人并不知道。
看上去很复杂,但是代码很简单。
#include<iostream> #include<iomanip> #include<cstdio> #include<cstring> #include<cstdlib> #include<cmath> #include<algorithm> #define maxn 250010 using namespace std; int ans,len,p; int read(){ int f=1,x=0;char ch=getchar(); while(isdigit(ch)==0 && ch!='-')ch=getchar(); if(ch=='-')f=-1,ch=getchar(); while(isdigit(ch))x=x*10+ch-'0',ch=getchar(); return x*f; } void write(int x){ int ff=0;char ch[15]; while(x)ch[++ff]=(x%10)+'0',x/=10; if(ff==0)putchar('0'); while(ff)putchar(ch[ff--]); putchar(' '); } typedef struct node{ int to[30],dis,fa; }spot; struct SAM{ spot x[maxn*2]; int cnt,rt,lst; char s[maxn]; void start(){ lst=rt=++cnt; scanf("%s",s+1); int ls=strlen(s+1); for(int i=1;i<=ls;i++) extend(i); } void extend(int pos){ int val=s[pos]-'a',p=lst,np=++cnt; lst=np,x[np].dis=pos; for(;p&&x[p].to[val]==0;p=x[p].fa)x[p].to[val]=np; if(p==0)x[np].fa=rt; else{ int q=x[p].to[val]; if(x[q].dis==x[p].dis+1)x[np].fa=q; else{ int nq=++cnt; x[nq].dis=x[p].dis+1; memcpy(x[nq].to,x[q].to,sizeof(x[q].to)); x[nq].fa=x[q].fa,x[np].fa=x[q].fa=nq; for(;x[p].to[val]==q;p=x[p].fa)x[p].to[val]=nq; } } } }t; int main(){ char s2[maxn]; t.start(); scanf("%s",s2+1); int ls2=strlen(s2+1); p=t.rt; for(int i=1;i<=ls2;i++){ int val=s2[i]-'a'; if(t.x[p].to[val])len++,p=t.x[p].to[val]; else{ while(p&&t.x[p].to[val]==0)p=t.x[p].fa; if(p==0)p=t.rt,len=0; else len=t.x[p].dis+1,p=t.x[p].to[val]; } ans=max(ans,len); } write(ans); return 0; }
其实是对劲的后缀自动机
根据以上的介绍,可以发现后缀自动机是由拓扑图和树组成的,也就是说树上和拓扑图上的dp好像就……
宣传一波电教,欢迎加入。