后缀自动机学习总结

来源:互联网 发布:php程序员的工作状态 编辑:程序博客网 时间:2024/06/11 03:51

常用的字符串处理工具:

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只在竞赛里面有些用,实际工程里面,该在线的算法还是必须在线。作为一个弱菜,我个人对算法的审美一直都是“简单而有用的东西,就是美的!”,后缀自动机算是一个吧,所以花了一点时间学习。本文主要是个人的总结,详细参考cljppt

 

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

 

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

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

后缀自动机只对后缀感兴趣。对于字符串str,设SAMstr)是其对应的后缀自动机,则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的任何一个子串sRight(s)为一个集合,该集合包含sstr里面所有出现区间的终点。比如子串sstr中出现了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集其实也可以看成后缀的集合,这些后缀可以被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(x)->SubStr(x),却没有那么直接,如果任意给定Right(x)当中的一个位置r,我们该如何确定SubStr(x)?答案是字符串长度!如果知道长度len,很容易知道str[r – len, r)是属于SubStr(x)的;如果知道所有的长度,就能确定整个SubStr(x)!可以证明:SubStr(x)当中字符串的长度刚好构成了一个连续的区间,可以用[max(s), min(s)]表示。

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

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) – 1trans(s, ch) != nulltrans(Parent(s), ch) != null

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

 

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

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

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

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

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

对于a),因为Tx的子串 = T的所有子串+ Tx的所有后缀,因此我们只需要保证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),即把npparent的指针链向q不就ok了嘛!额。。但这是有问题的!

我们先来看转移到q的前驱有哪些,根据性质9不妨设qm个前驱: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把前驱分成了两部分,前面一部分转移到qright集不变;后面一部分的right集应该要扩充。最简单的办法就是把q拆也成两个,对应两部分前驱,这就是构造算法里面的做法!其实理解这个做法的关键点就是性质9 

0 0
原创粉丝点击