Пусть дан набор строк в алфавите размера
Алгоритм открыт в 1975-м году и получил широкое распространение в системных программах для потоковой обработки текстов, например, в утилите grep.
Автору этой статьи понадобилось по работе реализовать алгоритм Ахо-Корасик целых два раза, что на два раза больше, чем все остальные «олимпиадные» алгоритмы.
Префиксное дерево или бор (англ. trie) — это структура данных для компактного хранения строк. Она устроена в виде дерева, где на рёбрах между вершинами написана символы, а некоторые вершины помечены терминальными.
Говорят, что бор принимает строку
Бор сам по себе можно использовать для разных задач:
Хранение строк — бор может занимать гораздо меньше места, чем массив или set
строк.
Сортировка строк — по бору можно пройтись dfs-ом и вывести все строки в лексикографическом порядке.
Просто множество строк — как мы увидим, в него легко добавлять и удалять слова, а также проверять, есть ли слово в боре.
Бор состоит из ссылающихся друг на друга вершин. В вершине обычно хранится следующая информация:
const int k = 26;
struct Vertex {
* to[k] = {0};
Vertexbool terminal = 0;
};
*root = new Vertex(); Vertex
Чтобы добавить слово в бор, нужно пройтись от корня по символам слова. Если перехода по для очередного символа нет — создать его, иначе пройти по уже существующему переходу. Последнюю вершину нужно пометить терминальной.
void add_string(string &s) {
= root;
v for (char c : s) {
-= 'a';
c if (!v->to[c])
->to[c] = new Vertex();
v= v->to[c];
v }
->terminal = true;
v}
Чтобы проверить, есть ли слово в боре, нужно так же пройти от корня по символам слова. Если в конце оказались в терминальной вершине — то слово есть. Если оказались в нетерминальной вершине или когда-нибудь потребовалось пройтись по несуществущей ссылке — то нет.
Удалить слово можно «лениво»: просто дойти до него и убрать флаг терминальности.
Хранить ссылки на детей не обязательно в массиве. Возможно, наш алфавит большой — тогда нам просто не хватит памяти инициализировать столько массивов, большинство из которых будут пустыми.
В этом случае можно придумать какой-нибудь другой способ хранить отображение из символа в ссылку. Например, хранить существующие ссылки в бинарном дереве (map
), хэш-таблице (unordered_map
) или просто в векторе. Они будут работать дольше, но зато потребление памяти в них будет линейным.
Вернёмся к основной теме статьи.
Пусть дан набор строк
Решать эту задачу будем следующим образом. Будем обрабатывать символы текста по одному и поддерживать наибольшую строку, являющуюся префиксом строки из словаря, и при этом также суффиксом считанного на данный момент текста. Если эта строка совпадает с каким-то
Для этой задачи нам нужно как-то эффективно хранить и работать со всеми префиксами слов из словаря — для этого нам и понадобится префиксное дерево.
Добавим все слова в префиксное дерево и пометим соответствующие им вершины как терминальные. Теперь наша задача состоит в том, чтобы при добавлении очередного символа быстро находить вершину в префиксном дереве, которая соответcтвует наидленнейшему входящему в бор суффиксу нового выписанного префикса. Для этого нам понадобятся несколько вспомогательных понятий.
Анти-формализм. Чтобы не писать везде громоздкое «строка
Определение. Суффиксная ссылка
Определение. Автоматный переход
Автоматные переходы — это именно то, что нам и надо в задаче: они ведут ровно в те вершины, которые соответствуют самому длинному «сматченному» суффиксу.
Заметим следующую связь суффиксных ссылок и автоматных переходов:
Если прямого перехода
Мы только что выразили
По сравнению с префиксным деревом, нам помимо массива переходов в боре to
нужно будет хранить некоторую дополнительную информацию:
Сам массив автоматных переходов размера go
.
Суффиксную ссылку link
.
«Родительский» (последний) символ pch
, который используется в формуле для суффиксной ссылки.
const int k = 26;
struct Vertex {
*to[k] = {0}, *go[k] = {0};
Vertex *link = 0, *p;
Vertex int pch;
(int _pch, Vertex *_p) { pch = _pch, p = _p; }
Vertex };
*root = new Vertex(-1, 0); Vertex
Добавление строки почти такое же:
void add_string(string &s) {
*v = root;
Vertex (char _c : s) {
for -= int(_c - 'a');
int c (!v->to[c])
if ->to[c] = new Vertex(c, v);
v= v->to[c];
v }
}
Подсчитывать динамики go
и link
будем «лениво» — введем для них две функции, которые будут мемоизировать свой результат выполнения.
// нам нужно объявить две функции, ссылающиеся друг на друга
// в C++ для этого нужно одну из них объявить только с сигнатурой перед другой
* go(Vertex *v, int c);
Vertex
* link(Vertex *v) {
Vertexif (!v->link) {
// для строк длины меньше двух суффиксная ссылка это корень
if (v == root || v->p == root)
->link = root;
velse // для остальных случаев формула работает
->link = go(link(v->p), v->pch);
v}
return v->link;
}
* go(Vertex *v, int c) {
Vertexif (!v->go[c]) {
// если переход есть, то автоматный должен вести туда же
if (v->to[c])
->go[c] = v->to[c];
v// если перехода нет из корня, то нужно сделать петлю
else if (v == root)
->go[c] = root;
velse // для остальных случаев формула работает
->go[c] = go(link(v), c);
v}
return v->go[c];
}
На самом деле, эффективнее и «чище» реализовывать построение автомата через bfs, но так получается немного сложнее для понимания.