[转]后缀自动机(SAM)

原文地址:http://blog.sina.com.cn/s/blog_8fcd775901019mi4.html 

感觉自己看这个终于觉得能看懂了!也能感受到后缀自动机究竟是一种怎样进行的数据结构了...

笔者自己的话会用楷体表示出来...[说不定能帮助大家理解,但是可能也破坏了大家的自主理解力?所以...看不懂的话再来看好咯...]

 

常用的字符串处理工具:

1.       整词索引:排序+二分;Hash表。可以解决整词匹配,但不支持前缀搜索;Hash表在模式串定长的情况下可以用RK解决多模式串搜索和匹配问题。总的说来整词索引在子串搜索里面的性能并理想。当然也有优点,就是空间小。

2.       前缀索引:KMP/Trie树/AC自动机。AC自动机可以看成KMP + Trie树的混合体,因为KMP支持单串搜索和fail指针;而Trie树支持多串搜索,但没有fail指针。两个杂交在一起就有了AC自动机,既支持多串匹配也有fail指针,O(n)的时间之内可以扫描出所有的模式串,确实很强大。

3.       后缀索引:后缀树、后缀数组。后缀索引一般只针对单串进行处理,可以反应该单串的内部结构信息(字典序、最长公共前缀等),当然也可以把多个串合并在一起做后缀索引,进而找到这些串之间的结构信息。

 

应该说单串索引里面,后缀数组已经非常强大了,已经可以解决很多问题。而后缀自动机我以前都没怎么听说过,网上查了下,好像没有太多资料介绍它,有啥用也没说。貌似很多它能完成的工作,后缀数组也能完成;它不能完成的,后缀数组也能完成。(额,被完爆了)。

尽管如此后缀自动机还是有很吸引人的一面:代码量还不到50行,太短了,太诱人了;而且是O(n)的在线算法,O(1)(均摊)增量式构造。相比之下,后缀数组的最大短板在于不能增量构造,虽然用一些离线的算法可以解决这个问题,但这种trick只在竞赛里面有些用,实际工程里面,该在线的算法还是必须在线。作为一个弱菜,我个人对算法的审美一直都是“简单而有用的东西,就是美的!”,后缀自动机算是一个吧,所以花了一点时间学习。本文主要是个人的总结,详细参考clj的ppt。

 [下面这段关于数据结构的话笔者十分赞同]

我们知道数据结构是一个二元组 = 数据 + 操作。数据结构的灵魂在于,在对数据进行操作的过程当中,保持某些性质不变从而高效的完成任务。这些性质往往分为两种:1. 功能性质; 2. 性能性质。 以平衡二叉树为例:功能性质是左右子树有序,进而实现按key检索、添加、删除;而性能性质是保持左右子树的深度平衡,以实现O(log(n))的操作上限。变化的操作当中保持关键性质不变,个人认为是数据结构设计的核心内容。

 

再来看后缀自动机,后面的内容主要是个人总结。

后缀自动机的功能性质是什么?

后缀自动机只对后缀感兴趣。对于字符串str,设SAM(str)是其对应的后缀自动机,则SAM(str)接收且仅接收str的所有后缀。也就是说对于str的所有后缀,在SAM(str)里面都有合法的转移,且转移到终态。作为附加功能,使得后缀自动机不仅仅能识别后缀,也能识别str的所有子串。[区别就是一个要转移到终态,一个会在中间停下]

后缀自动机的性能性质是什么?

str的长度为n,则后缀自动机SAM(str)的状态数为O(n)。因为只有n个后缀,所以状态数为O(n)好像也挺合理,一一对应嘛。但是别忘了,后缀自动机不仅仅能识别后缀,也能识别str的所有子串,这些子串的个数是O(n^2)的,这一下就变得不合理了,O(n)的状态数的如何做到识别O(n^2)个字符串的?在前缀索引里面,以trie树为例,状态和前缀(字符串)是一一对应的,所以trie树的状态数上线刚好等于串的总长度(也是前缀数),可以认为是O(n)的。但是后缀自动机却不能这样干,也来一一对应,这样干的后果就是状态数也变成了O(n^2)。这意味着,要实现O(n)的状态数,就必须使得一些子串被映射到同一个状态,以实现状态的重复利用!这里的问题又产生了?该把哪些串映射到同一个状态?随便搞行吗?

 

关键概念 + 主要观察:

1.       Right集: 对于str的任何一个子串s,Right(s)为一个集合,该集合包含s在str里面所有出现区间的终点。比如子串s在str中出现了k次,有s = str[l1, r1) = str[l2, r2) = ……. = str[lk, rk),则Right(s) = {r1, r2, ……, rk}。

2.       状态及其意义: 字符串s1, s2被映射到相同的状态,当且仅当他们有相同的Right集。即State(s1) = State(s2)当且仅当Right(s1) = Right(s2)。(也就是说状态可以被重复利用的)。

3         状态的性质1——Right集: 由于映射到相同状态的串具有相同的Right集,那么Right集不仅可以作为字符串的性质,也可以作为状态的性质。对于SAM(str)的任何一个状态x,用Right(x)表示对应的Right集。Right集其实也可以看成后缀的集合[就是以Right(x)中的某个元素r打头的后缀Suffix(r)],这些后缀可以被x的后继状态接收;反过来,状态x到终态的任意一条路径也对应str当中的某个后缀,这个后缀应该属于Right(x)。从这个层面上来讲,位置r属于Right(x)的充要条件是,Suffix(r)能被x的后继状态识别。

4.       状态的性质2——字符串:令SubStr(x)表示所有转移到状态x的子串。

5.       Right(x)与SubStr(x)之间的关系:

  SubStr(x) -> Right(x),任意给定SubStr(x)当中的一个字符串s,根据定义就可以确定Right(x)这个很直接[因为这个状态的Right就是任意一个SubStr(x)中串的Right]

  Right(x)->SubStr(x),没有上面的那么直接,如果任意给定Right(x)当中的一个位置r,我们该如何确定SubStr(x)?答案是字符串长度!如果知道长度len,很容易知道str[r – len, r)是属于SubStr(x)的;如果知道所有的长度,就能确定整个SubStr(x)!

  可以证明:SubStr(x)当中字符串的长度刚好构成了一个连续的区间,可以用[max(s), min(s)]表示。

  [怎么证明呢?]

    先举个例子如字符串"abcd"。

    那么"abcd","bcd","cd","d"的Right集合是相等的,它们应当被投射的一个状态下。

    于是就发现这些Right集合相等的元素之间其实有着很深的联系...它们之间是包含关系!而且是后缀的包含关系!

    然后一般化的思考,如果已经知道一个状态下的最长串,可以很清楚的知道,这个串的所有后缀的Right集合一定包含这个串的Right集合[很显然,可以脑中跟着出现一个线段进行思考]。

    当然到了某个位置之后的后缀,可能就会多出那么几个位置,如串"abcdcd","abcd"和"bcd"的Right集合是相等的,但是"cd"和"d"却多了一个地方,状态有所不同。至于为什么是连续的,也十分显然,因为不可能断开。

    而仔细思考Right(x)->SubStr(x)这条性质会发现其实Right集合等于这个的,也仅仅只有这个最长串和它的某些后缀!因为位置固定了,只有这一些。[感觉很啰嗦,不过笔者到了后面才理解这句话,其实在这就可以看出]

6.       状态与状态之间的关系: 对于两个不同的状态x, y。要么Right(x)与Right(y)是空集,要么一个是另一个的真子集。(这条性质保证了状态总数是线性的)。

  [怎么证明呢?]

  假设Right(x)={a1...ak1},Right(y)={b1...bk2}。

  若有ai=bj=r,则可以在substr(x)中取一个串A,在substr(y)中取一个串B,因为这两个串在r处末端重合,则要么A是B的后缀,要么B是A的后缀[A!=B],那就不妨设A是B的后缀。

  那么在B出现位置的末端A一定也出现了,也就是A所在的Right集合一定包含了B所在的Right集合,又因为x!=y,所以x包含y。

  所以如果Right集合有交集,那么就一定相互包含。

7.       Parent树: 根据性质6,对于每一个状态x,我们可以如下确定一个Parent(x)。 y = Parent(x)当且仅当,Right(y)是包含Right(x)的所有集合当中最小的那个;如果这样的y不存在,则置Parent(x)=初始态。要注意的是,这个Parent并不是转移关系里面的前驱,后缀自动机里面一个状态有多个前驱,却只有唯一一个Parent。另外,从Right集的角度来看Parent树,从叶子往根走,其实就是一些相交集合不断合并的过程。因此Parent指针的一个指向某个状态的本质意义:就是对Right集进行扩充,将一个Right集加入另一个Right集!同时,因为有了Parent树,我们不必对每个状态都存一个Right集,相反用一种层次化的结构来存,保证了空间复杂度是线性的。

8.       Parent(s)与s之间的关系:max(Parent(s)) = min(s) – 1;trans(s, ch) != null则trans(Parent(s), ch) != null。

  [怎么解释呢?]

    Parent(s)是包含s的最小集合,也就是断开位置所在的Right集合

    例如"abcdcdd"中Right有三个{4}{4,6}{4,6,7},其中"abcd","bcd"属于{4},"cd"属于{4,6},"d"属于{4,6,7},这些都是逐个移动中发现的第一个断开的位置。所以max(Parent(s))=min(s)-1.

    trans(s,ch)应该说的是s引出的转移边ch,那我们就考虑到从它们公共的位置引出的后缀,因为Parent(s)的Right更大,所以s所能引出的,Parent(s)也能引出,所以如果s能转移,那么Parent(s)也能转移。

9.       转移字符、前驱:如果有trans(x1, c1) = trans(x2, c2)……=trans(xk, ck) = x,则c1 = c2 …… = ck!且状态x1, x2, ……xk在parent树中构成一段连续的Parent链(即有父子关系)。链的最底部最小的儿子为xi,当且仅当step[xi] + 1 = step[x](step为构造新节点是给与的标记)!

[怎么证明呢?]

还有关于 step[xi] + 1 = step[x]的证明,暂时不知怎么证明...

再来理解后缀自动机的构造算法:

SAM(T)到SAM(Tx)需要更新什么?我们的依据是什么?

首先来看我们需要保持哪些性质?

a)       转移合法性:接收Tx的所有后缀,且保证Tx的所有子串有合法转移!(因此涉及转移矩阵的更新!)

b)       状态合法性:每个状态和新增状态的right集满足定义,即:转移到同一个状态的所有子串有相同的Right集。(涉及Parent链的更新)

对于a),因为Tx的子串 = T的所有子串+ Tx的所有后缀[就是包含x的和不包含x的],因此我们只需要保证Tx的所有后缀有合法转移就行!而Tx的后缀完全是由T的所有后缀增加一个字符x得来的,我们只需要挨个找出T的后缀在SAM(T)中的状态,然后再保证这些状态在SAM(Tx)当中有x转移即可!

如何找出SAM(T)中后缀对应的状态?由于T的所有后缀有一个公共的出现位置r = length(T),这导致他们的状态Right集交集非空,进而根据性质6知道,这些状态肯定是构成一个Parent链,所以要找这些状态最简单的办法就是沿着Parent链回溯![下面的内容:画图大法好!举例大法好!]

设SAM(T)当中后缀对应的终态={v1, v2, …….vk},回溯的时候会出现哪些情况呢?

一个是trans(p, x) = null,即不存在x转移,我们就必须增加一个x转移SAM(Tx)的终点np即可,同时保证了性质b);

那q = trans(p, x) != null的时候呢?很好嘛,已经有x转移了,我们只需要保持性质b)就行。扩充Right(q),即把np的parent的指针链向q不就ok了嘛!额。。但这是有问题的!

我们先来看转移到q的前驱有哪些,根据性质9不妨设q有m个前驱:p1, p2, …….,pm,且满足(parent[p1] = p2, parent[p2] = p3, ….)。

现在的问题是,p在这条链的哪个位置?

如果p = p1(此时step[p] + 1= step[q] ),前面的做法是没有问题的,因为从p2……pm转移到q的所有字符串,必定是从p转移到q的字符串的后缀,所以这些字符串也必定出现在位置length(Tx),因此扩充Right(q) = Right(q) + {length(Tx)}当然没问题!

但是,如果p != p1就麻烦了,对于出现在p前面任何一个节点pj,可以证明从pj转移到q的字符串一定不会出现在位置length(Tx)。 如果还是简单的令Right(q) = Right(q) + {length(Tx)},就导致性质b)对节点q失效,因为有些串转移到q但是它的Right对不上! 那如何办呢?

 可以看到这里p把前驱分成了两部分,前面一部分转移到q的right集不变;后面一部分的right集应该要扩充。最简单的办法就是把q拆也成两个,对应两部分前驱,这就是构造算法里面的做法!

其实理解这个做法的关键点就是性质9 !

[这时应该有一些人一脸懵逼][赶紧举例子啊...]

好了不管上面关于构造的部分到底说错了什么,反正是没有代码作证的...

这一部分内容是笔者看了代码之后写下的:

上面的图如果你觉得看的不过瘾,可以自己画一画[这是笔者在黑板上画的然后用画图重现的...]

下面是一段抄来并加上注释的代码:

struct SAM{
    int last,cnt;//last表示目前自动机的最终态[能接受最长串的态],cnt是正在打下的标号
    int a[maxn][26],fa[maxn],mx[maxn];
     
    SAM(){last=++cnt;} //初始化,先要建立一个初始态
     
    void extend(int c){
        int p=last,np=last=++cnt;
        mx[np]=mx[p]+1;
        while(!a[p][c] && p)//一直沿着parent指针往上找到第一个有c出边的终态
            a[p][c]=np,p=fa[p];
        if(!p) fa[np]=1;//如果所有的点之前都没有含c的出边,那么包含新后缀的Right集合不存在,就将np的parent指向初始态
        else{
            int q=a[p][c];
            if(mx[p]+1==mx[q])//如果这个后缀正好能包含前面所有的子串,即不会出现一个以后缀形式包含此串却不是上一个串的后缀的串也连接到q,这种情况就可以保证直接将np的Parent练接到q即可[q的Right集合包含了np]
                fa[np]=q;
            else{
                int nq=++cnt;mx[nq]=mx[p]+1;
                memcpy(a[nq],a[q],sizeof(a[q]));
                fa[nq]=fa[q];//新建一个nq节点,并且将q复制一份到nq节点上
                fa[np]=fa[q]=nq;//之前的q上连接的就是包含这个后缀的其它串,而这个后缀上的串因为末尾添加可以在Right集合中加入一个新的位置,从而连到一个新的状态
                while(a[p][c]==q) a[p][c]=nq,p=fa[p];//这个新的状态也是之前那些不能转移节点所到达状态的Parent
            }
        }
    }
};

然后我感觉这篇博客好像自己的东西和原文差不多一样多了...[所以,复制原文,也超越原文吧...]

希望能帮助大家理解SAM的一些概念与构造。

后来还写了一个与后缀自动机相关的,也是关于它的概念的一些温习.

大家有一点不懂的或是想再温习一遍的也可以看一下:http://www.cnblogs.com/Robert-Yuan/p/5589234.html

原文地址:https://www.cnblogs.com/Robert-Yuan/p/5187439.html