TL;DR
编辑 (03/2019): 我已经重新设计了我的实现以使用 C++17 string_view 来表示我的子字符串以及确保引用字符串不会移动的缓存机制。更新版本可以在 github 上找到:https://github.com/Rerito/suffix-tree-v2。这里是 the github for my old implementation (in C++) 给好奇的人。哦,新版本还包括测试!
为了构建通用后缀树,实际上不需要修改原始算法。
详细分析
我的预感是正确的。为了跟上原始算法中使用的技巧,我们确实需要添加对原始字符串的引用。此外,该算法是在线的,这意味着您可以将字符串即时添加到树中。
假设我们有一个 通用后缀树 GST(N) 用于字符串 (S1, ..., SN)。这里的问题是如何处理 GST(N+1) 的构建过程,使用 GST(N)。
调整数据模型
在简单的情况下(单个后缀树),每个转换都是一对(substring,end vertex)。 Ukkonen 算法中的技巧是使用一对指向原始字符串中适当位置的指针对子字符串进行建模。在这里,我们还需要将这样的子字符串链接到它的“父”字符串。这样做:
- 将原始字符串存储在哈希表中,为它们提供唯一的整数键。
- 一个子字符串现在变成:(ID,(左指针,右指针))。因此,我们可以使用 ID 在 O(1) 中获取原始字符串。
我们称之为映射子串。我使用的 C++ typedef 是在我原来的问题中找到的:
// This is a very basic draft of the Node class used
template <typename C>
class Node {
typedef std::pair<int, int> substring;
typedef std::pair<int, substring> mapped_substring;
typedef std::pair<mapped_substring, Node*> transition;
// C is the character type (basically `char`)
std::unordered_map<C, transition> g; // Called g just like in the article :)
Node *suffix_link;
};
如您所见,我们也将保留 reference pair 的概念。这一次,引用对,就像转换一样,将持有一个映射的子字符串。
注意:在 C++ 中,字符串索引将从 0 开始。
插入新字符串
我们想将 SN+1 插入 GST(N)。
GST(N) 可能已经有很多节点和转换。在一个简单的树中,我们只有根节点和特殊的汇节点。在这里,我们可能已经通过插入一些先前的字符串添加了 SN+1 的转换。要做的第一件事就是沿着树向下遍历转换,只要它匹配 SN+1。
通过这样做,我们以状态 r 结束。这种状态可能是显式的(即我们在一个顶点上结束)或隐式的(在转换中间发生不匹配)。我们使用与原始算法中相同的概念来模拟这种状态:参考对。快速示例:
- 我们要插入 SN+1 =
banana
- 代表
ba的节点s明确存在于GST(N)中
-
s 对子字符串
nal 有一个转换 t
当我们沿着树向下走时,我们最终会在字符l 处进行转换t,这是不匹配的。因此,我们得到的隐式状态 r 由 reference pair (s, m) 表示,其中 m 是映射的子串(N+1, (1,3))。
这里,r是构建banana的后缀树的算法第5次迭代的活动点。我们到达那个状态的事实恰恰意味着 bana 的树已经在 GST(N) 中构建。
在本例中,我们在第 5 次迭代中恢复算法,使用 bana 的树构建 banan 的后缀树。为了不失一般性,我们将声明 r = (s, (N+1, (k, i-1)), i 是第一个不匹配的索引。我们确实有 k ≤ i(均等性是 r 是显式状态的同义词)。
属性:我们可以在迭代 i 处恢复 Ukkonen 的算法来构建 GST(N)(在索引 i处插入字符> 在 SN+1)。本次迭代的活跃点是我们沿着树走下去得到的状态r。 唯一需要的调整是一些获取操作来解析子字符串。
财产证明
首先,这种状态 r 的存在意味着中间树的整个状态 T(N+1)i-1 也有。所以一切都设置好了,我们恢复算法。
我们需要证明算法中的每个过程仍然有效。有3个这样的子程序:
-
test_and_split:给定在当前迭代中插入的字符,测试我们是否需要将一个转换分成两个单独的转换,以及当前点是否是结束点。
-
canonize: 给定一个 reference pair (n, m) 其中 n 是一个顶点,而 m 是一个映射子字符串,返回代表相同状态的一对 (n', m') 例如 m' 是可能的最短子字符串。
-
update:更新 GST(N) 使其在末尾具有中间树 T(N+1)i 的所有状态运行。
test_and_split
输入:一个顶点s,一个映射子串m = (l, (k, p))和一个字符 t.
输出: 一个布尔值,指示状态 (s, m) 是否是当前迭代的终点和节点 r 明确表示 (s, m) 如果它不是终点。
最简单的情况先出现。如果子字符串为空 (k > p),则状态已经明确表示。我们只需要测试我们是否到达终点。 在 GST 中,就像在公共后缀树中一样,每个节点总是最多有一个以给定字符开头的转换。因此,如果有一个以t,我们返回 true(我们到达了终点),否则返回 false。
现在是困难的部分,当 k ≤ p 时。我们首先需要获取位于原始字符串表中索引 l(*) 的字符串 Sl。
令 (l', (k', p')) (resp. s') 为与转换 TR 相关的子串(或节点) 个 s 以字符 Sl(k) (*) 开头。存在这样的转换是因为 (s, (l,(k,p)) 表示中间树 T 的 边界路径 上的(现有)隐式状态(N+1)i-1。此外,我们确信此转换中的 p - k 首字符匹配.
我们需要拆分这个过渡吗?这取决于此转换 (**) 上的第 Δ = p - k + 1 个字符。为了测试这个字符,我们需要获取位于哈希表索引 l' 处的字符串,并获取索引 k' + Δ 处的字符。这个字符保证存在,因为我们正在检查的状态是隐式的,因此在转换 TR (Δ ≤ p' - k') 的中间结束。
如果等式成立,我们什么也不做,返回true(终点就在这里),什么也不做。如果没有,那么我们必须拆分转换并创建一个新状态r。 TR 现在变为 (l', (k', k' + Δ - 1)) → r。为 r 创建另一个转换:(l', (k' + Δ, p') → s'。我们现在返回 false 和 r.
(*):l不一定等于N+1。同样,l 和 l' 可能不同(或相等)。
(**):请注意,数字 Δ = p - k + 1 完全不依赖于选择作为映射子字符串引用的字符串。它仅取决于提供给例程的隐式状态。
规范化
输入:一个节点_s_和一个映射子串(l,(k,p)),表示树中的一个现有状态e。
输出: 一个节点 s' 和一个映射子字符串 (l',(k',p')) 表示规范引用对于状态 e
使用相同的抓取调整,我们只需要沿着树向下走,直到我们用尽字符“池”。在这里,就像test_and_split 一样,每个转换的唯一性以及我们将现有状态作为输入这一事实为我们提供了一个有效的过程。
更新
输入:当前迭代的活动点和索引。
输出:下一次迭代的活动点。
update 同时使用canonize 和test_and_split,它们对GST 友好。后缀链接编辑与普通树的编辑完全相同。唯一需要注意的是,我们将使用 SN+1 作为参考来创建 open 过渡(即通向节点的过渡)细绳。因此,在迭代 i 时,转换将始终链接到映射的子字符串 (N+1,(i,∞))
最后一步
我们需要“关闭”打开的转换。为此,我们只需遍历它们并编辑 ∞,将其替换为 L-1,其中 L 是 SN+ 的长度1。我们还需要一个标记字符来标记字符串的结尾。我们肯定不会在任何字符串中遇到的字符。这样一来,叶子就永远是叶子了。
结论
额外的获取工作增加了一些O(1) 操作,稍微增加了复杂性的常数因素。尽管如此,渐近复杂度显然与插入字符串的长度呈线性关系。因此,从长度为 (S1,..., SN) 的字符串构建 GST(N) n1,...,nN 是:
c(GST(N)) = Σi=1..N ni