【问题标题】:Ukkonen's algorithm for Generalized Suffix TreesUkkonen 的广义后缀树算法
【发布时间】:2015-04-01 11:04:58
【问题描述】:

我目前正在开发自己的后缀树实现(使用 C++,但问题仍然与语言无关)。我研究了the original paper from Ukkonen。这篇文章非常清楚,所以我开始着手实现并尝试解决广义后缀树的问题。

在树中,从一个节点到另一个节点的每个子字符串都用一对整数表示。虽然这对于常规后缀树来说很简单,但当多个字符串共存于同一棵树(这成为 通用后缀树)中时,就会出现问题。事实上,现在这样的一对是不够的,我们需要另一个变量来说明我们正在使用哪个引用字符串。

一个简单的例子。考虑字符串coconut

  • 子字符串nut 将是(4,6)
  • 我们在树中添加troublemaker(4,6) 现在可以是:
    • nut 如果我们引用第一个字符串。
    • ble 如果我们引用第二个字符串。

为了解决这个问题,我想添加一个表示字符串的 id:

// A pair of int is a substring (regular tree)
typedef std::pair<int,int> substring;
// We need to bind a substring to its reference string:
typedef std::pair<int, substring> mapped_substring;

我目前面临的问题如下:

我得到一个查询以在树中添加一个字符串。在算法过程中,我可能必须检查与其他已注册字符串相关的现有转换,表示为三元组(reference string idkp )。一些更新操作是基于子字符串索引的,我该如何在这种情况下执行它们

注意:这个问题与语言无关,所以我没有包含-标签,尽管显示了一点 sn-p。

【问题讨论】:

  • 您可以假设所有字符串在一个大字符串中以某种顺序显示为连续的子字符串(每个字符串后跟其不同的终止符号)。如果您使用二叉树从这个大虚拟字符串中的位置映射回(字符串 ID,pos),这将花费您一个 log(n) 因子。另一个方向的查找可以用一张表在 O(1) 内完成。
  • @j_random_hacker 我认为使用的“惰性”子字符串技术可能存在问题(例如,将子字符串的结尾标记为std::numeric_limits&lt;int&gt;::max()(文章中的无穷大)。
  • 我没有读过论文,但我似乎隐约记得广义后缀树中的每个字符串都需要一个不同的结束标记。如果是这样,那么您可以使用std::numeric_limits&lt;int&gt;::max() - 1等。

标签: c++ algorithm suffix-tree


【解决方案1】:

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)。

调整数据模型

在简单的情况下(单个后缀树),每个转换都是一对(substringend 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,这是不匹配的。因此,我们得到的隐式状态 rreference 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(终点就在这里),什么也不做。如果没有,那么我们必须拆分转换并创建一个新状态rTR 现在变为 (l', (k', k' + Δ - 1)) → r。为 r 创建另一个转换:(l', (k' + Δ, p') → s'。我们现在返回 false 和 r.

(*)l不一定等于N+1。同样,ll' 可能不同(或相等)。

(**):请注意,数字 Δ = p - k + 1 完全不依赖于选择作为映射子字符串引用的字符串。它仅取决于提供给例程的隐式状态

规范化

输入:一个节点_s_和一个映射子串(l,(k,p)),表示树中的一个现有状态e
输出: 一个节点 s' 和一个映射子字符串 (l',(k',p')) 表示规范引用对于状态 e

使用相同的抓取调整,我们只需要沿着树向下走,直到我们用尽字符“池”。在这里,就像test_and_split 一样,每个转换的唯一性以及我们将现有状态作为输入这一事实为我们提供了一个有效的过程。

更新

输入:当前迭代的活动点和索引。
输出:下一次迭代的活动点。

update 同时使用canonizetest_and_split,它们对GST 友好。后缀链接编辑与普通树的编辑完全相同。唯一需要注意的是,我们将使用 SN+1 作为参考来创建 open 过渡(即通向节点的过渡)细绳。因此,在迭代 i 时,转换将始终链接到映射的子字符串 (N+1,(i,∞))


最后一步

我们需要“关闭”打开的转换。为此,我们只需遍历它们并编辑 ∞,将其替换为 L-1,其中 LSN+ 的长度1。我们还需要一个标记字符来标记字符串的结尾。我们肯定不会在任何字符串中遇到的字符。这样一来,叶子就永远是叶子了。

结论

额外的获取工作增加了一些O(1) 操作,稍微增加了复杂性的常数因素。尽管如此,渐近复杂度显然与插入字符串的长度呈线性关系。因此,从长度为 (S1,..., SN) 的字符串构建 GST(N) n1,...,nN 是:

c(GST(N)) = Σi=1..N ni

【讨论】:

    【解决方案2】:

    如果您的通用后缀树中只有几个字符串,那么您可以在每个字符串之间使用唯一的终端符号(这些终端符号不应在输入字符串中使用)将它们连接到一个字符串中。

    例如,假设您有 5 个字符串:str1、str2、str3、str4 和 str5,那么您可以将这 5 个字符串连接为 str1$str2#str3@str4%str5,然后将连接后的字符串制作成后缀树.

    由于我们必须使用唯一的终端符号,因此可以在通用后缀树中添加多少个最大字符串是有限制的。任何不会在输入字符串中使用的字符都可以作为终端符号。

    所以基于预定义的一组终端符号,我们可以编写代码。

    以下文章可能会有所帮助。

    Generalized Suffix Tree

    【讨论】:

    • 我已经考虑过这个选项。但是,我更多的是寻找数学证明来扩展原始算法。我宁愿保留 on-line 属性。我不想听起来迂腐,但您提供的链接没有帮助(与 Ukkonen 的原始论文相比)!无论如何感谢您的意见:)
    猜你喜欢
    • 2010-11-21
    • 2014-11-21
    • 1970-01-01
    • 2012-03-16
    • 2012-12-25
    • 1970-01-01
    • 1970-01-01
    • 2014-01-19
    相关资源
    最近更新 更多