经过大量阅读后,我发现了后缀数组和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
我不能,只是无法了解这个算法是如何工作的。我尝试使用铅笔和纸张编写一个例子,并通过所涉及的步骤进行了写作,但至少对我来说,它之间的联系却很复杂。
任何有关解释的帮助,可能都是一个例子,我们非常感谢。
答案 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
都有两个步骤:
按2 i -grams排序,使用上一次迭代中的词典名称,以便在每个步骤(即O(1)时间)进行比较
创建新的词典名称
我们重复这个,直到所有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_i
或pos_j
在比较所有(2 *间隙)字符之前将达到N
,即使到那时为止的所有字符都相同。如果第i个后缀在最后,它将返回1,如果第j个后缀在结尾,则返回0。这是正确的,因为如果所有字符都相同,则较短字符在字典上较小。如果pos_i
已到达结尾,则第i个后缀必须短于第j个后缀。
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}}来定义该索引的值。所以tmp
,pos[0]:=0
,pos[4]:=0
,pos[1]:=1
,pos[5]:=1
等等。索引来自pos[6]:=2
,sa
的值。)
第二次迭代:
我们再次对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]
为02
而pos[4]
的二元组为01
,后者显然在字典上较小。深层原因是这两个分别代表abcx
和abcd
。
生成字典名称产生:
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
是字符串时,ab
,bc
,cd
,de
是S
的2克。同样,abcd
和bcde
为4克。通常,m-gram(对于正整数m)是m
个连续字符的序列。 1克也称为unigrams,2克称为bigrams,3克称为三卦。有些人继续使用四卦,五角星等。
请注意,S
的后缀i
的开头和位置S
是S
的(n-i)个图。此外,每个m-gram(对于任何m)都是{{1}}的后缀之一的前缀。因此,排序m-gram(尽可能大的m)可能是排序后缀的第一步。