某神犇:“初三还不会后缀自动机,那就退役吧!”
听到这句话后,我的内心是崩溃的。
我还年轻,我还不想退役……于是,我在后来,努力地学习后缀自动机。
终于,赶在初三开学前,我终于学会了后缀自动机。
好开心~~~
后缀自动机是一个很奇妙的东西。这是一个处理字符串的神器。如果可以灵活运用,就会有无限的奥妙。
这个东西,说真的,非常不好懂。网上的各种资料,想找到属于自己的,是一件非常难的事情。好不容易地,我找到几个资料,在这里分享一下。
另外,在学后缀自动机的时候,我充分地领悟了一个道理——千言万语不如一标。后缀自动机这种东西,稍微了解概念后,仔细看看标程,那么就会很快的理解做法。至于理解它是为什么……打多了就有感觉了。我的理解就有些感性、模糊,但我一定要好好弄清楚,这样才能将后缀自动机发扬光大。
还有,吐槽一点。周围的大神太多了,有的在几个月前已经学了后缀自动机,我对他们的智商感到羡慕不已,甚至是没学后缀数组就会后缀自动机了。看着他们手推后缀自动机时,我有一种很无奈的感觉,当初同一起点的我们已经相差越来越远,遥不可及……
参考资料
从最长公共子串到后缀自动机(LCS->SAM)
后缀自动机构造过程演示
后缀自动机学习总结
后缀自动机长啥样
假如我们将字符串的所有后缀加入一个Trie中,就会是下面这个玩意儿(以aabbab
为例)
这么大棵树,当字符串很大时,一定建不成这棵树。
我们可以发现,其实这些后缀是有很多相似之处的。
那么怎么办?
当然是,压!
怎么压?
有个叫后缀树的东西,但是,这不是今天的重点。
我们将它压起来,将它压成一个有向无环图。
你可以发现,在后缀自动机上,从起点开始跑,每个路径都能够表示不同的子串。
上面的那棵Trie上的每一个节点所表示的字符串(即原字符串的子串)都可以在后缀自动机上表示出来。
这个后缀自动机是能压到什么程度?
我可以告诉你,它的时空复杂度是O(n)O(n)的。
一些概念
节点所代表的含义
后缀自动机中,每一个节点表示子串的rightright集合相同的子串集合。
啊哈?啥意思?
一个子串的rightright集合,表示的是这个子串在字符串中结束位置集合。
而SAM上的每个节点,表示的是rightright集合相同的子串集合。
思考一下这个集合的性质。
很显然,因为所有结束位置相同,对于这个集合中任意两个子串,一个子串必定是另一个的后缀。
比如说,abcabcabc
。
那么子串,abc
、bc
、c
在同一个集合之中。
同时我们发现,这个集合中的所有的子串,都是集合中最大的子串的后缀,并且这些后缀是连续的。也就是说,如果abc
和c
在同一集合,bc
不可能不在这个集合。
那么每个节点所代表的子串有个长度的范围,记为minleniminleni和maxlenimaxleni。
对于字符串abcabcabcc
(注意和上面的例子不同),abc
和bc
在同一集合中,则范围是[2,3][2,3]。
还有,两个不同的节点,它们表示的子串集合不相交,而且rightright集要么一个真包含另一个,要么不相交。
fail指针
既然是自动机,那怎么可能没有failfail指针呢?
对于一个节点,它的failfail指针只有一个(废话!)。
failfail指针指向的节点的rightright集合真包含当前节点的rightright集合。而且failfail指针指向的节点的集合的maxlenmaxlen最大。
再思考一下有哪些性质。
rightright集合真包含当前节点的rightright集合,那么failfail指向节点表示的字符串集必定都是当前节点的字符串集中任意一个字符串的后缀。
又因为maxlenmaxlen最大,所以当前节点的最短后缀和它的最长后缀一定是连续的。也就是说,failmaxlen+1=thisminlenfailmaxlen+1=thisminlen。所以minlenminlen是不需要记录的。
对于字符串abcabcabcc
,表示abc
和bc
的节点的failfail指针指向的是表示c
的节点。
因为rightright集合真包含,所以,failfail指针形成了一棵树,称为failfail树。
上图是aabbab
的后缀自动机,黑色的是转移边,蓝色的是failfail边。
这幅图来自从最长公共子串到后缀自动机(LCS->SAM),在此再次感谢这个博主。不过为什么有水印……
后缀自动机的构建
增量法,即每一次插入一个字符。
先放标程,千言万语不如一标。
SAM的标程贼短,而且实用。比SA还好打。
#define MAXN 100000
struct Node{
int len;
Node *c[26];
Node *fail;
} d[MAXN*2+10];
Node *null,*last,*S;
int cnt;
void sam_init(){
null=&d[0];
++cnt;
d[cnt].len=0;
d[cnt].fail=null;
S=last=&d[cnt];
}
void sam_insert(int ch){
Node *now=&d[++cnt];
now->len=last->len+1;
Node *p;
for (p=last;p && !p->c[ch];p=p->fail)
p->c[ch]=now;
if (!p)
now->fail=S;
else{
Node *q=p->c[ch];
if (p->len+1==q->len)
now->fail=q;
else{
Node *clone=&d[++cnt];
clone->len=p->len+1;
memcpy(clone->c,q->c,sizeof q->c);
clone->fail=q->fail;
for (;p && p->c[ch]==q;p=p->fail)
p->c[ch]=clone;
now->fail=q->fail=clone;
}
}
last=now;
}
再描述一下这个过程。
1. 新建一个节点,设这个节点为uu。
2. 从lastlast(最后一个点)开始,沿着failfail边跳。如果没有chch的转移边,则向uu连上一条边。否则退出。
3. 如果一直未找到chch的转移边,那么failfail指向初始点SS。否则进入4。
4. 设这个点为pp,转移边连向的点为qq。如果pmaxlen+1=qmaxlenpmaxlen+1=qmaxlen,就直接将uu的failfail指向qq。否则进入5。
5. 新建一个节点cloneclone,clonemaxlen=pmaxlen+1clonemaxlen=pmaxlen+1,cloneclone连向qq连向的所有节点,并且cloneclone的failfail指针指向pp,进入6。
6. pp继续沿着failfail跳,所有指向qq的指针全都指向cloneclone。最后将uu和qq的failfail指向cloneclone。
7. 最后,更新lastlast。
原理?
首先,lastlast是先前最后的节点,而且maxlenmaxlen是最长的。在failfail树上,lastlast到SS的链上所有节点的集合,是前面的字符串的所有后缀。
所以,非常自然的,用链上的节点来连向新的节点。
上面的步骤中,主要讲一下步骤4~6。其它的比较易懂。
如果pmaxlen+1=qmaxlenpmaxlen+1=qmaxlen,那么qq所代表的子串只有一个,所以uu的failfail指向qq理所当然。
否则,新开一个节点,强行clonemaxlen=pmaxlen+1clonemaxlen=pmaxlen+1,然后替代qq的位置(当然,不意味着qq没了)。让uu和qq都指向它。
我怎么好像是在念过程啊……好吧,这一段我理解得比较感性,朦胧,不好描述。最好看上面的参考资料。
复杂度?
每次加入一个字符最多增加两个节点,所以节点个数是线性的。
边是线性的,整个建SAM的过程都是线性的……(我当然不会证)。
反正时空复杂度O(n)O(n)。
记住就好。
还有代码复杂度,简单易打,是吧……
例题
现在我打出了三道例题。
一个是洛谷上的模板。
一个是KMP裸题。
还有一个是求不同子串的第kk大。
这些都是简单的模板题。
这篇博客有很多好例题,只不过我初学,要有个慢热的过程。以后有时间就做