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