# теория · шаг 3 из 7
Префикс-функция и КМП: поиск, который не возвращается по тексту
Коротко
Префикс-функция образца - это список чисел: π[i] равно длине самого длинного начала образца, которое
одновременно стоит в конце куска p[0..i] и не совпадает с ним целиком. Для abracadabra это
0 0 0 1 0 1 0 1 2 3 4. Алгоритм Кнута-Морриса-Пратта (КМП) при расхождении не возвращается по тексту:
совпавший кусок текста - это уже известное начало образца, и π говорит, какая его часть годится как
начало следующей попытки. Каждый символ текста читается один раз, сравнений не больше 2n. Замер: тысяча
букв a и образец из 499 a с b в конце - 1501 сравнение вместо 250 500 у наивного поиска.
Если ты посчитаешь префикс-функцию для aabaaab, объяснишь, почему при расхождении берут π[k − 1], и докажешь оценку 2n, - листай до «Теперь сам».
Как думаешь, на сколько можно сдвинуть образец
Образец abracadabra приложен к тексту. Первые десять символов совпали - в тексте стоит abracadabr, -
а одиннадцатый не совпал. Наивный поиск сдвинет образец на одну позицию и начнёт сравнивать с начала.
Прежде чем читать дальше, подумай: десять символов текста ты уже знаешь, не заглядывая в него, - это
abracadabr. На сколько позиций можно сдвинуть образец сразу, и с какого символа образца продолжить
сравнение?
На что это похоже
Представь домофон, который открывается кодом 1213 и не требует сброса: он просто слушает поток цифр. Ты
нажал 1, 2, 1 - и ошибся, нажал 2 вместо 3. Начинать ли сначала?
Нет. Последние две нажатые цифры - 1, 2 - это начало правильного кода. Домофон уже на полпути:
осталось нажать 1, 3. Он не запоминает всё нажатое и не перематывает назад. Он помнит одно число:
сколько цифр кода сейчас набрано. После ошибки это число не обнуляется, а падает до длины самого длинного
начала кода, которым заканчивается только что набранное.
Механизм: грань и что помнит префикс-функция
Грань строки - её начало, которое одновременно является её концом и короче самой строки. У строки
abra грань одна - a. У abracadabra две: abra и a. У abc граней нет.
Префикс-функция образца p - список π той же длины: π[i] - длина самой длинной грани куска
p[0..i], то есть первых i + 1 символов. Ноль означает, что граней у куска нет.
индекс 0 1 2 3 4 5 6 7 8 9 10
образец a b r a c a d a b r a
π 0 0 0 1 0 1 0 1 2 3 4
π[9] = 3: a b r a c a d a b r начало abr = конец abr
───── ─────
Теперь поиск. Пусть k - сколько символов образца совпало с текстом к этому моменту. Пришёл новый символ
текста, и он не равен p[k]. Совпавшие k символов текста известны: это p[0..k−1]. Следующее вхождение
может начинаться только там, где конец этого куска совпадает с началом образца, - иначе расхождение
случится ещё на известных символах. Самый длинный такой конец имеет длину π[k − 1], и брать надо именно его:
самая длинная грань - это самый маленький сдвиг, а всякий сдвиг меньше дал бы расхождение на уже известных
символах. Значит, k падает до π[k − 1], и тот же символ текста сравнивается с p[k] заново. Не
подошёл - снова k = π[k − 1]: это грань грани. Она же - следующая по длине грань всего совпавшего куска:
любая его грань короче самой длинной стоит и в начале, и в конце этой самой длинной, то есть является её
гранью. Поэтому цепочка откатов перебирает все грани от длинной к короткой и ни одной не пропускает.
текст … a b r a c a d a b r X … совпало k = 10, X ≠ a
образец a b r a c a d a b r a
↓ k = π[9] = 3: сдвиг на 10 − 3 = 7
образец a b r a c … X сравнивается с p[3] = a
Это ответ на вопрос из начала: сдвиг на семь позиций, продолжать с четвёртого символа образца. Указатель в тексте при этом не двигается назад ни разу.
Внимательный глаз заметит странность: про X уже известно, что он не a, а p[3] - тоже a, так что
это сравнение обречено. Алгоритм этого не помнит и честно сравнивает; на оценку числа сравнений это не
влияет. Есть уточнённая таблица, которая такие заведомо неудачные попытки пропускает, - суть та же.
Правило
При расхождении после k совпавших символов КМП заменяет k на π[k − 1] и сравнивает тот же символ
текста снова; при k = 0 переходит к следующему символу текста. После найденного вхождения k тоже
становится π[m − 1], а не нулём - иначе теряются перекрывающиеся вхождения. Префикс-функция считается
тем же приёмом за O(m): образец ищется в самом себе. Весь поиск делает не больше 2n сравнений и
ни разу не возвращается по тексту.
Разбор: считаем префикс-функцию для abababca
def prefiks(p):
m = len(p)
pi = [0] * m
k = 0 # длина грани предыдущего куска
i = 1
while i < m:
while k > 0 and p[i] != p[k]:
k = pi[k - 1] # грань не продолжилась - берём грань грани
if p[i] == p[k]:
k = k + 1 # грань продолжилась на один символ
pi[i] = k
i = i + 1
return pi
Мысль одна: грань куска p[0..i] получается из грани куска p[0..i−1] дописыванием одного символа. Если
p[i] равен символу сразу за старой гранью - грань выросла на единицу. Если нет - пробуем продолжить
грань покороче.
i p[i] k до что происходит π[i]
1 b 0 b ≠ p[0] = a 0
2 a 0 a = p[0] 1
3 b 1 b = p[1] 2
4 a 2 a = p[2] 3
5 b 3 b = p[3] 4
6 c 4 c ≠ p[4] = a → k = π[3] = 2
c ≠ p[2] = a → k = π[1] = 0
c ≠ p[0] = a 0
7 a 0 a = p[0] 1
Итог: [0, 0, 1, 2, 3, 4, 0, 1]. Шаг i = 6 показывает цепочку откатов: грань abab не продолжилась
символом c, её собственная грань ab тоже, а короче граней нет - и с самого начала образца c тоже не
совпал.
Разбор: поиск и замер против наивного
Версия со счётчиком: каждый круг внутреннего цикла - ровно одно сравнение символа текста с символом образца.
def kmp(t, p):
pi = prefiks(p)
m = len(p)
k = 0 # сколько символов образца сейчас совпало
sravn = 0
nashli = 0
i = 0
while i < len(t):
while True:
sravn = sravn + 1
if t[i] == p[k]:
k = k + 1
break
if k == 0:
break
k = pi[k - 1] # откат по образцу; i не меняется
if k == m:
nashli = nashli + 1 # вхождение кончается в позиции i
k = pi[k - 1]
i = i + 1
return [nashli, sravn]
Те же данные, что в уроке про наивный поиск, n = 1000:
текст и образец наивный КМП
"a" * 1000, "a" * 9 + "b" 9 910 1 991
"a" * 1000, "a" * 99 + "b" 90 100 1 901
"a" * 1000, "a" * 499 + "b" 250 500 1 501
tekst(1000, 4, 1), t[500:510] 1 328 1 248
tekst(1000, 26, 1), t[500:510] 1 046 1 044
Почему КМП не выходит за 2n. Число k растёт только на единицу и только при успешном сравнении, а
успешных сравнений не больше одного на символ текста - всего не больше n. Каждое неуспешное сравнение
либо уменьшает k хотя бы на единицу, либо случается при k = 0 и заканчивает работу с символом. Убавить
k нельзя больше раз, чем его прибавили. Итого: успешных не больше n, неуспешных не больше n. Это
амортизационный счёт из раздела про рекуррентности: отдельный символ текста может стоить много откатов
подряд, но оплачены они совпадениями, накопленными раньше.
И граница, которую видно в двух нижних строках таблицы: на обычном тексте КМП почти ничего не выигрывает -
1248 против 1328, 1044 против 1046. Наивный поиск там и так линеен. КМП покупает не скорость на типичных
данных, а ГАРАНТИЮ на любых - и за неё платит подготовкой образца и списком из m чисел.
Что ломается без этого
Первое: if вместо while при откате. Один откат вместо цепочки. На многих образцах ответ верный:
второй откат подряд нужен редко. На образце aaab префикс-функция выходит 0 1 2 1 вместо 0 1 2 0,
и поиск с такой таблицей находит то, чего нет: в тексте aaabaab образец «найден» дважды, хотя входит
один раз.
Второе: k = pi[k] вместо k = pi[k − 1]. Совпало k символов, последний из них имеет индекс
k − 1, и грань нужна для куска, который кончается на нём. Ошибка на единицу здесь даёт неверные
сдвиги, а на образце aaaa - вечный цикл: π[1] = 1, и при откате k не уменьшается.
Третье: k = 0 после находки. В тексте abababababab образец abab входит пять раз; с обнулением
найдётся три. Если же не менять k вовсе, следующий круг прочитает p[m] и упадёт с IndexError.
Четвёртое: префикс-функция по определению. Перебрать для каждого i все длины грани и сравнить начало
с концом срезами - это O(m³). Для образца в десять символов разницы нет, для тысячи - миллиард действий
на подготовку, которая должна стоить тысячу.
Зачем это в работе
Первый сюжет - поток. КМП не возвращается по тексту, значит текст можно не хранить: читать из сети, из
канала, с ленты - по символу, держа в памяти одно число k. Наивному поиску при расхождении нужны
предыдущие m − 1 символов, их приходится держать в буфере.
Второй - сама префикс-функция как инструмент. Число m − π[m − 1] - длина самого короткого периода
строки, то есть куска, повторением которого она получается (последний повтор может быть обрезан): у abababab последнее значение π равно 6, период 8 − 6 = 2. Если период делит длину нацело,
строка целиком составлена из повторов одного куска - и это проверено без перебора всех делителей длины.
Третий - фундамент. Поиск сразу многих образцов (алгоритм Ахо-Корасика, через главу) - это та же идея
отката, перенесённая со строки на дерево. Кто понял π[k − 1], там не встретит ничего нового, кроме
структуры данных.
Теперь сам
Посчитай префикс-функцию для aabaaab. Затем ответь: образец приложен к тексту, совпало шесть символов,
седьмой не совпал. Каким станет k и на сколько позиций сдвинулся образец?
Ответ.
индекс 0 1 2 3 4 5 6
образец a a b a a a b
π 0 1 0 1 2 2 3
Трудное место - i = 5. До него k = 2 (грань aa у куска aabaa). Символ p[5] = a не равен
p[2] = b, откат: k = π[1] = 1. Теперь p[5] = a равен p[1] = a, грань выросла: k = 2. Если бы
вместо отката по таблице k сбрасывался в ноль, вышла бы единица: p[5] совпал бы только с p[0].
Совпало шесть символов - это кусок aabaaa, его грань π[5] = 2. После расхождения k = 2: образец
сдвинулся на 6 − 2 = 4 позиции, и несовпавший символ текста сравнивается с p[2] = b.
Главное
КМП отвечает на вопрос, который наивный поиск не задаёт: что я уже знаю о тексте после неудачной попытки?
Ответ - совпавший кусок, а он целиком состоит из символов образца. Поэтому всё, что нужно для следующей
попытки, можно посчитать заранее по одному образцу, не видя текста: для каждой длины совпадения - длину
самой длинной грани. Этот список и есть префикс-функция. Указатель в тексте не возвращается никогда,
сравнений не больше 2n на любых данных, а на обычном тексте выигрыш перед наивным поиском почти нулевой -
КМП берут ради гарантии и ради работы с потоком.
Комментарии
Пока нет комментариев. Будь первым!
Оставить комментарий