Префиксное дерево - Алгоритмика
Префиксное дерево

Префиксное дерево

авторы Сергей Слотин Глеб Лобанов
обновлено авг. 19, 2021

Префиксное дерево или бор (англ. trie) — структура данных для компактного хранения строк, устроенная в виде дерева, где на рёбрах между вершинами записаны символы, а некоторые вершины помечены терминальными.

Говорят, что префиксное дерево принимает строку ss, если существует такая терминальная вершина vv, что, если выписать подряд все буквы на путях от корня до vv, то получится строка ss.

Бор сам по себе можно использовать для разных задач:

  • Хранение строк: если есть много повторяющихся длинных префиксов, то бор может занимать гораздо меньше места, чем массив или set строк.
  • Сортировка строк: по построенному бору можно пройтись dfs-ом и вывести все строки в лексикографическом порядке.
  • Просто как множество строк: как мы увидим, в бор легко добавлять и удалять слова, а также делать проверки вхождения.

С точки зрения теории автоматов, каждая вершина — это состояние, а все корректные односимвольные дополнения являются корректными переходами в автомате. Бор таким образом является автоматом, проверяющим вхождение слова в множество.

#Реализация

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

Для латинского алфавита (в котором 26 строчных букв) изначально пустой бор можно реализовать так:

const int k = 26;

struct Vertex {
    Vertex* to[k] = {0}; // нулевой указатель означает, что перехода нет
    bool terminal = 0;
};

Vertex *root = new Vertex();

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

void add_string(string &s) {
    v = root;
    for (char c : s) {
        c -= 'a'; // получаем число от 0 до 25
        if (!v->to[c]) 
            v->to[c] = new Vertex();
        v = v->to[c];
    }
    v->terminal = true;
}

Чтобы проверить, есть ли слово в боре, нужно так же пройти от корня по символам слова. Если в конце оказались в терминальной вершине — то слово есть. Если оказались в нетерминальной вершине или когда-нибудь потребовалось пройтись по несуществующей ссылке — то нет.

bool find(string &s) {
    v = root;
    for (char c : s) {
        c -= 'a';
        if (!v->to[c])
            return false;
        v = v->to[c];
    }
    return v->terminal;
}

Удалить слово можно «лениво»: просто дойти до него и убрать флаг терминальности.

bool erase(string &s) {
    v = root;
    for (char c : s)
        v = v->to[c - 'a'];
    v->terminal = false;
}

В зависимости от задачи эти процедуры иногда следует изменить. Например, если мы хотим реализовать автодополнение — по запросу найти все слова с заданным префиксом — можно аккуратно удалять вершины, если они не ведут в терминальные вершины, и тогда при ответе на запрос можно просто пройтись dfs-ом из состояния-префикса, выводя ответ во всех терминальных вершинах.

#Как хранить ссылки

Иногда ограничения не позволяют хранить ссылки на детей просто в массиве.

Например, если алфавит большой — тогда нам не хватит ни времени, ни памяти инициализировать столько массивов, большинство из которых будут пустыми.

Помимо массивов указателей, есть много других способов хранить отображение из символа в ссылку:

  • расширяющийся массив (std::vector),
  • бинарное дерево (std::map),
  • хеш-таблица (std::unordered_map).

Чаще всего память является основным практическим соображением. Также на 64-битных системах может быть выгодно вместо new и указателей выделять всё на большом массиве или векторе и хранить 4-байтные индексы вершин вместо 8-байтных указателей на память.

#Цифровой бор

В некоторых задачах появляется идея хранить в боре числа, подобно строкам — такая структура называется цифровым бором.

Чаще всего числа надо записывать в двоичной системе счисления, и тогда все очень просто, но в некоторых задачах требуется какая-то другая система счисления, а так, например, как сравнивать числа в лексикографическом порядке в десятичной системе счисления нельзя (2 > 11). Чтобы избавиться от этого момента, будем считать, что все числа меньше какой-то степени 10, и тогда будем просто дополнять число ведущими нулями: теперь 02 < 11.

Задача. Задано некоторое множество чисел, и требуется отвечать на три вида запросов:

  1. Добавить число xx.
  2. Удалить число xx.
  3. Найти число yy в массиве, у которого xor c xx максимален.

Первые два вида запросов мы уже умеем делать в цифровом боре, а запрос третьего типа сложнее.

Заметим, что если существует yy из множества, такой что yixiy_i \neq x_i (то есть они отличаются в ii-ом бите), то взять его выгоднее, чем любое другое число, у которого префикс до ii-того бита включительно равен префиксу xx — потому что xor такого числа с xx будет содержать только меньшие биты, чем ii-тый, и соответственно не будет превосходить 2i2^i, который мы уже можем получить с yy.

Тогда при ответе на запрос третьего типа мы можем жадно спускаться по бору, каждый раз пытаясь пойти в ветку, у которой ii-тый бит не равен xix_i.