Русская Википедия:Префиксное дерево

Материал из Онлайн справочника
Перейти к навигацииПерейти к поиску

Файл:Trie example.svg
Бор, содержащий ключи «A», «to», «tea», «ted», «ten», «i», «in», «inn»

Префиксное дерево (также бор[1], луч[2], нагруженное деревоШаблон:Sfn, Шаблон:Lang-enШаблон:Sfn) — структура данных, позволяющая хранить ассоциативный массив, ключами которого чаще всего являются строки. Представляет собой корневое дерево, каждое ребро которого помечено каким-то символом так, что для любого узла все рёбра, соединяющие этот узел с его сыновьями, помечены разными символами. Некоторые узлы префиксного дерева выделены (на рисунке они подписаны цифрами) и считается, что префиксное дерево содержит данную строку-ключ тогда и только тогда, когда эту строку можно прочитать на пути из корня до некоторого (единственного для этой строки) выделенного узла. В некоторых приложениях удобно считать все узлы дерева выделенными.

Таким образом, в отличие от бинарных деревьев поиска, ключ, идентифицирующий конкретный узел дерева, неявно хранится в данном узле, а задаётся положением данного узла в дереве. Получить ключ можно выписыванием подряд символов, помечающих рёбра на пути от корня до узла. Ключ корня дерева — пустая строка. Часто в выделенных узлах хранят дополнительную информацию, связанную с ключом, и обычно выделенными являются только листья и, возможно, некоторые внутренние узлы.

Операции над префиксным деревом

Выделяют три основные операции над префиксным деревом: проверка наличия ключа в дереве, удаление ключа из дерева и вставка нового ключа (возможно, с какой-то дополнительной связанной информацией). Каждая из этих операций реализуется с помощью спуска по дереву из корня, но эффективность такой операции напрямую зависит от организации навигации по узлам. Для последующего анализа различных подходов к этой проблеме обозначим через <math>n</math> длину строки, которую запрашивают/удаляют/вставляют, а через <math>\sigma</math> обозначим размер алфавита, то есть количество различных символов на рёбрах данного префиксного дерева. Пусть данный узел <math>x</math> имеет <math>k</math> сыновей (при этом <math>k \le \sigma</math>). Обозначим через <math>x_1, x_2, \ldots, x_k</math> ссылки на этих сыновей, а через <math>a_1, a_2, \ldots, a_k</math> — символы, которые помечают рёбра, соединяющие <math>x</math> с соответствующими сыновьями.

  1. Наиболее простой способ организовать навигацию в <math>x</math> — хранить динамический массив пар <math>(a_i,x_i)</math>. При таком подходе все три операции выполняются за <math>O(n\sigma)</math>. Если же вставка и удаление не используются, то лучше отсортировать пары по ключу <math>a_i</math> и тогда операцию проверки наличия ключа в префиксном дереве можно будет выполнять за <math>O(n\log\sigma)</math> с помощью бинарного поиска в узлах.
  2. Можно добиться времени выполнения <math>O(n\log\sigma)</math> для всех трёх операций, если хранить пары <math>(a_i,x_i)</math> отсортированными по ключу <math>a_i</math> в каком-либо сбалансированном бинарном дереве поиска, например, в красно-чёрном дереве или АВЛ-дереве. В большинстве языков программирования реализация какого-то сбалансированного дерева поиска входит в стандартную библиотеку в виде ассоциативного массива.
  3. Другой популярный способ организации навигации в <math>x</math> — хранить пары <math>(a_i,x_i)</math> по ключу <math>a_i</math> в хеш-таблице. При таком подходе все три операции выполняются за ожидаемое время <math>O(n)</math> (в то время как два предыдущих варианта имеют гарантированное время выполнения). Во многих языках программирования хеш-таблицы входят в стандартную библиотеку. Можно ещё улучшить временные гарантии, заменив хеш-таблицу хешированием кукушки или другой аналогичной структурой: такой хеш позволяет выполнять запрос и удаление ключей за гарантированное время <math>O(n)</math> и только лишь вставка выполняется за ожидаемое время <math>O(n)</math>.

Сжатое префиксное дерево

Рассмотрим префиксное дерево, содержащее все суффиксы строки <math display="inline">\underbrace{aa\cdots a}_{k\text{ раз}}b\underbrace{aa\cdots a}_{k\text{ раз}}b</math>, имеющей длину <math>n = 2k + 2</math>. Это дерево имеет не менее <math>k^2 = \Theta(n^2)</math> узлов и занимает, таким образом, <math>\Theta(n^2)</math> памяти. В данном примере такое расточительное потребление памяти вызвано наличием большого числа узлов, обладающих лишь одним сыном. Для борьбы с этой проблемой Дональдом МоррисономШаблон:Sfn была разработана модификация префиксного дерева, называемая сжатое префиксное дерево (также встречаются варианты компактное префиксное дерево, базисное дерево, сжатый бор, компактный бор, сжатый луч, сжатое нагруженное дерево; сам МоррисонШаблон:Sfn называл свою структуру «PATRICIA tree» и это название до сих пор иногда встречается).

Определение и способы хранения

Файл:Radix-tree-ru.png
Пример сжатого префиксного дерева для русского языка.

Сжатое префиксное дерево, содержащее заданные строки <math>s_1, s_2, \ldots, s_k</math>, — это минимальное по числу узлов дерево, каждое ребро которого помечено непустой строкой (а не символом, как в обычном префиксном дереве) так, что любая строка <math>s_i</math> может быть прочитана на пути из корня до какого-то (выделенного) узла, и для любого узла первые символы на всех метках на рёбрах узел-сын различны. Например, изображённое на рисунке сжатое префиксное дерево содержит восемь слов русского языка и выделенными узлами в нём являются только листья.

Сжатое префиксное дерево получается из обычного префиксного дерева, содержащего ключи <math>s_1, s_2, \ldots, s_k</math>, путём последовательного удаления каждого узла (кроме корня), который имеет лишь одного сына и не является выделенным, при этом отец и сын удаляемого узла соединяются и образовавшееся ребро помечается строкой, полученной соединением меток на рёбрах отец-узел и узел-сын (хотя такой метод построения сжатого префиксного дерева не рекомендуетсяШаблон:Кем).

Эффективность сжатого префиксного дерева проистекает из способа представления меток на рёбрах. Поскольку каждая метка <math>t</math> является подстрокой какой-то строки <math>s_h</math>, можно представить <math>t</math> с помощью тройки чисел <math>(h, i, j)</math>, где <math>s_h[i..j] = t</math> (здесь <math>s_h[i..j]</math> обозначает подстроку строки <math>s_h</math>, начинающуюся в позиции <math>i</math> и заканчивающуюся в позиции <math>j</math>). Если все строки <math>s_h</math> являются подстроками какой-то одной заданной строки <math>s</math>, то метки можно представлять парами чисел <math>(i,j)</math>, соответствующими подстрокам <math>s[i..j]</math>. Навигация в узлах организуется теми же способами, что и в обычном префиксном дереве, но символами-ссылками служат первые символы в метках на рёбрах узел-сын: например, в сжатом префиксном дереве на рисунке узел, соответствующий строке «вест», имеет трёх сыновей и символами-ссылками в данном узле служат «и», «н», «ь», которые являются первыми символами в метках «иб», «ник», «ь» на рёбрах узел-сын. Можно показать, что сжатое префиксное дерево для набора строк <math>s_1, s_2, \ldots, s_k</math> имеет всего не более <math>2k</math> узлов и, таким образом, занимает <math>O(k)</math> памяти, если не считать память необходимую для хранения самих строк <math>s_1, s_2, \ldots, s_k</math>.

Операции над сжатым префиксным деревом

Операции запроса, удаления и вставки в сжатом префиксном дереве можно выполнять так же, как и в обычном префиксном дереве, при помощи спуска из корня. При этом алгоритм становится несколько более сложным из-за необходимости при спуске по рёбрам, помеченным строками длины два и более, читать содержимое метки из соответствующей подстроки одной из строк <math>s_1, s_2, \ldots, s_k</math>. Теоретически время работы такого алгоритма можно оценить так же, как и для обычного префиксного дерева (то есть как <math>O(n\sigma)</math>, <math>O(n\log\sigma)</math>, <math>O(n)</math> в зависимости от организации навигации в узлах), но на практике операции над сжатым префиксным деревом нередко оказываются быстрее из-за того, что большая часть пути от корня до узла проходит по рёбрам и нет необходимости часто обращаться к структурам данных в узлах.

Если длины всех строк <math>s_i</math> сравнительно невелики (например, в пределах длины одной кэш линии, которая на многих современных процессорах составляет 64 байта), то промахов кэша, вызванных частыми перескоками между различными метками, можно избежать с помощью другого метода спуска по дереву (как раз этот метод был описан в статье МоррисонаШаблон:Sfn). Для примера рассмотрим алгоритм поиска длиннейшего префикса заданной строки <math>t</math>, который можно прочитать на пути из корня до какого-то узла в данном сжатом префиксном дереве; остальные операции можно реализовать по аналогии.

Алгоритм заключается в том, чтобы первым проходом опросить только узлы дерева, пропуская рёбра, и, таким образом, спустившись как можно ниже в дереве, найти строку <math>s_i</math> из множества <math>s_1, s_2, \ldots, s_k</math>, имеющую самый длинный общий префикс со строкой <math>t</math>. Затем нужно вычислить общий префикс <math>t</math> и <math>s_i</math> обычным наивным алгоритмом и вернуть результат. В представленном ниже C-подобном псевдокоде s[i] обозначает строку <math>s_i</math>, root обозначает корень дерева, и каждый узел x содержит следующие поля/методы: x->len — длина метки на ребре от x к отцу x; x->child(c) — ссылка на сына узла x, соединённого с x ребром с меткой, начинающейся с символа c, или nullptr, если такого сына нет; x->src — число, такое что строка на пути от корня к x является префиксом строки <math>s_{x{-}{>}src}</math>.

size_t find_longest_prefix(string t) {
  struct node_t *x = root;
  for (size_t i = 0; i < t.length(); i += x->len)
    if (x->child(t[i]) != nullptr) x = x->child(t[i]);
    else break;
  return длина общего префикса t и s[x->src];
}

Приложения

Структура широко применяется в алгоритмах поиска и других приложениях.

Примечания

Шаблон:Примечания

Литература

Ссылки

Шаблон:Деревья (структуры данных)Шаблон:Строки

  1. В первом переводе монографии Кнута.
  2. В последующих переводах монографии Кнута.
  3. Pymorphy 2 https://habrahabr.ru/post/176575/ Шаблон:Wayback
  4. Robert Love. Linux Kernel Development. Third Edition. 2010.