首页 > 技术文章 > 后缀自动机小结

encodetalker 2019-05-06 01:41 原文

后缀自动机小结

太神仙了学不来

由于每次写SAM的题都感觉是一次升华,于是决定好好的捋一捋SAM的相关知识,也许下面的东西并不是很清楚(毕竟我还是有点迷糊),欢迎指正!

定义

先介绍自动机

自动机(有限状态自动机),它的功能就是识别一个字符串,对于一个自动机\(A\),若它能识别一个字符串\(str\),则\(A(str)=true\),否则\(A(str)=false\)

自动机有五个重要的组成部分:

  • 字符集:\(alpha\)
  • 状态集合:\(state\)
  • 初始状态集合:\(init\)(如\(AC\)自动机中的\(0\)号节点)
  • 终止状态集合:\(end\)(识别成功或识别失败)
  • 状态转移函数:\(trans\)

后缀自动机也就是识别串\(str\)的所有后缀的自动机,即当串\(x\)\(str\)的后缀时,\(SAM(x)=true\),否则\(SAM(x)=false\)

最简单的想法是将\(str\)的所有后缀像建\(trie\)树一样建出来,就如下图

A

但是这样的话时间和空间就都是\(O(n^2)\),并不优秀

我们又发现,有一些节点可以通过合并来减少状态,这也就是最简状态后缀自动机

下面给出一些性质证明时需要用到的定义:

  • \(S\),原串,我们将对它建立SAM
  • \(S_t\),原串\(S\)的第\(t\)个字符
  • \(S_{l,r}\),原串\(S\)的第\(l\)个字符到第\(r\)个字符组成的子串
  • \(P_{S,x}\),原串\(S\)的前\(x\)个字符
  • \(suf(S)\),串\(S\)的所有后缀的集合

\(right\)集合的性质

定义

对于一个串\(str\),定义\(right_{str}\)为它在原串\(S\)中出现的所有右端点的集合,特别的,空串的\(right\)集合为串\(S\)的所有位置

比如\(S=aabbabb,str=abb\),那么\(right_{str}=\{4,7\}\)

同时我们定义字符串\(a,b\)的等价关系\(aRb\)\(right_a=right_b\)

以及与串\(a\)等价的所有串组成的集合\(R(a)\)\(right_{R(a)}\)表示它们的\(right\)集合

相关性质

1、对于字符串\(a,b\),假设\(|a|>|b|\),若\(aRb\),则\(b\in suf(a)\)

\(right_a=right_b\)可知,\(\forall v\in right_b\),有\(b\in suf(P_{S,v})\),同时也会有\(a\in suf(P_{S,v})\)

又由长度限制知\(b\in suf(a)\)

这一条性质阐述了这样一个事实:若串\(a,b\)满足\(|a|=|b|\)\(aRb\),那么\(a=b\),因为此时这两个串互为对方的后缀

2、对于字符串\(a,b\),有\(a\in suf(b)\iff right_b\subseteq right_a\)

1)当\(a\in suf(b)\)时,\(\forall v\in right_b\),有\(b\in suf(P_{S,v})\),故\(a\in suf(P_{S,v})\),也就是\(a\in right_a\)

也就是\(right_b\subseteq right_a\)

2)当\(right_b\subseteq right_a\)时,将\(right_a\)拆成是否含有\(right_b\)的两部分,忽略与\(right_b\)不同的部分则有\(a\in suf(b)\)\(b\in suf(a)\),而\(right_a\)事实上还有\(right(b)\)所不含有的部分,所以后者是不可能的,即只有\(a\in suf(b)\)

这一条性质也说明了不可能出现一个较长的串的\(right\)包含了一个较短的串的\(right\)集合

3、对于一个等价类\(R(a)\),记\(x\)\(R(a)\)中长度最长的串,\(y\)\(R(a)\)中长度最短的串,则\(R(a)=\{z|z\in suf(x) 且 |y|\leq |z|\leq|x|\}\)

\(R(a)\)可以被看做是一个最长的串\(x\)删去一定量的首字母得到的串的集合

\(right\)集合的性质1知一定有\(y\in suf(z),x\in suf(y)\),于是有\(right_z\subseteq right_y,right_y\subseteq right_x\)

而我们已经有了\(xRy\)\(right_x=right_y\),所以得到\(xRyRz\)

\(fail\)的性质

定义

\(Max(R(a))\)\(R(a)\)中最长的串的长度

\(Min(R(a))\)\(R(a)\)中最短的串的长度

\(fail_{R(a)}\),表示满足\(right_a\subseteq right_{fail_{R(a)}}\)\(Max(fail(R(a)))\)最大的等价类,特别的,当\(R(a)\)中含有长度为\(1\)的串的时候,\(fail_{R(a)}\)即为空串的\(right\)集合

相关性质

1、对于\(\forall x\in R(a),y\in fail_{R(a)}\),有\(y\in suf(x)\)

由定义知\(right_x\subseteq right_y\),故\(y\in suf(x)\)

2、\(Min(R(a))-1=Max(fail_{R(a)})\)

\(right_{R(a)}\subseteq right_{fail_{R(a)}}\)可知\(Min(R(a))>Max(fail_{R(a)})\),否则就和\(right\)集合的性质中的第二条相违背了

那么现在考虑证\(Min(R(a))-1\leq Max(fail_{R(a)})\)即可

考虑反证法,假设\(Min(R(a))-1>Max(fail_{R(a)})\),那么我们一定可以找到一个\(v\in suf(a)\),使得\(Min(R(a))-1\geq|t|>Max(fail_{R(a)})\),我们注意到此时的串\(v\)肯定不在\(fail_{R(a)}\)中,故此时\(fail_{R(a)}\)\(v\)的后缀,我们对\(v\)的定义有\(right_{R(a)}\subseteq right_{R(v)}\),也就是说此时我们找到了一个新的等价类\(R(v)\),使得\(Max(R(v))>Max(fail_{R(a)})\)这与定义矛盾,证毕(十分诡异)

状态和转移的相关定义

类比\(AC\)自动机,我们给出状态和转移的定义

  • \(trans_c(s,c)\)\(s\)是当前识别到的串或者状态,\(c\)表示某一个字符
  • \(trans_c(s,c)\)表示从SAM中的状态\(s\)转移到状态\(s+c\)。若不存在这样的一个状态则转移到\(null\)

但是如果只有这样的转移的话就和我们最开始给出的例子是一样的了,这是肯定要优化的

考虑在同一个等价类的字符串\(R(a)\),注意到它们的\(trans_c\)应该是相同的,于是我们可以将相同的等价类的串放到一个相同的状态上,同时定义一类新的转移\(trans_T(R(a))\),表示从状态\(R(a)\)顺着\(fail\)跳到\(fail_{R(a)}\)

fail树

把所有的\(trans_T\)单拎出来,看一下这会形成什么东西

考虑一个不是\(init\)的状态\(t\),它顺着\(fail\)跳的时候只会跳到比它的长度更短的状态,所以最终一定会跳到初始节点

也就是说,所有的\(fail\)会形成一棵树,我们称作\(fail\)

补充定义

\(T_v\)\(fail\)树上的某一个节点

\(R(T_v)\):该点对应的等价类

\(right_{T_v}\)\(R(T_v)\)对应的\(right\)集合

性质

1、在\(fail\)树上,对于某个叶子结点\(T_u\),一定存在\(a\in R(T_u)\)使得\(a\)\(S\)的前缀;并且这条路径对应的所有字符串均为\(a\)的后缀,以及\(a\)的所有后缀均在这条路径上

1)对于前半部分,首先叶子结点一定有\(right_{T_u}=1\),否则一定可以继续在右端补充字符使得其\(right\)集合变小

考虑任意一个串\(a\in R(T_u)\),如果它不是前缀,我们可以考虑在左端补充字符使得其成为一个前缀,并且由于在左端补充字符不会影响到对\(right\)的值,于是它还是在\(T_u\)上的

2)对于后半部分,根据\(fail\)的第二条性质,它的左端点时固定不动的,只有右端点一直在动,故得

2、记\(son(i)\)\(T_u\)的第\(i\)个儿子,则\(right_{T_u}=right_{son(1)}\bigcup right_{son(2)}\bigcup \cdots \bigcup right_{son(k)}\bigcup\{z|z\in right_{T_u}且|z|=max(R(T_u))\}\)

根据前面,有\(right_{son(i)}\subseteq right_{T_u}\),但是唯一的一个有可能不会被\(son(i)\)继承的就是\(T_u\)所代表的前缀,特殊处理

这有什么用啊QAQ

这一条性质证明了SAM的状态数是\(O(n)\)级别的

对于串\(s\)的每一个前缀,必然对应着一个叶子结点,它的\(right\)大小为\(1\),规模是\(O(n)\)的,而对于一个非叶子结点\(T_u\),它至少有\(2\)个儿子(否则我们可以将儿子和父亲合并),于是非叶子结点的规模也是\(O(n)\)的,总状态数约为\(2n\)

3、对于所有\(trans_c(s_1,c)=trans_c(s_2,c)=\cdots=trans_c(s_n,c)=R(a)\),有\(s_1,s_2,\cdots,s_n\)\(fail\)树上是叶子到根的连续的一段

由于它们加上的字母是相同的\(c\),所以一开始它们就处于同一个等价类中,根据\(right\)集合的性质3,\(s_1\)\(s_n\)必然是连续的一段,再结合\(fail\)树的性质\(1\)也就不难得到了

4、对于一个状态\(p\)\(trans_x(p,c)\neq null\),那么从\(p\)到根的路径上的所有状态\(v\),均满足\(trans_x(v,c)\neq null\)

由前面知\(right_p\subseteq right_v\),即\(v\)\(p\)具有一部分相同的\(right\),故得

5、\(fail\)树上,两个字符串\(x,y\)的最长公共后缀的长度就是\(Max(lca(R(x),R(y)))\)

记后面那个东西对应的字符串是\(p\),首先\(right_{R(x)}\)\(right_{R(y)}\)均被其祖先\(p\)\(right\)包含,于是\(p\)肯定是\(x,y\)的公共后缀

假设存在一个更长的字符串\(q\)满足条件,根据\(fail\)树的性质\(1\)\(q\)肯定在\(x\)\(y\)到根的路径上;同时由假设知\(|q|>|p|\),故此时\(q\)\(p\)的子树中,这样的\(q\)不可能成为公共祖先,证毕

转移数的证明

状态数的证明已经在上边了QAQ

先考虑\(trans_T\)一开始就交代了这是一棵树,所以这里的转移数是\(O(|S|)\)级别的

剩下的话。。。我还是直接转一下menci大聚聚的博客吧(再带上一点我自己的简化想法)

先从\(trans_c\)中拎出一棵生成树,边数是\(O(|S|)\)级别的,接下来对于一条非树边\(u->v\),找到这样的一个字符串

1)从\(init\)开始,沿着树边走到\(u\)

2)\(u->v\)

3)从\(v\)走到一个可接受的节点\(w\),在这里,可接受的指的是能从\(init\)出发,走到\(w\)时得到的一个字符串、为原串\(S\)的一个后缀,记得到的总串为\(str\)

首先,由可接受的节点的定义知\(str\)一定是原串的一个后缀(\(fail\)性质1),而且注意到这三步所选取的字符串都具有唯一性,也就是说一条非树边与一个后缀是一一对应的关系,所以这部分边数也是\(O(|S|)\)级别的

(感性理解一下就好了)

构造

终于开始构造了,前面的证明不喜欢的话直接跳过吧。。。有些证明我自己都感觉十分之诡异

先丢代码

    void add(int x)
    {
        int p=last,np=(++tot),i;last=tot;siz[tot]=1;
        point[np].len=point[p].len+1;
        while ((p) && (!point[p].ch[x])) {point[p].ch[x]=np;p=point[p].fa;}
        if (!p) {point[np].fa=1;return;}
        int q=point[p].ch[x];
        if (point[p].len+1==point[q].len) {point[np].fa=q;return;}
        int nq=(++tot);
        memcpy(point[nq].ch,point[q].ch,sizeof(point[nq].ch));
        point[nq].fa=point[q].fa;point[nq].len=point[p].len+1;
        point[q].fa=nq;point[np].fa=nq;
        while ((p) && (point[p].ch[x]==q)) {point[p].ch[x]=nq;p=point[p].fa;}
    }

假设已经完成插入了\(|S|-1\)个字符,现在插入第\(|S|\)个字符,我们需要对这个SAM进行适当调整使得某些节点的right集合能新增一个\(|S|\)

首先肯定是新建一个状态\(np\)使得\(right_{np}=\{|s|\},Max(np)=|s|\),注意在这里由于\(fail\)的性质2可以不用维护\(Min(np)\)

这也就是这句话(先不去理会那些奇奇怪怪的变量,那是之后的事情,记住这个\(nq\)和第二行就行了)

int p=last,np=(++tot),i;last=tot;siz[tot]=1;
point[np].len=point[p].len+1;

接下来考虑原来的子串中哪些串的\(right\)集合发生了变化,答案是\(S\)的所有后缀,即对于所有的串\(x\)\(|S|-c\)的后缀,我们需要构建新串\(y=x+c\),根据\(fail\)树的性质\(1\),我们可以直接顺着\(fail\)树往上跳,开始分类讨论

1)\(trans_c(p,c)=null\)

也就是说当前串\(y\)是不存在的,构建新串之后\(right_y=|s|=right_{np}\),故直接\(trans_c(p,c)=np\)

也就是下面这句话

while ((p) && (!point[p].ch[x])) {point[p].ch[x]=np;p=point[p].fa;}
if (!p) {point[np].fa=1;return;}

注意结束条件即可

2)\(trans_c(p,c)\neq null\)

\(q=trans_c(p,c)\),此时\(q\)是第一个符合2)的状态,所以对于所有\(>Max(q)\)的后缀均是第一种情况

对于状态\(p\)而言,其所有后缀都会产生一个新的right值,也就是说所有长度在\(Max(p)+1\)以内的串的\(right\)值均会增加,首先一定有\(Max(p)<Max(q)\)故在满足\(Max(q)=Max(p)+1\)\(q\)内部的所有串均符合上述条件,于是直接增加它的\(right\)集合即可

对应着这句话

int q=point[p].ch[x];
if (point[p].len+1==point[q].len) {point[np].fa=q;return;}

那么要是不满足上面的条件怎么办?

现在\(q\)点代表的串有两类,一类需要增加一个\(right\)值而另一类并不需要,我们考虑新增加一个状态\(nq\),并将所有长度不超过\(Max(p)+1\)的串转移至它上面

首先对\(nq\)的参数我们显然有\(Max(nq)=Max(p)+1\)\(fail_{nq}=fail_q\);对于\(fail\)的话注意到最小长度的串此时已经转移到\(nq\)上面了,为了继续维持\(fail\)的性质2故需要转移

而又由于\(nq\)\(q\)分裂出来的节点,所以\(right_{nq}=\{z|z\in right_q或z=|S|\}\),而|S|又是现在传的末尾,于是我们需要直接使用\(q\)的出边来维持前半部分

好了,\(nq\)处理完了,现在看一看剩下的点

\(q\)的父亲被\(nq\)继承了,肯定不能再用原来的父亲了,注意到\(nq\)只转移了长度较小的串,即\(nq\)\(q\)又构成了一组父子关系,直接让\(fail_q=nq\),而对于\(np\),显然就是从\(nq\)的fail跳过来了

就是下面这段

int nq=(++tot);
memcpy(point[nq].ch,point[q].ch,sizeof(point[nq].ch));
point[nq].fa=point[q].fa;point[nq].len=point[p].len+1;
point[q].fa=nq;point[np].fa=nq;

结束了吗?并没有,还有最后一个历史遗留问题:\(q\)的祖先们

注意到还有一些状态\(t\)满足\(trans_c(t,c)=q\)\(Max(t)<Max(p)\),它们的\(right\)也发生了改变,但是\(q\)显然不能满足这一需求,相对应的,我们需要把这一部分的\(q\)换成\(nq\)

对就是这最后一句话

while ((p) && (point[p].ch[x]==q)) {point[p].ch[x]=nq;p=point[p].fa;}

3)\(trans_c(p,c)\neq null\)

诶怎么还是你,注意此时我们访问到的\(p\)并不是第一个符合第二个条件的状态了

注意到此时跳\(p\)已经是我们完成了\(nq\)的修改之后了,此时跳到的状态一定是\(q\)的祖先了,那也是\(nq\)的祖先,注意此时由\(fail\)树的性质2可以知道这个点一定是包含\(nq\)\(right\)集合,也就包含了\(|S|\),故不必再做修改了,可以直接结束了

一些应用

\(right\)集合

来回顾一下\(right\)集合的定义:某个串\(str\)在原串\(S\)中出现的右端点的集合

说的更清楚点,这个\(right\)集合的大小等价于\(str\)\(S\)中的出现次数

那么这个大小怎么求呢?

注意到每次插入新字符时,新建了一个状态\(np\),它和它在\(fail\)树上的父亲的\(right\)集合均增加了\(|S|\)这一位置

于是可以在建SAM的时候将\(np\)\(right\)大小置为\(1\),接下来按照\(toposort\)的顺序统计子树内的和即可,这样可以省一个\(dfs\)(毕竟\(fail\)其实是儿子指向父亲的)

大概就是下面这样

int i,j;
for (i=1;i<=tot;i++) tax[point[i].len]++;
for (i=1;i<=tot;i++) tax[i]+=tax[i-1];
for (i=1;i<=tot;i++) ord[tax[point[i].len]--]=i;
for (i=tot;i>=1;i--) siz[point[ord[i]].fa]+=siz[ord[i]];

定位子串

我们知道,对于原串\(S\)的任何一个子串\(str\),它在后缀自动机上仅有一个状态节点与之对应,考虑如何求出这个状态节点

\(str\)\(S\)中的右端点为\(x\),那么首先\(x\in right_{str}\),我们找到建\(SAM\)\(right\)集合第一个具有此值的状态\(p\),那么根据\(fail\)树的性质\(1\)且很显然\(str\in suf(P_{S,x})\)\(str\)对应的状态应该是fail树上的\(p\)的祖先(或者是\(p\)本身),再根据\(fail\)的性质\(2\),我们可以直接根据\(|str|\)来找到这个状态,倍增优化即可

所有的\(p\)均可以在\(SAM\)预处理时得到,代码如下

int u=pos[r];
per(i,19,0)
{
    int v=fa[u][i];
    if (!v) continue;
    if (point[v].len>=r-l+1) u=v;
}

参考资料:

menci的博客:https://oi.men.ci/suffix-automaton-notes/

史上最通俗的后缀自动机详解 - KesdiaelKen的雷蒻论坛 - 洛谷博客:http://www.baidu.com/link?url=JsrQnOJLCCxFzN3p_YXZFPBs9lk4iqfMt9p_0mC5rRsPsjp4GFncQQNT9HsXVjnbQrSDynqKvCVMqpEcMR5TnvbeFtklzi8C9zdgIUjGve_&wd=&eqid=cebda21e0005266b000000065ccf1fae

OI wiki-后缀自动机:https://oi-wiki.org/string/sam/

clj的ppt:自己去网上或UOJ群里找

推荐阅读