原文地址: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 !
[这时应该有一些人一脸懵逼][赶紧举例子啊...]