Извиняюсь, если мой ответ кажется излишним, но я недавно реализовал алгоритм Укконена и столкнулся с ним в течение нескольких дней; Мне пришлось прочитать несколько статей на эту тему, чтобы понять, почему и как некоторые основные аспекты алгоритма.
Я нашел подход «правил» предыдущих ответов бесполезным для понимания основных причин , поэтому я написал все ниже, сосредоточившись исключительно на прагматике. Если вы боролись с другими объяснениями, как и я, возможно, мое дополнительное объяснение заставит вас «щелкнуть».
Я опубликовал свою реализацию C # здесь: https://github.com/baratgabor/SuffixTree
Обратите внимание, что я не специалист по этому вопросу, поэтому следующие разделы могут содержать неточности (или еще хуже). Если вы столкнулись с любой, не стесняйтесь редактировать.
Предпосылки
Исходная точка следующего объяснения предполагает, что вы знакомы с содержанием и использованием деревьев суффиксов, а также с характеристиками алгоритма Укконена, например, как вы расширяете дерево суффиксов за символом от начала до конца. По сути, я предполагаю, что вы уже читали некоторые другие объяснения.
(Тем не менее, мне пришлось добавить некоторые основные повествования для потока, так что начало действительно может показаться излишним.)
Наиболее интересной частью является объяснение разницы между использованием суффиксных ссылок и повторным сканированием из корня . Это то, что дало мне много ошибок и головных болей в моей реализации.
Открытые конечные узлы и их ограничения
Я уверен, что вы уже знаете, что самым фундаментальным «трюком» является осознание того, что мы можем просто оставить конец суффиксов «открытым», то есть ссылаться на текущую длину строки вместо того, чтобы устанавливать конец в статическое значение. Таким образом, когда мы добавляем дополнительные символы, эти символы будут неявно добавляться ко всем меткам суффиксов без необходимости посещать и обновлять их все.
Но это открытое окончание суффиксов - по очевидным причинам - работает только для узлов, которые представляют конец строки, то есть листовых узлов в древовидной структуре. Операции ветвления, которые мы выполняем в дереве (добавление новых узлов ветвления и конечных узлов), не будут распространяться автоматически везде, где это необходимо.
Вероятно, это элементарно и не потребует упоминания, что повторяющиеся подстроки не появляются явно в дереве, поскольку дерево уже содержит их, поскольку они являются повторениями; однако, когда повторяющаяся подстрока заканчивается, встречая неповторяющийся символ, нам нужно создать разветвление в этой точке, чтобы представить расхождение с этой точки и далее.
Например, в случае строки 'ABCXABCY' (см. Ниже), ветвление к X и Y необходимо добавить к трем различным суффиксам, ABC , BC и C ; иначе это не было бы действительным деревом суффиксов, и мы не могли бы найти все подстроки строки, сопоставляя символы от корня вниз.
Еще раз, чтобы подчеркнуть - любая операция, которую мы выполняем с суффиксом в дереве, должна также отражаться его последовательными суффиксами (например, ABC> BC> C), в противном случае они просто перестают быть действительными суффиксами.
Но даже если мы примем, что мы должны выполнить эти обновления вручную, как мы узнаем, сколько суффиксов нужно обновить? Поскольку, когда мы добавляем повторяющийся символ A (и остальные повторяющиеся символы подряд), мы еще не знаем, когда и где нам нужно разделить суффикс на две ветви. Необходимость разделения определяется только тогда, когда мы сталкиваемся с первым неповторяющимся символом, в данном случае Y (вместо X, который уже существует в дереве).
Что мы можем сделать, это сопоставить самую длинную повторяющуюся строку и посчитать, сколько суффиксов нам нужно обновить позже. Это то, что означает «остаток» .
Понятие «остаток» и «повторное сканирование»
Переменная remainder
сообщает нам, сколько повторных символов мы добавили неявно, без разветвления; т.е. сколько суффиксов нам нужно посетить, чтобы повторить операцию ветвления, как только мы нашли первый символ, которому мы не можем сопоставить. По сути это равняется тому, сколько символов «глубоко» мы находимся в дереве от его корня.
Итак, оставаясь с предыдущим примером строки ABCXABCY , мы сопоставляем повторяемую часть ABC «неявно», увеличивая remainder
каждый раз, что приводит к остатку 3. Затем мы встречаем неповторяющийся символ «Y» . Здесь мы расколоть ранее добавленный ABCX в ABC -> X и ABC -> Y . Затем мы уменьшаем remainder
с 3 до 2, потому что мы уже позаботились о разветвлении ABC . Теперь мы повторяем операцию, сопоставляя последние 2 символа - BC - от корня до точки, где нам нужно разделить, и мы также разделяем BCX на BC->X и BC -> Y . Снова уменьшаем remainder
до 1 и повторяем операцию; пока не remainder
будет 0. Наконец, нам нужно добавить сам текущий символ ( Y ) в корень.
Эта операция, следующая за последовательными суффиксами от корня просто для того, чтобы достичь точки, где нам нужно выполнить операцию, называется так называемым «повторным сканированием» в алгоритме Укконена, и, как правило, это самая дорогая часть алгоритма. Представьте себе более длинную строку, в которой вам нужно «повторно сканировать» длинные подстроки на множестве десятков узлов (мы обсудим это позже), возможно, тысячи раз.
В качестве решения мы вводим то, что мы называем «суффиксными ссылками» .
Концепция «суффиксных ссылок»
Суффиксные ссылки в основном указывают на позиции, которые мы обычно должны «повторно сканировать» , поэтому вместо дорогостоящей операции повторного сканирования мы можем просто перейти к связанной позиции, выполнить нашу работу, перейти к следующей связанной позиции и повторять до тех пор, пока больше нет позиций для обновления.
Конечно, один большой вопрос, как добавить эти ссылки. Существующий ответ заключается в том, что мы можем добавлять ссылки, когда вставляем новые узлы ветвления, используя тот факт, что в каждом расширении дерева узлы ветвления естественным образом создаются один за другим в точном порядке, в котором мы должны связать их вместе. , Тем не менее, мы должны связать последний созданный узел ветвления (самый длинный суффикс) с ранее созданным, поэтому нам нужно кэшировать последний созданный нами объект, связать его со следующим созданным нами и кэшировать вновь созданный.
Одним из следствий этого является то, что у нас часто нет ссылок на суффиксы, потому что данный узел ветвления был только что создан. В этих случаях мы все равно должны вернуться к вышеупомянутому «повторному сканированию» из корня. Вот почему после вставки вы должны либо использовать суффиксную ссылку, либо перейти к корню.
(Или, в качестве альтернативы, если вы храните родительские указатели в узлах, вы можете попытаться проследить за родителями, проверить, есть ли у них ссылка, и использовать ее. Я обнаружил, что это очень редко упоминается, но использование ссылки суффикса не набор в камнях. Есть несколько возможных подходов, и если вы понимаете основной механизм можно реализовать тот , который соответствует вашим потребностям лучше всего.)
Концепция «активной точки»
До сих пор мы обсуждали несколько эффективных инструментов для построения дерева и смутно ссылались на обход по множеству ребер и узлов, но еще не исследовали соответствующие последствия и сложности.
Ранее объясненная концепция «остаток» полезно для отслеживания того, где мы находимся в дереве, но мы должны понимать, что оно не хранит достаточно информации.
Во-первых, мы всегда находимся на определенном ребре узла, поэтому нам нужно хранить информацию о ребрах. Мы будем называть это «активным краем» .
Во-вторых, даже после добавления информации о ребрах у нас все равно нет возможности определить позицию, которая находится ниже в дереве и не связана напрямую с корневым узлом. Таким образом, мы должны хранить узел также. Давайте назовем это «активный узел» .
Наконец, мы можем заметить, что «остаток» не подходит для определения позиции на ребре, которое напрямую не связано с корнем, потому что «остаток» - это длина всего маршрута; и мы, вероятно, не хотим беспокоиться о запоминании и вычитании длины предыдущих ребер. Таким образом, нам нужно представление, которое по сути является остатком на текущем ребре . Это то, что мы называем «активной длиной» .
Это приводит к тому, что мы называем «активной точкой» - пакетом из трех переменных, которые содержат всю информацию, которую мы должны поддерживать о нашей позиции в дереве:
Active Point = (Active Node, Active Edge, Active Length)
На следующем изображении вы можете наблюдать, как согласованный маршрут ABCABD состоит из 2 символов на ребре AB (от корня ) и 4 символов на ребре CABDABCABD (от узла 4) - в результате получается «остаток» из 6 символов. Таким образом, наша текущая позиция может быть идентифицирована как активный узел 4, активный край C, активная длина 4 .
Другая важная роль «активной точки» заключается в том, что она обеспечивает уровень абстракции для нашего алгоритма, а это означает, что части нашего алгоритма могут выполнять свою работу над «активной точкой» независимо от того, находится ли эта активная точка в корне или где-либо еще. , Это позволяет легко и просто реализовать использование суффиксных ссылок в нашем алгоритме.
Отличия повторного сканирования от использования суффиксных ссылок
Теперь сложная часть, которая, по моему опыту, может вызвать множество ошибок и головных болей, и которая в большинстве источников плохо объяснена, заключается в разнице в обработке случаев суффиксной ссылки по сравнению с случаями повторного сканирования.
Рассмотрим следующий пример строки «AAAABAAAABAAC» :
Вы можете наблюдать выше, как «остаток» от 7 соответствует общей сумме символов от корня, в то время как «активная длина» 4 соответствует сумме совпадающих символов от активного края активного узла.
Теперь, после выполнения операции ветвления в активной точке, наш активный узел может содержать или не содержать суффиксную ссылку.
Если имеется суффиксная ссылка: нам нужно только обработать часть «активной длины» . «Остаток» не имеет никакого значения, так как узел , где мы переходим к по ссылке суффиксом уже кодирует правильный «остаток» неявно , просто в силу того , в дереве , где он находится.
Если суффиксная ссылка НЕ присутствует: нам нужно «повторно сканировать» с нуля / root, что означает обработку всего суффикса с самого начала. Для этого мы должны использовать весь «остаток» в качестве основы для повторного сканирования.
Пример сравнения обработки с и без суффиксной ссылки
Рассмотрим, что происходит на следующем шаге примера выше. Давайте сравним, как добиться того же результата - т.е. перейти к следующему суффиксу для обработки - с суффиксной ссылкой и без нее.
Использование суффиксной ссылки
Обратите внимание, что если мы используем суффиксную ссылку, мы автоматически «в нужном месте». Что часто не совсем верно из-за того, что «активная длина» может быть «несовместима» с новой позицией.
В приведенном выше случае, поскольку «active length» равно 4, мы работаем с суффиксом « ABAA» , начиная с связанного узла 4. Но после нахождения ребра, соответствующего первому символу суффикса ( «A») ), мы замечаем, что наша «активная длина» переполняет это ребро на 3 символа. Таким образом, мы перепрыгиваем через полный край к следующему узлу и уменьшаем «активную длину» на символы, которые мы использовали при прыжке.
Затем, после того как мы нашли следующее ребро «B» , соответствующее уменьшенному суффиксу «BAA », мы наконец отметили, что длина ребра больше, чем оставшаяся «активная длина» 3, что означает, что мы нашли правильное место.
Обратите внимание: кажется, что эту операцию обычно не называют «повторным сканированием», хотя мне кажется, что это прямой эквивалент повторного сканирования, только с сокращенной длиной и начальной точкой без корня.
С помощью 'rescan'
Обратите внимание, что если мы используем традиционную операцию «повторного сканирования» (в данном случае притворяясь, что у нас нет суффиксной ссылки), мы начинаем с вершины дерева, с корня, и нам приходится снова идти вниз в нужное место, следуя по всей длине текущего суффикса.
Длина этого суффикса является «остатком», который мы обсуждали ранее. Мы должны поглотить весь этот остаток, пока он не достигнет нуля. Это может (и часто так) включать прыжки через несколько узлов, при каждом прыжке уменьшая остаток на длину ребра, через которое мы прыгнули. Затем, наконец, мы достигаем края, который длиннее нашего оставшегося «остатка» ; здесь мы устанавливаем активное ребро для данного ребра, устанавливаем «активную длину» для оставшегося «остатка» », и все готово.
Обратите внимание, однако, что фактическое «остаток» переменная должна быть сохранена и только уменьшена после каждой вставки узла. То, что я описал выше, предполагало использование отдельной переменной, инициализированной как «остаток» .
Примечания к суффиксным ссылкам и повторному просмотру
1) Обратите внимание, что оба метода приводят к одному и тому же результату. Однако переход по суффиксным ссылкам в большинстве случаев значительно быстрее; Вот и вся логика суффиксных ссылок.
2) Реальные алгоритмические реализации не должны отличаться. Как я упоминал выше, даже в случае использования суффиксной ссылки «активная длина» часто несовместима со связанной позицией, поскольку эта ветвь дерева может содержать дополнительное ветвление. По сути, вам просто нужно использовать «активную длину» вместо «остаток» и выполнять ту же логику повторного сканирования, пока не найдете край, который короче оставшейся длины суффикса.
3) Одно важное замечание, касающееся производительности, заключается в том, что нет необходимости проверять каждый символ во время повторного сканирования. Благодаря тому, как построено правильное дерево суффиксов, мы можем смело предположить, что символы совпадают. Таким образом, вы в основном считаете длины, и единственная потребность в проверке эквивалентности символов возникает, когда мы переходим к новому ребру, поскольку ребра идентифицируются по их первому символу (который всегда уникален в контексте данного узла). Это означает, что логика «повторного сканирования» отличается от логики полного совпадения строк (то есть поиск подстроки в дереве).
4) Описанное здесь оригинальное суффиксное связывание является лишь одним из возможных подходов. . Например, NJ Larsson et al. называет этот подход нод-ориентированным сверху вниз и сравнивает его с нодо-ориентированным снизу-вверх и двумя краев-ориентированными вариантами. Разные подходы имеют разные типичные и наихудшие характеристики, требования, ограничения и т. Д., Но обычно кажется, что краевые подходы являются общим улучшением по сравнению с оригиналом.