以下是描述Ukkonen算法的尝试,首先显示字符串简单(即不包含任何重复字符)时的行为,然后将其扩展为完整算法。
首先,一些初步陈述。
我们正在构建的基本上就像一个搜索树。因此,有一个根节点,边缘向外延伸到新节点,进一步的边缘向外延伸,依此类推
但是:与搜索Trie不同,边缘标签不是单个字符。相反,每个边都使用一对整数标记
[from,to]
。这些是文本的指针。从这个意义上讲,每个边都带有任意长度的字符串标签,但仅占用O(1)空间(两个指针)。
基本原则
我首先要演示如何创建一个特别简单的字符串(没有重复字符的字符串)的后缀树:
abc
该算法从左到右逐步执行。有一步到位的字符串的每个字符。每个步骤可能涉及多个操作,但是我们将看到(请参阅最后的观察结果)操作总数为O(n)。
因此,我们从左侧开始,首先a
通过创建从根节点(在左侧)到叶的边来插入单个字符
,并将其标记为[0,#]
,这意味着该边代表从位置0开始到结束的子字符串。在当前结束时。我使用符号#
来表示当前端点,该端点位于位置1(紧随其后a
)。
因此,我们有一个初始树,如下所示:
这意味着什么:
现在我们前进到位置2(紧随在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
和b
,但是由于ab
已经找到,所以我们更新了活动点,甚至没有尝试插入b
。为什么?因为if ab
在树中,
所以它的每个后缀(包括b
)也必须在树中。也许只是隐式地存在,但是必须存在,因为到目前为止我们构建树的方式。
我们通过递增到步骤6#
。该树将自动更新为:
因为remainder
是3,我们必须插入abx
,bx
和
x
。活动点告诉我们ab
结束点,因此我们只需要跳到那里并插入即可x
。确实x
还没有,所以我们拆分abcabx
边缘并插入一个内部节点:
边缘表示仍然是文本中的指针,因此可以在O(1)时间内完成内部节点的拆分和插入。
因此,我们已处理abx
并减remainder
为2。现在,我们需要插入下一个剩余的后缀bx
。但是在此之前,我们需要更新活动点。在拆分和插入边后,此规则将在下面称为规则1,并且只要active_node
根为root 就会适用
(对于下面进一步介绍的其他情况,我们将学习规则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
并且不执行其他任何操作。
现在,在步骤#
= 10中,remainder
为4,因此我们首先需要abcd
通过d
在活动点插入来进行插入
(保留3步之前)。
尝试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
(其后缀),并且at的节点abc
已链接到
bc
。
当前步骤尚未完成。remainder
现在是2,我们需要遵循规则3再次重置活动点。由于当前active_node
(上面的红色)没有后缀链接,因此我们重置为root。现在是活动点(root,'c',1)
。
因此,下一个插入发生在根节点的标签以c
:开头的根节点的一个输出边缘上cabxabcd
,在第一个字符之后,即在后面c
。这导致另一个分裂:
由于这涉及创建新的内部节点,因此我们遵循规则2,并从先前创建的内部节点设置新的后缀链接:
(我将Graphviz Dot用于这些小图形。新的后缀链接导致点重新排列了现有的边,因此请仔细检查以确认上方插入的唯一东西是新的后缀链接。)
有了这个,remainder
可以设置为1,因为active_node
是根,我们使用规则1,更新活动点(root,'d',0)
。这意味着当前步骤的最后插入是d
在root处插入一个:
那是最后一步,我们已经完成。但是,有许多最终观察结果:
在每一步中,我们向前移动#
1个位置。这会在O(1)时间自动更新所有叶节点。
但是它不处理a)先前步骤中剩余的任何后缀,以及b)当前步骤的最后一个字符。
remainder
告诉我们我们需要制作多少个附加插件。这些插入一对一地对应于在当前位置结束的字符串的最后后缀#
。我们考虑一个接一个,然后插入。重要提示:由于活动点会告诉我们确切的去向,因此每次插入都需要O(1)时间,并且我们只需在活动点添加一个字符即可。为什么?因为其他字符是隐式包含的
(否则,活动点将不在该位置)。
在每次这样的插入之后,我们都会减少remainder
并跟随后缀链接(如果有)。如果没有,我们就扎根(规则3)。如果我们已经是根用户,则使用规则1修改活动点。在任何情况下,只需要O(1)时间。
如果在这些插入操作之一中,我们发现要插入的字符已经存在,则即使remainder
> 0 ,我们也不执行任何操作并结束当前步骤。原因是任何剩余的插入内容都是我们刚刚尝试制作的内容的后缀。因此,它们都隐含在当前树中。remainder
> 0 的事实可确保我们稍后处理其余的后缀。
如果算法结束时remainder
> 0怎么办?每当文本的结尾是之前某个位置出现的子字符串时,情况就是如此。在这种情况下,我们必须在之前未出现过的字符串的末尾附加一个额外的字符。在文献中,通常使用美元$
符号来表示。为什么这么重要?->如果以后我们使用完整的后缀树搜索后缀,则只有当匹配项以leaf结尾时,我们才必须接受它们。否则,我们将得到很多虚假匹配,因为树中隐含了许多字符串,这些字符串不是主字符串的实际后缀。强迫remainder
在结尾处为0本质上是确保所有后缀在叶节点处结束的一种方式。但是,如果我们要使用树来搜索常规子字符串(不仅是主字符串的后缀),那么实际上并不需要最后一步,如以下OP的注释所建议。
那么整个算法的复杂度是多少?如果文本的长度为n个字符,则显然有n步(如果加美元符号,则为n + 1)。在每个步骤中,我们要么什么都不做(除了更新变量),要么进行remainder
插入,每个插入都花费O(1)时间。因为remainder
表示在上一步骤中我们什么都不做,并且现在执行的每个插入都会减少,所以我们做某件事的总次数正好是n(或n + 1)。因此,总复杂度为O(n)。
但是,有一件我没有适当解释的小事情:可能发生的情况是,我们跟随一个后缀链接,更新了活动点,然后发现它的active_length
组件不能与new一起很好地工作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
,并且remainder
不仅在每一步中都为O(n),而且remainder
在整个过程中对增量的总和也为O(n),因此有效点调整的次数为也以O(n)为界。