在研究了一段时间后,我确实找到了后缀树和后缀数组的替代方法。实现本身很简单,如您所愿,但即使代码(大致)简洁,但直觉却极低。
有一篇论文,The Suffix Binary Search Tree and Suffix AVL Tree,由格拉斯哥大学的 Robert W. Irving 和 Lorna Love 撰写,提出了后缀树和后缀数组的替代解决方案.作者声称,与后缀树相比,管理和构建后缀二叉搜索树要简单得多,并表明在后缀 AVL 树的情况下,可以保证构建时间不超过O(n log(n))(对于在平均情况下是标准的、非平衡的原始 BST,但在最坏的情况下会降级到O(n^2))。
当然,这比传统的后缀树更糟糕,因为在 SBST 中找到最长重复子串的时间受构建时间的限制。因此,严格来说,这并不能回答您的问题,但我决定将其发布以供将来参考和感兴趣的读者参考。
此外,论文指出,生成的树是传统后缀树和后缀数组的强大候选替代品。这篇论文是围绕在构建树后在对数时间内找到模式的问题而写的。您可以从CiteSeerX 下载它。
这个想法是每个节点都与一个整数i相关联,其中i是该节点所代表的后缀的目标文本中第一个字符的偏移量。对于长度为n的给定字符串[a1,a2,a3,...,an],会有n节点,节点i代表后缀[ai,ai+1,...,an]。
论文没有解决最长重复子串的确定问题,但是提出的构建二叉搜索树的方法可以很容易地解决最长重复子串问题。一旦我谈到构建过程,我将完成该扩展。了解如何构建树以了解解决最长重复字符串问题的扩展至关重要(否则代码将看起来像魔术)。
因此,我将首先解释树是如何构建的,并将根据我从论文中得到的想法来做。本文讨论了形式证明以及其他细节(包括如何将其转换为 AVL 树)。您可以随意跳到我讨论如何扩展算法的部分,但请记住,如果您这样做可能很难理解。
该论文描述了一种用于在后缀二叉搜索树中搜索模式的优化算法,该算法避免在我们访问的每个节点上从头开始将模式与来自节点的后缀进行比较。这样做在最坏的情况下需要l*m 字符比较,其中m 是模式的大小,l 是搜索路径。我们不想在每个节点上从头开始比较字符来决定分支的方向;相反,最好将模式中的每个字符最多比较一次。如果我们为每个节点存储两个附加属性,这是可能的。构建树的过程与此优化(以及我提出的扩展)紧密相关,因此理解它非常重要。
首先,一些术语:对于给定节点i,如果i 在j 的左子树中,则称节点j 是i 的右祖先。左祖先的定义类似。 i 的最近的右祖先 是节点j,因此j 的后代没有i 的右祖先;同样,类似的定义适用于最近的左祖先。从现在开始,我将使用缩写形式cra(i) 来指代i 的最近右祖先,而i 的最近左祖先缩写为cla(i)。我们还定义了j和i两个节点之间的最长公共前缀,我们将其表示为lcp(i, j)。
对于给定的节点i,我们将存储两个属性,m(i) 和d(i)。 m(i) 表示lcp(i, j) 的最高值,其中j 是i 的祖先集。请注意,由于二叉树的属性,节点j 是cla(i) 或cra(i)。 d(i) 是一个属性,用于跟踪 m(i) 的来源;如果j == cla(i),则d(i) 是RIGHT(意味着i 在节点j 的右子树中,其中lcp(i, j) 被最大化),否则d(i) 是@987654369 @。
以下是一组定理,它们共同构建了在 SBST 中执行给定模式搜索的基本算法。这些定理描述了当搜索模式到达节点i 时要做什么。正式证明请参考论文,我将尝试提供一个直观的证明来说明为什么规则是这样的。这些定理一起形成了一组规则,允许算法通过比较模式中的每个字符最多一次来搜索模式,这非常简洁!
当搜索到达节点i 时,我们使用两个值,llcp 和rlcp。 llcp 是 lcp(pattern, j) 的最高值,其中 j 是 i 的所有正确祖先的集合。 rlcp 是一样的,但是最大值会接管i 的所有左祖先。同样,由于二叉树的属性,llcp 只是 lcp(pattern, cra(i)),rlcp 是 lcp(pattern, cla(i))。
在深入研究定理之前,我认为最好在一张纸上绘制一个样本 SBST,并在树上可视化每个定理的语义含义。
定理 1 是最简单的,涵盖了m(i) > max(llcp, rlcp) 的情况。如果发生这种情况,llcp 和 rlcp 不会改变,因为我们将能够与 i 匹配与其祖先一样多,并且搜索分支的方向与 d(i) 相同。要了解原因,请考虑d(i) == LEFT 的情况。如果d(i) == LEFT,则表示m(i) 来自与cra(i) 的匹配。如果我们访问i,那是因为我们已经知道该模式低于cra(i),而lcp(cra(i), pattern) < lcp(i, cra(i)),这使得该模式低于i,所以我们向左移动。 d(i) == RIGHT的案例也可以采用同样的流程,所以,确实,我们只需要按照d(i)的方向进行即可。
定理 2 处理mi < max(llcp, rlcp) 的情况。这个比较难理解。让我们看看max(llcp, rlcp) == llcp 和max(llcp, rlcp) == rlcp 会发生什么。
案例 1:max(llcp, rlcp) == llcp
如果max(llcp, rlcp) == llcp,则意味着该模式与节点 i 的最近右祖先的共同点多于其最近的左祖先。此外,由于llcp > m(i),该模式与cra(i) 的共同点比节点i 与cra(i) 的共同点要多。这与i 低于cra(i)(根据BST 的定义)这一事实一起,意味着该模式大于i。因此,我们向右分支。
llcp 和 rlcp 的更新怎么样?因为我们向右分支,cra(i) 将在下一次迭代中保持不变,所以llcp 保持不变。更新rlcp 有点棘手。当我们向右分支时,新的cla 将是节点 i。接下来会发生什么取决于m(i) 是来自cra(i) 还是cla(i)。我们可以用d(i)知道:如果m(i)来自cra(i),那么d(i) == LEFT,否则,d(i) == RIGHT。
案例 1.1:max(llcp, rlcp) == llcp && d(i) == RIGHT
在这种情况下,我们知道m(i) 来自cla(i),这意味着节点i 与cla(i) 的共同点比与cra(i) 的共同点要多(记住,该模式与cra(i))。正如我们之前看到的,我们将向右分支,这使得模式大于 i。这意味着cla < i < pattern,因此pattern 和i 之间的最长公共前缀与pattern 和cla 之间的公共前缀相同;或者换句话说,lcp(i, cla) > lcp(i, pattern)(否则,pattern 必须小于i),所以rlcp,即lcp(i, pattern),保持不变。
案例 1.2:max(llcp, rlcp) == llcp && d(i) == LEFT
现在,我们知道m(i) 来自cra(i),但该模式与cra(i) 的共同点要多于i 与cra(i) 的共同点。这意味着节点i 充当“瓶颈”,使rlcp 更小-rlcp 只能与i 和cra(i) 之间的公共前缀一样大,即等于m(i)。因此,在这种情况下,rlcp 与 m(i) 相同。
可以对相反的情况进行类似的分析,其中max(llcp, rlcp) == rlcp(然后考虑子情况d(i) == RIGHT和d(i) == LEFT)。要执行的操作是前面情况的逆版本:我们向左分支,rlcp 保持不变,如果d(i) == RIGHT,llcp 变为m(i),否则保持不变。
简而言之:
定理 2 结果
d(i) == RIGHT d(i) == LEFT
max(llcp, rlcp) == llcp | branch right | branch right; rlcp = m(i)
max(llcp, rlcp) == rlcp | branch left; llcp = m(i) | branch left
定理 3 探讨了m(i) 等于模式与另一个祖先的最长公共前缀的两种情况。特别是,如果m(i) == llcp && llcp > rlcp && d(i) == RIGHT,我们知道i 与cla 的匹配程度与与cra 的模式匹配程度相同。由于d(i) == RIGHT 和m(i) == llcp,因此lcp(i, cra) < llcp 和i < cra,意味着模式大于i - 我们向右分支。类似的论点适用于相反的情况;如果m(i) == rlcp && rlcp > llcp && d(i) == LEFT,我们向左分支。在任何一种情况下,llcp 和 rlcp 都将保持不变:在前者中,llcp 永远不会改变,因为cra 仍然相同,rlcp 不会改变,因为d(i) == RIGHT && m(i) == llcp,即,与cla或i匹配模式是一样的;后一种情况也是如此。
定理 4 是我们必须执行实际字符比较的地方。每当我们无法推断出模式与当前节点之间的相对顺序时,都会发生这种情况,即m(i) == rlcp == llcp,或m(i) == llcp && llcp > rlcp && d(i) == LEFT,或相反的m(i) == rlcp && rlcp > llcp && d(i) == RIGHT。直观地说,我们知道无法推断出任何关于顺序的内容,并且字符比较从不属于最长公共前缀的第一个字符开始执行,并在第一个不同的字符处停止(此时我们可以推断命令)。同样,如果我们向右分支,那么cra 保持不变,所以llcp 不会改变,rlcp 现在将保存模式和节点i 之间新计算的最长公共前缀的值。相反的情况也会发生类似的过程:我们向左分支,rlcp 保持不变,llcp 成为先前计算的最长公共前缀的值。这个定理在直觉上是正确的,我不再赘述。
定理 3 & 4 结果
d(i) == RIGHT d(i) == LEFT
m(i) == llcp && llcp > rlcp | branch right | *
m(i) == rlcp && rlcp > llcp | * | branch left
* = compare(); if branch == left then rlcp = computed_lcp else llcp = computed_lcp
这是将所有这些结合在一起的伪代码,摘自第 9 页:
我对算法的修改
可能需要花一点时间来理解这个算法的神奇之处,但是一旦你掌握了所有细节,就可以看出找到最长重复子串的问题相当于找到节点i其中m(i) 是最大的。毕竟,最长的重复子串是任何两个节点之间可以找到的最长的公共前缀。如果我们在构建树时跟踪它,就可以在没有任何显着开销的情况下找到它:必须保留迄今为止看到的最大 m(i),并且每当插入新节点 j 时,我们将 m(j) 与最大值进行比较到目前为止,如果m(j) 更大,则更新它。事实上,这种方法只不过是一种奇特的方式来实现一种算法,相当于对所有后缀进行排序,并找到任意两个连续后缀之间的最长公共前缀,优点是不进行不必要的字符比较。这是一个相当不错的改进。
上面显示的伪代码几乎足以构建标准的 SBST。我们首先添加一个根节点,i == 1,代表整个文本。然后从左到右添加后缀。要插入一个新的后缀i,我们搜索一个后缀为i 的模式。这样做将使算法在插入的确切位置停止。但是,该论文没有详细介绍插入过程。对于定理 4,我们必须小心 return i; 在最后的 else 中。我们只能在搜索时返回。如果我们正在执行插入并到达一个节点,将我们带到定理 4 的情况,那么这意味着新后缀中的所有字符都与某个先前插入的后缀匹配。因为后缀是从左到右插入的,我们也知道新后缀的字符比另一个少,这意味着新后缀比另一个低:正确的移动是向左分支。由于我们向左分支,最近的左祖先保持不变,所以我们只需要更新llcp。 llcp 成为后缀本身的大小,因为正如我们所见,它都与现在是最近的右祖先的节点匹配。
显然,新节点的值m(i) 等于max(llcp, rlcp),根据定义,如果max(llcp, rlcp) == rlcp,则d(i) 为RIGHT,否则为LEFT。
我在 C 中的实现归结为伪代码以及插入逻辑。有两种数据结构:struct sbst 表示一个后缀二叉搜索树以及迄今为止看到的最大m(i);和struct node,它是树节点的描述符。
这是完整的程序列表:
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#define LEFT 0
#define RIGHT 1
#define MODE_INSERT 1
#define MODE_FIND 2
#define MAX_TEXT_SIZE 1024
#define max(a,b) ((a)>(b)?(a):(b))
struct node {
int m;
int d;
int i;
struct node *left;
struct node *right;
};
struct sbst {
struct node *root;
int max_mi; /* The maximum lcp in the tree */
int max_i; /* The node i with m(i) == max_mi */
};
struct node *allocate_node(int m, int d, int i) {
struct node *new_node = malloc(sizeof(*new_node));
new_node->m = m;
new_node->d = d;
new_node->i = i;
new_node->left = new_node->right = NULL;
return new_node;
}
int lcp(char *str1, char *str2);
/* The core where all the work takes place.
It is assumed that when this function is called, tree->root always points to a valid, allocated root. That is, it is assumed
that the tree contains at least one node.
This function provides both a find and an insert algorithm. To find a pattern, <mode> must be MODE_FIND. To insert,
<mode> must be MODE_INSERT. In the latter case, the parameter <text_i> corresponds to the index in the original string
of the suffix being inserted (index starts counting at 1, as described in the paper). Also, in MODE_INSERT, the pattern is
the suffix being inserted.
If in MODE_FIND, this function returns the index (starting at 1) in the text where <pattern> can be found, or 0 if no such
pattern could be found.
*/
int find_insert_aux(struct sbst *tree, char *pattern, size_t pattern_len, char *text, size_t text_len, char mode, int text_i) {
struct node *current, *prev;
int llcp, rlcp;
int next_dir;
current = tree->root;
llcp = rlcp = 0;
while (current != NULL) {
int max_pattern = max(llcp, rlcp);
if (current->m > max_pattern) {
next_dir = current->d;
} else if (current->m < max_pattern) {
if (llcp > rlcp) {
next_dir = RIGHT;
if (current->d == LEFT) {
rlcp = current->m;
}
} else if (rlcp > llcp) {
next_dir = LEFT;
if (current->d == RIGHT) {
llcp = current->m;
}
}
} else if (current->m == llcp && llcp > rlcp && current->d == RIGHT) {
next_dir = RIGHT;
} else if (current->m == rlcp && rlcp > llcp && current->d == LEFT) {
next_dir = LEFT;
} else {
int sub_lcp = lcp(pattern+current->m, text+current->m+current->i-1);
int t = current->m + sub_lcp;
if (t == pattern_len) {
if (mode == MODE_FIND) {
return current->i;
} else {
next_dir = LEFT;
llcp = t;
}
} else if (current->i+t-1 == text_len || pattern[t] > text[t+current->i-1]) {
next_dir = RIGHT;
rlcp = t;
} else {
next_dir = LEFT;
llcp = t;
}
}
prev = current;
current = (next_dir == RIGHT ? current->right : current->left);
}
if (mode == MODE_INSERT) {
struct node *new_node = allocate_node(max(llcp, rlcp), (llcp > rlcp ? LEFT : RIGHT), text_i);
if (next_dir == LEFT)
prev->left = new_node;
else
prev->right = new_node;
if (new_node->m > tree->max_mi) {
tree->max_mi = new_node->m;
tree->max_i = new_node->i;
}
}
return 0;
}
void sbst_insert(struct sbst *tree, char *text, size_t text_size, int i) {
(void) find_insert_aux(tree, text+i-1, text_size-i+1, text, text_size, MODE_INSERT, i);
}
int sbst_find(struct sbst *tree, char *text, size_t text_size, char *pattern, size_t pattern_size) {
return find_insert_aux(tree, pattern, pattern_size, text, text_size, MODE_FIND, 0);
}
/* Builds a Suffix Binary Search Tree that keeps track of the highest m(i) as it is built. */
struct sbst *build_sbst(char *text, size_t text_size) {
if (*text == '\0')
return NULL;
struct sbst *tree = malloc(sizeof(*tree));
tree->root = allocate_node(0, 0, 1);
tree->max_mi = 0;
tree->max_i = 1;
for (int i = 1; text[i] != '\0'; i++)
sbst_insert(tree, text, text_size, i+1);
return tree;
}
/* Given an SBST for the input, finds the longest repeated substring in O(1)
Stores the offset in *offset, and the size of the lrs in *size
*/
void find_lrs(struct sbst *tree, int *offset, int *size) {
*offset = tree->max_i-1;
*size = tree->max_mi;
}
/* Debug section */
void dump(struct node *n, char *text, int depth) {
if (!n)
return;
for (int i = 0; i < depth; i++)
putchar(' '), putchar(' ');
printf("%d|%d|%d|%s\n", n->m, n->d, n->i, text+n->i-1);
dump(n->left, text, depth+1);
dump(n->right, text, depth+1);
}
void dump_sorted(struct node *n, char *text) {
if (!n)
return;
dump_sorted(n->left, text);
printf("%s\n", text+n->i-1);
dump_sorted(n->right, text);
}
/* End debug section */
int lcp(char *str1, char *str2) {
int i;
for (i = 0; str1[i] != '\0' && str1[i] == str2[i]; i++);
return i;
}
int main(void) {
char text[MAX_TEXT_SIZE];
printf("Enter text, hit RETURN to terminate (max. %d chars): ", MAX_TEXT_SIZE-1);
fgets(text, sizeof text, stdin);
size_t text_size = strlen(text);
/* Trim newline */
text[--text_size] = '\0';
struct sbst *tree = build_sbst(text, text_size);
/* Debug */
#ifdef DEBUG_MODE
dump(tree->root, text, 0);
printf("\n");
dump_sorted(tree->root, text);
#endif
int lrs_offset, lrs_size;
find_lrs(tree, &lrs_offset, &lrs_size);
if (lrs_size == 0)
printf("No longest repeated substring.\n");
else {
printf("Longest repeated substring found at offset %d with size %d: %.*s\n", lrs_offset, lrs_size, lrs_size, text+lrs_offset);
}
return 0;
}
如果我没有阅读这篇论文,我会将此代码视为黑魔法。
请注意,这并不是最好的软件工程实践的一个很好的演示:不熟悉算法的人将很难阅读和理解它,它会泄漏内存,它不会检查返回值malloc,还有一些其他的缺陷,但我认为这足以说明我的观点。
虽然这可能不如后缀树那么理想,但它显然很容易构建并提供了一个很好的起点。例如,作为副产品,它可以在对数时间内执行模式匹配——这非常好!
注意:我没有太多时间来测试实现。我做了一些基本测试,似乎可以正常工作,但我不能保证没有错误。