Ukkonen的简明英语后缀树算法

Posted

tags:

篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了Ukkonen的简明英语后缀树算法相关的知识,希望对你有一定的参考价值。

此时我觉得有点厚。我花了几天时间试图完全用后缀树构建我的头,但由于我没有数学背景,因为他们开始过度使用数学符号系统时,许多解释都没有。最接近我发现的一个很好的解释是Fast String Searching With Suffix Trees,但他掩盖了各种点,算法的某些方面仍然不清楚。

在Stack Overflow上对此算法的逐步解释对于我以外的许多其他人来说都是非常宝贵的,我敢肯定。

作为参考,这里是Ukkonen关于算法的论文:http://www.cs.helsinki.fi/u/ukkonen/SuffixT1withFigs.pdf

到目前为止我的基本理解:

  • 我需要迭代给定字符串T的每个前缀P.
  • 我需要遍历前缀P中的每个后缀S并将其添加到树中
  • 要将后缀S添加到树中,我需要遍历S中的每个字符,迭代包括沿着现有分支向下走,该分支以S中的相同字符集C开头,并且可能将边缘拆分为后代节点在后缀中找到不同的字符,或者如果没有匹配的边缘则向下走。当没有找到匹配的边缘向下走C时,为C创建一个新的叶边。

基本算法似乎是O(n2),正如我们在大多数解释中所指出的那样,因为我们需要遍历所有前缀,然后我们需要逐步遍历每个前缀的每个后缀。由于他使用的后缀指针技术,Ukkonen的算法显然是独一无二的,尽管我认为这是我无法理解的。

我也很难理解:

  • 确切地指定,使用和更改“活动点”的时间和方式
  • 算法的典型化方面发生了什么
  • 为什么我看到的实现需要“修复”他们正在使用的边界变量

这是完成的C#源代码。它不仅工作正常,而且支持自动规范化,并提供更好看的输出文本图表。源代码和示例输出位于:

https://gist.github.com/2373868


更新2017-11-04

多年以后,我发现了后缀树的新用途,并在javascript中实现了该算法。要点如下。它应该没有错误。将它转储到同一位置的js文件npm install chalk中,然后使用node.js运行以查看一些彩色输出。在同一个Gist中有一个精简版本,没有任何调试代码。

https://gist.github.com/axefrog/c347bf0f5e0723cbd09b1aaed6ec6fc6

答案

以下是尝试描述Ukkonen算法,首先显示当字符串是简单时(即不包含任何重复字符)时它做什么,然后将其扩展到完整算法。

首先,一些初步的陈述。

  1. 我们正在构建的,基本上就像搜索trie。所以有一个根节点,从它出来的边缘导致新的节点,以及更远的边缘,等等
  2. 但是:与搜索trie不同,边缘标签不是单个字符。相反,每个边缘都使用一对整数[from,to]进行标记。这些是指向文本的指针。从这个意义上说,每条边都带有一个任意长度的字符串标签,但只占用O(1)空间(两个指针)。

基本原则

我想首先演示如何创建一个特别简单的字符串的后缀树,一个没有重复字符的字符串:

abc

该算法从左到右分步进行。字符串的每个字符都有一个步骤。每个步骤可能涉及多个单独的操作,但我们将看到(参见最后的最终观察结果)操作总数为O(n)。

因此,我们从左边开始,首先通过从根节点(左侧)到叶子创建边缘,并将其标记为a,仅将单个字符[0,#]插入,这意味着边缘表示从位置0开始的子字符串并在当前结束。我使用符号#来表示当前结束,它位于第1位(在a之后)。

所以我们有一个初始树,看起来像这样:

这意味着什么:

现在我们进入第2位(在b之后)。我们在每一步的目标是将所有后缀插入到当前位置。我们这样做

  • 将现有的a边缘扩展到ab
  • b插入一个新边缘

在我们的表示中,这看起来像

它意味着:

我们观察到两件事:

  • ab的边缘表示与以前在初始树中的边缘表示相同:[0,#]。其含义自动更改,因为我们将当前位置#从1更新为2。
  • 每个边消耗O(1)空间,因为它只包含两个指向文本的指针,无论它代表多少个字符。

接下来,我们再次增加位置并通过将c附加到每个现有边并为新后缀c插入一个新边来更新树。

在我们的表示中,这看起来像

它意味着:

我们观察到:

  • 树是每个步骤之后到当前位置的正确后缀树
  • 步骤与文本中的字符一样多
  • 每个步骤中的工作量是O(1),因为所有现有边缘都是通过递增#自动更新的,并且为最终字符插入一个新边缘可以在O(1)时间内完成。因此,对于长度为n的字符串,仅需要O(n)时间。

第一次扩展:简单重复

当然这很好用,因为我们的字符串不包含任何重复。我们现在看一个更现实的字符串:

abcabxabcd

它从前一个例子中的abc开始,然后重复ab,接着是x,然后重复abc,接着是d

步骤1到3:在前3个步骤之后,我们得到了上一个示例中的树:

第4步:我们将#移动到位置4.这会隐式更新所有现有边缘:

我们需要在根处插入当前步骤的最后一个后缀a

在我们这样做之前,我们引入了另外两个变量(除了#之外),当然这些变量一直存在,但到目前为止我们还没有使用它们:

  • 活跃点,这是一个三倍(active_node,active_edge,active_length)
  • remainder,它是一个整数,表示我们需要插入多少个新后缀

这两者的确切含义很快就会清楚,但现在让我们说:

  • 在简单的abc示例中,活动点始终为(root,'x',0),即active_node是根节点,active_edge被指定为空字符'x'active_length为零。这样做的结果是我们在每个步骤中插入的一个新边缘作为新创建的边缘插入根节点。我们很快就会看到为什么需要三元组来表示这些信息。
  • 在每个步骤开始时,remainder始终设置为1。这意味着我们必须在每个步骤结束时主动插入的后缀数量为1(始终只是最后一个字符)。

现在这将改变。当我们在根处插入当前的最终字符a时,我们注意到已经有一个以a开头的传出边缘,特别是:abca。以下是我们在这种情况下所做的事情:

  • 我们不会在根节点插入新鲜的边缘[4,#]。相反,我们只是注意到后缀a已经在我们的树中了。它在较长边缘的中间结束,但我们并不为此烦恼。我们只是按照他们的方式离开。
  • 我们将活跃点设置为(root,'a',1)。这意味着活动点现在位于以a开头的根节点的出口边缘的中间位置,具体地说,在该边缘上的位置1之后。我们注意到边缘仅由其第一个字符a指定。这就足够了,因为只有一个边缘以任何特定字符开头(在阅读整个描述后确认这是真的)。
  • 我们还增加remainder,所以在下一步的开始它将是2。

观察:当我们需要插入的最终后缀已经存在于树中时,树本身根本没有改变(我们只更新活动点和remainder)。树不再是后缀树的精确表示,直到当前位置,但它包含所有后缀(因为隐含地包含最后的后缀a)。因此,除了更新变量(全部是固定长度,因此这是O(1))之外,在此步骤中没有完成任何工作。

第5步:我们将当前位置#更新为5.这会自动将树更新为:

因为remainder是2,我们需要插入当前位置的两个最终后缀:abb。这基本上是因为:

  • 从前一步骤开始的a后缀从未正确插入。所以它一直存在,并且由于我们已经迈出了一步,它现在已经从a发展到ab
  • 我们需要插入新的最终边缘b

在实践中,这意味着我们转到活动点(指向a后面的abcab边缘),并插入当前的最终字符b。但是:再次证明,b也已存在于同一边缘。

所以,我们再次不改变树。我们只是:

  • 将活动点更新为(root,'a',2)(与之前相同的节点和边缘,但现在我们指向b后面)
  • remainder增加到3,因为我们仍未正确插入上一步的最终边缘,并且我们也没有插入当前的最终边缘。

要明确:我们必须在当前步骤中插入abb,但由于已经找到ab,我们更新了活动点,甚至没有尝试插入b。为什么?因为如果ab在树中,它的每个后缀(包括b)也必须在树中。也许只是含蓄地,但它必须存在,因为我们到目前为止构建树的方式。

我们通过增加#进入第6步。树自动更新为:

因为remainder是3,我们必须插入abxbxx。活跃点告诉我们ab在哪里结束,所以我们只需要跳到那里插入x。实际上,x还没有,所以我们拆分abcabx边缘并插入一个内部节点:

边缘表示仍然是指向文本的指针,因此可以在O(1)时间内完成拆分和插入内部节点。

所以我们已经处理了abx并将remainder减少到2.现在我们需要插入下一个剩余的后缀bx。但在我们这样做之前,我们需要更新活动点。在分割和插入边之后,此规则将在下面称为规则1,并且只要active_node为root,它就适用(我们将在下面的其他情况下学习规则3)。这是规则1:

从root插入后,

  • active_node仍然是根
  • active_edge设置为我们需要插入的新后缀的第一个字符,即b
  • active_length减少1

因此,新的活动点三重(root,'b',1)表明下一个插入必须在bcabx边缘,在1个字符后面,即在b后面。我们可以在O(1)时间内识别插入点并检查x是否已经存在。如果它存在,我们将结束当前步骤并将所有内容保留原样。但x不存在,所以我们通过分割边缘插入它:

再次,这需要O(1)时间,我们将remainder更新为1,并将活动点更新为(root,'x',0),如规则1所述。

但还有一件事我们需要做。我们称之为规则2:

如果我们分割边并插入一个新节点,并且如果它不是当前步骤中创建的第一个节点,我们通过特殊指针(后缀链接)连接先前插入的节点和新节点。我们稍后会看到为什么这是有用的。这是我们得到的,后缀链接表示为虚线边缘:

我们仍然需要插入当前步骤的最后一个后缀x。由于活动节点的active_length组件已降至0,因此最终插入将直接在根处进行。由于在x开头的根节点没有传出边,我们插入一个新边:

我们可以看到,在当前步骤中,所有剩余的插入物都已制成。

我们通过设置# = 7继续执行步骤7,它会像往常一样自动将下一个字符a附加到所有叶子边缘。然后我们尝试将新的最终字符插入活动点(根),并发现它已存在。因此,我们结束当前步骤而不插入任何内容并将活动点更新为(root,'a',1)

在步骤8中,# = 8,我们追加b,如前所述,这只意味着我们将活动点更新为(root,'a',2)并增加remainder而不做任何其他事情,因为b已经存在。但是,我们注意到(在O(1)时间内)活动点现在位于边缘的末尾。我们通过将其重新设置为(node1,'x',0)来反映这一点。在这里,我使用node1来指代ab边缘结束的内部节点。

然后,在步骤# = 9中,我们需要插入'c',这将有助于我们理解最后的技巧:

第二个扩展:使用后缀链接

和往常一样,#更新会自动将c附加到叶子边缘,然后我们转到活动点以查看是否可以插入“c”。事实证明'c'已存在于该边缘,因此我们将活动点设置为(node1,'c',1),增加remainder并且不执行任何其他操作。

现在步骤# = 10,remainder is 4,所以我们首先需要通过在活动点插入abcd来插入d(从3步前保留)。

尝试在活动点插入d会导致O(1)时间内的边缘分割:

开始分裂的active_node在上面用红色标记。这是最终规则,规则3:

在从不是根节点的active_node分割边缘之后,我们遵循从该节点出来的后缀链接(如果有的话),并将active_node重置为它指向的节点。如果没有后缀链接,我们将active_node设置为root。 active_edgeactive_length保持不变。

所以活跃点现在是(node2,'c',1)node2在下面用红色标记:

由于abcd的插入已经完成,我们将remainder减少到3并考虑当前步骤的下一个剩余后缀bcd。规则3将活动点设置为正确的节点和边缘,因此插入bcd可以通过简单地在活动点插入其最终字符d来完成。

执行此操作会导致另一个边缘拆分,并且由于规则2,我们必须创建从先前插入的节点到新节点的后缀链接:

我们观察:后缀链接使我们能够重置活动点,以便我们可以在O(1)努力下进行下一个剩余插入。查看上面的图表以确认标签ab上的节点确实链接到b(其后缀)的节点,而abc的节点链接到bc

目前的步骤尚未完成。 remainder现在是2,我们需要遵循规则3再次重置活动点。由于当前的active_node(上面的红色)没有后缀链接,我们重置为root。活跃点现在是(root,'c',1)

因此,下一个插入发生在根节点的一个输出边缘,其标签以ccabxabcd开头,在第一个字符后面,即在c之后。这导致另一个分裂:

由于这涉及创建新的内部节点,我们遵循规则2并从先前创建的内部节点设置新的后缀链接:

(我使用Graphviz Dot作为这些小图。新的后缀链接导致点重新排列现有边缘,因此请仔细检查以确认上面插入的唯一内容是新的后缀链接。)

有了这个,remainder可以设置为1,因为active_node是root,我们使用规则1将活动点更新为(root,'d',0)。这意味着当前步骤的最后一个插入是在根处插入一个d

这是最后一步,我们已经完成了。但是,有许多最终观察结果: