Семинар 1.
1. Задача: хотим научиться искать в строке S заданную подстроку T.
Наивный алгоритм работает за O(|S|⋅|T|), а мы хотим за O(|S|+|T|).
Такую асимптотику обеспечивает алгоритм Кнута-Морриса-Пратта.
Алгоритм заключается в следующем: мы прикладываем шаблон T
к строке S и двигаемся по S, пока S и T совпадают.
Если мы дошли до конца T, то подстрока найдена.
Как только мы нашли несовпадение в S и Т, мы двигаем шаблон T
вперёд вдоль строки S так чтобы начало T опять совпало с S
до текущего положения (не включая его). При этом мы стремимся
подвинуть T как можно меньше, чтобы не пропустить совпадений.
Затем мы снова сравниваем S и T в текущем положении. Если они
совпали, переходим к следующему символу S, если нет, то опять
двигаем T. И так далее.
2. Заметим, что куда нужно двигать T зависит только от текущего
положения в T. Действительно, пусть длина совпавших подстрок
S и T равна i, a S' — обработанная часть строки S, тогда нам
нужно найти максимальный собственный префикс T[1:i], который
является суффиксом S'. Но так как T[1:i] сама суффикс S', то
нам нужно найти максимальный собственный префикс T[1:i],
являющийся его же префиксом. Функция p(i), возвращающая длину
максимального собственного префикса строки, являющегося его
суффиксом, называется префикс-функцией. То есть, теперь для
работы алгоритма нам нужно найти префикс-функцию шаблона T.
3. Префикс-функция вычисляется индуктивно: пусть мы посчитали
p(i) для i = 1...k, хотим посчитать для k+1. Мы знаем, что
у нас только что закончился суффикс длины p(k), являющийся
префиксом. Сравним T[k+1] и T[p(k)+1]. Если они совпали, то
теперь у нас суффикс длины p(k)+1, являющийся префиксом,
то есть p(k+1) = p(k)+1. Если не совпали, то пытаемся взять
суффикс-префикс поменьше. Следующий по убыванию длины такой
суффикс будет иметь длину p(p(k)). Опять сравниваем T[k+1]
и T[p(p(k))+1], если совпали, то p(k+1) = p(p(k))+1, иначе
берём суффикс длины p(p(p(k))), и так далее.
4. Несмотря на то что в каких-то шагах вычисления префикс-
функции, а также непосредственно поиска мы можем делать
довольно много итераций для уменьшения длины текущего
префикса-суффикса, общее число действий всё равно будет
линейно. Действительно, так как p(i) < i, а i ⩾ 0, то на
каждое уменьшение длины суффикса (переход от i к p(i)),
когда-то было её увеличение (переход от i к i+1), значит
суммарно переходов от i к p(i) не больше, чем общая длина
строки.
5. Вместо того, чтобы сначала считать префикс-функцию для T,
а потом с помощью неё искать T в S, можно применить такой
хитрый приём: начнём считать префикс-функцию для строки
T#S, где # — особый символ, который не встречается в T,
тогда как только префикс-функция станет равна длине T,
у нас будет найдено вхождение T в S.
6. Теперь мы ищем в строке S не одну подстроку, а сразу несколько.
Для этого мы строим из искомых слов бор — дерево, на рёбрах
которого написаны буквы, так что идя от корня до какого-нибудь
листа, мы прочитаем одно из наших слов. При этом, из каждой
вершины должно выходить не более одного ребра, с каждой буквой
(то есть рёбра, выходящие из одной вершины с одной и той же
буквой, склеиваются).
Каждой вершине соответствует путь до неё из корня дерева и
строка, состоящая из букв, записанных на рёбрах этого пути.
Для удобства будем в дальнейшем отождествлять вершину и
соответствующую ей строку.
7. Мы снова идём по строке S и параллельно идём по дереву. Если
из текущей вершины выходит ребро с текущей в S буквой, то мы
по нему проходим. Если не выходит, то мы так же как и раньше
хотим найти в дереве максимальный собственный суффикс, совпавшей
ранее подстроки. И попробовать пройти по текущей букве из
этого суффикса. Если получилось — хорошо, нет — находим
максимальный собственный суффикс этого суффикса, и так далее.
Оказывается, как и раньше, все эти переходы можно предпосчитать
как только построим бор.
8. Мы добавим в наш бор суффиксные ссылки — из каждой строки
будет выходить стрелка, указывающая, куда следует перейти,
если ребро с нужной буквой не выходит из этой вершины, а
именно, в максимальный собственный суффикс этой строки,
который есть в дереве.
Заметим, что любая суффиксная ссылка идёт на уровень более
близкий к корню, чем вершина, из которой она выходит — это
следует из определения.
Для удобства, суффиксная ссылка из корня будет вести в корень.
Строить суффиксные ссылки будем поочерёдно для каждого уровня,
начиная с корня. Чтобы построить суффиксную ссылку для вершины
v, мы идём в её родителя p, и смотрим, по какому символу мы
перешли из p в v. Затем идём по суффиксной ссылке (она уже
есть!) из p в w, и смотрим, есть ли из w ребро, помеченное c.
Если есть, то в вершину, находящуюся на конце этого ребра,
пускаем суффиксную ссылку из v. Иначе опять переходим из w
по суффиксной ссылке, и так далее, пока не дойдём до вершины,
из которой выходит ребро, помеченное буквой c.
Аналогично оценке для префикс-функции, можно показать, что
построение бора и суффиксных ссылок линейно по сумме длин
искомых строк.
9. Теперь, имея, в руках такой бор с суффиксными ссылками, мы
можем искать в тексте строки, из которых он был построен.
Для этого ещё нужно пометить вершины, в которых заканчивались
слова из нашего набора. Теперь, на каждом шаге, из текущей
вершины ходим по суффиксным ссылкам до корня. Каждую помеченную
вершину, встретившуюся на этом пути, добавляем в ответ.
Однако, такое решение займёт O(|S|⋅max(|Ti|)). Его можно
улучшить. Для этого, в каждую вершину добавим ссылку на
ближайшую достижимую по суффиксным ссылкам помеченную вершину.
После этого, время станет линейным по длине S плюс количество
найденных подстрок.
Это был алгоритм Ахо-Корасик.
<<