下面尝试描述Ukkonen算法,首先展示当字符串很简单(即不包含任何重复字符)时它会做什么,然后将其扩展到完整的算法。
首先,做一些初步陈述。
我们正在构建的,基本上就像一个搜索树。因此,
是根节点,它的边指向新的节点
再往外延伸一些边,等等
但是:与搜索树不同,边缘标签不是单一的
字符。相反,每条边都使用一对整数进行标记
[,]。这些是指向文本的指针。在这个意义上,每一个
edge携带任意长度的字符串标签,但只接受O(1)
空格(两个指针)。
基本原理
我想首先演示如何创建的后缀树
特别简单的字符串,没有重复字符的字符串:
abc
该算法按从左到右的步骤工作。字符串的每个字符都有一个步骤。每一步可能涉及多个单独的操作,但我们将看到(参见最后的观察结果)操作的总数为O(n)。
因此,我们从左边开始,首先只插入单个字符
A通过创建一条从根节点(左边)到叶节点的边,
并将其标记为[0,#],这意味着这条边代表
从位置0开始到当前结束的子字符串。我
使用符号#表示当前的结束,它位于位置1
(就在a后面)。
我们有了一个初始树,它看起来像这样:
它的意思是:
现在我们前进到位置2(在b之后)。每一步的目标
是插入到当前位置的所有后缀。我们这样做
通过
将现有的a边扩展到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。这将隐式地更新所有现有的
边缘问题:
我们需要插入当前步骤的最后一个后缀,a, at
根。
在此之前,我们再引入两个变量(除了
#),当然,它一直都在那里,但我们没有使用过
到目前为止:
活动点,是一个三倍的
(active_edge active_node active_length)
余数,它是一个整数,表示有多少个新后缀
我们需要插入
这两个词的确切含义很快就会清楚,但现在
让我们这么说吧:
In the simple abc example, the active point was always
(root,'\0x',0), i.e. active_node was the root node, active_edge was specified as the null character '\0x', and active_length was zero. The effect of this was that the one new edge that
we inserted in every step was inserted at the root node as a
freshly created edge. We will see soon why a triple is necessary to
represent this information.
The remainder was always set to 1 at the beginning of each
step. The meaning of this was that the number of suffixes we had to
actively insert at the end of each step was 1 (always just the
final character).
现在这将会改变。当我们插入当前的final时
字符a在根,我们注意到已经有一个外向
以a开头的边,特别是abca。这是我们在
这种情况:
We do not insert a fresh edge [4,#] at the root node. Instead we
simply notice that the suffix a is already in our
tree. It ends in the middle of a longer edge, but we are not
bothered by that. We just leave things the way they are.
We set the active point to (root,'a',1). That means the active
point is now somewhere in the middle of outgoing edge of the root node that starts with a, specifically, after position 1 on that edge. We
notice that the edge is specified simply by its first
character a. That suffices because there can be only one edge
starting with any particular character (confirm that this is true after reading through the entire description).
We also increment remainder, so at the beginning of the next step
it will be 2.
观察:当我们需要插入的最后一个后缀被找到时
已经存在于树中,树本身根本没有改变(我们只更新活动点和余数)。这棵树
然后不是一个准确的表示后缀树到
当前位置不再存在,但它包含所有后缀(因为final
后缀a是隐式包含的)。因此,除了更新
变量(都是固定长度的,所以这是O(1)
这一步没有做功。
步骤5:我们将当前位置#更新为5。这
自动更新树如下:
因为余数是2,我们需要插入两个final
当前位置的后缀:ab和b。这基本上是因为:
上一步中的a后缀一直不正确
插入。所以它一直存在,自从我们进步了一个
Step,现在从a变成了ab。
我们需要插入最后一条新边b。
在实践中,这意味着我们去到活动点(指向
在a后面,也就是现在的abcab边缘),然后插入
现在的最终字符b。但是:再次证明b是
这条边也已经出现了。
同样,我们没有改变树。我们只是:
更新活动点到(根,'a',2)(相同的节点和边
和之前一样,但现在我们指向b)
将余数加为3,因为我们仍然没有正确地
插入前一步的最后一条边,我们不插入
当前的最终边。
明确一点:我们必须在当前步骤中插入ab和b,但是
因为ab已经被找到,所以我们更新了活动点
甚至没有尝试插入b。为什么?因为如果ab在树中,
它的每个后缀(包括b)都必须在树中,
了。也许只是隐含的,但它必须在那里,因为
就像我们到目前为止建立的树。
我们通过增加#来继续第6步。树是
自动更新为:
因为余数是3,我们必须插入abx bx和
活动点告诉我们ab的终点,所以我们只需要
跳转到那里并插入x,实际上,x还没有在那里,所以我们
分割abcabx边缘并插入一个内部节点:
边缘表示仍然是指向文本的指针,所以
分裂和插入内部节点可以在O(1)时间内完成。
我们处理了abx,并将余数减为2。现在我们
需要插入下一个剩余的后缀,bx。但在此之前
我们需要更新活动点。分裂后的规则
并插入一条边,将被称为下面的规则1,它适用于
Active_node是根(我们将进一步学习规则3用于其他情况
下文)。规则一:
从根节点插入后,
Active_node保持root
Active_edge被设置为新后缀we的第一个字符
需要插入,即b
Active_length减少1
因此,新的活动点三元组(根,'b',1)表示
下一个插入必须在bcabx边缘,后面1个字符,
我们可以在O(1)时间内识别插入点和
检查x是否已经存在。如果它存在,我们
将结束当前步骤,让一切保持原样。但x
不存在,所以我们通过分割边缘插入它:
同样,这花费了O(1)时间我们将余数更新为1
如规则1所述,活动指向(根,'x',0)。
但我们还需要做一件事。我们称之为规则2:
如果我们分割一条边并插入一个新节点,如果不是
在当前步骤中创建的第一个节点,连接先前的节点
插入节点和新节点通过一个特殊指针、后缀
链接。我们稍后会看到为什么这是有用的。这就是我们得到的
后缀link表示为虚线边:
我们仍然需要插入当前步骤的最后一个后缀,
x.由于主节点的active_length组件下降
对于0,最后的插入直接在根节点上进行。因为没有
从x开始的根节点的外向边,我们插入一条新的
优势:
正如我们所看到的,在当前步骤中,所有剩余的插入都已完成。
我们通过设置#=7继续到第7步,这将自动添加下一个字符,
A,到所有的叶边,一如既往。然后我们尝试插入新的final
字符到活动点(根),并发现它在那里
了。我们结束当前的步骤,不插入任何东西
将活动点更新为(root,'a',1)。
在第8步,#=8,我们附加b,正如前面看到的,这只是
这意味着我们将活动点更新为(root,'a',2),并增加余数
还有别的吗,因为b已经存在了。然而,我们注意到(在O(1)时间)活动点
现在在一条边缘的尽头。我们通过将其重新设置为来反映这一点
(node1 ' \ 0 x ' 0)。在这里,我使用node1来引用
ab边结束于的内部节点。
然后,在步骤#=9中,我们需要插入'c',这将帮助我们
了解最后一个技巧:
第二个扩展:使用后缀链接
像往常一样,# update会自动将c追加到叶边
我们到活动点,看看能不能插入c。结果
c已经在那条边存在了,所以我们把活动点设为
(node1,'c',1),增加余数,其他什么都不做。
现在在步骤#=10中,余数是4,所以我们首先需要插入
Abcd(从3步前保留下来)通过在active中插入d
点。
试图在活动点插入d会导致边缘分裂
O(1)时间:
在中标记了active_node,分裂就是从它开始的
上面的红色。这是最后的规则,规则3:
在从一个不是根的active_node中分离边缘之后
节点,如果有的话,我们跟踪从该节点出来的后缀链接
并将active_node重置为它所指向的节点。如果有的话
没有后缀链接,我们将active_node设置为根。active_edge
active_length保持不变。
所以活动点现在是(node2,'c',1), node2被标记进来
红色下面:
由于abcd的插入已经完成,我们将余数减为
3,并考虑当前步骤的下一个剩余后缀,
bcd。规则3将活动点设置为正确的节点和边
所以插入BCD可以通过简单地插入它的最后一个字符来完成
D在活动点。
这样做会导致另一条边分裂,由于规则2,我们
必须创建从先前插入的节点到新节点的后缀链接吗
一:
我们观察到:后缀链接使我们能够重置活动点,因此我们
可以以O(1)次努力进行下一个剩余插入。看看
以确认标签ab上的节点确实被链接到
b处的节点(它的后缀)和ABC处的节点被链接到
bc。
当前步骤还没有完成。余数现在是2,我们
需要按照规则3重新设置活动点。自
当前active_node(上面红色)没有后缀链接,我们重置为
根。活动点现在是(根,c',1)。
因此,下一次插入发生在根节点的一个外向边缘
其标签以c: cabxabcd开头,在第一个字符后面,
这导致了另一个分裂:
因为这涉及到一个新的内部节点的创建,我们遵循
规则2,并设置一个新的后缀链接之前创建的内部
节点:
(我使用Graphviz Dot的这些小
图表。新的后缀链接导致点重新排列现有的
边,所以仔细检查,以确认唯一的东西
上面插入了一个新的后缀链接。)
这样,余数可以设置为1,因为active_node是
根,我们使用规则1将活动点更新为(根,'d',0)。这
表示当前步骤的最后一个插入是插入单个d
根:
这是最后一步,我们做完了。有一些最终的
不过,观察:
In each step we move # forward by 1 position. This automatically
updates all leaf nodes in O(1) time.
But it does not deal with a) any suffixes remaining from previous
steps, and b) with the one final character of the current step.
remainder tells us how many additional inserts we need to
make. These inserts correspond one-to-one to the final suffixes of
the string that ends at the current position #. We consider one
after the other and make the insert. Important: Each insert is
done in O(1) time since the active point tells us exactly where to
go, and we need to add only one single character at the active
point. Why? Because the other characters are contained implicitly
(otherwise the active point would not be where it is).
After each such insert, we decrement remainder and follow the
suffix link if there is any. If not we go to root (rule 3). If we
are at root already, we modify the active point using rule 1. In
any case, it takes only O(1) time.
If, during one of these inserts, we find that the character we want
to insert is already there, we don't do anything and end the
current step, even if remainder>0. The reason is that any
inserts that remain will be suffixes of the one we just tried to
make. Hence they are all implicit in the current tree. The fact
that remainder>0 makes sure we deal with the remaining suffixes
later.
What if at the end of the algorithm remainder>0? This will be the
case whenever the end of the text is a substring that occurred
somewhere before. In that case we must append one extra character
at the end of the string that has not occurred before. In the
literature, usually the dollar sign $ is used as a symbol for
that. Why does that matter? --> If later we use the completed suffix tree to search for suffixes, we must accept matches only if they end at a leaf. Otherwise we would get a lot of spurious matches, because there are many strings implicitly contained in the tree that are not actual suffixes of the main string. Forcing remainder to be 0 at the end is essentially a way to ensure that all suffixes end at a leaf node. However, if we want to use the tree to search for general substrings, not only suffixes of the main string, this final step is indeed not required, as suggested by the OP's comment below.
So what is the complexity of the entire algorithm? If the text is n
characters in length, there are obviously n steps (or n+1 if we add
the dollar sign). In each step we either do nothing (other than
updating the variables), or we make remainder inserts, each taking O(1)
time. Since remainder indicates how many times we have done nothing
in previous steps, and is decremented for every insert that we make
now, the total number of times we do something is exactly n (or
n+1). Hence, the total complexity is O(n).
However, there is one small thing that I did not properly explain:
It can happen that we follow a suffix link, update the active
point, and then find that its active_length component does not
work well with the new active_node. For example, consider a situation
like this:
虚线表示树的其余部分。虚线是a
后缀链接。)
现在设活动点为(红色,'d',3),所以它指向某个位置
在defg边缘的f后面。现在假设我们做了必要的事情
更新,现在跟随后缀链接更新活动点
根据规则3。新的活动点是(绿色,'d',3)。然而,
绿色节点外的d边是de,所以它只有2
字符。为了找到正确的活动点,我们显然
需要沿着这条边到蓝色节点,并重置为(蓝色,'f',1)。
在特别糟糕的情况下,active_length可能大到
余数,可以大到n,这很可能发生
为了找到正确的活动点,我们不仅仅需要跳过一个
内部节点,但可能很多,最坏情况下最多n个。这
意味着算法有一个隐藏的O(n2)复杂度,因为
每步余数一般为O(n),后调整
到主节点后的后缀链接也可以是O(n) ?
No. The reason is that if indeed we have to adjust the active point
(e.g. from green to blue as above), that brings us to a new node that
has its own suffix link, and active_length will be reduced. As
we follow down the chain of suffix links we make the remaining inserts, active_length can only
decrease, and the number of active-point adjustments we can make on
the way can't be larger than active_length at any given time. Since
active_length can never be larger than remainder, and remainder
is O(n) not only in every single step, but the total sum of increments
ever made to remainder over the course of the entire process is
O(n) too, the number of active point adjustments is also bounded by
O(n).