以下是尝试描述Ukkonen算法的内容,首先展示了在字符串简单(即不包含任何重复字符)时它的工作原理,然后扩展到完整算法。
首先,需要说明几点:
1. 我们正在构建的基本上像搜索trie。因此有一个根节点,从中出发的边通向新节点,以及从那些节点出发的更多边等等。
2. 但是:与搜索trie不同,边标签不是单个字符。相反,每个边都使用一对整数[from,to]进行标记。这些是指针指向文本。从这个意义上讲,每个边都携带任意长度的字符串标签,但只占用O(1)空间(两个指针)。
基本原则:
我想先演示如何创建一个后缀树,它是一个特别简单的字符串,没有重复的字符。
abc
这个算法
从左到右逐步执行。对于字符串中的
每个字符,都有一个步骤。每个步骤可能涉及多个操作,但我们将看到(请参见最后的观察结果),总操作数为O(n)。
所以,我们从左边开始,首先只插入单个字符a
,通过从根节点(在左侧)到叶子节点创建一条边,并将其标记为[0,#]
,表示该边代表以位置0开始并以当前结尾结束的子字符串。我使用符号#
表示当前结尾,它位于位置1(紧接着a
)的右侧。
因此,我们有了一个初始树,看起来像这样:
![](https://istack.dev59.com/aOwIL.webp)
它的意思是这样的:
![](https://istack.dev59.com/SZH4k.webp)
现在我们进入第二个位置(紧接着
b
后面)。
我们每一步的目标是插入
到当前位置为止的所有后缀。我们通过以下方式实现:
在我们的表示中,它看起来像这样:
![enter image description here](https://istack.dev59.com/onmqt.webp)
它的意思是:
![](https://istack.dev59.com/tchAx.webp)
我们观察到两件事情:
ab
的边缘表示与初始树中的相同:[0,#]
。由于我们将当前位置#
从1更新为2,因此其含义已自动更改。
- 每个边缘消耗O(1)的空间,因为它只包含两个指向文本的指针,无论它代表多少个字符。
接下来,我们再次增加位置,并通过将c附加到每个现有边缘并为新后缀c插入一个新边缘来更新树。
在我们的表示中,这看起来像:
![](https://istack.dev59.com/wCEdI.webp)
它的意思是:
![](https://istack.dev59.com/UpUFw.webp)
我们观察到:
- 每一步后,树是正确的后缀树,直到当前位置
- 步骤数与文本中字符数相同
- 每个步骤中的工作量为O(1),因为通过递增
#
自动更新所有现有边,并且可以在O(1)时间内插入最后一个字符的新边。因此,对于长度为n的字符串,仅需要O(n)的时间。
第一个扩展:简单重复
当然,这只适用于我们的字符串不包含任何重复的情况。现在我们看一个更加真实的字符串:
abcabxabcd
开始于先前示例中的
abc
,然后重复
ab
并跟随
x
,然后重复
abc
并跟随
d
。
步骤1到3:在前面3个步骤之后,我们得到了先前示例中的树形结构。
![](https://istack.dev59.com/AclCh.webp)
步骤4:我们将
#
移动到第4个位置。这将隐式更新所有现有的边缘为此内容:
![](https://istack.dev59.com/xhVMY.webp)
我们需要在根节点插入当前步骤的最终后缀a
。
在这之前,除了#
,我们还引入了另外两个变量,虽然它们一直存在但我们还没有使用过:
- 活动点,是一个三元组
(active_node, active_edge, active_length)
remainder
,是一个整数,表示我们需要插入多少个新的后缀
这两个变量的确切含义很快就会变得清晰,但现在让我们暂且说:
- 在简单的
abc
示例中,活动点始终为(root,'\0x',0)
,即active_node
为根节点,active_edge
指定为空字符'\0x'
,active_length
为零。这样做的效果是,在每一步中插入的一个新边作为全新创建的边插入到根节点。我们很快就会看到为什么需要三元组来表示这个信息。
remainder
总是在每个步骤开始时设置为1。这意味着我们必须主动插入的后缀数量是1(始终只有最后一个字符)。
现在这将会改变。当我们在根节点插入当前的最后一个字符a
时,我们注意到已经有一条以a
开头的出边,具体来说是:abca
。在这种情况下,我们要做以下操作:
- 我们不会在根节点插入一个新的边
[4,#]
。相反,我们只是注意到后缀a
已经存在于我们的树中。它以较长的边的中间结束,但我们并不介意。我们只是让事情保持原样。
- 我们设置活动点为
(root,'a',1)
。这意味着活动点现在位于以a
开头的根节点的出边的中间,具体来说,在该边上的位置1之后。我们注意到该边仅由其第一个字符a
指定。这足够了,因为只能有一个以任何特定字符开头的边(阅读整个描述后确认此内容是否正确)。
- 我们还增加了
remainder
,因此在下一步开始时它将为2。
Observation: 当发现需要插入的最终后缀已经存在于树中时,树本身不会发生任何变化(我们只更新活动点和remainder)。因此,当前位置之前的后缀树不再是一个准确的表示,但它包含所有后缀(因为最终后缀a被隐式包含在内)。因此,在这一步中除了更新变量(这些变量都是固定长度的,因此是O(1)),没有进行任何工作。
Step 5:我们将当前位置#更新为5。这会自动将树更新为以下内容:
![](https://istack.dev59.com/XL6bg.webp)
由于remainder
为2,因此我们需要在当前位置插入两个最终后缀:ab
和b
。这基本上是因为:
- 来自前一步骤的
a
后缀从未被正确插入。因此它保留了下来,并且由于我们前进了一步,现在它从a
变成了ab
。
- 我们需要插入新的最终边缘
b
。
实际上,这意味着我们转到活动点(它指向现在是abcab
边缘上的a
之后),并插入当前的最终字符b
。但是:同样地,发现b
也已经出现在同一条边上。
因此,我们不改变树。我们只需:
- 将活动点更新为
(root,'a',2)
(与之前相同的节点和边,但现在我们指向b
后面)
- 将
remainder
增加到3,因为我们仍未正确插入上一步中的最终边缘,并且我们也不插入当前最终边缘。
明确一下:我们必须在当前步骤中插入ab
和b
,但是因为已经找到了ab
,所以我们更新了活动点,甚至没有尝试插入b
。为什么?因为如果树中有ab
,它的每个后缀(包括b
)也必须在树中。也许只是隐含地存在,但是由于我们迄今为止构建树的方式,它必须存在。
我们通过递增#
进行第6步。树会自动更新为:
![](https://istack.dev59.com/bLLT9.webp)
因为余数是3,我们需要插入abx,bx和x。活动点告诉我们ab的结束位置,所以我们只需要跳到那里并插入x。确实,x还没有在那里,所以我们要分裂abcabx边缘并插入一个内部节点:
![](https://istack.dev59.com/6HYtR.webp)
"The edge representations are still pointers into the text, so splitting and inserting an internal node can be done in O(1) time."
"边缘表示仍然是指向文本的指针,因此在O(1)时间内可以拆分和插入内部节点。"
"So we have dealt with abx and decrement remainder to 2. Now we need to insert the next remaining suffix, bx. But before we do that we need to update the active point. The rule for this, after splitting and inserting an edge, will be called Rule 1 below, and it applies whenever the active_node is root (we will learn rule 3 for other cases further below). Here is rule 1:"
"所以我们已经处理了abx并将remainder减少到2。现在我们需要插入下一个剩余的后缀bx。但在此之前,我们需要更新活动点。在拆分和插入边缘后,这个规则被称为下面的规则1,并且适用于active_node是root的情况(我们将在下面进一步学习其他情况的规则3)。以下是规则1:"
"After an insertion from root,
- active_node remains root
- active_edge is set to the first character of the new suffix we need to insert, i.e. b
- active_length is reduced by 1"
“从根插入后,
- active_node仍然是root
- active_edge设置为我们需要插入的新后缀的第一个字符,即b
- active_length减少1”
因此,新的活动点三元组
(root,'b',1)
表示下一个插入操作必须在
bcabx
边缘进行,向后移动1个字符,即在
b
之后。我们可以在O(1)时间内确定插入点并检查
x
是否已经存在。如果存在,我们将结束当前步骤并保持一切不变。但是
x
不存在,因此我们通过分割边来插入它:
![](https://istack.dev59.com/YVvbJ.webp)
再次说明,这需要O(1)的时间,我们更新
remainder
为1,根据规则1将活动点更新为
(root,'x',0)
。
但是还有一件事情要做。我们将其称为
规则2:
如果我们分割一个边并插入一个新节点,并且如果这不是在当前步骤中创建的第一个节点,则通过一个特殊指针——后缀链接连接先前插入的节点和新节点。稍后我们将看到它的用处。这里是我们得到的结果,后缀链接表示为虚线边:
![](https://istack.dev59.com/zL9yl.webp)
我们仍然需要插入当前步骤的最终后缀
x
。由于活动节点的
active_length
组件已经降为0,因此最终插入是直接在根节点进行的。由于根节点没有以
x
开头的出边,因此我们需要插入一条新的边:
![](https://istack.dev59.com/992gV.webp)
根据当前步骤,所有剩余的插入操作均已完成。
我们将通过设置#
=7进入第7步,这自动将下一个字符a
附加到所有叶子边缘,如常。然后,我们尝试将新的最终字符插入到活动点(根节点)中,并发现它已经存在。因此,我们在不添加任何内容的情况下结束当前步骤,并将活动点更新为(root,'a',1)
。
在第8步中,#
=8,我们附加b
,并像之前一样只更新活动点为(root, 'a', 2)
并增加remainder
而没有做其他事情,因为b
已经存在。 然而,我们注意到(在O(1)时间内)活动点现在位于一条边的末端。我们通过重新设置为(node1,'\0x',0)
来反映这一点。在这里,我使用node1
来引用ab
边结束的内部节点。
然后,在第
#
=9步中,我们需要插入'c',这将帮助我们理解最终的技巧:
第二个扩展:使用后缀链接
像往常一样,#
更新自动将c
附加到叶边,并且我们转到活动点以查看是否可以插入'c'。结果发现该边已经存在'c',因此我们将活动点设置为(node1,'c',1)
,增加remainder
并且什么也不做。
现在,在第#
=10步中,remainder
是4,因此我们首先需要通过在活动点插入d
来插入abcd
(从3步之前剩下的)。
尝试在活动点插入d
会导致边界分裂,时间复杂度为O(1):
![](https://istack.dev59.com/Rkdzd.webp)
上面标为红色的是发起分裂的active_node
。这里是最终的规则Rule 3:
从一个不是根节点的active_node
分裂一条边后,如果有后缀链接,则跟随该节点指向的后缀链接,并将active_node
重置为该节点。如果没有后缀链接,则将active_node
设置为根节点。 active_edge
和active_length
保持不变。
因此,现在的活动点是(node2,'c',1)
,下面的node2
被标记为红色:
![](https://istack.dev59.com/0IS5C.webp)
自从插入
abcd
完成后,我们将
remainder
减少到3,并考虑当前步骤的下一个剩余后缀
bcd
。规则3已将活动点设置为刚好在右侧节点和边上,因此可以通过在活动点插入其最后一个字符
d
来插入
bcd
。
这样做会导致另一个边分裂,并且由于规则2的缘故,我们必须从先前插入的节点创建一个后缀链接到新节点:
![](https://istack.dev59.com/DNVQO.webp)
我们观察到:后缀链接使我们能够重置活动点,以便我们可以在O(1)的时间内进行下一个剩余插入。查看上面的图表以确认标签为ab的节点确实链接到标签为b的节点(它的后缀),标签为abc的节点链接到bc。
当前步骤还未完成。remainder现在是2,我们需要按照规则3再次重置活动点。由于当前的活动节点(上面的红色)没有后缀链接,我们将其重置为根节点。现在活动点是(root,'c',1)。
因此,下一个插入发生在根节点的一个出边上,其标签以c开头:cabxabcd,在第一个字符之后,即在c之后。这导致另一个分裂。
![](https://istack.dev59.com/wZ7Bj.webp)
由于这涉及到创建一个新的内部节点,我们遵循规则2并从先前创建的内部节点设置一个新的后缀链接:
![](https://istack.dev59.com/urgol.webp)
我正在使用Graphviz Dot来绘制这些小图。新的后缀链接导致dot重新排列现有的边,因此请仔细检查以确认上面插入的唯一内容是一个新的后缀链接。
有了这个,remainder
可以设置为1,由于active_node
是根节点,我们使用规则1来更新活动点为(root,'d',0)
。这意味着当前步骤的最终插入是在根节点插入一个单独的d
:
![](https://istack.dev59.com/TPxLe.webp)
这是最后一步,我们完成了。然而还有一些最终的观察:
在每一步中,我们将#
向前移动1个位置。这会自动更新所有叶节点,时间复杂度为O(1)。
但它无法处理a) 任何先前步骤中剩余的后缀和b) 当前步骤的最后一个字符。
remainder
告诉我们需要进行多少次额外的插入操作。这些插入操作一一对应于以当前位置#
结尾的字符串的最终后缀。我们依次考虑并进行插入。 重要提示:每次插入都需要O(1)时间,因为活动点告诉我们确切的插入位置,我们只需要在活动点添加一个单一字符即可。为什么?因为其他字符是隐含的(否则活动点就不会在那里)。
在每次这样的插入之后,我们减少remainder
并遵循后缀链接(如果有的话)。如果没有,我们就回到根节点(规则3)。如果我们已经在根节点,我们使用规则1修改活动点。在任何情况下,这只需要O(1)时间。
如果在这些插入之一期间,我们发现要插入的字符已经存在,即使remainder
>0,我们也不会做任何事情并结束当前步骤。原因是剩余的所有插入操作都将是我们刚刚尝试进行的插入操作的后缀。因此,它们都是当前树中隐含的。remainder
>0确保我们稍后处理剩余的后缀。
如果在算法结束时remainder
>0怎么办?每当文本结尾是在某个地方出现过的子字符串时,就会出现这种情况。在这种情况下,我们必须在字符串末尾附加一个以前未出现过的额外字符。在文献中,通常使用美元符号$
作为该符号。 为什么这很重要? --> 如果以后我们使用完整的后缀树搜索后缀,我们必须只接受在叶节点结束的匹配。否则,我们将得到许多虚假的匹配,因为有许多在树中隐含的字符串不是主字符串的实际后缀。强制remainder
在最后为0本质上是一种确保所有后缀都以叶节点结束的方式。 然而,如果我们想要使用树来搜索一般子字符串,而不仅仅是主字符串的后缀,则确实不需要进行此最终步骤,正如下面的OP评论所建议的那样。
那么整个算法的复杂度是多少?如果文本长度为n个字符,则显然有n个步骤(或者如果我们添加美元符号,则为n+1)。在每个步骤中,我们要么什么也不做(除了更新变量),要么进行remainder
次插入操作,每次操作都需要O(1)时间。由于remainder
指示我们在以前的步骤中有多少次什
![](https://istack.dev59.com/7t0dg.webp)
虚线表示整个树的其余部分。点状线是后缀链接。
现在让活动点为(红色,'d',3)
,因此它指向defg
边上f
后面的位置。现在假设我们进行了必要的更新,然后按照规则3跟随后缀链接更新活动点。新的活动点是(绿色,'d',3)
。但是,从绿色节点出发的d
边是de
,因此它只有2个字符。为了找到正确的活动点,我们显然需要沿着该边到达蓝色节点并重置为(蓝色,'f',1)
。
在一个特别糟糕的情况下,active_length
可能会很大,甚至和 remainder
一样大,而后者可能达到 n。并且,为了找到正确的活动点,我们可能需要跳过不止一个内部节点,而是最坏情况下多达 n 个节点。这是否意味着该算法具有隐藏的 O(n2) 复杂度,因为每一步中 remainder
通常是 O(n),在遵循后缀链接后对活动节点进行的后续调整也可能是 O(n)?
不行。原因是如果我们必须调整活动点(例如从绿色到蓝色),那么我们会到达一个新的节点,该节点具有自己的后缀链接,并且active_length将减少。随着我们沿后缀链接链向下进行剩余插入操作,active_length只能减少,并且我们在途中可以进行的活动点调整次数不能大于任何给定时间的active_length。由于active_length永远不可能大于remainder,而且remainder在每一步都是O(n),而且在整个过程中对remainder进行的所有增量的总和也是O(n),因此活动点调整的数量也受到O(n)的限制。