为了正常的体验网站,请在浏览器设置里面开启Javascript功能!

后缀树和后缀数组

2018-02-02 21页 doc 149KB 19阅读

用户头像

is_597436

暂无简介

举报
后缀树和后缀数组后缀树和后缀数组 基本概念 子串:字符串S的子串S[i..j],i?j,表示S串中从i到j这一段,也就是顺次排列S[i],S[i+1],...,S[j] 形成的字符串。 字符集:一个字符集Σ是一个建立了全序关系的集合,也就是说,Σ中的任意两个不同的元素α和β都可以比较大小,要么αβ)。字符集Σ中的元素称为字符。 字符串:一个字符串S是将n个字符顺次排列形成的数组,n称为S的长度,表示为len(S)。S的第i个字符表示为S[i]。 子串:字符串S的子串S[i..j],i?j,表示S串中从i到j这一段,也就是顺次排列S[...
后缀树和后缀数组
后缀树和后缀数组 基本概念 子串:字符串S的子串S[i..j],i?j,表示S串中从i到j这一段,也就是顺次排列S[i],S[i+1],...,S[j] 形成的字符串。 字符集:一个字符集Σ是一个建立了全序关系的集合,也就是说,Σ中的任意两个不同的元素α和β都可以比较大小,要么α<β,要么β<α(也就是α>β)。字符集Σ中的元素称为字符。 字符串:一个字符串S是将n个字符顺次排列形成的数组,n称为S的长度,表示为len(S)。S的第i个字符表示为S[i]。 子串:字符串S的子串S[i..j],i?j,表示S串中从i到j这一段,也就是顺次排列S[i],S[i+1],...,S[j] 形成的字符串。 后缀:后缀是指从某个位置i开始到整个串末尾结束的一个特殊子串。字符串S的从i开头的后缀表示为Suffix(i),也就是Suffix(i)=S[i..len(S)] 例如S = mississippi,那么它的所有后缀为: Suffix(1) = mississippi = S Suffix(2) = ississippi Suffix(3) = ssissippi Suffix(4) = sissippi Suffix(5) = issippi Suffix(6) = ssippi Suffix(7) = sippi Suffix(8) = ippi Suffix(9) = ppi Suffix(10) = pi Suffix(11) = i Suffix(12) = (empty) 不难发现,S的任意一个子串一定是某一个后缀的前缀。 字符串的大小比较:指通常所说的“字典顺序”比较,也就是对于两个字符串u、v,令i从1开始顺次比较u[i]和v[i],如果u[i]=v[i]则令i加1,否则若u[i]v[i]则认为u>v(也就是vlen(u)或者i>len(v)仍比较出结果,那么若len(u)len(v)则u>v。 Trie Trie是一棵树,它的结点(或全部用边)是?中单个字符,每个结点的所有子结点字符都不同,从树根到某一个叶结点所经过路径中的字符就构成一个字符串。 上图的Trie存放了字符串”aa”和”ab”。但是这样的话,我们无法再存储字符串”a”,所以引入$这个不在?的字符,让它成为叶结点。下图的Trie就存放了字符串”a”和”aa”。 还有另一种实现方法,不添加$,而在节点上做标记。 Trie的构造和查找 增量式:每次插入一个串S 从根开始,依次检查S的每个字符,沿着边走 如果串结束,则标记当前结点为S的终点 如果走到叶子时串未结束,给该叶子增加子树 “沿着走”需要定位每个结点的指定儿子,给叶子增加子树需要修改结点的儿子集合。小字典用数组,大字典用Hash。一般情况只考虑小字典,可以在常数时间内完成儿子的查找和插入。因此,在Trie中查找和插入串P,时间为O(|P|)。 为了减少不分支的结点所带来的浪费,将Trie改进为压缩Trie。压缩Trie与Trie类似, 只是将没有分支的连续结点压缩存放在同一个结点中。下图左边是一般的Trie,右边是压缩Trie。 后缀树的定义 后缀树(Suffix Trees)是一种能快速处理很多与字符串有关问题的数据结构。 严格定义:串S[1…m]$的后缀树(suffix tree)是一棵有根树T,它有m个叶子(一一对应), 标记为1,2,…,m。除根之外每个内结点都至少有两个儿子(没有局部链),每条 边上有S的一个非空子串,且同一结点出发的任两条边上标记的子串的第一个 字母都不相同。若用L(v)表示从树根走到结点v时经过所有边的子串连接,则对 于i=1,2,…,m,有L(i) = S[i…m]。因此,后缀树代表了S所有后缀的集合。 如下图所示,就是字符串”banana”的后缀树。 后缀树的实现 先来看看后缀树所耗费的空间。如果按照上面描述的方法存储,由于每个结点存的都是字符串,这样在最坏的情况下,需要平方级的空间,例如字符串”abcd…xyz”。 4 a bxa c xc c 2 a bxabxac 5 c 1 c 3 6 S=xabxac 但如果我们在每个结点只存储起始和终止下标,那么就是线性的了。 4 2,3,6, 1,2 6 6,6 2 2 3,3,6,5 6 6 1 6 6 3 6 S=xabxac 因为不难发现,根据压缩trie的特点,每一个内部结点都是要分支的。所以内部结点的个数不超过叶结点的个数,而叶结点的个数正好等于|T|。并且每个结点只存两个数值,于是空间是线性的。 既然空间是线性的,我们就有希望在线性时间内将之构造出来, 事实上,后缀树确实可以在线性时间内构造出来~ Weiner 1973:太复杂,内存开销太大 McCreight 1976:仍然复杂 Ukkonen 1995:在线算法,内存更省 Ukkonen算法是在线算法,即: 1. 一个字符一个字符的处理S 2. 处理完S的前k个字符,得到S[1…k]的“后缀树” 由于S[1…k]并不以$结尾,它可能并不存在后缀树,因此加上引号,称为隐式后缀树 (implicit suffix tree)。S[1…k]的隐式后缀树记为T i Ukkonen算法是从T开始依次构造T,T,…,T,T,则T是真正的后缀树。 123mm+1m+1 每次从T构造T的过程称为一个阶段(phase),则整个过程被形象的称为“生长过程”。 ii+1 举例:axa$的三个阶段 后缀a的结束点在边的中间 ax xaxa x a a 1 1 2 1 2 生长的三种情况 把T生长为T,就是要给S[1„i]的每个后缀S[j„i]增加字符S[i+1]: ii+1 情况一:S[j„i]以叶子结尾,则直接在S[i+1]的标号后增加字符S[i+1]即可(修改边标号) 情况二:S[j„i]在中间结束,且后一个字符不是S[i+1],则在该处增加一个分支,连接到一个新叶子f。该边的编号为S[i+1]。如果结束点在一条边的中间,还需进行边分裂(改变树结构) 情况三:S[j„i]在中间结束,且后一个字符就是S[i+1],则什么都不用做(不改变树结构和边标号,仅仅移动生长点) 三阶段定理 刚才的演示暗含了三阶段定理:每次生长过程总是依次进行 1次或多次情况1扩展(增加) 0次或多次情况2扩展(分裂) 0次或多次情况3扩展(空操作) 两个有用的结论 1. 一日为叶子,终生为叶子 2. 在所有阶段中,情况2扩展总次数为O(|S|) 实现细节 后缀的末尾称为“生长点”,则每阶段是在依次扩展每个生长点。生长点可以在结点上,也可以在边的中间。设边标号为(i,j),当前增加的字符为S[k]: 情况1:把边标号从[?,k-1]改为[?,k]。如果用特殊符号“-”表示当前处理字符,则边标号不变~整棵树没有一点变化,结构显然不变,边标号和扩展前完全一样:从k到当前字符。 情况2:由刚才的结论,一共只有O(|S|)次扩展,每次只需常数时间。 2情况3:扩展可能出现O(|S|)次,如果一一处理的话,即使每次处理时间为常数级别也不行~ 解决方法:忽略一些情况3扩展。 情况3不改变树结构,也不改变边标号,仅仅是生长点的移动。移动次数过多,不能一一模拟。直观的想法:合并一些移动,或者说在一些情况3出现时“偷懒”,把移动延迟到必须执行时才做。每次只保留一个生长点的位置,称为“活动生长点”。 我们用表格来解释一下这个算法: 每行对应一个生长点,每列对应一个阶段;每阶段从上到下依次扩展每个生长点。中灰、深灰和浅灰分别代表三种情况。由于一日为叶子,终身为叶子,情况一的边界应该是阶梯型的(左图): 去掉情况1,每列只保留第一次情况3扩展,得到一条从左上到右下的路径。只保留活动生长点,让它沿路径移动(右图)。该路径长度是O(|S|)的,因此Ukkonen算法是线性的。 后缀树的应用 匹配 我们先假设后缀树已经构造好了,来解决字符串匹配问题。 使用文本T构造出的后缀树是S,模式为P,那么可以在O(|P|)的时间里找出P出现的位置。 下图显示在T=”banana”中查找P=”an”的过程。 匹配的过程是:i表示匹配到P的第i个字符,i从1开始。从S的树根出发,接下来访问哪个结点取决于P[i],要使得那个顶点的第一个字符等于P[i],假设那个串的长度是k。接着,将P[i..i+k-1]和那个顶点所代表的字符串比较,那么以后要将i的值改成i+k。这个过程中可能会出现以下几种情况: 1、 如果这个顶点不存在,那么P在T中没有出现; 2、 如果P[i..i+k-1]和那个顶点所代表的字符串在某个位置不同,那么P在T中没有出现; 3、 如果P[i..n]的长度不超过那个顶点所代表的字符串的长度k,而且每个对应位置都相 同,那么P就在T中出现了,树中这个顶点所能到达的所有叶结点都是P出现的位 置; 4、 如果P[i..n]的长度大于那个顶点所代表的字符串的长度k,而且每个对应位置都相 同,那么将i的值改为i+k,继续找下一个去访问的子结点。 这种方法的正确性是根据前面所指出的:字符串T的任意一个子串一定是它某一个后缀的前缀以及后缀树的特性决定的。 由于i是递增的,而且每增加1就比较了1次,所以时间复杂度是O(|P|)的。 通过上面的讨论,我们还可以一些其他问题的答案: 1、 如果只问P在T中出现了几次,那么可以事先将每个结点的子孙叶结点统计出来, 然后找到这个结点停下来后,用O(1)的时间回答。 2、 如果问题是找一个至少出现两次的最长子串,也可以用后缀树。对所有的内部结点 定义一个“模式深度”,表示从树根到这个结点经过的字符总数。这样,“模式深度” 的最大值就是最长子串的长度,这个子串就是从树根到这个结点所经过的字符串。 所需要的时间就是O(|T|)。 后缀树和LCA的应用 将LCA应用到后缀树上可以产生许多的效果。 考虑到如果将LCA应用到压缩Trie上,就能够求出两个字符串的最长公共前缀(LCP)。也就是说,如果将LCA应用到T的后缀树S上,就能求出T的两个子串的LCP。 最长回文子串 求字符串T中的最长回文子串可以在O(T)时间内完成。 预处理: 1、 使用T和R(T)构造后缀树S,R(T)表示T的倒字符串; 2、 计算每个顶点的“模式深度”; 3、 做一个映射:对所有的i,存两个指针,指向表示T[i..n]和R(T)[i..n]的后缀树结点 (叶结点); 4、 对S进行LCA的预处理。 这些预处理可以用O(|T|)时间完成。 求解: 对所有的i=1..|T|,询问LCA(T[i..n],R(T)[|T|-i+1..n])和LCA(T[i..n],R(T)[|T|-i+2..n]),就SS 可以得到以i为中心或以i-1和i为中心的最长回文串。这是因为LCA求出的是最长公共前缀LCP,T和R(T)是反向的,T[i]和R(T)[|T|-i+1]是同一个字符,LCA(T[i..n],R(T)[|T|-i+1..n])S 的答案结点的“模式深度”乘以2再减1就是问题的解;如果询问是LCA(T[i..n],R(T)[|T|-i+2..n]),那么答案结点的“模式深度”乘以2就是问题的解。 S 我们只要在这个过程中,保存一个最大值就可以了,当然也能够获得最长的串是什么。由于LCA每次询问处理只需要O(1)的时间,所以这个算法的时间复杂度是O(|T|)。 允许错误的字符串匹配问题 给出文本T和模式P,它们的字符都取自字符集?。问P在T中是否出现,但允许至多k个位置上的对应字符不同。 算法: 1、 根据T和P构造后缀树S; 2、 做一个映射:对所有的i,存一个指针,指向表示T[i..n]的后缀树结点(叶结点); 3、 对S进行LCA的预处理; 4、 对每一个i=1..|T|: a) 求T[i..n]和P的LCP(LCA); b) 如果正好匹配,那么就做完了; c) 如果在P[p]处出现问题,那么就继续求T[i+p+1..n]和P[p+1..m]的LCP,这样最 多做k次,如果还有问题,就说明不能匹配。 最坏情况下,对每一个i都要调用k次LCA,所以时间复杂度是O(k*|T|)。 最长公共子串 算法: 1、 构造T1和T2的后缀树,用不同的标记表示字符串结尾; 2、 计算每个顶点的“模式深度”; 3、 找出“模式深度”最大的、子结点中带有两个字符串结尾标记的结点。 后缀数组 后缀数组是后缀树的一个非常精巧的替代品,它比后缀树容易编程实现,能够实现后缀树的很多功能而时间复杂度也不太逊色,并且,它比后缀树所占用的空间小很多。可以说,在信息学竞赛中后缀数组比后缀树要更为实用。 我们约定一个字符集Σ和一个字符串S,设len(S)=n,且S[n]='$',也就是说S以一个特 殊字符'$'结尾,并且'$'小于Σ中的任何一个字符。除了S[n]之外,S中的其他字符都属于Σ。 后缀数组:后缀数组SA是一个一维数组,它保存1..n的某个排列SA[1],SA[2],...SA[n],并且保证Suffix(SA[i])n或者j+k>n的时候Suffix(i+k)或Suffix(j+k)是无明确定义的表达式,但实际上不需要考虑这个问题,因为此时Suffix(i)或者Suffix(j)的长度不超过k,也就是说它们的k-前缀以'$'结尾,于是k-前缀比较的结果不可能相等,也就是说前k个字符已经能够比出大小,后面的表达式自然可以忽略,这也就看出我们规S以'$'结尾的特殊用处了。 定义k-后缀数组SA保存1..n的某个排列SA[1],SA[2],„SA[n]使得Suffix(SA[i])?kkkkkSuffix(SA[i+1]),1?ij时可交换i,j,i=j时可以直接输出结果n-SA[i]+1。 直接根据定义,用顺次比较对应字符的方法来计算LCP(i,j)显然是很低效的,时间复杂度为O(n),所以我们必须进行适当的预处理以降低每次计算LCP的复杂度。 经过仔细分析,我们发现LCP函数有一个非常好的性质: 设i标准
算法,可以在O(n)时间内进行预处理,每次询问可以在常数时间内完成。 对于一个固定的字符串S,其height数组显然是固定的,只要我们能高效地求出height数组,那么运用RMQ方法进行预处理之后,每次计算LCP(i,j)的时间复杂度就是常数级了。于是只有一个问题——如何尽量高效地算出height数组。 根据计算后缀数组的经验,我们不应该把n个后缀看作互不相关的普通字符串,而应该尽量利用它们之间的联系,下面证明一个非常有用的性质:为了描述方便,设h[i]=height[Rank[i]],即height[i]=h[SA[i]]。h数组满足一个性质: 性质3:对于i>1且Rank[i]>1,一定有h[i]?h[i-1]-1。 根据性质3,可以令i从1循环到n按照如下方法依次算出h[i]: 若Rank[i]=1,则h[i]=0。字符比较次数为0。 若i=1或者h[i-1]?1,则直接将Suffix(i)和Suffix(Rank[i]-1)从第一个字符开始依次比较直到有字符不相同,由此计算出h[i]。字符比较次数为h[i]+1,不超过h[i]-h[i-1]+2。 否则,说明i>1,Rank[i]>1,h[i-1]>1,根据性质3,Suffix(i)和Suffix(Rank[i]-1)至少有前h[i-1]-1个字符是相同的,于是字符比较可以从h[i-1]开始,直到某个字符不相同,由此计算出h[i]。字符比较次数为h[i]-h[i-1]+2。 可以证明,这个求h[i]的算法复杂度是线性的。 求出了h数组,根据关系式height[i]=h[SA[i]]可以在O(n)时间内求出height数组,于是可以在O(n)时间内求出height数组。 结合RMQ方法,在O(n)时间和空间进行预处理之后就能做到在常数时间内计算出对任意(i,j)计算出LCP(i,j)。 因为lcp(Suffix(i),Suffix(j))=LCP(Rank[i],Rank[j]),所以我们也就可以在常数时间内求出S的任何两个后缀之间的最长公共前缀。这正是后缀数组能强有力地处理很多字符串问题的重要原因之一。 后缀数组与后缀树的比较 比较它们的空间: 后缀数组占用的空间比后缀树要小,刚才分析中我们并没有提到空间复杂度的问题,这里简单说一下:后缀数组SA和名词数组Rank都只需要n个整数的空间,而在由Rank计k算出SA的过程中需要用两个一维数组来辅助完成,各占n个整数的空间,滚动地进行操2k 作,整个算法只需要这四个一维数组和常数个辅助变量,因此总的空间占用为4n个整数。 而后缀树通常有2n个以上节点,通常每个节点要两个整数(即使采用一些技巧,至少还是要保存一个整数),每个节点要有两个指针(假设采用儿子-兄弟表示方法),因此总共的空间占用至少是4n个指针和2n个整数(至少是n个整数)。如果采用其他方法表示树状结构,需要的空间更大。可以看出后缀数组的空间需求比后缀树小。 比较它们的时间: 即使是用三分法,在线性时间复杂度内后缀数组,其速度还是比后缀树慢很多。 小结 后缀数组实际上可以看作后缀树的所有叶结点按照从左到右的次序排列放入数组中形成的,所以后缀数组的用途不可能超出后缀树的范围。甚至可以说,如果不配合LCP,后缀数组的应用范围是很狭窄的。但是LCP函数配合下的后缀数组就非常强大,可以完成大多数后缀树所能完成的任务,因为LCP函数实际上给出了任意两个叶子结点的最近公共祖先,这方面的内容大家可以自行研究。
/
本文档为【后缀树和后缀数组】,请使用软件OFFICE或WPS软件打开。作品中的文字与图均可以修改和编辑, 图片更改请在作品中右键图片并更换,文字修改请直接点击文字进行修改,也可以新增和删除文档中的内容。
[版权声明] 本站所有资料为用户分享产生,若发现您的权利被侵害,请联系客服邮件isharekefu@iask.cn,我们尽快处理。 本作品所展示的图片、画像、字体、音乐的版权可能需版权方额外授权,请谨慎使用。 网站提供的党政主题相关内容(国旗、国徽、党徽..)目的在于配合国家政策宣传,仅限个人学习分享使用,禁止用于任何广告和商用目的。

历史搜索

    清空历史搜索