Алгоритм Ахо-Корасик: поиск множества образцов в тексте

Алгоритм Ахо-Корасик (Aho-Corasick, 1975) - стандартный способ искать сразу множество образцов в тексте за один линейный проход. Время работы - , где - суммарное число вхождений всех образцов в текст. Никакой зависимости от или от длин отдельных паттернов в основном цикле: текст читается строго по одному символу слева направо, и каждое новое чтение стоит амортизированно константу. Опубликовали его Альфред Ахо и Маргарет Корасик в Bell Labs, и именно их статья лежит в основе fgrep, антивирусного сканирования сигнатур и многих фильтров содержимого.
Зачем нужен отдельный алгоритм для множественного поиска
Если паттернов мало, можно запустить KMP или Бойер-Мур раз - получится . На двух-трёх паттернах это нормально, но антивирус хочет проверить файл против десятков тысяч сигнатур, а почтовый фильтр - против списка из миллиона запрещённых фраз. Множитель становится неприемлемым.
Главная идея Ахо-Корасик - объединить все паттерны в одну структуру и провести по тексту только один указатель. Бор (trie) образцов даёт компактное представление всех паттернов сразу: общие префиксы хранятся в одной цепочке. Дальше остаётся приделать к бору механизм «куда переходить при несовпадении», аналогичный префикс-функции KMP. Этот механизм называется суффиксная ссылка (suffix link, fail link).
Бор образцов (trie)
Бор - дерево, в котором каждое ребро помечено символом, а корень соответствует пустой строке. Чтобы добавить паттерн , спускаемся от корня по символам , создавая недостающие узлы. Узел, в котором паттерн заканчивается, помечаем - «здесь принимается ».
Например, для , , , получится бор из 9 узлов: корень (принимающий ) (принимающий ), отдельная ветка (принимающий ), отдельная ветка (принимающий ). Суммарный размер бора - .
Узел бора однозначно соответствует префиксу одного или нескольких паттернов. Это ключевое свойство: пока мы стоим в узле , путь от корня до - это последнее, что мы успешно сопоставили с текстом.
Суффиксные ссылки
Когда из узла нет перехода по очередному символу текста, нам нужно отступить. В KMP мы сдвигались по префикс-функции - на самый длинный собственный префикс, совпадающий с суффиксом. В Ахо-Корасике делаем то же самое, но в боре: суффиксная ссылка ведёт в узел, соответствующий самому длинному собственному суффиксу строки , который сам является префиксом какого-нибудь паттерна (то есть присутствует в боре).
Формально: если соответствует строке , то - это узел, соответствующий самой длинной строке такой, что - собственный суффикс и есть в боре. Для корня и его прямых детей равна корню.
В примере выше для узла «» суффиксная ссылка ведёт в узел «» - самый длинный собственный суффикс , присутствующий в боре. Для узла «» она ведёт в узел «» (если он есть как префикс какого-то паттерна) или в корень.
Это прямой аналог префикс-функции для случая множества строк. Когда паттерн один - Ахо-Корасик вырождается ровно в KMP: бор становится цепочкой, а суффиксные ссылки - массивом .
Построение суффиксных ссылок через BFS
Суффиксные ссылки строятся обходом бора в ширину (BFS) с корня. Для каждого узла с родителем и ребром-символом суффиксная ссылка вычисляется так:
- Берём - суффиксную ссылку родителя.
- Пока из нет перехода по и не корень - поднимаемся: .
- Если из есть переход по и он не равен самому - это и есть . Иначе корень.
Псевдокод:
queue = [root]
suf[root] = root
for child v of root:
suf[v] = root
queue.push(v)
while queue not empty:
u = queue.pop()
for each edge (u, c) -> v:
w = suf[u]
while w != root and not has_edge(w, c):
w = suf[w]
suf[v] = go(w, c) if has_edge(w, c) and go(w, c) != v else root
queue.push(v)
BFS гарантирует, что суффиксная ссылка родителя уже известна к моменту, когда обрабатывается ребёнок. Амортизированно построение стоит - тот же аргумент про сумму подъёмов по и спусков по , что и в анализе KMP.
В индустриальных реализациях бор сразу превращают в полный автомат: для каждого узла и каждого символа кэшируют - куда переходить из по с учётом суффиксных ссылок. Тогда переход стоит ровно одну операцию, без while. Памяти уходит больше - , но скорость поиска становится максимальной.
Финальные ссылки (dict-link)
Одна суффиксная ссылка отвечает на вопрос «куда упасть при несовпадении». Но есть и другой вопрос: какие паттерны заканчиваются в текущем узле или в одном из его суффиксных предков. Если узел принимающий - заканчивается паттерн в самом ; но если или - тоже принимающие, в позиции, соответствующей , заканчиваются и они.
Чтобы не ходить по цепочке на каждом шаге поиска, заводят финальную ссылку (dict-link, output link) - ближайший принимающий узел в цепочке суффиксных ссылок, не считая самого . Если такого нет - корень (или null).
Финальные ссылки строятся тем же BFS-проходом:
При посещении узла в поиске мы выводим паттерн в (если принимающий) и потом идём по , пока не упрёмся в корень. Это даёт ровно операций суммарно за весь поиск - за это и отвечает слагаемое в сложности.
Алгоритм поиска
Имея автомат, текст обрабатывается одним указателем - текущий узел:
v = root
for i in 0..n-1:
while v != root and not has_edge(v, T[i]):
v = suf[v]
if has_edge(v, T[i]):
v = go(v, T[i])
# вывод вхождений, заканчивающихся в этой позиции
u = v
while u != root:
if u is accepting:
report match of pattern(u) ending at i
u = out[u]
При полном автомате (с кэшированными ) внутренний while исчезает: переход становится одним обращением к таблице. Внешний цикл по тексту - итераций. Сумма всех итераций цикла «вывод вхождений» - ровно . Итого для самого поиска, плюс на построение. Если бор хранится компактно (с переходами по хешу или массиву), память тоже .
Подробный пример
Образцы , , , . Текст .
Бор и суффиксные ссылки:
- (приём ) (приём ).
- (приём ).
- (приём ), .
Шаг , . Из корня нет перехода по , остаёмся в корне.
Шаг , . Из корня переход по есть, идём в узел .
Шаг , . Из переход по - в узел .
Шаг , . Из переход по - в узел . Узел принимающий → вхождение , заканчивается в позиции 3. Идём по - тоже принимающий → вхождение , заканчивается в позиции 3.
Шаг , . Из перехода по нет. Идём по . Из переход по - в узел . Узел не принимающий, корень.
Шаг , . Из переход по - в узел . Принимающий → вхождение , заканчивается в позиции 5.
Итого три вхождения за один линейный проход по тексту длины 6.
Связь с KMP и Бойером-Муром
Ахо-Корасик - это KMP, обобщённый на множество паттернов. При бор вырождается в цепочку из узлов, суффиксные ссылки совпадают с массивом префикс-функции , и алгоритм работает буква в букву как KMP. С Бойером-Муром аналогия слабее: его сравнение справа налево плохо обобщается на паттерны разной длины. Существует алгоритм Commentz-Walter (1979) с эвристиками плохого символа и хорошего суффикса на боре - быстрее в среднем на больших алфавитах, но сложнее и теряет линейность в худшем случае. Поэтому в fgrep GNU и большинстве библиотек по умолчанию стоит именно Ахо-Корасик.
Применения
- Антивирусные сканеры. ClamAV, Avast, ESET и большинство сканеров используют Ахо-Корасик для проверки файла против базы из десятков тысяч сигнатур за один проход. Без него антивирус был бы медленнее на порядки.
grep -fиfgrep. Когдаgrepполучает файл паттернов через-fили работает в режиме фиксированных строк (fgrep=grep -F), строится автомат Ахо-Корасика.fgrep -f huge_list.txt big_fileостаётся линейным даже на миллионе шаблонов.- Веб-фильтры и DLP. Корпоративные шлюзы проверяют трафик против списков запрещённых терминов, ключей и кодов проектов - сотни тысяч паттернов за один проход.
- Биоинформатика. Множественный поиск мотивов промоторов, сайтов связывания транскрипционных факторов и рестриктаз в геноме: все мотивы - в бор, геном - через автомат.
Частые ошибки
- Считают, что суффиксная ссылка ведёт в родителя. Нет: суффиксная ссылка ведёт по строке-суффиксу, а не по узлу-родителю. Эти направления почти всегда разные.
- Забывают про финальные ссылки. Без при поиске нужно на каждом шаге проходить вверх по всей цепочке , проверяя принимающие узлы. Это даёт лишний множитель и может довести до в патологических случаях.
- Строят суффиксные ссылки в DFS, а не в BFS. В DFS суффиксная ссылка родителя ещё не вычислена, когда обрабатывается ребёнок. Алгоритм перестаёт работать без явных дополнительных проходов.
- Не учитывают паттерны-префиксы друг друга. Если и , в узле «» заканчивается , но не . Финальная ссылка из узла «» должна указать на «», иначе при поиске «» в тексте вхождение «» потеряется.
- Применяют Ахо-Корасик там, где достаточно одного KMP. На одиночном паттерне накладные расходы построения и навигации по бору не оправданы - KMP с массивом проще и быстрее по константам.
FAQ
Чем алгоритм Ахо-Корасик отличается от KMP? KMP ищет один паттерн через префикс-функцию массива . Ахо-Корасик ищет сразу множество паттернов: все они укладываются в бор, поверх строятся суффиксные ссылки (аналог ) и финальные ссылки (для перехода между вложенными вхождениями). За один проход по тексту находятся все вхождения всех паттернов. При Ахо-Корасик буквально вырождается в KMP.
Зачем нужна отдельная финальная ссылка, если есть суффиксная? Суффиксная отвечает «куда идти при несовпадении». Финальная отвечает «какие паттерны заканчиваются в этой позиции, кроме паттерна в текущем узле». Без неё пришлось бы при каждом шаге поиска проходить вверх по цепочке и проверять каждый узел на принимаемость. Финальная ссылка перепрыгивает сразу на ближайший принимающий узел в этой цепочке, что даёт корректную оценку .
Как Ахо-Корасик масштабируется на миллион паттернов? Время и память построения линейны по сумме длин паттернов: . На миллионе коротких паттернов это умеренные мегабайты бора, и сам поиск остаётся линейным по тексту. На практике именно поэтому он стоит в антивирусах и DLP-системах, где число сигнатур постоянно растёт.
Коротко
Алгоритм Ахо-Корасик ищет все вхождения множества образцов в тексте за , где - общее число вхождений. Структура: бор всех паттернов плюс суффиксные ссылки (аналог префикс-функции KMP - указывают на самый длинный собственный суффикс, присутствующий в боре) и финальные ссылки (на ближайший принимающий узел в цепочке суффиксов). Построение - BFS по бору, поиск - один проход по тексту с переходами по бору и суффиксным ссылкам при несовпадении. При алгоритм вырождается в KMP. Стандартный инструмент в антивирусах (сигнатуры), fgrep со списком шаблонов, веб-фильтрах и поиске мотивов в биоинформатике.
Читайте также

Алгоритм Рабина-Карпа: поиск подстроки за O(n+m)
Разбираем алгоритм Рабина-Карпа: как полиномиальный хеш и скользящее окно ускоряют поиск подстроки до O(n+m) в среднем, почему бывают ложные совпадения и при чём тут плагиат.

Алгоритм Манакера: поиск всех палиндромов за O(n)
Алгоритм Манакера находит все палиндромные подстроки за линейное время O(n). Разбираем разделители, массив радиусов и зеркальную симметрию на понятном примере.

Алгоритм Кнута-Морриса-Пратта: поиск подстроки за O(n+m)
Алгоритм Кнута-Морриса-Пратта ищет подстроку без возвратов по тексту. Разбираем префикс-функцию по шагам, оцениваем сложность и смотрим понятный пример работы.