# теория · шаг 1 из 6
Поиск автоматом: одно обращение к таблице на символ
Коротко
Автомат для образца - это таблица. Строки - состояния от 0 до m: сколько символов образца сейчас
совпало. Столбцы - символы алфавита. В клетке записано, сколько символов будет совпадать после того, как
придёт этот символ. Поиск - одна строка кода: q = d[q][c] на каждый символ текста; ровно n обращений к
таблице и ни одного сравнения символов. Это КМП, у которого цепочки откатов посчитаны заранее для каждого
символа. Плата - размер таблицы, (m + 1) · k клеток: для образца в тысячу символов над байтами это
256 256 чисел, над Юникодом - больше миллиарда.
Если ты построишь таблицу переходов для aab, объяснишь, чем автомат отличается от КМП по цене одного символа, и скажешь, когда таблица не помещается в память, - листай до «Теперь сам».
Как думаешь, сколько может стоить один символ
КМП гарантирует 2n сравнений на весь текст. Про отдельный символ он не гарантирует ничего. Образец -
девять букв a и буква b. В тексте совпало девять a, и приходит символ c.
Прежде чем читать дальше, посчитай, сколько сравнений потратит КМП на этот один символ. А теперь представь устройство, которое разбирает сетевой поток и обязано тратить на каждый байт одинаковое время. Можно ли сделать поиск, в котором любой символ стоит ровно одно действие?
На что это похоже
Представь настольную игру-ходилку. Фишка стоит на клетке с номером. На каждой клетке напечатана табличка:
«выпала a - иди на клетку 3, выпала b - иди на клетку 0». Ты не рассуждаешь и ничего не сравниваешь:
читаешь букву, смотришь в табличку, переставляешь фишку.
Вся сообразительность потрачена заранее - тем, кто печатал таблички. Игроку остаётся одно движение на ход, всегда одно. Номер клетки - единственное, что надо помнить о всей прошлой игре.
Механизм: состояния и таблица переходов
Конечный автомат - это конечный набор состояний, одно из которых начальное, и таблица переходов: для каждой пары «состояние и символ» записано следующее состояние. Больше у автомата ничего нет: ни памяти о прошлом, ни счётчиков.
Для поиска образца состояний m + 1, и у каждого прямой смысл: состояние q означает «последние q
прочитанных символов текста - это первые q символов образца, и больше совпасть не могло». Состояние m
означает вхождение.
Образец abab, алфавит из двух букв:
состояние что совпало по a по b
0 ничего 1 0
1 a 1 2
2 ab 3 0
3 aba 1 4
4 abab - вхождение 3 0
Прогон текста abaababab. Под каждым символом - состояние после него:
текст a b a a b a b a b
состояние 1 2 3 1 2 3 4 3 4
▲ ▲ вхождения кончаются в позициях 6 и 8
На четвёртом символе видно, что делает таблица. Совпало aba, пришла a: получилось abaa, образцом это
не продолжается. Но последняя a - начало образца, поэтому автомат уходит не в ноль, а в состояние 1.
КМП пришёл бы туда же тремя сравнениями: с p[3], после отката до единицы - с p[1], после отката до нуля -
с p[0]. Автомат знает ответ заранее.
Правило
Переход d[q][c] равен длине самого длинного начала образца, которым заканчивается строка «первые q
символов образца плюс символ c». Если c совпал с p[q], это q + 1. Иначе в состоянии 0 это ноль, а
в любом другом состоянии ответ тот же, что у автомата в состоянии π[q − 1] на том же символе, - и он уже
посчитан, потому что π[q − 1] < q. Таблица
строится за (m + 1) · k действий, поиск делает ровно n обращений к ней, и цена одного символа текста
не зависит ни от образца, ни от данных.
Разбор: строим таблицу через префикс-функцию
def avtomat(p, alf):
m = len(p)
pi = prefiks(p) # из прошлой главы
d = []
q = 0
while q <= m:
stroka = {}
for c in alf:
if q < m and c == p[q]:
stroka[c] = q + 1 # символ продолжил совпадение
elif q == 0:
stroka[c] = 0 # откатываться некуда
else:
stroka[c] = d[pi[q - 1]][c] # как после отката - а это уже известно
d.append(stroka)
q = q + 1
return d
def poisk(t, d, m):
q = 0
nashli = 0
for c in t:
q = d[q][c] # вся работа с символом
if q == m:
nashli = nashli + 1
return nashli
Проверим две клетки таблицы для abab. Префикс-функция образца - 0 0 1 2.
Состояние 3, символ a. Совпало aba, а p[3] = b, значит символ не продолжил. Откат:
π[2] = 1. Берём готовую клетку d[1]["a"] - там 1. Проверка по определению: строка abaa кончается на
a, это начало образца длины 1; на ab, aba или abab она не кончается. Сходится.
Состояние 4, символ a - что делать после найденного вхождения. Условие q < m ложно, откат:
π[3] = 2, клетка d[2]["a"] = 3. По определению: ababa кончается на aba. Поэтому в тексте
abababababab автомат находит все пять перекрывающихся вхождений - строка таблицы для состояния m
делает то же, что откат после найденного вхождения в КМП.
Разбор: цена символа и цена таблицы
Ответ на вопрос из начала урока. Образец aaaaaaaaab, совпало девять a, пришла c. КМП сравнивает c
с p[9], откатывается до 8, сравнивает с p[8], откатывается до 7 - и так до нуля. Десять сравнений на
один символ. Автомат, построенный над алфавитом abc, делает одно обращение: d[9]["c"] = 0.
На весь текст счёт такой, n = 1000, образец "a" * 9 + "b":
наивный поиск 9 910 сравнений
КМП 1 991 сравнение
автомат 1 000 обращений к таблице, сравнений символов нет
Сумма у КМП и автомата одного порядка. Разница в распределении: у автомата каждый символ стоит ровно
единицу, у КМП - от одного до m. Теперь плата:
образец m алфавит k клеток (m + 1) · k
10 2 (биты) 22
10 26 (латиница) 286
1000 256 (байты) 256 256
1000 1 114 112 (Юникод) больше миллиарда
Последняя строка - причина, по которой полный автомат не строят для текста в Юникоде. Выход - хранить в
строке таблицы только символы, которые есть в образце, а для остальных отвечать нулём:
q = d[q].get(c, 0). Символ, которого нет в образце, не может продолжить никакое его начало.
Разбор: тот же приём в методе четырёх русских
Автомат - частный случай общей идеи: если кусок работы зависит от небольшого числа возможных входов, ответы для ВСЕХ входов считают заранее и потом только читают из таблицы.
Метод четырёх русских назван по авторам статьи 1970 года - Арлазарову, Диницу, Кронроду и Фараджеву; они
ускоряли так умножение таблиц из нулей и единиц. К таблицам динамического программирования для строк
приём перенесли Масек и Патерсон в 1980 году. Возьми таблицу наибольшей общей подпоследовательности из
раздела про динамику для двух строк длины N: N × N клеток, соседние клетки отличаются не больше чем
на единицу. Значит, границу квадрата можно записать не числами, а разностями - нулями и единицами.
Разрежем таблицу на квадраты со стороной в r клеток. Всё, что квадрат выдаёт на нижней и правой границе,
определяется его входом: r разностей сверху, r слева и два куска строк по r символов.
Разных входов конечное число, и если взять r равным небольшой доле log N (какой именно - зависит от
размера алфавита), их окажется меньше N. Ответы для всех считаются один раз. После этого таблица
проходится квадратами: квадратов (N / r)², на каждый - одно обращение и r выходных значений. Итог -
порядка N² / log N вместо N². На практике выигрыш скромный - в несколько раз даже на строках в
миллион символов, - и его перекрывают битовые приёмы, которые обрабатывают 64 клетки одной машинной
командой. Но сама идея «таблица для всех малых входов» встречается в алгоритмах постоянно.
Что ломается без этого
Первое: символ, которого нет в таблице. Автомат построен для алфавита ab, а в тексте встретилась
c: d[q]["c"] - ошибка KeyError. Либо алфавит таблицы покрывает все символы текста, либо обращение
идёт через get с нулём по умолчанию.
Второе: таблица без строки для состояния m. Цикл построения написан как q < m - и строки для
вхождения нет. Первый же символ после найденного образца роняет программу с IndexError. Если вместо
этого сбрасывать q в ноль, потеряются перекрывающиеся вхождения.
Третье: построение по определению. Для каждой клетки перебрать все длины начала и сравнить срезами -
это m длин по m символов на каждую из m · k клеток, порядка m³ · k. Для образца в тысячу символов
над байтами - сотни миллиардов действий вместо четверти миллиона.
Четвёртое: автомат там, где хватило бы КМП. Образец ищется один раз в коротком тексте, а подготовка
таблицы стоит (m + 1) · k. При m = 1000 над байтами это 256 256 записей ради прохода по тексту,
который может быть короче. Автомат окупается, когда текста много или когда важна цена каждого символа.
Зачем это в работе
Первый сюжет - регулярные выраженияРазбирается в разделе 21, глава «Конечные автоматы и регулярные языки»: Регулярные выражения: три операции, автомат и возвраты, которые не кончаются. Инструменты с гарантией линейного времени (утилита grep, библиотека
RE2) превращают выражение в такой же автомат: состояния, таблица, одно обращение на символ. Поиск
фиксированной строки - простейший случай; про общий будет раздел о вычислимости.
Второй - железо и поток. Сетевой фильтр, антивирус на шлюзе, разбор протокола в микросхеме: байты идут с постоянной скоростью, и обработчик обязан успевать на КАЖДЫЙ. Амортизированная оценка тут не годится - нужна гарантия на символ, и её даёт только таблица.
Третий - разбор текста программ. Первая стадия любого компилятора режет исходник на слова автоматом: по символу за шаг, без возвратов.
Теперь сам
Построй таблицу переходов для образца aab над алфавитом из букв a и b. Затем прогони по ней текст
aaabaab и назови позиции, в которых кончаются вхождения.
Ответ. Префикс-функция aab - 0 1 0.
состояние что совпало по a по b
0 ничего 1 0
1 a 2 0
2 aa 2 3
3 aab - вхождение 1 0
Клетка, на которой ошибаются: состояние 2, символ a. Совпало aa, пришла третья a. Получилось aaa,
и оно кончается на aa - автомат остаётся в состоянии 2, а не падает в 1. Через откат: π[1] = 1,
клетка d[1]["a"] = 2.
текст a a a b a a b
состояние 1 2 2 3 1 2 3
Вхождения кончаются в позициях 3 и 6.
Главное
Автомат - это КМП, у которого всё, что можно, сделано заранее. КМП хранит m чисел и при расхождении
вычисляет переход цепочкой откатов; автомат хранит (m + 1) · k чисел и переход читает. Отсюда и выбор:
общая цена у них одного порядка, но автомат гарантирует единицу на КАЖДЫЙ символ, а платит памятью,
которая растёт вместе с алфавитом. Сам приём - посчитать ответы для всех возможных малых входов и дальше
только читать - шире поиска строк: на нём же стоит метод четырёх русских.
Комментарии
Пока нет комментариев. Будь первым!
Оставить комментарий