EssayAI
Блог
Блог
Математика и алгоритмы

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

20 февраля 2026Время чтения: 11 минут
#алгоритмы#строки#множественный поиск#бор#суффиксные ссылки
Алгоритм Ахо-Корасик: поиск множества образцов в тексте

Алгоритм Ахо-Корасик (Aho-Corasick, 1975) - стандартный способ искать сразу множество образцов P1,P2,,PkP_1, P_2, \ldots, P_k в тексте TT за один линейный проход. Время работы - O(T+Pi+z)O(|T| + \sum |P_i| + z), где zz - суммарное число вхождений всех образцов в текст. Никакой зависимости от kk или от длин отдельных паттернов в основном цикле: текст читается строго по одному символу слева направо, и каждое новое чтение стоит амортизированно константу. Опубликовали его Альфред Ахо и Маргарет Корасик в Bell Labs, и именно их статья лежит в основе fgrep, антивирусного сканирования сигнатур и многих фильтров содержимого.

Зачем нужен отдельный алгоритм для множественного поиска

Если паттернов мало, можно запустить KMP или Бойер-Мур kk раз - получится O(kT+Pi)O(k|T| + \sum|P_i|). На двух-трёх паттернах это нормально, но антивирус хочет проверить файл против десятков тысяч сигнатур, а почтовый фильтр - против списка из миллиона запрещённых фраз. Множитель kk становится неприемлемым.

Главная идея Ахо-Корасик - объединить все паттерны в одну структуру и провести по тексту только один указатель. Бор (trie) образцов даёт компактное представление всех паттернов сразу: общие префиксы хранятся в одной цепочке. Дальше остаётся приделать к бору механизм «куда переходить при несовпадении», аналогичный префикс-функции KMP. Этот механизм называется суффиксная ссылка (suffix link, fail link).

Бор образцов (trie)

Бор - дерево, в котором каждое ребро помечено символом, а корень соответствует пустой строке. Чтобы добавить паттерн PiP_i, спускаемся от корня по символам PiP_i, создавая недостающие узлы. Узел, в котором паттерн заканчивается, помечаем - «здесь принимается PiP_i».

Например, для P1=heP_1 = \text{he}, P2=sheP_2 = \text{she}, P3=hisP_3 = \text{his}, P4=hersP_4 = \text{hers} получится бор из 9 узлов: корень he\to h \to e (принимающий he\text{he}) rs\to r \to s (принимающий hers\text{hers}), отдельная ветка hish \to i \to s (принимающий his\text{his}), отдельная ветка shes \to h \to e (принимающий she\text{she}). Суммарный размер бора - O(Pi)O(\sum |P_i|).

Узел бора однозначно соответствует префиксу одного или нескольких паттернов. Это ключевое свойство: пока мы стоим в узле vv, путь от корня до vv - это последнее, что мы успешно сопоставили с текстом.

Суффиксные ссылки

Когда из узла vv нет перехода по очередному символу текста, нам нужно отступить. В KMP мы сдвигались по префикс-функции - на самый длинный собственный префикс, совпадающий с суффиксом. В Ахо-Корасике делаем то же самое, но в боре: суффиксная ссылка suf(v)\text{suf}(v) ведёт в узел, соответствующий самому длинному собственному суффиксу строки vv, который сам является префиксом какого-нибудь паттерна (то есть присутствует в боре).

Формально: если vv соответствует строке ww, то suf(v)\text{suf}(v) - это узел, соответствующий самой длинной строке ww' такой, что ww' - собственный суффикс ww и ww' есть в боре. Для корня и его прямых детей suf\text{suf} равна корню.

В примере выше для узла «she\text{she}» суффиксная ссылка ведёт в узел «he\text{he}» - самый длинный собственный суффикс she\text{she}, присутствующий в боре. Для узла «his\text{his}» она ведёт в узел «ss» (если он есть как префикс какого-то паттерна) или в корень.

Это прямой аналог префикс-функции для случая множества строк. Когда паттерн один - Ахо-Корасик вырождается ровно в KMP: бор становится цепочкой, а суффиксные ссылки - массивом π\pi.

Построение суффиксных ссылок через BFS

Суффиксные ссылки строятся обходом бора в ширину (BFS) с корня. Для каждого узла vv с родителем uu и ребром-символом cc суффиксная ссылка вычисляется так:

  1. Берём w=suf(u)w = \text{suf}(u) - суффиксную ссылку родителя.
  2. Пока из ww нет перехода по cc и ww не корень - поднимаемся: wsuf(w)w \leftarrow \text{suf}(w).
  3. Если из ww есть переход по cc и он не равен самому vv - это и есть suf(v)\text{suf}(v). Иначе suf(v)=\text{suf}(v) = корень.

Псевдокод:

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 гарантирует, что суффиксная ссылка родителя уже известна к моменту, когда обрабатывается ребёнок. Амортизированно построение стоит O(Pi)O(\sum |P_i|) - тот же аргумент про сумму подъёмов по suf\text{suf} и спусков по cc, что и в анализе KMP.

В индустриальных реализациях бор сразу превращают в полный автомат: для каждого узла vv и каждого символа cc кэшируют go(v,c)\text{go}(v, c) - куда переходить из vv по cc с учётом суффиксных ссылок. Тогда переход стоит ровно одну операцию, без while. Памяти уходит больше - O(борσ)O(|\text{бор}| \cdot \sigma), но скорость поиска становится максимальной.

Финальные ссылки (dict-link)

Одна суффиксная ссылка отвечает на вопрос «куда упасть при несовпадении». Но есть и другой вопрос: какие паттерны заканчиваются в текущем узле или в одном из его суффиксных предков. Если узел vv принимающий - заканчивается паттерн в самом vv; но если suf(v)\text{suf}(v) или suf(suf(v))\text{suf}(\text{suf}(v)) - тоже принимающие, в позиции, соответствующей vv, заканчиваются и они.

Чтобы не ходить по цепочке suf\text{suf} на каждом шаге поиска, заводят финальную ссылку (dict-link, output link) out(v)\text{out}(v) - ближайший принимающий узел в цепочке суффиксных ссылок, не считая самого vv. Если такого нет - out(v)=\text{out}(v) = корень (или null).

Финальные ссылки строятся тем же BFS-проходом:

out(v)={suf(v),если suf(v) принимающий,out(suf(v)),иначе.\text{out}(v) = \begin{cases} \text{suf}(v), & \text{если } \text{suf}(v) \text{ принимающий}, \\ \text{out}(\text{suf}(v)), & \text{иначе}. \end{cases}

При посещении узла vv в поиске мы выводим паттерн в vv (если принимающий) и потом идём по out\text{out}, пока не упрёмся в корень. Это даёт ровно zz операций суммарно за весь поиск - за это и отвечает слагаемое zz в сложности.

Алгоритм поиска

Имея автомат, текст обрабатывается одним указателем vv - текущий узел:

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]

При полном автомате (с кэшированными go\text{go}) внутренний while исчезает: переход становится одним обращением к таблице. Внешний цикл по тексту - T|T| итераций. Сумма всех итераций цикла «вывод вхождений» - ровно zz. Итого O(T+z)O(|T| + z) для самого поиска, плюс O(Pi)O(\sum |P_i|) на построение. Если бор хранится компактно (с переходами по хешу или массиву), память тоже O(Pi)O(\sum |P_i|).

Подробный пример

Образцы P1=heP_1 = \text{he}, P2=sheP_2 = \text{she}, P3=hisP_3 = \text{his}, P4=hersP_4 = \text{hers}. Текст T=ushersT = \text{ushers}.

Бор и суффиксные ссылки:

  • hheh \to h e (приём he\text{he}) herhers\to her \to hers (приём hers\text{hers}).
  • hhihish \to hi \to his (приём his\text{his}).
  • sshshes \to sh \to she (приём she\text{she}), suf(she)=he\text{suf}(she) = he.

Шаг i=0i = 0, T[0]=uT[0] = u. Из корня нет перехода по uu, остаёмся в корне.

Шаг i=1i = 1, T[1]=sT[1] = s. Из корня переход по ss есть, идём в узел ss.

Шаг i=2i = 2, T[2]=hT[2] = h. Из ss переход по hh - в узел shsh.

Шаг i=3i = 3, T[3]=eT[3] = e. Из shsh переход по ee - в узел sheshe. Узел принимающий → вхождение she\text{she}, заканчивается в позиции 3. Идём по out(she)=he\text{out}(she) = he - тоже принимающий → вхождение he\text{he}, заканчивается в позиции 3.

Шаг i=4i = 4, T[4]=rT[4] = r. Из sheshe перехода по rr нет. Идём по suf(she)=he\text{suf}(she) = he. Из hehe переход по rr - в узел herher. Узел не принимающий, out(her)=\text{out}(her) = корень.

Шаг i=5i = 5, T[5]=sT[5] = s. Из herher переход по ss - в узел hershers. Принимающий → вхождение hers\text{hers}, заканчивается в позиции 5.

Итого три вхождения за один линейный проход по тексту длины 6.

Связь с KMP и Бойером-Муром

Ахо-Корасик - это KMP, обобщённый на множество паттернов. При k=1k = 1 бор вырождается в цепочку из P1|P_1| узлов, суффиксные ссылки совпадают с массивом префикс-функции π\pi, и алгоритм работает буква в букву как 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. Корпоративные шлюзы проверяют трафик против списков запрещённых терминов, ключей и кодов проектов - сотни тысяч паттернов за один проход.
  • Биоинформатика. Множественный поиск мотивов промоторов, сайтов связывания транскрипционных факторов и рестриктаз в геноме: все мотивы - в бор, геном - через автомат.

Частые ошибки

  • Считают, что суффиксная ссылка ведёт в родителя. Нет: суффиксная ссылка ведёт по строке-суффиксу, а не по узлу-родителю. Эти направления почти всегда разные.
  • Забывают про финальные ссылки. Без out\text{out} при поиске нужно на каждом шаге проходить вверх по всей цепочке suf\text{suf}, проверяя принимающие узлы. Это даёт лишний множитель и может довести до O(Tглубина)O(|T| \cdot \text{глубина}) в патологических случаях.
  • Строят суффиксные ссылки в DFS, а не в BFS. В DFS суффиксная ссылка родителя ещё не вычислена, когда обрабатывается ребёнок. Алгоритм перестаёт работать без явных дополнительных проходов.
  • Не учитывают паттерны-префиксы друг друга. Если P1=heP_1 = \text{he} и P2=helloP_2 = \text{hello}, в узле «hehe» заканчивается P1P_1, но не P2P_2. Финальная ссылка из узла «hellohello» должна указать на «hehe», иначе при поиске «hellohello» в тексте вхождение «hehe» потеряется.
  • Применяют Ахо-Корасик там, где достаточно одного KMP. На одиночном паттерне накладные расходы построения и навигации по бору не оправданы - KMP с массивом π\pi проще и быстрее по константам.

FAQ

Чем алгоритм Ахо-Корасик отличается от KMP? KMP ищет один паттерн через префикс-функцию массива π\pi. Ахо-Корасик ищет сразу множество паттернов: все они укладываются в бор, поверх строятся суффиксные ссылки (аналог π\pi) и финальные ссылки (для перехода между вложенными вхождениями). За один проход по тексту находятся все вхождения всех паттернов. При k=1k = 1 Ахо-Корасик буквально вырождается в KMP.

Зачем нужна отдельная финальная ссылка, если есть суффиксная? Суффиксная отвечает «куда идти при несовпадении». Финальная отвечает «какие паттерны заканчиваются в этой позиции, кроме паттерна в текущем узле». Без неё пришлось бы при каждом шаге поиска проходить вверх по цепочке suf\text{suf} и проверять каждый узел на принимаемость. Финальная ссылка перепрыгивает сразу на ближайший принимающий узел в этой цепочке, что даёт корректную оценку O(T+z)O(|T| + z).

Как Ахо-Корасик масштабируется на миллион паттернов? Время и память построения линейны по сумме длин паттернов: O(Pi)O(\sum |P_i|). На миллионе коротких паттернов это умеренные мегабайты бора, и сам поиск остаётся линейным по тексту. На практике именно поэтому он стоит в антивирусах и DLP-системах, где число сигнатур постоянно растёт.

Коротко

Алгоритм Ахо-Корасик ищет все вхождения множества образцов P1,,PkP_1, \ldots, P_k в тексте TT за O(T+Pi+z)O(|T| + \sum |P_i| + z), где zz - общее число вхождений. Структура: бор всех паттернов плюс суффиксные ссылки (аналог префикс-функции KMP - указывают на самый длинный собственный суффикс, присутствующий в боре) и финальные ссылки (на ближайший принимающий узел в цепочке суффиксов). Построение - BFS по бору, поиск - один проход по тексту с переходами по бору и суффиксным ссылкам при несовпадении. При k=1k = 1 алгоритм вырождается в KMP. Стандартный инструмент в антивирусах (сигнатуры), fgrep со списком шаблонов, веб-фильтрах и поиске мотивов в биоинформатике.

Доверьте текст нейросети EssayAI

Открыть EssayAI

Бесплатно, на русском языке и без VPN

Читайте также