🐍🌳 Реализация алгоритма Укконена на Python
Суффиксное дерево Укконена – один из тех коварных алгоритмов, которые выглядят просто и понятно в теории, но представляют определенные сложности для реализации. Разбираемся, как превратить алгоритм в рабочий код для построения и визуализации дерева.
Алгоритм Укконена предназначен для построения суффиксного дерева из строки. На момент создания (в 1995 году) он был самым эффективным алгоритмом в своем роде:
- При наивном подходе суффиксное дерево можно создать лишь за O(n3), в лучшем случае за O(n2), а решение Укконена выполняется за линейное время.
- Существующие алгоритмы построения суффиксных деревьев, предложенные Вайнером (в 1973 году) и МакКрейтом (в 1976 году), относятся к оффлайновым, то есть для начала работы им нужна вся строка целиком, а алгоритм Укконена – онлайновый и работает последовательно.
Алгоритм не свободен от недостатков – суффиксное дерево должно быть полностью загружено в оперативную память, что может привести к проблемам при очень больших входных данных или алфавитах. Для обработки небольшого объема текстовых данных проще использовать префикс-функцию, а для работы с очень большими объемами данных лучше подходят кэш-эффективные алгоритмы, оптимизированные для выполнения на современных процессорах.
Тем не менее, для решения определенного класса задач со средним объемом данных алгоритм Укконена подходит отлично, а процесс реализации этого метода станет полезным опытом для любого разработчика.
Что такое суффиксное дерево
Суффиксное дерево – это компактное, сжатое древовидное представление всех суффиксов данной строки. Каждый путь от корня к листу соответствует одному суффиксу исходной строки. Суффиксные деревья позволяют очень быстро находить вхождения подстрок в строку, и благодаря этому:
- Существенно упрощают решение некоторых важных задач, связанных с обработкой текста (поиск паттернов и сжатие).
- Находят широкое применение в вычислительной биологии и биоинформатике (анализ белковых последовательностей, ДНК, поиск точных совпадений).
Продемонстрируем компактность суффиксных деревьев на конкретном примере.
Так выглядит концептуальное древовидное представление строки MISSISSIPPI$, которая имеет 12 непустых суффиксов:
Эта же строка в виде сжатого суффиксного дерева выглядит гораздо компактнее:
Как работает алгоритм Укконена
Алгоритм Укконена строит суффиксное дерево для строки S длиной n, разбивая процесс на n отдельных фаз. На первой фазе он создает суффиксное дерево T1, содержащее только первый символ строки S.
Затем, на каждой последующей фазе i (где i идет от 2 до n):
- Алгоритм берет уже построенное суффиксное дерево Ti-1 для префикса S[1..i-1].
- Он расширяет это дерево, добавляя i-ый символ строки S к концу всех путей в дереве.
- Вместо создания новых вершин для каждого добавленного пути, алгоритм использует технику неявного суффиксного дерева.
- Он ссылается на уже существующие вершины в Ti-1 и хранит только дополнительную информацию (индексы, ссылки) для представления новых суффиксов.
- В результате получается неявное суффиксное дерево Ti для префикса S[1..i].
Таким образом, на каждом шаге алгоритм расширяет существующее дерево, избегая создания многочисленных повторяющихся вершин, благодаря использованию неявных ссылок и индексов. В финале получается полное неявное суффиксное дерево для всей строки S.
Курс «Алгоритмы и структуры данных»
Курс «Алгоритмы и структуры данных» для тех, кто хочет научиться работать с алгоритмами, подготовиться к собеседованию в технологическую компанию или участвовать в сложных проектах.
После изучения вы:
- Узнаете о популярных алгоритмах и структурах данных
- Получите практический опыт решения сложных алгоритмических задач
- Сможете легко пройти техническое собеседование
Программа обучения
- Введение. Производительность алгоритмов
- Работа с числами
- Массивы
- Списки. Стек, очередь, дек
- Очередь с приоритетом
- Основы сортировки
- Порядковые статистики
- Деревья
- Хеш-таблицы
- Жадные алгоритмы. Динамическое программирование
- Графы и рекурсия
- Строки
- Криптография
- Длинные числа. Итоги
Неявное суффиксное дерево
Основная идея неявного суффиксного дерева заключается в том, чтобы не хранить явно все вершины дерева, а использовать специальную технику ссылок для их имитации. Вместо того чтобы создавать новую вершину для каждого префикса суффикса, в НСД используются ссылки на уже существующие вершины и позиции в исходной строке. Это достигается с помощью:
- Суффиксных ссылок. Каждая вершина содержит ссылку на каноническую вершину, представляющую тот же суффикс, что и вершина, но без первого символа.
- Точек продолжения. Для каждого суффикса имеется точка входа в уже построенное суффиксное дерево, называемая точкой продолжения.
Фаза добавления символа
На каждой фазе i алгоритм выполняет i расширений, добавляя новый символ S[i] к концам всех суффиксов S[j..i-1], где j=1..i. Существует 3 правила расширения:
- Продление листа. Если путь от корня, помеченный S[j..i-1], заканчивается в листе, то символ S[i] просто добавляется в метку этого ребра.
- Ответвление. Если путь заканчивается в вершине, не являющейся листом, а следующий символ отличается от S[i], то создается новый лист с меткой, начинающейся с S[i]. Может также создаваться новая внутренняя вершина, если путь заканчивается посередине ребра.
- Ничего не делать. Если путь заканчивается в вершине, из которой есть путь по si, то ничего делать не надо.
Суффиксные ссылки
Для эффективного нахождения конца текущего пути в дереве используется вспомогательная структура – суффиксные ссылки. Каждая внутренняя вершина v хранит ссылку на вершину, представляющую конец альтернативного пути, начинающегося префиксом, общим для всех ребер из v.
Обновление суффиксных ссылок
После каждого расширения производится специальный проход по только что созданным внутренним вершинам для обновления их суффиксных ссылок соответствующим образом. Это позволяет поддерживать корректность структуры и гарантировать линейное общее время.
Пример
Рассмотрим построение
суффиксного дерева для строки S = "xabxac"
с помощью алгоритма
Укконена:
- Пустое дерево
- Фаза 1 ('x'): создание листа x
- Фаза 2 ('a'): добавление a к x -> xa, новый лист a
- Фаза 3 ('b'): xa -> xab, a -> ab, новый лист b
- Фаза 4 ('x'): xab -> xabx, ab -> bx, b -> x (правило 3)
- Фаза 5 ('a'): xabx-> xabxa, bx -> bxa, xa (правило 3)
- Фаза 6 ('c'): xabxa -> xabxac, bxa -> bxac, a -> ac, новый лист c
Итоговое дерево компактно представляет все суффиксы строки:
Публикации и ресурсы
Реализации алгоритма Укконена посвящено множество публикаций. Все началось с поста на Stack Overflow, в котором разработчики делились сложностями, объяснениями, и попытками реализации алгоритма. Построение суффиксного дерева методом Укконена быстро стало одной из самых популярных тем на Stack Overflow, а реализация алгоритма на том или ином языке – одним из профессиональных вызовов для продвинутых разработчиков.
Вот что стоит почитать и посмотреть на тему алгоритма Укконена:
- Тот самый пост на Stack Overflow, в комментарии к которому пользователь jogojapan в 2012 году изложил основные концепции построения суффиксного дерева.
- Перевод этого комментария на Хабре.
- Анимированная визуализация процесса создания суффиксного дерева.
- Видео с объяснением алгоритма.
- Пост автора одной из немногих полных реализаций алгоритма (на Python 2). По словам разработчика, впервые в жизни ему пришлось нарисовать блок-схему, чтобы наконец-то разобраться в работе алгоритма:
- Самое полное математическое объяснение и доказательство алгоритма – в книге Дэна Гасфилда «Строки, деревья и последовательности в алгоритмах» (Algorithms on strings, trees, and sequences: computer science and computational biology, Dan Gusfield).
Реализация алгоритма Укконена на Python
В ходе реализации алгоритма построения суффиксного дерева нужно учитывать множество деталей для корректной обработки различных ситуаций. Подробнее всего эти тонкости изложены здесь.
Главная функция
Функция main() управляет основным потоком построения суффиксного дерева, в соответствии с приведенной выше блок-схемой:
- Процесс начинается в верхнем левом углу диаграммы.
- В блоках принятия решений выполняются проверки специальных случаев и условий.
- Операции перемещения, развертывания, разделения и обработки суффиксных ссылок вынесены в отдельные функции.
Перемещение
Прыжки – функция hop() – используются, когда нужно перейти от текущего узла к следующему узлу операции. Переход от узла к узлу выполняется вдоль ребер.
Шаги – функция step() применяются, когда активный узел верен, но операция должна быть выполнена посередине ребра. Переход по символам к нужной позиции также выполняется вдоль ребра.
Развертывание
Операция развертывания – функция unfold() – запускается, как только мы встречаем новый символ, отличающийся от предыдущих. При развертывании в дереве создается новое ребро для оставшейся части строки.
Процесс развертывания обрабатывает различные ситуации, когда остаток строки исчерпан или нет, и устанавливает суффиксные ссылки для новых узлов, разбивая ребра при необходимости.
Важные наблюдения:
1. В зависимости от длины цепочки суффиксных ссылок может потребоваться один или несколько циклов развертывания.
2. Если строка не заканчивается уникальным символом, добавляется специальный символ $
.
3. Развертывание прекращается в двух случаях:
- Ситуация 1: Оставшаяся строка исчерпана
- Ситуация 2: Оставшаяся строка не исчерпана
Разбиение
Процесс разбиения ребра – функция split() – необходим для поддержания инвариантов суффиксного дерева, когда новый символ не может быть добавлен к существующей ветви. Разбиение позволяет создавать новые узлы и ребра, чтобы отражать все уникальные суффиксы в дереве. В зависимости от положения actkey (активного ключа), разбиение может произойти либо на узле, либо на ребре.
На узле:
- Если текущий символ в остатке строки REMAINS не совпадает ни с одним из исходящих ребер из текущего узла actnode, то создается новая ветвь (новый лист).
- Переменные actnode, actlength (длина активного ребра), actkey, REMAINS и ind_remain (индекс остатка) обновляются соответствующим образом.
На ребре:
- Если текущий символ в остатке строки не совпадает с символом на активном ребре actkey, то происходит разбиение ребра.
- Создается новый узел в середине ребра.
- Исходное ребро разбивается на два ребра: от родительского узла до нового узла и от нового узла до конца исходного ребра.
- Добавляется новая ветвь (лист) из нового узла с меткой, соответствующей остатку строки.
- Переменные actnode, actlength, actkey, REMAINS и ind_remain обновляются соответствующим образом.
Суффиксные ссылки
Суффиксные ссылки играют важную роль: они связывают узлы, представляющие суффиксы строки, и помогают эффективно перемещаться по дереву при его построении. Перечисленные ниже правила и наблюдения гарантируют, что суффиксные ссылки устанавливаются корректно, позволяя алгоритму обрабатывать различные ситуации во время разворачивания дерева и добавления новых символов.
Правило 1
Если после вставки из actnode == root, а actlength > 0, то: actnode = root, actlength -= 1, ind_remain += 1, actkey = REMAINS[ind_remain].
Правило 2
Если мы разбиваем ребро или добавляем лист во внутреннем узле, и текущий actnode не является первым расширенным узлом в этом раунде процесса развертывания, связываем предыдущий actnode с этим actnode с помощью суффиксной ссылки.
Правило 3
После расширения (либо разбиения ребра, либо только добавления листа) в actnode != root, следуем по суффикс-ссылке и устанавливаем actnode = suffixlink. Если suffixlink равен None
, устанавливаем actnode = root. В любом случае actlength и actedge остаются неизменными.
Код построения суффиксного дерева на Python
С учетом всех изложенных наблюдений и правил код алгоритма Укконена выглядит так:
А это суффиксное дерево строки программирование
:
Подведем итоги
Алгоритм Укконена – элегантное решение для построения суффиксного дерева строки за линейное время. Ключевым моментом здесь является использование неявного суффиксного дерева – компактного представления, которое позволяет избежать избыточного дублирования вершин. Благодаря эффективным техническим приемам – суффиксным ссылкам и точкам продолжения, – алгоритм обновляет неявное суффиксное дерево на каждой итерации, добавляя новый символ строки.
Хотя реализация алгоритма кажется непростой на первый взгляд, вникнуть в него стоит: этот оригинальный подход к построению деревьев может обеспечить идеальное решение многих сложных задач, связанных с обработкой строк.