关于字符串:纯英语中的Ukkonen后缀树算法

Ukkonen's suffix tree algorithm in plain English

在这一点上我觉得有点发胖。我花了好几天的时间试图把我的头完全围绕在后缀树的构造上,但是因为我没有数学背景,很多解释在我开始过度使用数学符号的时候就消失了。我发现最接近一个很好的解释是用后缀树快速搜索字符串,但是他掩盖了不同的点,算法的某些方面仍然不清楚。

我敢肯定,对于除我之外的许多其他人来说,在堆栈溢出中逐步解释这个算法是非常宝贵的。

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

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

  • 我需要迭代给定字符串t的每个前缀p
  • 我需要遍历前缀p中的每个后缀s并将其添加到树中
  • 要将后缀s添加到树中,我需要迭代s中的每个字符,迭代过程包括沿着以s中相同的字符集c开头的现有分支遍历,并在后缀中到达不同的字符时可能将边缘拆分为子代节点,或者如果没有匹配的边缘要遍历,则执行以下操作WN。当找不到匹配的边来遍历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算法,首先显示字符串简单时的操作(即不包含任何重复字符),然后将其扩展到完整算法。好的。

首先,一些初步声明。好的。

  • 我们正在建造的,基本上就像一个搜索引擎。所以那里是一个根节点,边缘会从中流出,导致新节点,以及从这些边缘向外延伸,等等好的。

  • 但是:与搜索trie不同,边缘标签不是单一的字符。相反,每个边都用一对整数标记[from,to]。这些是指向文本的指针。在这个意义上,每个边缘带有任意长度的字符串标签,但只接受O(1)空格(两个指针)。好的。

  • 基本原理

    我想首先演示如何创建特别简单的字符串,没有重复字符的字符串:好的。

    1
    abc

    该算法从左到右分步骤工作。字符串中的每个字符都有一个步骤。每个步骤可能涉及多个单独的操作,但我们将看到(最后的观察结果)操作总数是O(N)。好的。

    所以,我们从左边开始,首先只插入一个字符a通过创建从根节点(左侧)到叶的边,并将其标记为[0,#],这意味着边缘代表子字符串从位置0开始,到当前结束。我使用符号#表示当前端,即位置1。(在a之后)。好的。

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

    >好的。<P>意思是:好的。<P><img src=好的。

    意思是:好的。

    >好的。<P>我们观察到两件事:好的。</p>
<ul>
<li><wyn>ab</wyn>的边缘表示与以前相同。在初始树中:<wyn>[0,#]</wyn>。它的意思已经自动改变了因为我们将当前位置从1更新到2。</li>
<li>每个边都消耗O(1)空间,因为它只包含两个指向文本的指针,不管它有多少个字符代表。</li>
</ul>
<p><P>接下来,我们再次增加位置,并通过附加将<wyn>c</wyn>插入到每个现有边,并为新边插入一个新边。后缀<wyn>c</wyn>。好的。<P>在我们的陈述中,这看起来像好的。<P><img src=

    1
    abcabxabcd

    它从abc开始,如前一个示例中所示,然后重复ab。接着是x,然后是abc,接着是d。好的。

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

    >好的。<P>第四步:我们把<wyn>#</wyn>移到4号位置。这将隐式更新所有现有的边缘:好的。<P><img src=和往常一样,#更新会自动将c附加到叶边。然后我们转到活动点,看看是否可以插入"c"。它转弯out"c"已经存在于该边缘,因此我们将活动点设置为(node1,'c',1),增加remainder,不做其他的。好的。

    现在在步骤#=10,remainder is 4中,我们首先需要插入通过在活动的点。好的。

    尝试在活动点插入d会导致边缘裂开。O(1)时间:好的。

    >好的。<P>发起分割的<wyn>active_node</wyn>标记在上面是红色。最后一条规则,规则3:好的。</p>
<blockquote>
<p>
After splitting an edge from an <wyn>active_node</wyn> that is not the root<br />
  node, we follow the suffix link going out of that node, if there is<br />
  any, and reset the <wyn>active_node</wyn> to the node it points to. If there is<br />
  no suffix link, we set the <wyn>active_node</wyn> to the root. <wyn>active_edge</wyn><br />
  and <wyn>active_length</wyn> remain unchanged.
</p>
<p>Ok.</p>
</blockquote>
<p><P>所以活跃点现在是<wyn>(node2,'c',1)</wyn>,<wyn>node2</wyn>标记在红色如下:好的。<P><img src=


    我尝试使用jojojapan的答案中给出的方法实现后缀树,但由于规则中使用的措辞,它在某些情况下不起作用。此外,我还提到,没有人能够使用这种方法实现绝对正确的后缀树。下面我将写一个"概述"Jojojapan的答案,并对规则做一些修改。我还将描述当我们忘记创建重要的后缀链接时的情况。

    使用的附加变量

  • 活动点-三倍(活动节点;活动边;活动长度),显示必须从何处开始插入新后缀。
  • 余数-显示必须显式添加的后缀数。例如,如果我们的单词是"a bca a bca",余数为3,则意味着我们必须处理最后3个后缀:bca、ca和a。
  • 让我们使用一个内部节点的概念——除了根节点和叶节点之外,所有的节点都是内部节点。

    观察1

    当我们需要插入的最后一个后缀已经存在于树中时,树本身就不会发生任何变化(我们只更新active pointremainder)。

    观察2

    如果在某一点上,active_length大于或等于当前边的长度(edge_length),我们将active point向下移动,直到edge_length严格大于active_length

    现在,让我们重新定义规则:

    规则1

    If after an insertion from the active node = root, the active length is greater than 0, then:

  • active node is not changed
  • active length is decremented
  • active edge is shifted right (to the first character of the next suffix we must insert)
  • 规则2

    If we create a new internal node OR make an inserter from an internal node, and this is not the first SUCH internal node at current step, then we link the previous SUCH node with THIS one through a suffix link.

    Rule 2的这个定义不同于jojojojapan,因为这里我们不仅考虑了新创建的内部节点,还考虑了从中进行插入的内部节点。

    规则3

    After an insert from the active node which is not the root node, we must follow the suffix link and set the active node to the node it points to. If there is no a suffix link, set the active node to the root node. Either way, active edge and active length stay unchanged.

    在这个Rule 3的定义中,我们还考虑了叶节点的插入(不仅是拆分节点)。

    最后,观察3:

    当我们要添加到树的符号已经在边缘时,我们根据Observation 1只更新active pointremainder,保持树不变。但是,如果有一个标记为需要后缀链接的内部节点,我们必须通过后缀链接将该节点与当前的active node连接起来。

    如果我们在这种情况下添加了后缀链接,那么让我们看看cdddcdc的后缀树示例,如果我们没有:

  • 如果我们不通过后缀链接连接节点:

    • 在添加最后一个字母c之前:

    ></P></p>
<ul>
<li>添加最后一个字母c后:</li>
</ul>
<p><P><img src=active node被设置为红色节点。但我们不从红色节点进行插入,因为字母"c"已经在边缘。这是否意味着蓝色节点必须没有后缀链接?不,我们必须通过后缀链接将蓝色节点连接到红色节点。为什么是正确的?因为Active Point方法保证我们到达正确的位置,即到下一个必须处理较短后缀插入的位置。

    最后,下面是后缀树的实现:

  • 爪哇
  • C++
  • 希望这个"概述"与Jojojapan的详细答案相结合,将有助于某人实现自己的后缀树。


    感谢@jojojapan的讲解,我在python中实现了这个算法。

    @jojojojapan提到的几个小问题比我预期的要复杂,需要非常小心地处理。我花了几天时间使我的实现足够健壮(我想)。问题及解决办法如下:

  • Remainder > 0结束,结果表明这种情况也可能发生在展开步骤中,而不仅仅是整个算法的结束。当这种情况发生时,我们可以保持其余部分actnode、actedge和actlength不变,结束当前展开步骤,并根据原始字符串中的下一个字符是否在当前路径上继续折叠或展开来开始另一个步骤。

  • 跳过节点:当我们跟踪后缀链接时,更新活动点,然后发现其活动长度组件与新的活动节点不匹配。我们必须前进到正确的地方去分裂,或者插入一片叶子。这个过程可能不那么简单,因为在移动actlength和actedge的过程中一直在变化,当您必须移回根节点时,actedge和actlength可能因为这些移动而出错。我们需要额外的变量来保存这些信息。

    enter image description here

  • @managonov不知何故指出了另外两个问题

  • 分割可能会在尝试分割边缘时退化,有时您会发现分割操作正好在节点上。在这种情况下,我们只需要向该节点添加一个新的叶,将其作为标准的边缘分割操作,这意味着如果有后缀链接,就应该相应地维护它。

  • 隐藏的后缀链接还有另一个特殊情况,这是由问题1和问题2引起的。有时我们需要跳几个节点到正确的点进行拆分,如果我们通过比较剩余的字符串和路径标签来移动,可能会超过正确的点。在这种情况下,后缀链接将被无意中忽略(如果应该有)。这可以通过向前移动时记住正确的点来避免。如果拆分节点已经存在,或者在展开步骤中出现问题1,则应保持后缀链接。

  • 最后,我在python中的实现如下:

    • Python

    Tips: It includes a naive tree printing function in the code above, which is very important while debugging. It saved me a lot of
    time and is convenient for locating special cases.


    我的直觉是:

    在主循环的k次迭代之后,您构建了一个后缀树,其中包含以前k个字符开始的完整字符串的所有后缀。

    开始时,这意味着后缀树包含一个表示整个字符串的根节点(这是唯一从0开始的后缀)。

    在len(string)迭代之后,您有一个包含所有后缀的后缀树。

    在循环过程中,关键是活动点。我猜这代表了后缀树中最深的点,它对应于字符串前k个字符的适当后缀。(我认为正确的意思是后缀不能是整个字符串。)

    例如,假设您看到了字符"abcabc"。活动点将表示树中与后缀"abc"对应的点。

    活动点由(原点、第一个、最后一个)表示。这意味着您当前处于树中的某个点上,可以从节点原点开始,然后以字符串形式输入字符[第一个:最后一个]

    添加新字符时,将查看活动点是否仍在现有树中。如果是这样,你就完了。否则,您需要在活动点向后缀树添加一个新节点,回滚到下一个最短匹配,然后再次检查。

    注1:后缀指针为每个节点提供到下一个最短匹配的链接。

    注2:添加新节点并回退时,将为新节点添加新的后缀指针。此后缀指针的目标将是位于缩短的活动点的节点。此节点将已经存在,或者将在此回退循环的下一次迭代中创建。

    注3:规范化部分简单地节省了检查活动点的时间。例如,假设您总是使用origin=0,并且只更改了第一个和最后一个。要检查活动点,您必须每次沿着所有中间节点跟踪后缀树。通过只记录到最后一个节点的距离来缓存跟踪此路径的结果是有意义的。

    你能给一个你所说的"固定"边界变量的代码例子吗?

    健康警告:我也发现这个算法特别难理解,所以请意识到这个直觉很可能在所有重要细节上都是错误的…


    @Jogojapan你带来了很棒的解释和可视化。但是正如@makagonov提到的,它缺少一些设置后缀链接的规则。在http://brenden.github.io/ukkonen-animation/通过单词"aabaab"一步一步地进行时,可以很好地看到它。当您从步骤10转到步骤11时,从节点5到节点2没有后缀链接,但是活动点突然移动到那里。

    Makangov,因为我生活在爪哇世界,我也尝试遵循您的实现,以掌握ST建设工作流程,但这对我来说很难,因为:

    • 将边与节点组合
    • 使用索引指针而不是引用
    • 中断语句;
    • 继续陈述;

    因此,我最终在爪哇实现了这样的实现,我希望以更清晰的方式反映所有的步骤,并减少其他Java用户的学习时间:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    35
    36
    37
    38
    39
    40
    41
    42
    43
    44
    45
    46
    47
    48
    49
    50
    51
    52
    53
    54
    55
    56
    57
    58
    59
    60
    61
    62
    63
    64
    65
    66
    67
    68
    69
    70
    71
    72
    73
    74
    75
    76
    77
    78
    79
    80
    81
    82
    83
    84
    85
    86
    87
    88
    89
    90
    91
    92
    93
    94
    95
    96
    97
    98
    99
    100
    101
    102
    103
    104
    105
    106
    107
    108
    109
    110
    111
    112
    113
    114
    115
    116
    117
    118
    119
    120
    121
    122
    123
    124
    125
    126
    127
    128
    129
    130
    131
    132
    133
    134
    135
    136
    137
    138
    139
    140
    141
    142
    143
    144
    145
    146
    147
    148
    149
    150
    151
    152
    153
    154
    155
    156
    157
    158
    159
    160
    161
    162
    163
    164
    165
    166
    167
    168
    169
    170
    171
    172
    173
    174
    175
    176
    177
    178
    179
    180
    181
    182
    183
    184
    185
    import java.util.arrays;导入java.util.hashmap;导入java.util.map;公共类ST{公共类节点{私人最终利息ID;私有最终映射<character,edge>edges;私有节点slink;公共节点(最终int id){这个ID=ID;this.edges=new hashmap<>();}公共void setslink(最终节点slink){this.slink=slink;}公共映射<character,edge>getedges()。{返回this.edges;}公共节点getslink()。{返回this.slink;}公共字符串ToString(最终字符串字){返回新的StringBuilder()。追加("{")追加("ID")追加(":").append(此.id)追加(",").append("slink")。追加(":").append(this.slink!= NULL?this.slink.id:空)追加(",").append("边")。追加(":").append(edgestostring(word))。追加()<hr><P>嗨,我已经尝试在Ruby中实现上面解释的实现,请检查一下。它似乎工作得很好。</P><P>实现中唯一的区别是,我尝试使用边缘对象而不是仅仅使用符号。</P><P>它也出现在https://gist.github.com/suchitpuri/9304856</P>[cc]    require 'pry'


    class Edge
        attr_accessor :data , :edges , :suffix_link
        def initialize data
            @data = data
            @edges = []
            @suffix_link = nil
        end

        def find_edge element
            self.edges.each do |edge|
                return edge if edge.data.start_with? element
            end
            return nil
        end
    end

    class SuffixTrees
        attr_accessor :root , :active_point , :remainder , :pending_prefixes , :last_split_edge , :remainder

        def initialize
            @root = Edge.new nil
            @active_point = { active_node: @root , active_edge: nil , active_length: 0}
            @remainder = 0
            @pending_prefixes = []
            @last_split_edge = nil
            @remainder = 1
        end

        def build string
            string.split("").each_with_index do |element , index|


                add_to_edges @root , element        

                update_pending_prefix element                          
                add_pending_elements_to_tree element
                active_length = @active_point[:active_length]

                # if(@active_point[:active_edge] && @active_point[:active_edge].data && @active_point[:active_edge].data[0..active_length-1] ==  @active_point[:active_edge].data[active_length..@active_point[:active_edge].data.length-1])
                #   @active_point[:active_edge].data = @active_point[:active_edge].data[0..active_length-1]
                #   @active_point[:active_edge].edges << Edge.new(@active_point[:active_edge].data)
                # end

                if(@active_point[:active_edge] && @active_point[:active_edge].data && @active_point[:active_edge].data.length == @active_point[:active_length]  )
                    @active_point[:active_node] =  @active_point[:active_edge]
                    @active_point[:active_edge] = @active_point[:active_node].find_edge(element[0])
                    @active_point[:active_length] = 0
                end
            end
        end

        def add_pending_elements_to_tree element

            to_be_deleted = []
            update_active_length = false
            # binding.pry
            if( @active_point[:active_node].find_edge(element[0]) != nil)
                @active_point[:active_length] = @active_point[:active_length] + 1              
                @active_point[:active_edge] = @active_point[:active_node].find_edge(element[0]) if @active_point[:active_edge] == nil
                @remainder = @remainder + 1
                return
            end



            @pending_prefixes.each_with_index do |pending_prefix , index|

                # binding.pry          

                if @active_point[:active_edge] == nil and @active_point[:active_node].find_edge(element[0]) == nil

                    @active_point[:active_node].edges << Edge.new(element)

                else

                    @active_point[:active_edge] = node.find_edge(element[0]) if @active_point[:active_edge]  == nil

                    data = @active_point[:active_edge].data
                    data = data.split("")              

                    location = @active_point[:active_length]


                    # binding.pry
                    if(data[0..location].join == pending_prefix or @active_point[:active_node].find_edge(element) != nil )                  


                    else #tree split    
                        split_edge data , index , element
                    end

                end
            end
        end



        def update_pending_prefix element
            if @active_point[:active_edge] == nil
                @pending_prefixes = [element]
                return

            end

            @pending_prefixes = []

            length = @active_point[:active_edge].data.length
            data = @active_point[:active_edge].data
            @remainder.times do |ctr|
                    @pending_prefixes << data[-(ctr+1)..data.length-1]
            end

            @pending_prefixes.reverse!

        end

        def split_edge data , index , element
            location = @active_point[:active_length]
            old_edges = []
            internal_node = (@active_point[:active_edge].edges != nil)

            if (internal_node)
                old_edges = @active_point[:active_edge].edges
                @active_point[:active_edge].edges = []
            end

            @active_point[:active_edge].data = data[0..location-1].join                
            @active_point[:active_edge].edges << Edge.new(data[location..data.size].join)


            if internal_node
                @active_point[:active_edge].edges << Edge.new(element)
            else
                @active_point[:active_edge].edges << Edge.new(data.last)        
            end

            if internal_node
                @active_point[:active_edge].edges[0].edges = old_edges
            end


            #setup the suffix link
            if @last_split_edge != nil and @last_split_edge.data.end_with?@active_point[:active_edge].data

                @last_split_edge.suffix_link = @active_point[:active_edge]
            end

            @last_split_edge = @active_point[:active_edge]

            update_active_point index

        end


        def update_active_point index
            if(@active_point[:active_node] == @root)
                @active_point[:active_length] = @active_point[:active_length] - 1
                @remainder = @remainder - 1
                @active_point[:active_edge] = @active_point[:active_node].find_edge(@pending_prefixes.first[index+1])
            else
                if @active_point[:active_node].suffix_link != nil
                    @active_point[:active_node] = @active_point[:active_node].suffix_link              
                else
                    @active_point[:active_node] = @root
                end
                @active_point[:active_edge] = @active_point[:active_node].find_edge(@active_point[:active_edge].data[0])
                @remainder = @remainder - 1    
            end
        end

        def add_to_edges root , element    
            return if root == nil
            root.data = root.data + element if(root.data and root.edges.size == 0)
            root.edges.each do |edge|
                add_to_edges edge , element
            end
        end
    end

    suffix_tree = SuffixTrees.new
    suffix_tree.build("abcabxabcd")
    binding.pry