Я чувствую себя немного толстым в этот момент. Я потратил несколько дней, пытаясь полностью обернуть голову вокруг построения суффиксного дерева, но поскольку у меня нет математического фона, многие объяснения ускользают от меня, поскольку они начинают чрезмерно использовать математические символы. Наиболее близким к хорошему объяснению, которое я нашел, является быстрый поиск строк с суффиксными деревьями , но он закрывает глаза на различные моменты, и некоторые аспекты алгоритма остаются неясными.
Я уверен, что пошаговое объяснение этого алгоритма здесь, на переполнении стека, было бы неоценимо для многих других, кроме меня.
Для справки вот статья Укконена об алгоритме: http://www.cs.helsinki.fi/u/ukkonen/SuffixT1withFigs.pdf
Мое базовое понимание, пока:
- Мне нужно перебрать каждый префикс P данной строки T
- Мне нужно перебрать каждый суффикс S в префиксе P и добавить его в дерево
- Чтобы добавить суффикс S к дереву, мне нужно выполнить итерацию по каждому символу в S, причем итерации состоят из перехода по существующей ветви, начинающейся с того же набора символов C в S, и потенциального разбиения ребра на дочерние узлы, когда я достигните другого символа в суффиксе, ИЛИ, если не было подходящего края, чтобы идти вниз. Когда не найдено подходящего ребра для C, создается новое ребро листа для C.
Базовый алгоритм выглядит как O (n 2 ), как указано в большинстве объяснений, поскольку нам нужно пройти по всем префиксам, а затем нам нужно пройти по каждому из суффиксов для каждого префикса. Алгоритм Укконена, по-видимому, уникален, потому что он использует технику указателей суффиксов, хотя я думаю, что это то, что мне трудно понять.
У меня также есть проблемы с пониманием:
- когда и как «активная точка» назначается, используется и изменяется
- что происходит с аспектом канонизации алгоритма
- Почему реализации, которые я видел, должны "исправить" ограничивающие переменные, которые они используют
Вот завершенный исходный код C # . Он не только работает правильно, но поддерживает автоматическую канонизацию и визуализирует текстовый график с более приятным видом. Исходный код и пример выходных данных находятся по адресу:
Обновление 2017-11-04
После многих лет я нашел новое применение для деревьев суффиксов и реализовал алгоритм в JavaScript . Суть ниже. Это должно быть без ошибок. Извлеките его в файл js npm install chalk
из того же места, а затем запустите с node.js, чтобы увидеть некоторые красочные результаты. В том же Gist есть урезанная версия без какого-либо кода отладки.
https://gist.github.com/axefrog/c347bf0f5e0723cbd09b1aaed6ec6fc6
источник
Ответы:
Следующее является попыткой описать алгоритм Укконена, сначала показывая, что он делает, когда строка простая (то есть не содержит повторяющихся символов), а затем расширяя его до полного алгоритма.
Сначала несколько предварительных заявлений.
То, что мы строим, в основном похоже на поиск. Таким образом, есть корневой узел, выходящие из него ребра, ведущие к новым узлам, и выходящие из них ребра и т. Д.
Но : в отличие от поиска, метки ребер не являются одиночными символами. Вместо этого каждое ребро помечается с использованием пары целых чисел
[from,to]
. Это указатели на текст. В этом смысле каждое ребро несет строковую метку произвольной длины, но занимает только O (1) пробел (два указателя).Основной принцип
Я хотел бы сначала продемонстрировать, как создать дерево суффиксов для особенно простой строки, строки без повторяющихся символов:
Алгоритм работает пошагово, слева направо . Существует один шаг для каждого символа строки . Каждый шаг может включать более одной отдельной операции, но мы увидим (см. Заключительные наблюдения в конце), что общее количество операций составляет O (n).
Итак, мы начинаем слева и сначала вставляем только один символ
a
, создавая ребро из корневого узла (слева) до листа и помечая его как[0,#]
, что означает, что ребро представляет подстроку, начиная с позиции 0 и заканчивая в текущем конце . Я использую символ#
для обозначения текущего конца , который находится в позиции 1 (сразу послеa
).Итак, у нас есть исходное дерево, которое выглядит так:
И что это значит, это:
Теперь мы переходим к позиции 2 (сразу после
b
). Наша цель на каждом шаге - вставить все суффиксы до текущей позиции . Мы делаем это путемa
края доab
b
В нашем представлении это выглядит так
И что это значит:
Мы наблюдаем две вещи:
ab
является таким же , как это было в исходном дереве:[0,#]
. Его значение автоматически изменилось, потому что мы обновили текущую позицию#
с 1 до 2.Затем мы снова увеличиваем позицию и обновляем дерево, добавляя a
c
к каждому существующему ребру и вставляя одно новое ребро для нового суффиксаc
.В нашем представлении это выглядит так
И что это значит:
Мы наблюдаем:
#
, и вставка одного нового ребра для последнего символа может быть выполнена за O (1). Следовательно, для строки длины n требуется только время O (n).Первое расширение: простые повторения
Конечно, это работает так хорошо только потому, что наша строка не содержит повторений. Теперь посмотрим на более реалистичную строку:
Он начинается с,
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
. Почему? Потому что еслиab
есть в дереве, каждый его суффикс (включаяb
) тоже должен быть в дереве. Возможно, только неявно , но это должно быть там, из-за того, как мы построили дерево до сих пор.Переходим к шагу 6 , увеличивая его
#
. Дерево автоматически обновляется до:Потому что
remainder
это 3 , мы должны вставитьabx
,bx
иx
. Активная точка сообщает нам, гдеab
заканчивается, поэтому нам нужно просто прыгнуть туда и вставитьx
. Действительно,x
пока нет, поэтому мы разбиваемabcabx
край и вставляем внутренний узел:Представления ребер по-прежнему являются указателями на текст, поэтому разбиение и вставка внутреннего узла могут быть выполнены за O (1) времени.
Таким образом , мы имели дело с
abx
и декрементremainder
до 2. Теперь нужно вставить следующий оставшийся суффиксbx
. Но прежде чем мы это сделаем, нам нужно обновить активную точку. Правило для этого, после разделения и вставки ребра, будет называться Правилом 1 ниже, и оно применяется всякий раз, когдаactive_node
есть корень (мы изучим правило 3 для других случаев далее). Вот правило 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
(что осталось от 3 шагов назад), вставивd
в активную точку.Попытка вставить
d
в активную точку приводит к расщеплению ребра за время O (1):Объект
active_node
, с которого было начато разделение, отмечен красным сверху. Вот последнее правило, правило 3:Таким образом, активная точка сейчас
(node2,'c',1)
иnode2
помечена красным ниже:Так как вставка
abcd
завершена, мы уменьшаемremainder
до 3 и рассмотрим следующий оставшийся суффикс текущего шагаbcd
. Правило 3 установило активную точку как раз на правый узел и ребро, поэтому вставкаbcd
может быть сделана простым вставлением последнего символаd
в активную точку.Это вызывает другое разделение ребер, и из-за правила 2 мы должны создать суффиксную ссылку от ранее вставленного узла к новому:
Мы наблюдаем: Суффиксные ссылки позволяют нам сбросить активную точку, чтобы мы могли выполнить следующую оставшуюся вставку за O (1). Посмотрите на приведенный выше график, чтобы убедиться, что действительно узел at label
ab
связан с узлом atb
(его суффикс), а узел atabc
связан сbc
.Текущий шаг еще не закончен.
remainder
теперь 2, и нам нужно следовать правилу 3, чтобы снова сбросить активную точку. Так как текущийactive_node
(красным цветом выше) не имеет суффиксной ссылки, мы сбрасываем его в root. Активная точка сейчас(root,'c',1)
.Следовательно, следующая вставка происходит на одном исходящем ребре корневого узла, чья метка начинается с
c
:,cabxabcd
позади первого символа, то есть позадиc
. Это вызывает еще один раскол:А поскольку это включает создание нового внутреннего узла, мы следуем правилу 2 и устанавливаем новую ссылку на суффикс из ранее созданного внутреннего узла:
(Я использую Graphviz Dot для этих маленьких графиков. Новая ссылка на суффикс заставила точку переставить существующие ребра, поэтому проверьте, чтобы убедиться, что единственное, что было вставлено выше, это новая ссылка на суффикс.)
При этом
remainder
может быть установлено значение 1, а посколькуactive_node
это root, мы используем правило 1 для обновления активной точки(root,'d',0)
. Это означает, что последняя вставка текущего шага - вставка единственногоd
в корень:Это был последний шаг, и мы сделали. Есть несколько заключительных замечаний :
На каждом шаге мы продвигаемся
#
на 1 позицию. Это автоматически обновляет все конечные узлы за O (1) раз.Но это не касается а) любых суффиксов, оставшихся от предыдущих шагов, и б) с одним последним символом текущего шага.
remainder
говорит нам, сколько дополнительных вставок нам нужно сделать. Эти вставки соответствуют один к одному конечным суффиксам строки, которая заканчивается в текущей позиции#
. Рассмотрим один за другим и сделаем вкладыш. Важное замечание : Каждая вставка выполняется за O (1) раз, поскольку активная точка точно указывает нам, куда идти, и нам нужно добавить только один единственный символ в активной точке. Почему? Потому что другие символы содержатся неявно (иначе активная точка не будет там, где она есть).После каждой такой вставки мы уменьшаем
remainder
и следуем по суффиксной ссылке, если она есть. Если нет, мы идем к корню (правило 3). Если мы уже в корне, мы модифицируем активную точку, используя правило 1. В любом случае, это займет всего O (1) времени.Если во время одной из этих вставок мы обнаружим, что символ, который мы хотим вставить, уже есть, мы ничего не делаем и завершаем текущий шаг, даже если
remainder
> 0. Причина в том, что любые оставшиеся вставки будут суффиксами той, которую мы только что попытались сделать. Следовательно, они все неявные в текущем дереве. Тот факт, чтоremainder
> 0 гарантирует, что мы будем иметь дело с остальными суффиксами позже.Что если в конце алгоритма
remainder
> 0? Это будет иметь место всякий раз, когда конец текста является подстрокой, которая произошла где-то раньше. В этом случае мы должны добавить один дополнительный символ в конец строки, который не встречался ранее. В литературе, как правило, для этого используется знак доллара$
. Почему это имеет значение? -> Если позже мы используем заполненное дерево суффиксов для поиска суффиксов, мы должны принимать совпадения, только если они заканчиваются на листе . В противном случае мы получили бы много ложных совпадений, потому что в дереве неявно содержится много строк , которые не являются действительными суффиксами основной строки. форсированиеremainder
быть равным 0 в конце - это, по сути, способ гарантировать, что все суффиксы заканчиваются на листовом узле. Однако, если мы хотим использовать дерево для поиска общих подстрок , а не только суффиксов основной строки, этот последний шаг действительно не требуется, как предлагается в комментарии ОП ниже.Так в чем же сложность всего алгоритма? Если длина текста n символов, очевидно, что n шагов (или n + 1, если мы добавим знак доллара). На каждом шаге мы либо ничего не делаем (кроме обновления переменных), либо делаем
remainder
вставки, каждая из которых занимает O (1) времени. Посколькуremainder
указывает, сколько раз мы ничего не делали на предыдущих шагах, и уменьшается для каждой вставки, которую мы делаем сейчас, общее число раз, когда мы что-то делаем, равно n (или n + 1). Следовательно, общая сложность O (n).Однако есть одна небольшая вещь, которую я не объяснил должным образом: может случиться так, что мы перейдем по суффиксной ссылке, обновим активную точку, а затем обнаружим, что ее
active_length
компонент не работает с новойactive_node
. Например, рассмотрим ситуацию, подобную этой:(Пунктирные линии обозначают остальную часть дерева. Пунктирная линия является суффиксной ссылкой.)
Теперь активная точка будет
(red,'d',3)
, поэтому она указывает на место позадиf
наdefg
краю. Теперь предположим, что мы сделали необходимые обновления, и теперь перейдем по суффиксной ссылке, чтобы обновить активную точку в соответствии с правилом 3. Новая активная точка - это(green,'d',3)
. Тем не менее,d
выход из зеленого узла равен -edgede
, поэтому он содержит только 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
О (п) не только в каждом шаге, но общая сумма приращений когда - либо сделанныхremainder
в течение всего процесса является O (п) тоже количество настроек активных точек является также ограничен O (n).источник
abcdefabxybcdmnabcdex
. Начальная частьabcd
повторяется вabxy
(это создает внутренний узел послеab
) и снова вabcdex
, и заканчиваетсяbcd
, что появляется не только вbcdex
контексте, но и вbcdmn
контексте. После того,abcdex
как вставлено, мы следуем за суффиксной ссылкой, чтобы вставитьbcdex
, и там наступитЯ попытался внедрить Суффикс-дерево с помощью подхода, приведенного в ответе Джогожапана, но в некоторых случаях он не работал из-за формулировок, используемых для правил. Более того, я упомянул, что никому не удалось реализовать абсолютно правильное суффиксное дерево с использованием этого подхода. Ниже я напишу «обзор» ответа джогоджапана с некоторыми изменениями в правилах. Я также опишу случай, когда мы забываем создавать важные суффиксные ссылки.
Используются дополнительные переменные
Давайте используем концепцию внутреннего узла - все узлы, кроме корневого и конечного, являются внутренними узлами .
Наблюдение 1
Когда будет найдено, что последний суффикс, который нам нужно вставить, уже существует в дереве, само дерево вообще не изменяется (мы только обновляем
active point
иremainder
).Наблюдение 2
Если в некоторой точке
active_length
больше или равно длине текущего ребра (edge_length
), мы сдвигаемсяactive point
вниз до тех пор, пока неedge_length
станет строго больше, чемactive_length
.Теперь давайте переопределим правила:
Правило 1
Правило 2
Это определение
Rule 2
отличается от jogojapan ', поскольку здесь мы учитываем не только вновь созданные внутренние узлы, но и внутренние узлы, из которых мы делаем вставку.Правило 3
В этом определении
Rule 3
мы также рассматриваем вставки листовых узлов (не только сплит-узлов).И наконец, Наблюдение 3:
Когда символ, который мы хотим добавить к дереву, уже находится на краю, мы, в соответствии с этим
Observation 1
, обновляем толькоactive point
иremainder
оставляя дерево без изменений. НО если есть внутренний узел, помеченный как нуждающийся в суффиксной ссылке , мы должны связать этот узел с нашим текущимactive node
через суффиксную ссылку.Давайте рассмотрим пример дерева суффиксов для cdddcdc, если мы добавим ссылку на суффикс в таком случае и если мы этого не сделаем:
Если мы НЕ соединяем узлы через суффиксную ссылку:
Если мы действительно подключим узлы через суффиксную ссылку:
Похоже, что нет существенной разницы: во втором случае есть еще две суффиксные ссылки. Но эти суффиксные ссылки верны , и одна из них - от синего до красного - очень важна для нашего подхода с активной точкой . Проблема в том, что если мы не добавим здесь суффиксную ссылку, позже, когда мы добавим несколько новых букв в дерево, мы могли бы пропустить добавление некоторых узлов в дерево из-за
Rule 3
, потому что, согласно этому, если нет Суффикс ссылки, то мы должны положитьactive_node
в корень.Когда мы добавляли последнюю букву к дереву, красный узел уже существовал до того, как мы сделали вставку из синего узла (ребро помечено 'c' ). Поскольку из синего узла была вставка, мы помечаем ее как нужную суффиксную ссылку . Затем, опираясь на подход активной точки подход , был установлен красный узел. Но мы не делаем вставку из красного узла, так как буква «с» уже на краю. Означает ли это, что синий узел должен остаться без суффиксной ссылки? Нет, мы должны соединить синий узел с красным через суффиксную ссылку. Почему это правильно? Потому что активная точка , мы гарантируем, что мы попали в нужное место, то есть к следующему месту, где мы должны обработать вставку
active node
коротким суффиксом.Наконец, вот мои реализации дерева суффиксов:
Надеюсь, что этот «обзор» в сочетании с подробным ответом jogojapan поможет кому-то реализовать свое собственное Suffix Tree.
источник
aabaacaad
Это один из случаев, когда добавление дополнительного суффикса ссылки может сократить время обновления тройки. Заключение в последних двух параграфах поста jogojapan неверно. Если мы не добавим ссылки на суффиксы, упомянутые в этом посте, средняя сложность по времени должна составлять O (nlong (n)) или более. Потому что требуется дополнительное время, чтобы пройтись по дереву, чтобы найти правильныйactive_node
.Спасибо за хорошо объясненное руководство @jogojapan , я реализовал алгоритм на Python.
Несколько мелких проблем, упомянутых @jogojapan, оказываются более сложными, чем я ожидал, и к ним нужно относиться очень осторожно. Мне понадобилось несколько дней, чтобы сделать мою реализацию достаточно надежной (я полагаю). Проблемы и решения перечислены ниже:
Конец с
Remainder > 0
Оказывается, эта ситуация может произойти и на этапе развертывания , а не только в конце всего алгоритма. Когда это происходит, мы можем оставить остаток, actnode, actedge и actlength без изменений , завершить текущий шаг разворачивания и начать другой шаг, продолжая сворачивание или разворачивание в зависимости от того, находится ли следующий символ в исходной строке на текущем пути или не.Перепрыгивание через узлы: когда мы переходим по суффиксной ссылке, обновляем активную точку, а затем обнаруживаем, что ее компонент active_length плохо работает с новым active_node. Мы должны двигаться вперед в нужное место, чтобы разделить или вставить лист. Этот процесс может быть не таким простым, потому что во время перемещения длина акта и актига постоянно меняются, когда вам нужно вернуться обратно к корневому узлу , актедж и длина акта могут быть неправильными из-за этих движений. Нам нужны дополнительные переменные для хранения этой информации.
Две другие проблемы были как-то указаны @managonov
Расщепление может вырваться При попытке разбить ребро, иногда вы обнаружите, что операция расщепления находится прямо на узле. В этом случае нам нужно только добавить новый лист к этому узлу, принять его как стандартную операцию разбиения ребер, что означает, что ссылки суффикса, если они есть, должны поддерживаться соответственно.
Скрытые суффиксные ссылки Существует еще один особый случай, связанный с проблемой 1 и проблемой 2 . Иногда нам нужно перепрыгнуть через несколько узлов в правильную точку для разделения, мы можем превзойти правильную точку, если мы будем двигаться, сравнивая оставшуюся строку и метки пути. В этом случае ссылка на суффикс будет непреднамеренно игнорироваться, если таковая будет. Этого можно избежать, если вспомнить правильную точку при движении вперед. Суффиксная ссылка должна сохраняться, если разделенный узел уже существует, или даже проблема 1 возникает на этапе развертывания.
Наконец, моя реализация в Python выглядит следующим образом:
источник
Извиняюсь, если мой ответ кажется излишним, но я недавно реализовал алгоритм Укконена и столкнулся с ним в течение нескольких дней; Мне пришлось прочитать несколько статей на эту тему, чтобы понять, почему и как некоторые основные аспекты алгоритма.
Я нашел подход «правил» предыдущих ответов бесполезным для понимания основных причин , поэтому я написал все ниже, сосредоточившись исключительно на прагматике. Если вы боролись с другими объяснениями, как и я, возможно, мое дополнительное объяснение заставит вас «щелкнуть».
Я опубликовал свою реализацию 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. называет этот подход нод-ориентированным сверху вниз и сравнивает его с нодо-ориентированным снизу-вверх и двумя краев-ориентированными вариантами. Разные подходы имеют разные типичные и наихудшие характеристики, требования, ограничения и т. Д., Но обычно кажется, что краевые подходы являются общим улучшением по сравнению с оригиналом.
источник
@jogojapan вы привели потрясающее объяснение и визуализацию. Но, как заметил @makagonov, в нем отсутствуют некоторые правила, касающиеся установки ссылок на суффиксы. Это хорошо видно, когда вы идете шаг за шагом http://brenden.github.io/ukkonen-animation/ через слово «aabaaabb». При переходе от шага 10 к шагу 11 не существует суффиксной связи от узла 5 к узлу 2, но активная точка внезапно перемещается туда.
@makagonov, так как я живу в мире Java, я также пытался следовать вашей реализации, чтобы понять рабочий процесс построения ST, но мне было трудно из-за:
В итоге я получил такую реализацию на Java, которая, как я надеюсь, более четко отражает все этапы и сократит время обучения для других людей на Java:
источник
Моя интуиция заключается в следующем:
После k итераций основного цикла вы создали дерево суффиксов, которое содержит все суффиксы всей строки, начинающиеся с первых k символов.
В начале это означает, что дерево суффиксов содержит один корневой узел, представляющий всю строку (это единственный суффикс, начинающийся с 0).
После итерации len (строки) у вас есть дерево суффиксов, которое содержит все суффиксы.
Во время цикла ключ является активной точкой. Я предполагаю, что это представляет самую глубокую точку в дереве суффиксов, которая соответствует правильному суффиксу первых k символов строки. (Я думаю, что правильно означает, что суффикс не может быть всей строкой.)
Например, предположим, что вы видели символы «abcabc». Активная точка будет представлять точку в дереве, соответствующую суффиксу «abc».
Активная точка представлена как (начало, начало, конец). Это означает, что вы в данный момент находитесь в той точке дерева, к которой вы попадаете, начиная с начала узла и затем вводя символы в строке [first: last]
Когда вы добавляете нового персонажа, вы смотрите, находится ли активная точка в существующем дереве. Если это так, то все готово. В противном случае вам нужно добавить новый узел в дерево суффиксов в активной точке, вернуться к следующему кратчайшему совпадению и проверить еще раз.
Примечание 1: Суффиксные указатели дают ссылку на следующее самое короткое совпадение для каждого узла.
Примечание 2: Когда вы добавляете новый узел и запасной вариант, вы добавляете новый суффиксный указатель для нового узла. Пункт назначения для этого указателя суффикса будет узлом в сокращенной активной точке. Этот узел либо уже существует, либо будет создан на следующей итерации этого резервного цикла.
Примечание 3: часть канонизации просто экономит время при проверке активной точки. Например, предположим, что вы всегда использовали origin = 0 и просто меняли первый и последний. Чтобы проверить активную точку, вам нужно будет каждый раз следовать дереву суффиксов по всем промежуточным узлам. Имеет смысл кэшировать результат следования по этому пути, записывая только расстояние от последнего узла.
Можете ли вы привести пример кода того, что вы подразумеваете под «исправлением» ограничивающих переменных?
Предупреждение о вреде для здоровья: я также обнаружил, что этот алгоритм особенно трудно понять, поэтому, пожалуйста, поймите, что эта интуиция, вероятно, будет неправильной во всех важных деталях ...
источник
Привет, я пытался реализовать вышеописанную реализацию в ruby, пожалуйста, проверьте это. кажется, работает нормально.
единственное отличие в реализации состоит в том, что я пытался использовать объект края вместо использования только символов.
его также присутствует на https://gist.github.com/suchitpuri/9304856
источник