【串和序列处理 3】Suffix Tree 子串匹配结构

来源:互联网 发布:淘宝网二手市场 编辑:程序博客网 时间:2024/05/24 07:30

一道Amazon的面试题:找出给定字符串里的最长回文。例子:输入XMADAMYX。则输出MADAM。这道题的流行解法是用后缀树(Suffix Tree)。这坨数据结构最酷的地方是用它能高效解决一大票复杂的字符串编程问题: 

 

Suffix Tree 又称后缀Tree或后缀树。它与Trie树的最大不同在于,后缀Tree的字符串集合是由指定字符串的后缀子串构成的。给定一长度为n的字符串S=S1S2..Si..Sn,和整数i,1 <= i <= n,子串SiSi+1...Sn都是字符串S的后缀。比如、完整字符串"minimize"的后缀子串组成的集合S分别如下:

         s1=minimize

         s2=inimize

         s3=nimize

         s4=imize

         s5=mize

         s6=ize

         s7=ze

         s8=e

      然后把这些子串的公共前缀作为内部结点构成一棵"minimize"的后缀树,如图所示,其中上图是Tree树的字符表示,下图是压缩表示。可见Suffic Tree是一种很适合操作字符串子串的数据结构。 它和PAT tree在这一点上类似。

Suffix Tree的创建 

      标准Trie树的每一个内部结点只有一个字符,也就是说公共前缀每一次只找一个。而Suffix Tree的公共前缀可以是多个字符,因此在创建Suffix Tree的时候,每插入一个后缀子串,就可能对内部结点造成一次分类。下面我们我们看一种后缀树构造算法。以"minimize"为例:

当插入子串时,发现叶子结点中的关键字与子串有公共前缀,则需要将该叶子结点分裂。如上图第3到4步。否则,重新创建一个叶子结点来存放后缀,如上图第1到2步

Suffix Tree的子串查询

     如果在后缀树T中查找子串P,我们需要这样的过程:

     (1) 从根结点root出发,遍历所有的根的孩子结点:N1,N2,N3....

     (2) 如果所有孩子结点中的关键字的第一个字符都和P的第一个字符不匹配,则没有这个子串,查找结束。

     (3) 假如N3结点的关键字K3第一个字符与P的相同,则匹配K3和P。

          若 K3.length>=P.length  并且K3.subString(0,P.length-1)=P,则匹配成功,否则匹配失败。

          若 K3.length<=P.length  并且K3=P.subString(0, K3.length-1),则将子串P1=P.subString(K3.length, P.length); 即取出P中排除K3之后的子串。然后P1以N3为根结点继续重复(1)~(3)的步骤。直到匹配完P1的所有字符,则匹配成功。否则匹配失败。

      查询效率:很显然,在上面的算法中。匹配成功正好比较了P.length次字符。而定位结点的孩子指针,和Trie情况类似,假如字母表数量为d。则查询效率为O(d*m),实际上,d是固定常数,如果使用Hash表直接定位,则d=1.

      因此,后缀树查询子串P的时间复杂度为O(m),其中m为P的长度。

Suffix Tree的应用

      标准Trie树只适合前缀匹配和全字匹配,并不适合后缀和子串匹配。而后缀树在这方面则非常合适。

      另外后缀树也可以进行前缀匹配。 如果模式串P是字符串S的前缀的话,那么从根结点出发遍历后缀树,一定能够寻找到一条路径完全匹配完P。比如上图: 模式串P=“mini”,主串S="minimize"。P从根节点出发,首先匹配到结点mi,然后再匹配孩子结点nimize。直到P中所有的字符都找到为止。所以P是S的前缀。

  • 在文本T里查询T是否包含子串P(复杂度同流行的KMP相当)。
  • 文本T里找出最长重复子串。比如abcdabcefda里abc同da都重复出现,而最长重复子串是abc。
  • 找出字符串S1同S2的最长公共子串。注意不是常用作动态规划例子的LCS哈。比如字符串acdfg同akdfc的最长公共子串为df,而他们的LCS是adf。
  • Ziv-Lampel无损压缩算法。

    还有就是这道面试题问的最长回文了。

    另外后缀树在生物信息学里应该应用广泛。碱基匹配和选取的计算本质上就是操作超长的{C, T, A, G, U}*字符串。

    Suffix Tree解决文章开头提到的最长回文

    那后缀树同最长回文有什么关系呢?我们得先知道两坨坨简单概念:

    • 最低共有祖先,LCA(Lowest Common Ancestor,一棵有根樹,樹上兩點共同祖先當中,離根最遠、深度最深的那一個祖先),也就是任意两节点(多个也行)最长的共有前缀。
    • 广义后缀树(Generalized Suffix Tree)。传统的后缀树处理一坨单词的所有后缀。广义后缀树存储任意多个单词的所有后缀。例如下图是单词XMADAMYX与XYMADAMX的广义后缀树。注意我们需要区分不同单词的后缀,所以叶节点用不同的特殊符号与后缀位置配对。

    有了上面的概念,查找最长回文相对简单了。思维的突破点在于考察回文的半径,而不是回文本身。所谓半径,就是回文对折后的字串。比如回文MADAM的半径为MAD,半径长度为3,半径的中心是字母D。显然,最长回文必有最长半径,且两条半径相等。还是以MADAM为例,以D为中心往左,我们得到半径DAM;以D为中心向右,我们得到半径DAM。二者肯定相等。因为MADAM已经是单词XMADAMYX里的最长回文,我们可以肯定从D往左数的字串DAMX与从D往右数的子串DAMYX共享最长前缀DAM。而这,正是解决回文问题的关键。现在我们有后缀树,怎么把从D向左数的字串DAMX变成后缀呢?到这个地步,答案应该明显:把单词XMADAMYX翻转就行了。于是我们把寻找回文的问题转换成了寻找两坨后缀的LCA的问题。当然,我们还需要知道到底查询那些后缀间的LCA。这也简单,给定字符串S,如果最长回文的中心在i,那从位置i向右数的后缀刚好是S(i),而向左数的字符串刚好是翻转S后得到的字符串S‘的后缀S'(n-i+1)。这里的n是字符串S的长度。有了这套直观解释,算法自然呼之欲出:

    1. 预处理后缀树,使得查询LCA的复杂度为O(1)。这步的开销是O(N),N是单词S的长度
    2. 对单词的每一位置i(也就是从0到N-1),获取LCA(S(i), S(N-i+1)) 以及LCA(S(i+1), S(n-i+1))。查找两次的原因是我们需要考虑奇数回文和偶数回文的情况。这步要考察每坨i,所以复杂度是O(N)
    3. 找到最大的LCA,我们也就得到了回文的中心i以及回文的半径长度,自然也就得到了最长回文。总的复杂度O(n)。

    用上图做例子,i为3时,LCA(3$, 4#)为DAM,正好是最长半径。当然,这只是直观的叙述。

     

    这篇帖子只大致描述了后缀树的基本思路。要想写出实用代码,至少还得知道下面的知识:

    • 创建后缀树的O(n)算法。至于是Peter Weiner的73年年度最佳算法,还是Edward McCreight1976的改进算法,还是1995年E. Ukkonen大幅简化的算法,还是Juha Kärkkäinen 和 Peter Sanders2003年进一步简化的线性算法,各位老大随喜。
    • 实现后缀树用的数据结构。比如常用的子结点加兄弟节点列表,Directed优化后缀树空间的办法。比如不存储子串,而存储读取子串必需的位置。以及Directed Acyclic Word Graph,常缩写为黑哥哥们挂在嘴边的DAWG。

    Suffix Tree解决其他文

    1. 查找字符串o是否在字符串S中。 
        方案:用S构造后缀树,按在trie中搜索字串的方法搜索o即可。 
        原理:若o在S中,则o必然是S的某个后缀的前缀。 
      例如S: leconte,查找o: con是否在S中,则o(con)必然是S(leconte)的后缀之一conte的前缀.有了这个前提,采用trie搜索的方法就不难理解了。。 
    2. 指定字符串T在字符串S中的重复次数。 
        方案:用S+’$'构造后缀树,搜索T节点下的叶节点数目即为重复次数 
        原理:如果T在S中重复了两次,则S应有两个后缀以T为前缀,重复次数就自然统计出来了。。 
    3. 字符串S中的最长重复子串 
        方案:原理同2,具体做法就是找到最深的非叶节点。 
        这个深是指从root所经历过的字符个数,最深非叶节点所经历的字符串起来就是最长重复子串。 
      为什么要非叶节点呢?因为既然是要重复,当然叶节点个数要>=2。 
    4. 两个字符串S1,S2的最长公共部分 
        方案:将S1#S2$作为字符串压入后缀树,找到最深的非叶节点,且该节点的叶节点既有#也有$(无#)。 

    Suffix Tree的构造方法

    Ukkonen的构造法O(n), 它比Sartaj Sahni的构造法O(nr), r为字母表大小 在时间上更有优势. 但我们不能说Sartaj Sahni的算法慢, 因为r往往会很小, 因此实际效率也接近线性, 两种构造法在思想上均有可取之处.

    问题的起源

    字符串匹配问题是程序员经常要面对的问题. 字符串匹配算法的改进可以使许多工程受益良多, 比如数据压缩和DNA排列。你可以把自己想象成一名工作于DNA排列工程的程序员. 那些基因研究者们天天忙着分切病毒的基因材料, 制造出一段一段的核苷酸序列. 他们把这些序列发到你的服务器里, 指望你在基因数据库中定位. 要知道, 你的数据库里有数百种病毒的数据, 而一个特定的病毒可以有成千上万的碱基. 你的程序必须像C/S工程那样实时向博士们反馈信息, 这需要一个很好的方案。

    很明显, 在这个问题上采取暴力算法是极其低效的. 这种方法需要你在基因数据库里对比每一个核苷酸, 测试一个较长的基因段基本会把你的C/S系统变成一台古老的批处理机。

    直觉上的解决方法

    由于基因数据库一般是不变的, 通过预处理来把搜索简化或许是个好主意. 一种预处理的方法是建立一棵Trie. 我们通过Trie引申出一种东西叫作后缀Trie. (后缀Trie离后缀树仅一步之遥.) 首先, Trie是一种n叉树, n为字母表大小, 每个节点表示从根节点到此节点所经过的所有字符组成的字符串. 而后缀Trie的 “后缀” 说明这棵Trie包含了所给字段的所有后缀 (也许正是一个病毒基因).下图是bananas的后缀Trie:

    关于这棵Trie有两个地方需要注意. 第一, 从根节点开始, BANANAS的每一个后缀都插入到Trie中, 包括BANANAS, ANANAS, NANAS, ANAS, NAS, AS, S. 第二, 鉴于这种结构, 你可以通过从根节点往下匹配的方式搜索到单词的任何一个子串.

    这里所说的第二点正是我们认为后缀Trie优秀的原因. 如果你输入一个长度为N的文本并想在其中搜索一个长度为M的串, 传统的暴力匹配需要进行N*M次字符对比, 而一些改进过的匹配技术, 比如像Boyer-Moore算法, 可以在O(N+M)的时间开销内解决问题, 平均效率更是令人满意. 然而, 后缀Trie亮出了O(M)的牌子, 彻底鄙视了其他算法的成绩, 后缀Trie对比的次数仅仅相当于被搜索串的长度!

    这确实是可圈可点的威力, 这意味着你能通过仅仅7次对比便在莎士比亚所有作品中找出BANANAS. 但有一点我们可不能忘了, 构造后缀Trie也是需要时间的.

    后缀Trie之所以没有家喻户晓正是因为构造它需要O(n2)的时间和空间. 平方级的开销使它在最需要它的领域 --- 长串搜索中被拒之门外.

    横空出世

    直到1976年, Edward McCreigh发表了一篇论文, 咱们的后缀树问世了. 后缀Trie的困境被彻底打破.

    后缀树跟后缀Trie有着一样的布局, 但它把只有一个儿子的节点给剔除了. 这个过程被称为路径压缩, 这意味着树上的某些边将表示一个序列而不是单独的字符.

    这是由上图的后缀Trie转化而来的后缀树. 你会发现这树基本还是那个形状, 只是节点变少了. 在剔除了只有一个儿子的节点之后, 总节点数由23降为11. 经过证明, 在最坏情况下, 后缀树的节点数也不会超过2N (N为文本的长度). 这使构造后缀树的线性时空开销成为可能.

    然而, McCreight最初的构造法是有些缺陷的, 原则上它要按逆序构造, 也就是说字符要从末端开始插入. 如此一来, 便不能作为在线算法, 它变得更加难以应用于实际问题, 如数据压缩.

    20年后, 来自赫尔辛基理工大学的Esko Ukkonen把原算法作了一些改动, 把它变成了从左往右. 本文接下来的所有描述和代码都是基于Esko Ukkonen的成果.

    对于所给的文本T, Esko Ukkonen的算法是由一棵空树开始, 逐步构造T的每个前缀的后缀树. 比如我们构造BANANAS的后缀树, 先由B开始, 接着是BA, 然后BAN, … . 不断更新直到构造出BANANAS的后缀树.

    初窥门径

    加入一个新的前缀需要访问树中已有的后缀. 我们从最长的一个后缀开始(上图中的BAN), 一直访问到最短的后缀(空后缀). 每个后缀会在以下三种节点的其中一种结束.

    下图是加入BOOK之后的BOOKKEEPER后缀树

    • 一个叶节点. 这个是常识了, 下图中标号为1, 2, 4, 5的就是叶节点.
    • 一个显式节点. 下图中标号为0, 3的是显式节点, 它表示该节点之后至少有两条边.
    • 一个隐式节点. 下图中, 前缀BO, BOO, 或者非前缀OO, 它们都在某条表示序列的边上结束, 这些位置就叫作隐式节点. 它表示后缀Trie中存在的由于路径压缩而剔除的节点. 在后缀树的构造过程中, 有时要把一些隐式节点转化为显式节点。

     

    在加入BOOK之后, 树中有5个后缀(包括空后缀). 那么要构造下一个前缀BOOKK的后缀树的话, 只需要访问树中已存在的每一个后缀, 然后在它们的末尾加上K.

    前4个后缀BOOK, OOK, OK和K都在叶节点上结束. 由于我们要路径压缩, 只需要在通往叶节点的边上直接加一个字符, 而不需要创建一个新节点.

    在所有叶节点更新之后, 我们还需要在空后缀后面加上K. 这时候我们发现已经存在一条从0节点出发的边的首字符为K, 没必要画蛇添足了. 换句话说, 新加入的后缀K可以在0节点和2节点之间的隐式节点中找到. 最终形态见

    树的结构没有发生变化

    如果你是一位敏感的读者, 可能要发问了, 如果加入K我们什么都不做的话, 在查找的时候如何知道它到底是一个后缀呢还是某个后缀的一截? 如果你同时又是一位熟悉字符串算法的朋友, 心里可能马上就有答案了 --- 我们只需要在文本后面加个字母表以外的字符, 比如$或者#. 那我们查找到K$或K#的话就说明这是一个后缀了.

    稍微麻烦一点的事情

    这个更新过程是相对简单的, 其中我们执行了两种更新: 一种是将某条边延长, 另一种是啥都不做. 但接下来往图5继续加入BOOKKE, 我们则会遇到另外两种更新:

    1. 创建一个新节点来割开某一隐式节点所处的边, 并在其后加一条新边.
    2. 在显式节点后加一条新边.

    当我们往树中加入BOOKKE的时候, 我们是从已存在的最长后缀BOOKK开始, 一直操作到最短的后缀空后缀. 更新最长的后缀必然是更新叶节点, 之前提到了, 非常简单. 除此之外, 此时结束在叶节点上的后缀还有OOKK, OKK, KK. 上图的第一棵树展示了这一类节点的更新.

    对于首个不是结束在叶节点上的后缀是K. 这里我们先引入一个定义:

    在每次更新后缀树的过程中, 第一个非叶节点称为激活节点. 它有以下性质:

    1. 所有比激活节点长的后缀都在叶节点上结束.
    2. 所有在激活节点之后加入的后缀都不在叶节点上结束.

    后缀K在边KKE上的隐式节点结束. 在后缀树中我们要判断一个节点是不是非叶节点需要看它是否有跟待加入字符相同的儿子, 即本例中的E.

    一眼可以看出, KKE中的第一个K只有一个儿子: K. 所以它是非叶节点(这里同时也是激活节点), 我们要给他加一个儿子来表示E. 这个过程有两个步骤:

    1. 在第一个K和第二个K之间把边分割开, 于是第一个K(隐式节点)成了一个显式节点, 如上图第二棵树.
    2. 在刚刚变身而来的显式节点后加一个新节点表示E, 如上图第三棵树. 由此我们又多了一个叶节点。

    后缀K更新之后, 别忘了还有空后缀. 空后缀在根节点(节点0)结束, 显然此时根节点是一个显式节点. 我们看一下它后面有没有以E开头的边---没有, 那么加入一个新的叶节点(如果存在以E开头的边, 则不用任何操作). 最终如图.

    归纳,反思,优化

    借助后缀树的特性, 我们可以做出一个相当有效的算法. 首先一个重要的特性是: 一朝为叶, 终生为叶. 一个叶节点自诞生以后绝不会有子孙. 更重要的是, 每当我们往树上加入一个新的前缀, 每一条通往叶节点的边都会延长一个字符(新前缀的最后一个字符). 这使得处理通往叶节点的边变得异常简单, 我们完全可以在创建叶节点的时候就把当前字符到文本末的所有字符一股脑塞进去. 是的, 我们不需要知道后面的字符是啥, 但我们知道它们最终都要被加进去. 因此, 一个叶节点诞生的时候, 也正是它可以被我们遗忘的时候. 你可能会担心通往叶节点的边被分割了怎么办, 那也不要紧, 分割之后只是起点变了, 尾部该怎么着还是怎么着.

    如此一来, 我们只需要关心显式节点和隐式节点上的更新.

    还要提到一个节约时间的方法. 当我们遍历所有后缀时, 如果某个后缀的某个儿子跟待加字符(新前缀最后一个字符)相同, 那么我们当前前缀的所有更新就可以停止了. 如果你理解了后缀树的本质, 你会知道一旦待加字符跟某个后缀的某个儿子相同, 那么更短的后缀必然也有这个儿子. 我们不妨把首个这样的节点定义为结束节点. 比结束节点长的后缀必然是叶节点, 这一点很好解释, 要么本来就是叶节点, 要么就是新创建的节点(新创建的必然是叶节点). 这意味着, 每一个前缀更新完之后, 当前的结束节点将成为下一轮更新的激活节点.

    好了, 现在我们可以把后缀树的更新限制在激活节点和结束节点之间, 效率有了很大的改善. 整理成伪代码如下:

    Update( 新前缀 )
    {
      当前后缀 = 激活节点
      待加字符 = 新前缀最后一个字符
           done = false;
      while ( !done ) {
      if ( 当前后缀在显式节点结束 ) 
      {
        if ( 当前节点后没有以待加字符开始的边 )
          在当前节点后创建一个新的叶节点
        else
          done = true;
      } else {
        if ( 当前隐式节点的下一个字符不是待加字符 ) 
        {
          从隐式节点后分割此边
           在分割处创建一个新的叶节点
        } else
          done = true;
        if ( 当前后缀是空后缀 )
          done = true;
        else
          当前后缀 = 下一个更短的后缀
         }
      激活节点 = 当前后缀

    }


    后缀指针

    上面的伪代码看上去很完美, 但它掩盖了一个问题. 注意到第21行, “下一个更短的后缀”, 如果呆板地沿着树枝去搜索我们想要的后缀, 那这种算法就不是线性的了. 要解决此问题, 我们得附加一种指针: 后缀指针. 后缀指针存在于每个结束在非叶节点的后缀上, 它指向“下一个更短的后缀”. 即, 如果一个后缀表示文本的第0到第N个字符, 那么它的后缀指针指向的节点表示文本的第1到第N个字符.下图是文本ABABABC的后缀树. 第一个后缀指针在表示ABAB的节点上. ABAB的后缀指针指向表示BAB的节点. 同样地, BAB也有它的后缀指针, 指向AB. 如此这般.

    介绍一下如何创建后缀指针. 后缀指针的创建是跟后缀树的更新同步的. 随着我们从激活节点移动到结束节点, 我把每个新的叶节点的父亲的路径保存下来. 每当创建一条新边, 我同时也在上一个叶节点的父亲那儿创建一个后缀指针来指向当前新边开始的节点. (显然, 我们不能在第一条新边上做这样的操作, 但除此之外都可以这么做.)

    有了后缀指针, 就可以方便地一个后缀跳到另一个后缀. 这个关键性的附加品使得算法的时间上限成功降为O(N)。

    后缀树与后缀数组

    假设有一个长度为 n 的字符串 T[0 ... n);S(i) 表示 T 的从下标 i 开始的后缀,即 T[i ... n)。那么 T 的后缀数组就是把 S(i) ~ S(n - 1) 这 n 个后缀按字典序排好序的一个数组。它对于查找 T 的子串之类的问题是很有用的。问题就在于怎样快速地把这些后缀排好序。

    最简单的方法就是把所有 S(i) 快速排序。快速排序本身的时间是 O(n log n),但是由于排序的对象是一个个字符串,所以每次比较的时间在最差情况下都会变成线性的(也就是 O(n) 的),因此总的时间在最差情况下可能会升到 O(n2) 左右,这就很慢了。对此,我学到了三个更快的算法。

    1. Ukkonen 算法

        Ukkonen 算法先用 O(n) 的时间构造一棵后缀树,然后再用 O(n) 的时间从后缀树得到后缀数组。在http://www.cs.helsinki.fi/u/ukkonen/,介绍了作者 Esko Ukkonen,并列出了他的一些论文;其中的一篇《On-line construction of suffix-trees》是可以下载的,里面就讲解了什么是后缀树,怎样在 O(n) 的时间内构造它,以及怎样从它得到后缀数组。

        不过我一开始还没发现这篇论文,我是从 Dan Gusfield 的《Algorithms on Strings, Trees and Sequences - COMPUTER SCIENCE AND COMPUTATIONAL BIOLOGY》这本书里学到这个算法的。这本书在中国没的卖,想买的话,可以找代购网站去 Amazon 买。我是在 eMule 上搜到并下载的。这本书中的这节内容讲得还可以,虽然我觉得它示例比较少,但是花了点功夫还是看懂了。学会了之后,原作者的论文我就没有仔细看过了,所以没法评论。

       Ukkonen 算法还是比较复杂的,代码比较长;而且后缀树这个结构本身也比较费空间。总而言之,虽然该算法在理论上是最快的,后缀树也是一个很优美的结构,但是在许多实际应用中不是很实惠。

    2. DC3 算法

       我在 Google 上搜到了这篇论文,《Linear Work Suffix Array Construction》,其中介绍了一个可以在 O(3n) 的时间内构造出后缀数组的算法,叫作 DC3 (Difference Cover mod 3) 算法。

        该算法的基本原理大致是这样的。针对所有后缀的前 3 个字符,做基数排序;对于不满 3 个字符的后缀,排序时在后面补 0(这里的 0 是结束符,在 T 中不能出现;0 的字典序最优先);排序时还要包括进从结束符(即 T[n])开始的后缀 S(n): “000”。如果所有后缀的前 3 个字符都不完全相同,那么这一次就排好了,最后去掉多余的 “000” 后缀(它一定排在第一个),就得到答案了,时间是 O(3n)。如果存在前 3 个字符相同的,则需要生成一个名次数组 R, R(i) 表示 S(i) 在排好序后位于第几名(名次从 1 开始计),接着再用上述方法递归地求 R[0 ... n] 的后缀数组,其结果和 T 的后缀数组是完全对应的,也就是说 SR(i) 排在第几位,则 S(i) 也应该排在第几位。但问题是如果这样递归层数多了,时间也就大大增加了。

        接下来,在上述算法的基础上,需要一个优化。首先,只对满足 i mod 3 = 1 或 i mod 3 = 2 的那些 S(i) 按照前 3 个字符进行基数排序;如果这其中有前 3 个字符相同的,同样也需要递归地求它们的名次数组的后缀数组。排好了 i mod 3 = 1、2 的后缀之后,就可以得到一个总的名次数组 R,其中那些 i mod 3 = 0 的后缀的名次还是未知的。接着对于所有 i mod 3 = 0 的 S(i),靠 T[i] 和 R(i + 1) 这两个关键字就可以对它们排序了。最后把排好序的 mod 3 = 1、2 和 mod 3 = 0 的后缀归并起来就是答案了。归并的时候,比较两个后缀 S(i) 和 S(j) 的方法也是看它们的前 3 个字符,如果都相同,那么比较 R(i + 1) 和 R(j + 1),若不可比(其中有一个是未知的)则再比较 R(i + 2) 和 R(j + 2)。

        有了以上的优化,即使当中出现了需要递归的情况,每次递归求解的字符串长度也只有原来的 2 / 3,那么即使递归的层数再多,总的时间之和也是会收敛的。

        以上我只是潦草地介绍一下,具体的还是自己看论文吧。论文写得还是蛮清楚的。尤其是最后有一个用 C++ 实现的代码,其中有很多细节实现地很巧妙,很值得学习。

    3. 倍增算法

        我是从 IOI 2004 国家集训队论文集中的一篇名为《后缀数组》(许智磊)的文章中学到这个算法的。该文章在 Google 上搜得到,讲得还是蛮清楚的。我在此就不多介绍了,请自己看文章。

        倍增算法最大的优点是实现简单,速度也还可以,O(n log n)。如果程序的时间要求不是很紧的话,应该作为首选的算法。

    4. 多个字符串的后缀数组

        在很多问题中,都需要求多个字符串的后缀数组,也就是把多个字符串的所有后缀都放在一起排序。这个结构对于查找公共子串之类的问题是很有用的。后缀树是可以表示多个字符串的,但是 DC3 算法和倍增算法都只能求单个字符串的后缀数组。

        其实多个字符串的后缀数组可以转化成单个字符串的后缀数组。比如要求 “abc” 和 “def” 这两个字符串的后缀数组,可以转化成求 “abc1def” 的后缀数组。其中 1 是字典顺仅次于结束符 0 的字符,它也不出现在任何字符串中。这样求出来的后缀数组和 “abc” 与 “def” 的后缀数组是等价的;只是多了一个以 1 开头的后缀,但它一定排序在最前面,很容易去掉。在倍增算法中,用 0 替代 1 好像也可以;在 DC3 算法中好像不能用 0 替代 1,但是我忘记怎么重现那个错误了,所以现在也不好说。但是用 1 肯定是没错的,这样符合 “结束符在字符串中不出现” 的原则。

  • 原创粉丝点击