1. 1
  2. 2
  3. 3
  4. 4
  5. 5
  6. 6
  7. 7

# теория · шаг 3 из 7

Ахо-Корасик: откаты КМП, перенесённые на дерево

Коротко

Чтобы найти в тексте сто образцов, не нужно сто проходов. Образцы складываются в бор, и у каждого узла появляется суффиксная ссылка - на узел, строка которого есть самый длинный собственный конец строки этого узла, существующий в боре. Это префикс-функция, построенная не по одной строке, а по дереву. Поиск идёт по тексту один раз: есть ребро по символу - спускаемся, нет - уходим по ссылке и пробуем снова. Переходов не больше 2n при любом числе образцов. Замер: сто образцов по шесть символов в тексте из 10 000 - сто проходов КМП стоят 1 244 537 сравнений, алгоритм Ахо-Корасика - 18 140 переходов.

Если ты расставишь суффиксные ссылки в боре для ab, abd, bc, объяснишь, почему их считают обходом в ширину, и докажешь оценку 2n, - листай до «Теперь сам».

Как думаешь, от чего зависит цена прохода

Текст - 10 000 символов. Образцов сто, каждый по шесть символов. Самое простое решение - запустить КМП сто раз: около 12 500 сравнений на проход, миллион с четвертью на всё.

Прежде чем читать дальше, ответь себе: должна ли цена прохода по тексту расти вместе с числом образцов? Если образцов станет тысяча - обязан ли поиск стать в десять раз дороже?

На что это похоже

В уроке про КМП был домофон с одним кодом: он помнит, сколько цифр кода сейчас набрано, и после ошибки падает до самого длинного начала кода, которым кончается набранное.

Теперь представь пульт на сто квартир, у каждой свой код. Поставить сто отдельных слушателей - это сто проходов. Один слушатель справится, если будет помнить не «сколько цифр набрано», а «в каком месте дерева кодов я стою». После ошибки он переходит туда, где записано самое длинное начало ХОТЬ КАКОГО-ТО кода, которым кончается набранное. Коды разные, а слушатель один - потому что все коды лежат в одном дереве и общие начала у них общие.

Механизм: суффиксная ссылка

Образцы - ak, rak, kurs, rakurs. Бор:

(корень)
   ├ a ─ k*                           ak
   ├ r ─ a ─ k* ─ u ─ r ─ s*          rak, rakurs
   └ k ─ u ─ r ─ s*                   kurs

Узел будем называть строкой, которая к нему ведёт: узел raku - это четыре шага от корня.

Суффиксная ссылка узла ведёт в узел, строка которого - самый длинный собственный конец строки этого узла, имеющийся в боре. Собственный - значит короче самой строки. Если такого нет, ссылка ведёт в корень.

узел       концы его строки (от длинного)        ссылка
a          -                                      корень
ak         k                                      k
r          -                                      корень
ra         a                                      a
rak        ak, k                                  ak
raku       aku, ku, u                             ku
rakur      akur, kur, ur, r                       kur
rakurs     akurs, kurs, urs, rs, s                kurs
k          -                                      корень
ku         u                                      корень      (узла u в боре нет)
kur        ur, r                                  r
kurs       urs, rs, s                             корень

Смысл ссылки тот же, что у π[k − 1] в КМП. Стоя в узле raku, мы знаем последние четыре символа текста. Если следующий символ не продолжает путь, самое длинное, что ещё может оказаться началом образца, - конец ku. Туда и идём, не возвращаясь по тексту.

Прогон текста rakursa:

символ    r     a     k      u       r        s         a
узел      r     ra    rak    raku    rakur    rakurs    a
                      ▲                       ▲
                      rak                     rakurs           ▲ - узел помечен, найдено слово

последний символ:   rakurs → kurs → корень → a                 два ухода по ссылке и спуск

На последнем символе у узла rakurs нет ребра a. Ссылка ведёт в kurs - там тоже нет. Ссылка из kurs ведёт в корень, у корня ребро a есть. Семь символов, девять переходов: семь спусков и два ухода по ссылке.

Правило

Суффиксная ссылка сына u узла v по символу c находится так: взять ссылку v, и пока в том узле нет ребра c и это не корень - идти по ссылке дальше; если ребро c нашлось, ссылка u ведёт в его конец, иначе в корень. Ссылки сыновей корня ведут в корень. Узлы обходятся В ШИРИНУ - по уровням, потому что ссылка всегда ведёт на меньшую глубину и должна быть уже посчитана. При поиске число переходов не больше 2n: каждый символ текста опускает на один уровень, а каждый уход по ссылке поднимает хотя бы на один.

Разбор: строим ссылки и ищем

Бор строит функция bor из прошлого урока. Ссылки:

def ssylki(deti):
    ssyl = [0] * len(deti)            # ссылка каждого узла; 0 - корень
    ochered = []
    for c in deti[0]:
        ochered.append(deti[0][c])    # сыновья корня: ссылка в корень уже стоит
    g = 0
    while g < len(ochered):           # обход в ширину
        v = ochered[g]
        g = g + 1
        for c in deti[v]:
            u = deti[v][c]
            k = ssyl[v]
            while k > 0 and c not in deti[k]:
                k = ssyl[k]           # то же, что k = pi[k - 1] в КМП
            if c in deti[k]:
                k = deti[k][c]
            ssyl[u] = k
            ochered.append(u)
    return ssyl

Проверим одну строку таблицы. Узел rakur - сын узла raku по символу r. Ссылка raku ведёт в ku. У ku есть ребро r, оно ведёт в kur. Значит, ссылка rakur - узел kur. И вторая: kur - сын ku по r. Ссылка ku ведёт в корень, у корня есть ребро r. Ссылка kur - узел r.

Поиск. Пока сообщаем слово, только когда пришли в помеченный узел:

def poisk(t, deti, konec, ssyl):
    v = 0
    nashli = []
    perehodov = 0
    i = 0
    while i < len(t):
        c = t[i]
        while v > 0 and c not in deti[v]:
            v = ssyl[v]                           # ребра нет - уходим по ссылке
            perehodov = perehodov + 1
        if c in deti[v]:
            v = deti[v][c]
        perehodov = perehodov + 1
        if konec[v] >= 0:
            nashli.append([i, konec[v]])          # слово кончается в позиции i
        i = i + 1
    return [nashli, perehodov]

На тексте rakursa эта программа находит rak в позиции 2 и rakurs в позиции 5. Но в тексте есть ещё ak и kurs - и они пропущены. Это не ошибка ссылок, а недосказанность самого поиска; её закрывает следующий урок.

Разбор: замер против ста проходов

Текст tekst(10000, k, 1). Образцы - куски текста по шесть символов, взятые с позиций 13, 74, 135 и дальше с шагом 61; кусок, который уже есть в наборе, пропускается, и позиции перебираются, пока не наберётся нужное число разных.

алфавит    образцов    узлов в боре    проходы КМП, сравнений    Ахо-Корасик, переходов
   4            1             6                12 455                   12 456
   4           10            50               124 217                   18 583
   4          100           348             1 244 537                   18 140
  26          100           519             1 038 306                   19 193

Ответ на вопрос из начала урока: цена прохода по тексту вместе с числом образцов не растёт. Столбец КМП растёт в сто раз, столбец Ахо-Корасика остаётся под потолком 2n = 20 000, и потолок этот от числа образцов не зависит. От числа образцов зависит только подготовка - размер бора, - а она стоит порядка суммы длин образцов и делается один раз.

Откуда потолок. Глубина текущего узла растёт не больше чем на единицу за символ текста - всего не больше n раз. Уход по ссылке уменьшает глубину хотя бы на единицу. Уменьшить нельзя больше, чем увеличили. Это дословно доказательство для КМП, в котором «число совпавших символов» заменено на «глубину узла».

Обрати внимание на строку с одним образцом: 12 456 переходов против 12 455 сравнений. Алгоритм Ахо-Корасика на одном образце и есть КМП - бор вырождается в цепочку, ссылки в префикс-функцию. Разница в единицу - счёт после найденного вхождения: КМП заменяет k на π[m − 1] без сравнения, а здесь тот же шаг - уход по ссылке, и он посчитан как переход.

Что ломается без этого

Первое: ссылки обходом в глубину. Ссылка узла вычисляется через ссылки узлов уровнем выше - в том числе из СОСЕДНИХ веток. При обходе в глубину до соседней ветки ещё не дошли, там стоят нули, и ссылки выходят короче правильных. Программа работает, но пропускает вхождения.

Второе: сын корня ссылается на себя. Если для сыновей корня запустить общий цикл, то для узла a получится: ссылка родителя - корень, у корня есть ребро a, значит ссылка a ведёт в a. Поиск, попав в такой узел без нужного ребра, зациклится. Поэтому сыновья корня кладутся в очередь отдельно, с уже готовой ссылкой в корень.

Третье: условие v > 0 забыто. В корне без подходящего ребра идти некуда: символ просто пропускается, поиск остаётся в корне. Без проверки цикл ухода по ссылке не кончится.

Четвёртое: сообщать только слово своего узла. На rakursa найдено два слова из четырёх. Так теряется каждое слово, которое является концом другого слова или его начала.

Зачем это в работе

Первый сюжет - список слов и большой текст. Команда grep -F -f slova.txt journal.log печатает строки журнала, в которых встретилось хотя бы одно слово из файла slova.txt: ключ -f велит взять образцы из файла, ключ -F - искать их как есть, без регулярных выраженийРазбирается в разделе 21, глава «Конечные автоматы и регулярные языки»: Регулярные выражения: три операции, автомат и возвраты, которые не кончаются. Для набора фиксированных строк GNU grep строит именно этот автомат: проход по журналу один, сколько бы слов ни было в файле.

Второй - сигнатуры. Антивирус сверяет файл с сотнями тысяч известных последовательностей байтов, система обнаружения вторжений - сетевой поток с тысячами правил. Запускать поиск по разу на правило невозможно; все сигнатуры лежат в одном боре, и на байт данных в среднем уходит не больше двух переходов.

Третий - фильтры и разметка. Найти в сообщении все слова из списка запрещённых, расставить ссылки на все термины словаря в статье - это та же задача: много коротких образцов, один текст, один проход.

Теперь сам

Образцы - ab, abd, bc. Расставь суффиксные ссылки во всех узлах бора. Затем прогони текст abc: в каких узлах побывает поиск и сколько сделает переходов?

Ответ. Узлы: a, ab, abd, b, bc.

узел     концы его строки     ссылка
a        -                     корень
ab       b                     b
abd      bd, d                 корень
b        -                     корень
bc       c                     корень

Прогон: символ a - узел a; символ b - узел ab, помечен, найдено ab. Символ c: у ab нет ребра c, уходим по ссылке в b; у b ребро c есть - узел bc, помечен, найдено bc. Переходов четыре: три спуска и один уход по ссылке.

Без ссылки из ab в b поиск после ab вернулся бы в корень, и слово bc, которое началось внутри уже прочитанного куска, было бы потеряно.

Главное

Алгоритм Ахо-Корасика - это КМП, в котором строка заменена деревом. Состояние поиска - узел бора, то есть самое длинное начало какого-нибудь образца, которым кончается прочитанный текст. Суффиксная ссылка говорит, в какой узел перейти, когда текущий путь не продолжается, и вычисляется так же, как префикс-функция, - через ссылки более коротких строк, поэтому узлы обходят по уровням. Число образцов влияет на размер бора и не влияет на проход по тексту: не больше 2n переходов хоть для одного слова, хоть для ста тысяч.

Комментарии

Пока нет комментариев. Будь первым!

Оставить комментарий

Комментарий появится после проверки. Email не публикуется. Войти, чтобы не вводить имя каждый раз.