下面尝试描述 Ukkonen 算法,首先显示字符串简单时(即不包含任何重复字符)时它的作用,然后将其扩展到完整算法。
首先,一些初步的陈述。
我们正在构建的基本上就像一个搜索尝试。所以有一个根节点,从它出来的边通向新节点,还有更多的边从这些节点出来,依此类推
但是:与搜索树不同,边缘标签不是单个字符。相反,每条边都使用一对整数进行标记
[from,to]
。这些是指向文本的指针。从这个意义上说,每条边都带有一个任意长度的字符串标签,但只占用 O(1) 空间(两个指针)。
基本原则
我想先演示一下如何创建一个特别简单的字符串的后缀树,一个没有重复字符的字符串:
abc
该算法按步骤工作,从左到右。字符串的每个字符都有一个步骤。每个步骤可能涉及不止一个单独的操作,但我们将看到(请参阅最后的观察结果)操作总数为 O(n)。
因此,我们从左边a
开始,首先通过创建从根节点(左侧)到叶子的边,并将其标记为,从而仅插入单个字符
[0,#]
,这意味着边表示从位置 0 开始并结束的子字符串在当前结束。我用这个符号#
来表示当前的 end,它位于位置 1 (就在 之后a
)。
所以我们有一个初始树,它看起来像这样:
这意味着:
现在我们前进到位置 2(就在 之后b
)。我们每一步的目标
是将所有后缀插入到当前位置。我们这样做是通过
- 将现有的
a
-edge 扩展到ab
- 插入一个新的边缘
b
在我们的代表中,这看起来像
它的意思是:
我们观察两件事:
- 的边缘表示与它在初始树中的
ab
表示相同[0,#]
:由于我们将当前位置#
从 1 更新为 2,因此其含义已自动更改。
- 每条边消耗 O(1) 空间,因为它只包含两个指向文本的指针,无论它代表多少个字符。
接下来,我们再次增加位置并通过将 a 附加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,'\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。
观察:当发现我们需要插入的最终后缀已经存在于树中时,树本身并没有发生任何变化(我们只更新了活动点和remainder
)。然后该树不再是直到当前位置的后缀树的准确表示,但它包含所有后缀(因为最终后缀a
被隐式包含)。因此,除了更新变量(都是固定长度的,所以这是 O(1))之外,
这一步没有做任何工作。
第 5 步:我们将当前位置更新#
为 5。这会自动将树更新为:
并且因为remainder
是 2,所以我们需要插入当前位置的两个最终后缀:ab
和b
。这基本上是因为:
- 上一步的
a
后缀从未正确插入。所以它一直存在,并且由于我们前进了一步,它现在已经从a
到ab
。
- 我们需要插入新的最终边缘
b
。
在实践中,这意味着我们转到活动点(指向a
现在abcab
边缘的后面),并插入当前的最终字符b
。但是:同样,事实证明它b
也已经存在于同一边缘。
所以,再一次,我们不改变树。我们简单地:
- 将活动点更新为
(root,'a',2)
(与之前相同的节点和边,但现在我们指向后面b
)
- 将
remainder
增加到 3,因为我们仍然没有正确插入上一步的最终边,而且我们也没有插入当前的最终边。
需要明确的是:我们必须在当前步骤中插入ab
和,但因为已经找到,我们更新了活动点,甚至没有尝试插入。为什么?因为 if在树中,
它的每个后缀(包括)也必须在树中。也许只是隐含的,但它必须在那里,因为到目前为止我们构建树的方式。b
ab
b
ab
b
我们通过递增进行第 6 步#
。树会自动更新为:
因为remainder
是 3,我们必须插入abx
,bx
和
x
。活动点告诉我们在哪里ab
结束,所以我们只需要跳转到那里并插入x
. 确实,x
还没有,所以我们分割abcabx
边缘并插入一个内部节点:
边缘表示仍然是指向文本的指针,因此可以在 O(1) 时间内完成拆分和插入内部节点。
所以我们已经处理abx
并递减remainder
到2。现在我们需要插入下一个剩余的后缀,bx
。但在我们这样做之前,我们需要更新活动点。这个规则,在分割和插入一条边之后,将在下面被称为规则 1,它适用于任何时候
active_node
是根的(我们将在下面进一步学习规则 3)。这是规则 1:
从根插入后,
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,'\0x',0)
。在这里,我node1
用来指代ab
边缘结束的内部节点。
然后,在步骤#
=9中,我们需要插入“c”,这将有助于我们理解最后的技巧:
第二个扩展:使用后缀链接
与往常一样,#
更新会自动附加c
到叶子边缘,然后我们会转到活动点,看看是否可以插入“c”。事实证明 'c' 已经存在于那个边缘,所以我们将活动点设置为
(node1,'c',1)
,递增remainder
并且什么都不做。
现在在step #
=10中,remainder
是 4,所以我们首先需要
通过在活动点插入来插入abcd
(从 3 步前保留) 。d
尝试d
在活动点插入会在 O(1) 时间内导致边缘分裂:
开始拆分的active_node
,在上面用红色标记。这是最终规则,规则 3:
active_node
在从一个不是根节点的边缘分割出一条边之后,我们沿着从那个节点出来的后缀链接(如果有的话),并将 重置active_node
为它指向的节点。如果没有后缀链接,我们将其设置active_node
为根。active_edge
并active_length
保持不变。
所以活动点现在是(node2,'c',1)
,并node2
在下面用红色标记:
由于 的插入abcd
已完成,我们减remainder
为 3 并考虑当前步骤的下一个剩余后缀
bcd
。规则 3 已将活动点设置为恰好正确的节点和边,因此bcd
只需将其最后一个字符插入
d
活动点即可完成插入。
这样做会导致另一个边分裂,并且由于规则 2,我们必须创建一个从先前插入的节点到新节点的后缀链接:
我们观察到:后缀链接使我们能够重置活动点,以便我们可以在 O(1) 的努力下进行下一个剩余的插入。查看上图,确认 label 处的节点确实ab
链接到节点 at b
(其后缀),并且节点 atabc
链接到
bc
.
当前步骤尚未完成。remainder
现在是 2,我们需要按照规则 3 重新设置活动点。由于当前active_node
(上图红色)没有后缀链接,我们重置为 root。活动点是现在(root,'c',1)
。
c
因此,下一个插入发生在标签以:开头的根节点的一个出边处cabxabcd
,在第一个字符之后,即在 之后c
。这会导致另一个分裂:
由于这涉及到创建一个新的内部节点,我们遵循规则 2 并从先前创建的内部节点设置一个新的后缀链接:
(我正在为这些小图使用Graphviz Dot。新的后缀链接导致 dot 重新排列现有边,因此请仔细检查以确认上面插入的唯一内容是新的后缀链接。)
有了这个,remainder
可以设置为 1 并且由于active_node
是根,我们使用规则 1 将活动点更新为(root,'d',0)
。d
这意味着当前步骤的最后插入是在根处插入一个:
这是最后一步,我们完成了。不过,最终的观察结果有很多:
在每一步中,我们向前移动#
1 个位置。这会在 O(1) 时间内自动更新所有叶节点。
但它不处理 a)先前步骤中剩余的任何后缀,以及 b) 当前步骤的最后一个字符。
remainder
告诉我们需要做多少额外的插入。这些插入与在当前位置结束的字符串的最终后缀一一对应#
。我们一个接一个地考虑并进行插入。重要提示:每次插入都在 O(1) 时间内完成,因为活动点会告诉我们确切的去向,我们只需要在活动点添加一个字符。为什么?因为其他字符被隐式包含
(否则活动点不会在它所在的位置)。
在每个这样的插入之后,我们递减remainder
并遵循后缀链接(如果有的话)。如果不是,我们就进入根目录(规则 3)。如果我们已经在根节点,我们使用规则 1 修改活动点。无论如何,它只需要 O(1) 时间。
如果在其中一个插入过程中,我们发现要插入的字符已经存在,我们不做任何事情并结束当前步骤,即使remainder
>0。原因是剩下的任何插入都将是我们刚刚尝试制作的插入的后缀。因此,它们都隐含在当前树中。>0的事实remainder
确保我们稍后处理剩余的后缀。
如果算法结束时remainder
>0怎么办?只要文本的结尾是之前某处出现的子字符串,就会出现这种情况。在这种情况下,我们必须在字符串末尾附加一个以前没有出现过的字符。在文献中,通常使用美元符号$
作为其符号。为什么这很重要?--> 如果稍后我们使用完整的后缀树来搜索后缀,我们必须只接受以叶子结尾的匹配。否则我们会得到很多虚假匹配,因为树中隐含的许多字符串不是主字符串的实际后缀。强迫remainder
结尾为 0 本质上是一种确保所有后缀都以叶节点结尾的方法。但是,如果我们想使用树来搜索一般子字符串,而不仅仅是主字符串的后缀,这最后一步确实不是必需的,正如下面 OP 的评论所建议的那样。
那么整个算法的复杂度是多少呢?如果文本长度为 n 个字符,则显然有 n 个步骤(如果我们添加美元符号,则为 n+1)。在每一步中,我们要么什么都不做(除了更新变量),要么进行remainder
插入,每个都花费 O(1) 时间。因为remainder
表示我们在前面的步骤中有多少次什么都没做,并且对于我们现在进行的每一次插入都会递减,所以我们做某事的总次数正好是 n(或 n+1)。因此,总复杂度为 O(n)。
但是,有一件小事我没有正确解释:可能会发生我们跟随后缀链接,更新活动点,然后发现其active_length
组件与新的active_node
. 例如,考虑这样的情况:
(虚线表示树的其余部分。虚线是后缀链接。)
现在让活动点为(red,'d',3)
,因此它指向边缘后面f
的位置。defg
现在假设我们进行了必要的更新,现在按照后缀链接根据规则 3 更新活动点。新的活动点是(green,'d',3)
。但是,d
从绿色节点出来的 -edge 是de
,所以它只有 2 个字符。为了找到正确的活动点,我们显然需要沿着那条边到达蓝色节点并重置为(blue,'f',1)
。
在一个特别糟糕的情况下,active_length
可能与 一样大
remainder
,也可能与 n 一样大。并且很可能会发生这样的情况,即要找到正确的活动点,我们不仅需要跳过一个内部节点,而且可能需要跳过很多,在最坏的情况下最多可达 n 个。这是否意味着该算法具有隐藏的 O(n 2 ) 复杂度,因为在每个步骤remainder
中通常是 O(n),并且在遵循后缀链接之后对活动节点的后调整也可能是 O(n)?
不。原因是如果我们确实必须调整活动点(例如从绿色到蓝色),这会将我们带到一个拥有自己后缀链接的新节点,并且active_length
会减少。随着我们沿着后缀链接链进行剩余的插入,active_length
我们只能减少,并且我们可以在途中进行的活动点调整的数量不能大于active_length
任何给定时间。由于
active_length
永远不能大于remainder
, 并且不仅在每一步都是 O(n),而且在整个过程中remainder
所做的增量总和也是 O(n),因此活动点调整的数量是remainder
也以 O(n) 为界。