浅谈后缀自动机
虽然是第二次学后缀自动机,这个学习的过程在我看来仍然是学习过程中最困难的,因为这个东西比较抽象,应用的性质很多,即使是构造理解起来也十分困难。另外,讲解这个东西的博客都太长了,一个一个写着预计阅读时间一个小时?而且看一句就要好好想一会,不时还要往上翻,晦涩的定义也太多了让人产生抗拒。
本文面向初步了解SAM的人,通过对论文suffix_automata的翻译的解读加深对SAM的理解。
后缀自动机
后缀自动机可以理解为一个串所有子串的简明信息,其空间复杂度为\(O(n)\),构建的复杂度为\(O(n)\)
后缀自动机的英文为\(suffix\ automata\)或者单词的有向无环图\("direcged\ acyclic\ word\ graph"\)简写为\(”DWAG"\)
后缀自动机的定义
对字符串s的后缀自动机是一个最小化确定有限状态自动机,它能接受\(s\)的所有后缀。
对后缀自动机定义的解释:
首先对确定有限状态自动机的概念解释:
1、一个有向无环图,其中节点代表一个状态,而边代表了状态的转移。
2、某一个状态\(t_0\)被称为是初始状态,从这个状态经过若干次转移可以到达所有的状态。
3、每一个转移(有向图中的每一条边),都有一个标记(字母),从某一个状态出发的转移不能有相同的标记。
4、有若干个终止状态。
后缀自动机上从\(t_0\)开始按某一个路径转移到终止状态,顺序写出这个路径上转移上的标记(也就是字母)之后得到的串是串\(S\)的一个后缀。
所有符合以上特征的自动机中,后缀自动机拥有最少的状态数。
后缀自动机的最简性
即从\(t_0\)开始的某一条路径唯一代表着\(S\)一个子串(这里不是从\(t_0\)开始到一个终止状态,而是到任意一个点),同时S的一个子串唯一地由从\(t_0\)开始的某一条路径表示。
我们称从\(t_0\)开始的任意一条路径匹配(一对一)了S中的一个子串。
但是一个状态可能对应这很多条从\(t_0\)开始的路径。
一个线性构建后缀自动机的方法
在这之前必须要介绍一些性质,这对理解这个构建的方法是不可缺少的。
结束位置endpos及其性质
考虑字符串\(s\)中的一个子串\(t\),\(t\)的所有结束位置的集合为\(endpos(t)\)
对于所有\(endpos\)相同的\(t_i\),我们将它们归位一个等价类(一个字符串的集合)。
这跟后缀自动机有什么关系?其实后缀自动机中的一个状态(节点)就是这里的一个等价类。其最简性由迈希尔-尼罗德定理证明
引理1:S的两个非空子串u、v(lenght(u)<lenght(v)),如果它们的endpos相等当且仅当u在字符串S中作为v的后缀出现。
证明显然(狗头)
进而可以得到一个类中不能有两个相同长度的串。
引理2:考虑两个非空字符串类u、w(lenght(u)<lenght(w)),它们的终点集合只能是包含关系或者没有交集。这取决于u是否是w的后缀。
证明显然(狗头)
如果\(u\)是\(w\)的后缀那么u的终点集合包含w的终点集合。
引理3:考虑一个等价类,将该等价类中的字符串按长度从大到小排序,每一个字符串是上一个串的后缀并且长度是上一个串长度-1。或者说这个等价类中的串长度连续。
考虑这个等价类中的任意两个串\(u\),\(v(lenght(u)<lenght(v))\),考虑v的任意一个长度在\([lenght(u),lenght(v)]\)之中的后缀,由引理2它所在的类一定被u包含且包含\(v\),即在这个等价类中。
后缀链接
通过对\(endpos\)的性质的分析,我们知道所有有着相同终点集合的点存在于一个等价类中,并且设\(w\)为这个类\(u\)中最长的字符串,这个类中其他的串是这个串的连续后缀。其他后缀在另外的若干个等价类中。
令\(u\)后缀链接\(link(u)\)代表不在这个类中的最长的u的后缀所在的类。
换言之,\(u\)的后缀链接\(link(u)\)指向在不同等价类中的u的最长后缀所在的类。
特别地,我们让初始状态\(t_0\)是一个特别地类中,其对应的终点集合是全集,可以理解为这个类中的子串是空串。
引理4:后缀链接构成了一个以\(t_0\)为根的树。
任取一点\(u\),不停地找不在这个类中最长后缀\((u=link(u))\),这个后缀的长度单调减少,最后一定会到代表空串的\(t_0\)
其实这颗树就是\(parent\)树
引理5:一个类(树中的一个节点),的儿子都是这个类终点集合的不相交子集。
结合引理2和引理4。
总结这两个引理:后缀链接形成一棵以\(t_0\)为根的树,而这棵树事实上是所有终点集合的树状包含关系。
其他定义及性质
定义:每个状态\(u\)对应一个或多个字符串,我们记\(longest(u)\)是其中最长者,\(len(u)\)是其长度。我们记\(shortest(u)\)是这些字符串中的最短者,其长度为\(minlen(u)\)。
1、引理2可以改写成:一状态\(u\)对应的所有字符串是\(longest(u)\)的不同后缀,并且包括\([minlen(u),len(u)]\)之间的所有长度。
2、后缀链接的定义可以改写成:对每个状态\(u≠t_0\)定义的后缀链接指向的状态对应\(longest(u)\)的长度为\(minlen(u)-1\)的后缀。
一些性质:\(minlen(u)\)和\(link(u)\)的关系表示如下:\(minlen(u)=len(link(u))+1\)。 如果我们从任意节点\(u\)开始沿后缀链接移动,我们早晚会到达初始状态\(t_0\).在此情况下,我们得到了一系列不相交的区间\([minlen(u_i),len(u_i)]\),其并集是一个连续区间。
代码
int x=++tot;
len[x]=len[u]+1;size[x]=1;
for(;u&&trans[u][c]==0;u=fa[u])trans[u][c]=x;
if(u==0)fa[x]=1;
else{
int v=trans[u][c];
if(len[v]==len[u]+1)fa[x]=v;
else{
int w=++tot;len[w]=len[u]+1;
fa[w]=fa[v];fa[x]=fa[v]=w;
memcpy(trans[w],trans[v],sizeof(trans[v]));
for(;u&&trans[u][c]==v;u=fa[u])trans[u][c]=w;
}
}
u=x;
\(trans\)边要满足从根开始沿着\(trans\)边走到任意一点经过的\(trans\)上字符连起来是原串的一个子串。考虑把\(trans[u][c]\)设成\(x\)代表什么?代表u中某一个字符串可以通过在后边加上一个字符\('c'\)成为\(x\)节点中的某一个的串。进一步的代表\(u\)中所有字符串可以在x中找到后面加上一个字符\('c'\)的串,即令\(u\)的字符串类为\(s1\),\(x\)的字符串为\(s2\),\(s1+'c'\in s2\)(注意这里是包含于并不是等于)
\(SAM\)就是\(parent\)树上的点与\(trans\)边构成的有向无环图(并不算上\(parent\)树的边)。
接下来解释构建\(SAM\)的代码。
\(SAM\)在每次插入一个字符\(c\)之后维护
我们除去辅助数据之外其实只关心两个东西,也就是\(SAM\)的两个部分:\(parent\)树上的点,\(trans\)边。
int x=++tot;
len[x]=len[u]+1;size[x]=1;
为了方便说话,把插入\(c\)之前的串叫做旧串,把插入\(c\)之后的串叫做新串(比如构建\(“aababa”\)的\(SAM\)时现在插入最后一个\(a\),\(“aabab”\)是旧串,\(“aababa"\)是新串)。
\(x\)是新加入一个字符\(c\)之后新的\(parent\)树的节点,x节点目前代表新串的所有后缀。
\(len[x]\)是节点\(x\)代表类中子串的最长长度。
\(size[x]\)是广义\(SAM\)的内容先不用管,\(u\)是包含旧串的类。
为什么一定有新的节点?设插入前字符串长度为\(n\),上面说到\(parent\)树的节点代表一个子串类,新加入一个字符之后,只以\(n+1\)为位置结尾的类不存在,所以一定有新的节点。
for(;u&&trans[u][c]==0;u=fa[u])trans[u][c]=x;
\(fa[u]\)是\(u\)在\(parent\)树上的父亲。这是在遍历所有后缀。在插入一个字符之后需要修改的\(trans[u][c]\)的\(u\)只可能是代表旧串的后缀的类,即\(u\)的祖先。
现在x节点代表新串的所有后缀,显然old串的所有后缀加上字符'c'之后都包含于x的类,结合trans[u][c]的定义所以如果\(trans[u][c]\)为0的话就将其设为x。
if(u==0)fa[x]=1;
遍历了全部的祖先包括代表空串的根节点之后还是没有向\(c\)转移的边,说明\(c\)从来没有出现过。直接把\(x\)接到根上。因为c都没出现过,其他的类必然不可能会包含\(n+1\)这个结束位置,所以只能接在代表全集的根上才能满足引理5:一个类(树中的一个节点),的儿子都是这个类终点集合的不相交子集
相反如果存在一个\(u\),说明\(c\)这个字符出现过。
int v=trans[u][c];
if(len[v]==len[u]+1)fa[x]=v;
\(len[v]==len[u]+1\)这个判断是板子里最难以理解的。
\(u\)是什么,\(v\)是什么?
\(u\)是最深的\(trans[u][c]\)非\(0\)的节点。
是旧串中满足可以通过后接一个字符\(c\)后成为新串的一个后缀且这个后缀已经出现过的最长的一些子串所代表的等价类。
\(v\)是包含旧串中已经出现过的最长的新串的一些后缀的等价类。
重要的是\(len[u]\)和\(len[v]\)
回想一下\(len[u]\)是\(u\)节点所包含的一些子串中最长的长度。
\(len[u]+1\)就是旧串中出现过的最长的新串后缀的长度。
\(len[v]=len[u]+1\)代表了什么?\(len[v]!=len[u]+1\)代表了什么?
\(len[v]=len[u]+1\)说明\(v\)中字符串恰好包含\(u\)中最长字符串+\(c\)
\(len[v]!=len[u]+1\)即\(len[v]>len[u]+1\),说明\(v\)中字符串不但有u中字符串+\(c\),还一定有不是新串后缀的串存在(即所有长度大于\(len[u]+1\)的串)。举个例子:构建\(aababa\)这个串的\(SAM\)的过程中加入第二个\(b\)时\(u\)为\(\{a\}\),\(v\)为\(\{aab,ab,b\}\),\(v\)中包含了\(aab\)不是新串的后缀,因为根据定义\(u\)中最长的串加+\(c\)之后是旧串中存在的最长的新串后缀长度为\(len[u]+1\),大于这个长度的串显然不可能是新串的后缀。
这个时候应该怎么维护\(SAM\)呢?
首先考虑\(parent\)树。
把\(v\)中保留程度小于等于\(len[u]+1\)的串分出去成为\(w\),\(v\)剩下的串要成为\(w\)的儿子,新串代表的节点\(x\)也要是\(v\)的儿子。
然后考虑\(trans\)边。
\(w\),\(v\)相当于从前的一个集合一分为二,\(trans[w][c]\)应该等于\(trans[v][c]\)。因为\(w\)恰好包含了\(u\)中最长串+\(c\),\(u\)的祖先包含的串后+\(c\)长度小于\(len[u]+1\)也被包含,所以\(u\)和\(u\)的祖先的\(trans[u][c]\)都要从指向\(v\)变成指向\(w\)。
就是这个代码
if(len[v]==len[u]+1)fa[x]=v;
else{
int w=++tot;len[w]=len[u]+1;
fa[w]=fa[v];fa[x]=fa[v]=w;
memcpy(trans[w],trans[v],sizeof(trans[v]));
for(;u&&trans[u][c]==v;u=fa[u])trans[u][c]=w;
}
至此后缀自动机的构造结束。
广义后缀自动机就是满足多个串的后缀自动机。
struct SAM{
int trans[N][30],cnt,head[N],tot,u,size[N],len[N],fa[N];
struct edge{
int to,nxt;
}e[N];
void add_edge(int u,int v){
cnt++;
e[cnt].nxt=head[u];
e[cnt].to=v;
head[u]=cnt;
}
void init(){tot=u=1;}
void rebuild(){u=1;}
void ins(int c){
if(trans[u][c]){
int v=trans[u][c];
if(len[v]==len[u]+1)size[v]++,u=v;
else{
int x=++tot;
size[x]=1;
len[x]=len[u]+1;
memcpy(trans[x],trans[v],sizeof(trans[x]));
fa[x]=fa[v];fa[v]=x;
for(;u&&trans[u][c]==v;u=fa[u])trans[u][c]=x;
u=x;
}
}
else{
int x=++tot;
len[x]=len[u]+1;size[x]=1;
for(;u&&trans[u][c]==0;u=fa[u])trans[u][c]=x;
if(u==0)fa[x]=1;
else{
int v=trans[u][c];
if(len[v]==len[u]+1)fa[x]=v;
else{
int w=++tot;len[w]=len[u]+1;
fa[w]=fa[v];fa[x]=fa[v]=w;
memcpy(trans[w],trans[v],sizeof(trans[v]));
for(;u&&trans[u][c]==v;u=fa[u])trans[u][c]=w;
}
}
u=x;
}
}
}
留坑待填
状态的数量
从代码看出来,每次加入一个字符之后状态最多加2,所以状态数最多是2N+1
不从代码的角度分析,由性质5可以得出最多parent树最多有n个叶子节点,然后推出状态数最多是2*N-1
构建算法的时间复杂度
for(;u&&trans[u][c]==0;u=fa[u])trans[u][c]=x;
for(;u&&trans[u][c]==v;u=fa[u])trans[u][c]=w;
纯属口胡
复杂度主要是这里,只对这两行代码分析即可。
设\(shortlen[x]\)代表\(x\)中字符串最短长度。
先考虑第一个\(for\)
势能分析这显然总共复杂度是线性的。
之后考虑第二个\(for\)
不会(doge)
所以是线性的。
后缀树跟后缀自动机的联系
将一个串的所有后缀扔进一个\(trie\)中,然后对节点进行压缩,这样的树就是后缀树。
首先我们假定输入的字符串的每一个后缀都在后缀树中对应了一个节点(这对于任意的字符串不一定成立,如\(aaaa...\)),在后缀树的经典实现中,通过在后缀树的末位加上一个特殊字符如\(#\)来保证这点。
令\(rev[s]\)代表\(s\)串反写,\(DAWG[s]\)代表由字符串\(s\)建立的后缀自动机,\(ST[s]\)代表\(s\)的后缀树。
我们介绍“扩展指针”的概念:对于树节点\(v\)和字符\(c\),\(ext[c,v]\)指向树中对应于字符串\(c+v\)(注意这里顺序)的节点(如果路径\(c+v\)在某边的终点结束,那就将其指向该边的较低点);如果这样一条路径\(c+v\)不在树中,那么扩展指针未定义。在某种意义上,扩展指针的对立面就是后缀链接。