后缀数组算法

时间:2013-07-20 11:21:40

标签: c++ algorithm data-structures suffix-array

经过大量阅读后,我发现了后缀数组和LCP数组所代表的含义。

后缀数组:表示数组每个后缀的_lexicographic等级。

LCP数组:包含两个连续后缀之间的最大长度前缀匹配,在按字典顺序排序后

我几天来一直在努力理解,后缀数组和LCP算法究竟是如何工作的。

以下是代码,取自Codeforces

/*
Suffix array O(n lg^2 n)
LCP table O(n)
*/
#include <cstdio>
#include <algorithm>
#include <cstring>

using namespace std;

#define REP(i, n) for (int i = 0; i < (int)(n); ++i)

namespace SuffixArray
{
    const int MAXN = 1 << 21;
    char * S;
    int N, gap;
    int sa[MAXN], pos[MAXN], tmp[MAXN], lcp[MAXN];

    bool sufCmp(int i, int j)
    {
        if (pos[i] != pos[j])
            return pos[i] < pos[j];
        i += gap;
        j += gap;
        return (i < N && j < N) ? pos[i] < pos[j] : i > j;
    }

    void buildSA()
    {
        N = strlen(S);
        REP(i, N) sa[i] = i, pos[i] = S[i];
        for (gap = 1;; gap *= 2)
        {
            sort(sa, sa + N, sufCmp);
            REP(i, N - 1) tmp[i + 1] = tmp[i] + sufCmp(sa[i], sa[i + 1]);
            REP(i, N) pos[sa[i]] = tmp[i];
            if (tmp[N - 1] == N - 1) break;
        }
    }

    void buildLCP()
    {
        for (int i = 0, k = 0; i < N; ++i) if (pos[i] != N - 1)
        {
            for (int j = sa[pos[i] + 1]; S[i + k] == S[j + k];)
            ++k;
            lcp[pos[i]] = k;
            if (k)--k;
        }
    }
} // end namespace SuffixArray

我不能,只是无法了解这个算法是如何工作的。我尝试使用铅笔和纸张编写一个例子,并通过所涉及的步骤进行了写作,但至少对我来说,它之间的联系却很复杂。

任何有关解释的帮助,可能都是一个例子,我们非常感谢。

1 个答案:

答案 0 :(得分:106)

概述

这是用于后缀数组构造的O(n log n)算法(或者更确切地说,如果代替::sort使用了2遍存储桶排序)。

首先将2克(*),然后是原始字符串S的4克,然后是8克,等等进行排序,在第i次迭代中,我们对2 i -grams进行排序。显然只有log 2 (n)这样的迭代,并且诀窍是通过制作在第i步中对2 i -grams进行排序是有利的。确保两个2 i -grams的每次比较都在O(1)时间内完成(而不是O(2 i )时间。)

它是如何做到的?好吧,在第一次迭代中它对2-gram(又名bigrams)进行排序,然后执行所谓的词典重命名。这意味着它创建了一个新的数组(长度为n),为每个二元组存储其在bigram排序中的 rank

字典重命名的示例:假设我们有一些已排序的某些重力字符{'ab','ab','ca','cd','cd','ea'}列表。然后我们通过从左到右分配 rank (即词典名称),从等级0开始并在我们遇到 new bigram更改时递增排名。所以我们分配的等级如下:

ab : 0
ab : 0   [no change to previous]
ca : 1   [increment because different from previous]
cd : 2   [increment because different from previous]
cd : 2   [no change to previous]
ea : 3   [increment because different from previous]

这些等级称为词典名称

现在,在下一次迭代中,我们排序4克。这涉及不同4克之间的大量比较。我们如何比较两个4克?好吧,我们可以逐个字符地比较它们。每次比较最多可以进行4次操作。但相反,我们使用前面步骤中生成的排名表,通过查找包含在其中的两个bigrams的行列来比较它们。该等级代表前一个2克分类的词典排名,因此如果对于任何给定的4克,其第一个2克的排名高于另一个4克的前2克,那么它必须在字典上更大前两个字符中的某个地方。因此,如果两个4克的前2克的等级相同,则它们必须在前两个字符中相同。换句话说,排名表中的两个查找足以比较两个4克的所有4个字符。

排序后,我们再次创建新的词典名称,这次是4克。

在第三次迭代中,我们需要按8克排序。同样,上一步的词典排名表中的两个查找足以比较两个给定8克的所有8个字符。

等等。每次迭代i都有两个步骤:

  1. 按2 i -grams排序,使用上一次迭代中的词典名称,以便在每个步骤(即O(1)时间)进行比较

  2. 创建新的词典名称

  3. 我们重复这个,直到所有2 i -grams不同。如果发生这种情况,我们就完成了。我们怎么知道所有的都不一样?好吧,字典名称是一个递增的整数序列,从0开始。因此,如果迭代中生成的最高词典名称与n-1相同,那么每个2 i -gram必须已被赋予了自己独特的词典名称。


    实施

    现在让我们看看代码以确认所有这些。使用的变量如下:sa[]是我们正在构建的后缀数组。 pos[]是排名查找表(即它包含字典名称),具体而言,pos[k]包含上一步的k - m-gram的词典名称。 tmp[]是一个辅助数组,用于帮助创建pos[]

    我将在代码行之间进一步解释:

    void buildSA()
    {
        N = strlen(S);
    
        /* This is a loop that initializes sa[] and pos[].
           For sa[] we assume the order the suffixes have
           in the given string. For pos[] we set the lexicographic
           rank of each 1-gram using the characters themselves.
           That makes sense, right? */
        REP(i, N) sa[i] = i, pos[i] = S[i];
    
        /* Gap is the length of the m-gram in each step, divided by 2.
           We start with 2-grams, so gap is 1 initially. It then increases
           to 2, 4, 8 and so on. */
        for (gap = 1;; gap *= 2)
        {
            /* We sort by (gap*2)-grams: */
            sort(sa, sa + N, sufCmp);
    
            /* We compute the lexicographic rank of each m-gram
               that we have sorted above. Notice how the rank is computed
               by comparing each n-gram at position i with its
               neighbor at i+1. If they are identical, the comparison
               yields 0, so the rank does not increase. Otherwise the
               comparison yields 1, so the rank increases by 1. */
            REP(i, N - 1) tmp[i + 1] = tmp[i] + sufCmp(sa[i], sa[i + 1]);
    
            /* tmp contains the rank by position. Now we map this
               into pos, so that in the next step we can look it
               up per m-gram, rather than by position. */
            REP(i, N) pos[sa[i]] = tmp[i];
    
            /* If the largest lexicographic name generated is
               n-1, we are finished, because this means all
               m-grams must have been different. */
            if (tmp[N - 1] == N - 1) break;
        }
    }
    

    关于比较功能

    函数sufCmp用于按字典顺序比较两个(2 * gap)-grams。所以在第一次迭代中它比较了双字母,在第二次迭代中4克,然后是8克,依此类推。这由gap控制,这是一个全局变量。

    sufCmp的天真实现是这样的:

    bool sufCmp(int i, int j)
    {
      int pos_i = sa[i];
      int pos_j = sa[j];
    
      int end_i = pos_i + 2*gap;
      int end_j = pos_j + 2*gap;
      if (end_i > N)
        end_i = N;
      if (end_j > N)
        end_j = N;
    
      while (i < end_i && j < end_j)
      {
        if (S[pos_i] != S[pos_j])
          return S[pos_i] < S[pos_j];
        pos_i += 1;
        pos_j += 1;
      }
      return (pos_i < N && pos_j < N) ? S[pos_i] < S[pos_j] : pos_i > pos_j;
    }
    

    这将比较第i个后缀pos_i:=sa[i]开头的(2 * gap)-gram与第j个后缀pos_j:=sa[j]开头的结果。它会逐个字符地比较它们,即将S[pos_i]S[pos_j]进行比较,然后将S[pos_i+1]S[pos_j+1]进行比较,依此类推。只要字符相同,它就会继续。一旦它们不同,如​​果第i个后缀中的字符小于第j个后缀中的字符,则返回1,否则返回0。 (注意,返回return a<b的函数中的int表示如果条件为真则返回1,如果为假则返回0。)

    return语句中复杂的外观条件处理其中一个(2 * gap)-grams位于字符串末尾的情况。在这种情况下,pos_ipos_j在比较所有(2 *间隙)字符之前将达到N,即使到那时为止的所有字符都相同。如果第i个后缀在最后,它将返回1,如果第j个后缀在结尾,则返回0。这是正确的,因为如果所有字符都相同,则较短字符在字典上较小。如果pos_i已到达结尾,则第i个后缀必须短于第j个后缀。

    显然,这种天真的实现是O(间隙),即其复杂度在(2 *间隙) - 格的长度上是线性的。但是,代码中使用的函数使用词典名称将其降低到O(1)(特别是最多两次比较):

    bool sufCmp(int i, int j)
    {
      if (pos[i] != pos[j])
        return pos[i] < pos[j];
      i += gap;
      j += gap;
      return (i < N && j < N) ? pos[i] < pos[j] : i > j;
    }
    

    如您所见,我们检查第i个和第j个后缀的词典排名,而不是查找单个字符S[i]S[j]。在前一次迭代中,对于gap-gram计算了词典排名。因此,如果pos[i] < pos[j],那么第i个后缀sa[i]必须以一个空格键开头,这个空格键在字典上小于sa[j]开头的gap-gram。换句话说,只需查看pos[i]pos[j]并进行比较,我们就比较了两个后缀的第一个 gap 字符。

    如果排名相同,我们会继续将pos[i+gap]pos[j+gap]进行比较。这与比较(2 *间隙) - 格式的下一个间隙字符相同,即后半部分。如果排名再次变得狭窄,则两个(2 * gap)-grams是同义的,所以我们返回0.否则,如果第i个后缀小于第j个后缀,则返回1,否则返回0。


    实施例

    以下示例说明了算法的运行方式,并特别演示了词典名称在排序算法中的作用。

    我们要排序的字符串是abcxabcd。为此需要三次迭代来生成后缀数组。在每次迭代中,我将显示S(字符串),sa(后缀数组的当前状态)和tmp以及pos,它们代表词典名称。

    首先,我们初始化:

    S   abcxabcd
    sa  01234567
    pos abcxabcd
    

    请注意词典名称(最初代表unigrams的词典排名)与字符(即unigrams)本身完全相同。

    第一次迭代:

    使用bigrams作为排序标准对sa进行排序:

    sa  04156273
    

    前两个后缀是0和4,因为那些是bigram&#39; ab&#39;的位置。然后是1和5(bigram&#39; bc&#39;的位置),然后是6(bigram&#39; cd&#39;),然后是2(bigram&#39; cx&#39;)。然后7(不完整的二元组&#39; d&#39;),然后是3(bigram&#39; xa&#39;)。显然,这些位置与订单相对应,仅基于角色双字母。

    生成词典名称:

    tmp 00112345
    

    如上所述,词典名称被指定为递增整数。前两个后缀(都以bigram和#​​39; ab开头)获得0,接下来的两个(从bigram开始&#39; bc&#39;)获得1,然后是2,3,4,5(每一个都是一个不同的二元组。)

    最后,我们会根据sa中的位置对其进行映射,以获得pos

    sa  04156273
    tmp 00112345
    pos 01350124
    

    (生成pos的方式是:从左到右遍历sa,并使用该条目在pos中定义索引。使用{{中的相应条目1}}来定义该索引的值。所以tmppos[0]:=0pos[4]:=0pos[1]:=1pos[5]:=1等等。索引来自pos[6]:=2sa的值。)

    第二次迭代:

    我们再次对tmp进行排序,然后再次查看来自sa的bigrams(每个都代表原始字符串的两个双字母序列)。

    pos

    注意与之前版本的sa 04516273 相比,1 5的位置是如何切换的。它曾经是15,现在是51.这是因为sa处的二元组和pos[1]处的二元组在上一次迭代中曾经是相同的(pos[5]},但是现在bc的二元组是pos[5],而12的二元组是pos[1]。因此,位置13在位置5之前来自。这是因为词典名称现在每个都代表原始字符串的bigrams:1代表pos[5]bc代表&#39; cd&#39;。所以,它们一起代表pos[6],而bcd代表pos[1]bc代表pos[2],所以它们一起代表cx,这实际上是字典上的大于bcx

    同样,我们通过从左到右筛选当前版本的bcd并比较sa中相应的双字母组来生成词典名称:

    pos

    前两个条目仍然相同(均为0),因为tmp 00123456 中的相应双字母都是pos。其余的是一个严格增加的整数序列,因为01中的所有其他双字母都是唯一的。

    我们像以前一样执行到新pos的映射(从pos获取索引,从sa获取值):

    tmp

    第三次迭代:

    我们再次对sa 04516273 tmp 00123456 pos 02460135 进行排序,使用sa的bigrams(一如既往),现在每个都代表原始字符串的4个字符串的序列。

    pos

    您现在注意到前两个条目已切换位置:sa 40516273 已成为04。这是因为40的二元组pos[0]02pos[4]的二元组为01,后者显然在字典上较小。深层原因是这两个分别代表abcxabcd

    生成字典名称产生:

    tmp 01234567
    

    它们都是不同的,即最高的是7,即n-1。所以,我们已经完成了,因为排序现在基于m-gram,它们都是不同的。即使我们继续,排序顺序也不会改变。


    改进建议

    用于在每次迭代中对2 i -grams进行排序的算法似乎是内置的sort(或std::sort)。这意味着它是一种比较排序,在最坏的情况下需要O(n log n)时间,在每次迭代中需要 。由于在最坏的情况下存在log n次迭代,因此这使得它成为O(n(log n) 2 ) - 时间算法。然而,排序可以通过使用两次桶排序来执行,因为我们用于排序比较的键(即前一步骤的词典名称)形成递增的整数序列。因此,这可以改进为实际的O(n log n)时间算法,用于后缀排序。


    备注

    我相信这是后缀数组构造的原始算法,这是由Manber和Myers(link on Google Scholar在1992年的论文中提出的;它应该是第一个命中,它可能有一个链接到那里的PDF )。这(同时,与Gonnet和Baeza-Yates的论文无关)是引入后缀数组(当时也称为pat数组)作为有待进一步研究的数据结构。

    后缀数组构造的现代算法是O(n),因此上述不再是可用的最佳算法(至少不是理论上的,最坏情况下的复杂性)。


    脚注

    (*) 2-gram 我的意思是原始字符串的两个连续字符的序列。例如,当S=abcde是字符串时,abbccddeS的2克。同样,abcdbcde为4克。通常,m-gram(对于正整数m)是m个连续字符的序列。 1克也称为unigrams,2克称为bigrams,3克称为三卦。有些人继续使用四卦,五角星等。

    请注意,S的后缀i的开头和位置SS的(n-i)个图。此外,每个m-gram(对于任何m)都是{{1}}的后缀之一的前缀。因此,排序m-gram(尽可能大的m)可能是排序后缀的第一步。