概述
后缀自动机是一类确定性有限自动机,其可以以O(n)的时空复杂度在长度为n的模式串P上进行编译,得到的则是Suffix Automaton,即后缀自动机。后缀自动机可以在O(m)的时间复杂度内判别长度为m的串是否是P的子串,是否是P的后缀。
理论部分
后缀自动机实际上是一副有向图,我们从起点start出发,每读入一个字符,就沿着字符对应的边移动到下一个结点(如果没有边,则进入失败态fail,失败态没有外向边)。以上是从图的角度来看的,从状态机的角度来看,所有结点实际上都是自动机中的一个状态,而边则是状态之间的转移过程。如果自动机在处理完文本后停留在接受态,则称为自动机接受输入,如果停止在失败态,则称为自动机拒绝输入。
我们记为T(u,c)=v表示从u到v有一条字符c对应的边,我们简称为c边。这在自动机中对应的就是自动机从u状态在条件c下转移到状态v。对于字符串s,我们同样记T(u,s)为T(T(u,s[1]),s[2...m]),即从u出发,沿着s[1],...,s[m]边所抵达的结点。记ST(s)=T(start,s)。
从每个状态u到接受状态accept之间的路径代表的字符串s,可知T(u,s)=accept,我们称状态u能接受字符串s。而对应的从初始态start到u的任意路径代表的字符串w,由于ST(ws)=accept,故ws是P的后缀,而由于s也是P的后缀,故w必定是P的子串。故从中我们得知如何判断输入串是否是P的子串,只要在输入结束时,自动机状态不是fail态,那么输入就是P的子串。
所有能被u所接受的字符串的集合,我们记作Reg(u)。
若ST(s)=u,那么我们称s为u的前置条件。事实上由于s可能在P上多次出现,设其出现的区间为[L1,R1),[L2,R2),...,[Lk,Rk),显然每一段对应的子串S[L1...R1),...,S[Lk...Rk)都是u的前置,我们将其右端点集合{R1,...Rk}记作Right(u),读作u的Right集合。
显然Right集合与Reg是一一对应的。如果两个状态u与v的Reg(u)=Reg(v),即两个状态接受相同的字符串,那么我们显然可以合并两个状态,并将所有导向这两个状态的边的终点重定向为合并后的状态,那么自动机其处理字符串的结果是不会改变的。因此我们两个状态u,v相同当且仅当Reg(u)=Reg(v),考虑到Right集合与Reg是一一对应的,因此u,v相同的充分必要条件也可以写作Right(u)=Right(v)。
显然在确定u的Right集合Right(u)后,对于任意Right(u)中的元素r,若S[r-len,r)是u的前置,那么对于任意Right(u)中的元素t,很显然S[t-len,t)也是u的前置。对于满足S[r-len,r)是u的前置的len,我们称其为u的合适前置长度。再考虑若a<=b<=c,而a与c均为u的合适前置长度。由于[r-a,r)与[r-c,r)均是u的前置,换言之所有以[r-a,r)与[r-c,r)在P中匹配的区间的右边界集合相同,因此考虑到[r-a,r)是[r-b,r)的后缀,而[r-b,r)的后缀,因此[r-b,r)出现的区间的右边界与前二者一致,故Right(ST(S[r-b,r)))=Right(u),即ST(S[r-b,r))=u,故S[r-b,r)是u的前置,b是u的有效长度。因此状态的有效长度应该是一个区间,我们简单记作[min(u),max(u)]。
若两个状态u与v的Right集合有交集,即存在r同时属于Right(u)与Right(v)。由于不同的状态的前置必定不同,因此u与v不可能有相同有效长度,即[min(u),max(u)]与[min(v),max(v)]没有交集,我们不妨令max(u)<min(v),则可以发现所有u的前置都必然是v的前置的后缀,这也意味着v的前置出现的位置必然同样会出现u的前置,故Right(v)是Right(u)的子集,再结合状态与Right集合是一一对应的,因此Right(u)不等于Right(v),故Right(u)是Right(v)的真子集。
由上面的结论我们得知任意两个状态的Right数组或者无交集,或者有真包含关系。对于任意两个状态u与v,若$ Right\left(u\right)\supset Right\left(v\right) $,我们称v是u的树子结点,而u是v的树父结点。如果二者没有交集,意味两个集合在不同的子树之中。按照上面的说明我们可以建立起多株树。我们可以很自然的令start的前置为空字符串,同时对应的start的Right集合则为{1,2,...,n},有效长度为0。这样所有之前建立的树的根就有了共同的树父结点,start。这样我们就得到了最终的树。
对于非start的状态u,记其树父为father(u)=f。由于u的有效长度为[min(u),max(u)],那么对于任意Right(u)中的元素r,[r-min(u)+1,r)不是u的前置,而其父亲f的有效长度为[min(f),max(f)],其中max(f)<min(u),而考虑到Right(f)是所有Right(u)的超集中最小的,因此考虑到包含关系,很容易得知max(f)=min(u)-1。
如果存在转移T(u,c)=v,其中c是一个字符。那么可知对于任意Reg(v)中的后缀s,cs属于Reg(u),换言之,若r属于Right(v),那么r-1必定包含于Right(u)中,即Right(v)被Right(u)所限制,因此$ Right\left(v\right)=\left\{r+1|r\in Right\left(u\right)\land S\left[r\right]=c\right\} $。而且由于S[r-max(u),r)是u的前置,而S[r-max(u),r)c则必然是v的前置,故max(v)>=max(u)+1。
如果f=father(u),T(u,c)=v,T(f,c)=p。由上面命题可以得知Right(v)是Right(p)的子集(未必为真子集)。如果v是p的树后代,即Right(v)是Right(p)的真子集,且T(u,c)=v,T(f,c)=p。那么考虑到Right(v)与Right(p)有交集,很自然能保证Right(u)与Right(f)也有交集,即u与f有包含关系,而由前面的命题可知u必定是f的树后代。因此综合起来得到,在已知T(u,c)=v且T(f,c)=p时,u是f的树后代等价于v是p的树后代。
实现部分
到了这里,需要的理论都讲了,下面我们该说一下如何构建后缀自动机了。
如果一个自动机满足上面的所有理论,那么其就是一个正确的后缀自动机。
假设我们已经在串S上建立了正确的自动机,接下来,我们要建立Sc上的正确的自动机,其中c是一个字符。我们先创建一个新的结点,记作cur,令Sc为cur的唯一前置,此时Right(cur)={|S|+2},并设置max(cur)=|S|+1,即当前模式串的长度。考虑到这一步骤,那么在上一次读入字符时创建的结点我们记为last(初始时为start)。last的前置为S,而cur的前置为Sc,因此有T(last,c)=cur。我们记状态序列x[1],...,x[k],其中x[1]是last,而x[k]是start。其中x[i+1]是x[i]的树父。那么我们可以保证所有cur的树父(除了start这个树根)均可以利用x[1],..,x[k]序列通过c转移得到。我们已经将cur连接到了自动机中,接下来还需要设置father(cur)和将所有此时满足T(u,c)=cur的结点u正确关联到cur。father(cur)自然要在T(x[1],c),...,T(x[k],c)之中挑选,而所有满足T(u,c)=cur的结点也自然得在x[1],...,x[k]序列中找。故我们要从last出发,不断沿着树链向上移动,若x[1],...,x[p-1]之前的c转移均是失败态,那么按照理论,x[1],...,x[p-1]的转移至少也是cur的超集,故我们将x[1],..,x[p-1]的c转移全部设置为cur是合适的。实际上更详细可以这样解释,x[i]的Right集合为r1,...,rt,其按数值递增,而显然rt=|S|+1,故而Right(cur)={|S|+2},故我们设置为cur是合适的,而由于在S上建立的自动机下x[i]没有c转移,因此其余S[r1],...,S[rt-1]均不是c,因此Right(cur)也只能设置为cur。如果x[1],...,x[k]均没有c转移,这样我们将father(cur)设置为start,因为其超集不存在于树中,因此其父亲为start。当然如果我们找到了这样的p使得,q=T(x[p],c)不为失败态fail,那么有两种可能。一种是q是cur的超集,一种q不是。我们在实现中一般不会真的维护Right集合并借此来判断,因此我们用一个简单但正确的方法来判断。如果max(x[p])+1=max(q),那么Right(q)就是Right(cur)的超集,否则与cur无关系,即在不同的子树下。若max(x[p])+1=max(q),由于Right(x[p])中包含rt+1,而当前S[rt]=c,因此S[rt-max(x[p]),rt]也是q的前置,Right(q)中包含rt,因此Right(q)是Right(cur)的超集,因此q是cur的树父,我们将father(cur)设置为q,且由于之前的说明我们知道x[p],...,x[k]均有c转移,且其c转移的目标状态均为q的祖先(自然也是cur的祖先),因此我们不用再向上查找。但是如果max(x[p])+1不等于max(q)呢,必然有max(x[p])+1<max(q),这意味着q必定不是cur的树父。实际上只要考察Right(q)是否包含rt+1,如果Right(q)包含rt+1,那么我们得知S[rt+1-max(q),rt]是q的前置,由于S[rt+1-max(q),rt)同样是x[p]的某个树孩子的前置,因此我们能找到这样一个l<p,使得max(p)>=max(q)-1>=min(p),因此q应该也是x[l]的c转移状态,考虑到p应该是最小的含c转移的last的祖先,因此假设相悖。故max(q)=max(x[p])+1等价于q是cur的祖先。在这个认知下,我们需要做的其实是先克隆q,得到clone,之后将T(x[p],c)设置为clone。由于我们用clone替换了q,因此为了保证之前的转移依旧有效,我们需要拷贝所有q的转移过程,即对于任意字符w,满足T(clone,w)=T(q,w),当然只是当前完全相同,并不要求之后都要求同步修改。之后我们将max(clone)设置为max(x[p])+1,此时clone应该是q与cur的公共祖先,我们令father(q)=clone及father(cur)=clone。这样修改看似已经完成了,但还有一小步遗漏,就是x[p]的祖先可能有c转移,且目标状态为q,考虑到理论部分要求树祖先结点与树后代结点的经过相同转移的关系得到的新状态也有祖先和后代关系(非严谨,可能是相同的),因此保留为q显然是不合理的,我们将所有x[p]的祖先且能通过c转移到q的结点,将其c转移重定向到clone上。
说了那么多,上面就是全部的实现内容了。用代码表述如下:
consume(c) cur = new-node max(cur)=|S|+1 p = last last = cur while(p != NIL && T(p, c)==NIL) T(p,c)=cur if(p == NIL) cur.father = start else q = T(p,c) if(max(q) == max(p)+1) father(cur)=q else cl = clone(q) father(cur)=cl father(q)=cl while(p != NIL && T(p,c)==q) T(p,c)=cl
上面说明的是构建后缀自动机的过程,那么又该如何使用自动机呢。如果我们只需要用其来判断字符串s是否是模式P的后缀,我们只需要构建一个轨迹指针,其一开始指向start。之后遍历s中的字符,按照字符对应的转移将轨迹指针指向下一个目标状态。如此往复,直到s中的字符被读完,此时如果处于不是处于接受态,那么就不是后缀。对于判断s在P中的所有出现次数或出现位置,可以利用与AC自动机和KMP类似的思路,将树子u与树父f的关系f=father(u),作为由u指向f的失败指针,在进入失败态之后,沿着失败指针移动到下一个可能匹配的状态,这个状态的前置很显然是u的前置的最长后缀。
复杂度分析
记n为模式串P的长度,考虑到最多从模式串中取出n个字符并建立后缀自动机,而每次最多建立两个状态,因此空间复杂度为O(n)。
我们分成两部分来讲。先不考虑需要用clone来替换所有q所花费的时间复杂度。我们可以按照结点u的深度d(u)来进行复杂度分析。我们每次加入一个新结点,并向上寻找父状态,这个循环每次执行我们都会使得cur的父状态的距离降低至少1。而我们每次确定father(cur)后,d(cur)<=d(last)+1,而距离不可能小于0,而初始时d(last)=0,因此循环最多执行n次,时间复杂度为O(n)。
我们按照min(father(last))来进行分析。在初始时其为0。当我们用clone替换在树路径上q出现的位置时,每次向上循环,会降低min(clone),因为在clone中出现了更短的前置。而我们知道由于p是last的祖先,因此min(father(last))>=min(p),min(clone)初始时我们认为不可能大于min(p)+1,而我们每次循环都至少会使得min(clone)降低1,故我们得知min(father(cur))<=min(father(last))+1-k,其中k表示循环次数。而最后将last设置为cur,故相当于我们依据此次发生的循环k次,保证min(last)降低k-1。而显然消费掉n个字符后,min(father(last))>=0,故由(1-k1)+(1-k2)+...+(1-km)>=0可以推出k1+k2+...+km<=m,其中m为在创建P的后缀自动机时创建的clone的数目,而ki表示第i次向上搜索深度,因此我们发现用clone替换q的时间复杂度为O(n)级别。
综合上面部分,我们得知后缀自动机建立的时间与空间复杂度均为O(n)。
匹配长度为L的字符串,由于失败链的存在(指向当前状态的树父状态),因此时间复杂度为O(L),类似于AC自动机和KMP算法。
后缀自动机构建后缀树
后缀树是用于保存模式P所有后缀的Trie树,但是后缀树和后缀自动机的区别在于,后缀树的结点对应一个字符,而后缀自动机的边对应一个字符。我们可以按照简便的方法来构建后缀树,将P的|P|个后缀全数插入到Trie树中,但是这样做时间和空间复杂度均为O(n^2),对于大文本,这是不合适的。很容易发现我们生成的Trie树,有相当多的结点只有唯一的孩子结点,我们可以将这两个结点合并,新的结点代表长度为2的字符串。同样做这个操作,将所有只有一个孩子结点与其孩子合并,这样空间复杂度就可以降下来,而时间复杂度是依赖于空间复杂度的,因此也能得到相应的降低。
譬如abac的后缀Trie树:
r
/ | \
a c b
| \ |
b c a
| |
a c
|
c
压缩后得到后缀树:
r
/ | \
a c bac
| \
bac c
我们再构建caba的后缀自动机,我们仅关注其内部的树形结构而不关注转移图(我们用S[r-max(u),r)表示状态u,其中r是Right(u)中任意元素):
r
/ | \
c a b
/ \
ca caba
我们将后缀进行共享得到:
r
/ | \
c a b
/ \
c cab
很显然后缀自动机的树形图与后缀树的树形图是有一定关联的。实际上后缀树的核心就是通过共享后缀的前缀来降低复杂度,而后缀自动机的树形图有如下性质,父结点的前置必定是子结点的前置的后缀。这样我们先构建父结点,再在父结点已有的情况下,复用父结点代表的后缀,并通过在该后缀之前添加一定的字符串,得到子结点的前置。因此后缀自动机的树形图和后缀树的区别在于一者共享的是后缀,一者共享的是前缀。但是我们只要在构建后缀自动机之前将模式串P反转得到P‘,并在P‘的基础上构建后缀自动机。这样得到的后缀自动机共享的就是前缀了。但是我们存储的串是P‘中的串,因此在从单个结点中取出其对应字符串的时候需要逆转,即形如abc应该以cba取出。这些实际上我们只需要做一些适当的改变即可,对每个状态的变更都能在常数时间内完成,因此时间复杂度为O(1)*状态数+建立后缀自动机的时间复杂度,即O(n)。