Если вы не знаете, что такое префиксное дерево или автомат, рекомендуется сначала почитать про Ахо-Корасик.
Рассмотрим задачу:
Дан текст
$T$ и$n$ строк$s_i$ , для каждой из которых нужно узнать, встречается ли она в тексте.
Есть два основных подхода к решению этой задачи. Первый: алгоритм Ахо-Корасик, который строит по набору строк автомат, распознающий эти строки в потоке текста. Второй: использование суффиксных структур, под которыми обычно подразумневают суффиксный массив, суффиксный автомат или суффикское дерево.
Данная статья посвящена последним двум из них.
Возьмем все суффиксы
Идея дальнейшей оптимизации заключается в убирании «лишних» состояний в этом боре.
От квадратиченой сложности можно избавиться двумя разными способами — ведущими, соответственно, либо к суффиксному автомату, либо к суфффиксному дереву.
Сжатое суффиксное дерево. Любой путь от корня в этом боре будет подстрокой
Выясняется, что такая структура данных занимает
Пусть мы бор строли путем добавления суффиксов. Тогда, каждая новая строка совпадала каким-то префиксом с уже имеющейся, а потом отпочковалась и породила новый путь. Получается, что вершин тут не больше, чем строк.
Сжатое суффиксное дерево тоже годится для нашей задачи: только теперь по рёбрам нужно шагать виртуально, просматривая один символ за другим.
Существуют алгоритмы, которые строят сжатое суффиксное дерево эффективно — например, алгортим Укконена. Мы их рассматривать в этой статье не будем и сразу перейдём ко второму методу.
Суффиксным автоматом строки
Выясняется, что он тоже маленький, и если хранить переходы в std::map
, то автомат можно построить за
Сперва обозначим основные свойства суффиксного автомата и его состояний, затем опишем алгоритм, а затем поймём, как и почему он работает.
Любое состояние
Теперь можем перейти к алгоритму построения суффиксного автомата.
Наш алгоритм будет "индуктивным": он будет перестраивать автомат, коррекно построенный для строки
Предположим, что мы построили суффиксный автомат для строки
- Перехода по
$c$ из текущего состояния нет. Тогда мы можем и должны добавить переход по символу$c$ , ведущий в$U$ : теперь$U$ принимает ещё больший набор суффиксов. Снова перейдём по суффиксной ссылке из текущего состояния и продолжим пытаться строить переходы. - Переход по
$c$ из текущего состояния есть. Таким образом, для оставшихся суффиксов$sc$ уже существовуют свои состояния, на этом моменте следует остановиться и перейти к следующей части алгоритма.
Опять есть два случая: первый - ни из одного состояния, в котором мы побывали, перехода по
Второй случай: в какой-то момент мы попали в состояние
- Префиксная ссылка из
$Q$ ведёт в$P$ . Это означает, что состояние$Q$ , как и все достижимые по суфф. ссылкам из$Q$ состояния, принимает только суффиксы$sc$ : исходя из определения префиксной ссылки,$l$ является самой длинной строкой, принимаемой$Q$ , с приписанным в конце символом$c$ , то есть суффиксом$sc$ , а все остальные строки, принимаемыми$P$ , являются суффиксами самой длинной. - Префиксная ссылка из
$Q$ ведёт не в$P$ . Это означает, что существует строка, принимаемая$Q$ , длина которой больше, чем$lc$ ; но такая строка если и является суффиксом$sc$ , то из одного из ранее посещённых (в первой части алгоритма) состояний есть переход по$c$ - противоречие; значит,$Q$ помимо суффиксов$sc$ также принимает строки, не являющиеся суффиксами$sc$ , что недопустимо. Поэтому мы должны сделать две версии$Q$ : одна принимает строки старого$Q$ , являющиеся суффиксами$sc$ - назовём их основным набором, а вторая - все остальные строки$Q$ - назовём их дополнительным набором. Для этого клонируем состояние$Q$ : создадим новое состояние$Q'$ , которое будет отвечать за строки основного набора, скопируем в него все переходы, которые были в$Q$ и скопируем суффиксную ссылку из$Q$ . Префиксную ссылку из$Q'$ проведём в$P$ . Состояние$Q$ теперь будет отвечать только за строки дополнительного набора, поэтому мы должны перенаправить его суффссылку в$Q'$ . Суффиксная ссылка из$U$ также должна указывать на$Q'$ , т.к. все суффиксы$sc$ большей длины уже принимаются$U$ . Осталось только перенаправить некоторые переходы, ведущие в$Q$ , на состояние$Q'$ : заметим, что это будут в точности все переходы по$c$ в$Q'$ из всех состояний, достижимых по суффиксным ссылкам из$P$ , включая$P$ .
Для строки длины 0 суффиксный автомат - это единственное, корневое состояние. Таким образом, мы получили корректный алгоритм построения суффиксного автомата.
Рассмотрим ребро в суффиксном дереве строки
Пусть
Рассмотрим строку
Если же
Аналогичным образом можно показать, что для любой строки
Для любого состояния
Это целиком описывает состояния автомата и позволяет разработать алгоритм его построения. Код суффиксного автомата можно найти в конце статьи.
Достаточно очевидно, что вершин в автомате (и, соответственно, префиксных и суффиксных ссылок) не более
-
Число различных подстрок. Дана строка
$s$ , необходимо посчитатьколичество её различных подстрок. В каждом состоянии встречаются
строки длины от
$len(link(q)) + 1$ до$len(q)$ . Всего$len(q) - len(link(q))$ строк. Просуммировав эту величину по всемсостояниям, получим ответ.
Упражнение: решите эту же задачу за
$O(n)$ , учитывая, что к строке$s$ символы дописываются по одному и после каждого нового символанеобходимо сказать текущее число различных подстрок строки
$s$ .Упражнение*: Возьмём задачу из предыдущего упражнения и скажем,
что теперь мы можем не только дописывать символы в конец, но и
удалять их с начала строки. Вам требуется отвечать на те же
запросы. Время работы решения всё ещё должно линейно зависеть от
размера входа. Подсказка: иногда алгоритм Укконена также бывает
полезен.
-
Поиск подстрок в тексте. Пропустив строку через автомат мы
сможем сказать, входит ли она в текст. Допустим, мы хотим узнать
какую-то информацию о её вхождениях. Например, нам нужно выдать
любое конкретное вхождение. Как мы уже знаем, каждому вхождению
соответствует строка
$x$ такая что$ax$ --- суффикс$s$ . Или, прощеговоря, путь из состояния
$q$ в какое-то финальное состояние.Динамикой по автомату как ациклическому ориентированному графу мы
можем найти длину какого-нибудь такого пути (например, для
определённости минимального или максимального). Отметим, что
аналогичной динамикой считаются многие другие полезные значения,
например, количество строк в правом контексте состояния (или, что то
же самое, количество вхождений строк из состояния в
$s$ ).Альтернативным решением будет обратиться к дереву суффиксных ссылок,
которое, как мы помним, является суффиксным деревом для
$s^T$ . Какмы упоминали в самом начале, любая подстрока строки
$s$ являетсяпрефиксом одного из суффиксов исходной строки. Таким образом, если
мы запишем в каждую "суффиксную" вершину индекс соответствующего ей
суффикса, то все позиции вхождений строки
$t$ в$s$ можно будетобнаружить в поддереве вершины, которая соответствует строке
$t$ .Значит, в частности, динамикой можно будет найти самое первое или
самое последнее вхождение.
Более того, учитывая, что в последнем случае мы работали с деревом,
мы можем обойти его таким образом, чтобы на каждом шаге иметь в
вершине множество возможных позиций, в которых встречаются строки из
соответствующего состояния. Для этого нужно применить идею быстрого
слияния множеств, когда мы всегда добавляем элементы из меньшего
множества в большее, а не наоборот. Тогда такой обход потребует
$O(n \log n)$ операций добавления в множество, т.к. каждый раз когдамы переносим между множествами элемент
$k$ , размер нового множествабудет как минимум, в два раза больше старого, в котором он хранился.
Упражнение*: дана строка
$s$ . Найти число строк$t$ таких, чтоони имеют хотя бы
$3$ непересекающихся вхождения в строку$s$ . -
Наибольшая общая подстрока. Нам дано
$k$ строк$s_1, s_2, \dots, s_k$ . Нужно найти наибольшую строку$t$ , котораявстречается в каждой из строк
$s_i$ . Одно из возможных решений ---построить автомат для строки
$s_1 t_1 s_2 t_2 \dots s_n t_n$ , где$t_i$ --- уникальный для каждой строки символ-разделитель. Теперь мыможем завести динамику
$dp[q][i]$ , в которой хранить$1$ , если изсостояния
$q$ можно добраться до состояния, из которого есть переходпо
$t_i$ , не проходя при этом через другие символы-разделители. Этобудет равносильно тому, что строки из
$q$ входят в$s_i$ . Как и впрошлый раз, динамику можно пересчитывать по топологической
сортировке автомата как ориентированного ациклического графа. Итого
решение будет работать за
$O(k \cdot \sum |s_i|)$ .Упражнение*: решить указанную задачу за
$O(\sum |s_i|)$ .Модификация суффиксного автомата
Эта модификация была придумана и рассказана Филиппом Грибовым, в частности, в рамках программы третьего курса кружка по алгоритмам Tinkoff Generation (2018-2019). Суть заключается в следующем: заметим, что при построении и доказательстве асимптотики на самом деле мы никак не использовали, что строится автомат только для одной строки. То есть после того, как мы построили суффиксный автомат по некоторой строке, мы можем сказать, что вершина, принимающая всю строку (last) - это корень автомата, и спокойно добавить в автомат вторую строку, и после этого автомат будет принимать все различные подстроки как первой, так и второй строк. Очевидно, можно так же построить автомат и для трёх, четырёх и т.д. строк, что даёт нам просто решать такие сложные задачи, как следующая:
Дан словарь строк, изначально пустой. Приходят запросы трёх типов: добавить строку в словарь, если её нет, удалить строку из словаря, если она есть, и найти суммарное количество вхождений строк словаря в заданный текст. Задача в онлайн.
Сперва введём вспомогательное понятие. Подавтоматом состояния
struct node {
int link = -1, p = -1, len = 0; // Суффиксная, префиксная ссылки и максимальная длина принимаемой состоянием строки, соответственно
char pc = '#'; // Символы, переходы по которым ведут в состояние
map<char, int> next; // Переходы по символам
node() {}
node(int p, int len, char pc) : p(p), len(len), pc(pc) {}
};
node v[2 * MAXN]; // MAXN - максимально возможная суммарная длина строк
int mx = 0; // номер последнего добавленного состояния
int add_char(int ls, char c) { // ls - состояние, к которому мы "подвешиваем" следующий символ, c - сам символ
if (v[ls].next.find(c) != v[ls].end()) return v[ls].next[c]; // Если переход по символу c из ls уже был, то ничего добавлять не надо
v[++mx] = node(ls, v[ls].len + 1, c);
int p = ls;
for (; p != -1 && v[p].next.find(c) == v[p].end(); p = v[p].link)
v[p].next[c] = mx;
if (p == -1) {
v[mx].link = 0; // Если перехода по c не нашлось
return mx;
}
int q = v[p].next[c];
if (v[q].p == p) {
v[mx].link = q;
return mx;
}
v[++mx] = node(p, v[p].len + 1, c); // Клонирование
v[mx].next = v[q].next; v[mx].link = v[q].link;
v[q].link = v[mx - 1].link = mx;
for (; p != -1 && v[p].next[c] == q; p = v[p].link)
v[p].next[c] = mx;
return mx - 1;
}
int used[MAXN * 2]; // Массив времён посещения обходом подавтомата
void subautomaton(int x, int tm) { // Обход подавтомата
if (x == -1 || used[x] == tm) return;
used[x] = tm;
//....
subautomaton(v[x].p, tm);
subautomaton(v[x].link, tm);
}