📜  Ukkonen的后缀树构造–第3部分(1)

📅  最后修改于: 2023-12-03 14:48:09.464000             🧑  作者: Mango

Ukkonen的后缀树构造 – 第3部分

本文是 Ukkonen 的后缀树构造的第三篇,建议先了解前两篇:

这篇文章将介绍 Suffix Tree 的一些优化,方便构建与查询。我们将学习如何快速查找字符串是否为另一个字符串的子串,以及如何找到最长公共子串。同时我们还将学习如何在构造后缀树时,并发地插入多个文本。

公共节点

在第2篇文章中,我们讨论了如何向树中添加新的后缀,和如何通过活动点(active point)在树中移动。在构建完整个后缀树后,树上会包含很多不同的字符串,但它们有很多共同的前缀。

例如,假设我们已经在后缀树中加入了以下几个字符串:

A = abcabc
B = bcabc
C = abcab

它们组成的后缀树如下所示:

example-1

在这个例子中,根节点是三个字符串的共同前缀,而后面的叶子节点则对应于不同的字符串。我们可以通过这些共同的节点,快速查找某个字符串是否为另一个字符串的子串,比如,对于字符串 "bc",只需要从 "b" 开始,往下遍历后缀树即可。如果在树上能够找到 "c",同时该节点所在的子树中会包含一个以上的字符串,则说明 "bc" 是所有字符串的子串。

另一个常见的需求是查找多个字符串的最长公共子串(Longest Common Substring)。这个问题的解决方法很简单:找到所有输入字符串中的所有后缀。我们将所有后缀按顺序排列,在后缀树中找到其中相邻两个后缀的最长公共前缀即可。这个过程可以通过遍历后缀树稍微修改一下即可实现。

左/右扩展规则

上文中,我们将通过到达某个节点的方式来确定某个子串是否为其他字符串的子串。那么问题来了:到达哪个节点呢?

假设我们想要检查 "abc" 是否为字符串 "ababc" 的子串。我们首先从根节点开始,匹配字符 "a"。现在我们处理了一个字符串 "ab",已经到达了前缀节点 "ab"。

接下来,我们需要决定如何继续匹配剩下的子串 "c"。然而,有多种途径可以到达包含 "ab" 的节点 "b"。我们需要选择正确的路径。

我们可以选择左路径或者右路径。左路径应该是包含了最长公共前缀的,它指向节点 "a",而右路径则其它。

我们可以定义一个规则使得算法总是选择左路径,这就是所谓的左扩展规则(Left Extension Rule)。或者,我们也可以选择总是选择右路径,这就是右扩展规则(Right Extension Rule)。注意:扩展规则一旦确定,就必须在整个算法过程中保持一致。

一般来说,选择左路径比较简单。这意味着对于每个节点,算法只需要考虑对应的边的左端点。我们将采用左扩展规则进行以下描述。

后缀链接

通过左扩展规则,我们可以快速地在后缀树中找到某个子串是否为另一个字符串的子串,也能够计算两个字符串的最长公共子串。但这种方法也有一些潜在的问题。

例如,假设我们在 "ababc" 中查找子串 "ababc",它显然是个后缀。通过左扩展规则,我们可以发现需要从根节点开始匹配 "a",然后循环遍历接下来的字符,最终到达匹配到的后缀节点。这个过程不仅比较缓慢,而且容易出错。

我们可以通过另一种特殊的链接来解决这个问题。对于每个中间节点,我们引入一个后缀链接(suffix link),它指向其对应的最长后缀。需要注意的是,根节点没有后缀链接。

为了看到如何使用后缀链接,让我们再次考虑构建下面这个后缀树:

A = abcabc
B = bcabc
C = abcab

我们注意到,字符串 "abcab" 和字符串 "bcabc" 有一个共同的后缀,并且需要在相同的位置(节点 "bca")停止。由于后缀链接的存在,我们不需要从根节点重新遍历整个树。相反,我们只需从 "abcab" 的叶子节点出发,沿着后缀链接向上移动即可。这个过程可以通过后缀链接的连续以下跳实现。

example-2

这种技术可以加速一些常见的操作,如查找某个字符串是否为另一个字符串的后缀,并且使得计算最长公共子串的算法更简单。

隐式后缀树

我们现在拥有了一种新的构建后缀树的方式。我们从空后缀开始,并逐渐添加一个接一个字符。在构建的过程中,当我们到达了一个位置,无法确定如何利用当前的后缀树节点来表示一个新的后缀时,我们就会创建一个新的后缀树节点。

从这个角度来看,我们可以看出,后缀树的节点大量冗余,它们必须以某种方式包含所有由空后缀到此节点表示的后缀。此外,后缀树节点的数量往往与文本长度成正比。

为了回避这个问题,我们可以使用隐式后缀树(Implicit Suffix Tree)的概念。隐式后缀树是一棵由后缀树的边组成的树,每条边都表示一个字符串的一部分,该字符串是通过文本中从根节点到该边所在节点的路径表示的后缀。

我们可以在隐式后缀树上执行相同的操作,无需考虑后缀树节点本身的内容。

example-3

图中显示了文字 "ABCD#ABC" 的隐式后缀树,$N2$ 和 $N6$ 的各自涉及的后缀为:

  • $N2$ 对应的后缀是 "D#ABC"。
  • $N6$ 对应的后缀是 "C"。

隐式后缀树是后缀树的基本性质的直接结果,也是计算最长公共子串和在构建后缀树时并行插入多个字符串的基础。在实际应用中,隐式后缀树与后缀树的区别很小。

并行处理

最后,我们将介绍如何使用并行处理来更快地构建后缀树。在并行计算中,我们将输入文本拆分为若干个部分,以便在不同的处理器上并行计算树。由于所有子串的最长公共前缀都在最深的节点上,这些节点是构建该部分的后缀树的 "瓶颈"。

因此,在将文本分成多个部分并为每个部分分配一个处理器之前,我们应该确定分割点。一种简单的启发式方法是在原始文本中听过较大数量的后缀处分割它。这样做的局限性是,许多计算和平衡负载的策略会遵循这种技术,并降低其效率。

example-4

例如,为了并行计算 "ABCD#ABCD",我们可以将字符串 "ABCD" 和 "ABCD" 分别分配到不同的处理器上。各自构建后缀树后,将带有 "#ABCD" 标记的子树接合在一起。

构建并行后缀树通常涉及很多细节,包括如何处理边缘节点、如何合并后缀链接等,但是使用适当的并行算法可以将性能提高数倍。

总结

在本文中,我们已经对 Ukkonen 的后缀树构造算法进行了进一步的优化。我们学习了如何使用后缀链接和左扩展规则来简化和加速字符串的操作,以及如何将隐式后缀树和并行处理与后缀树构造中充分利用。在下一篇文章中,我们将介绍 Suffix Array 的概念和如何使用它来解决与后缀树相关的问题。