Алгоритм Рабина-Карпа: поиск подстроки за O(n+m)

Алгоритм Рабина-Карпа (Rabin-Karp, 1987) ищет вхождения образца длины в тексте длины через хеширование подстрок. Вместо посимвольного сравнения каждой позиции он сравнивает хеш образца с хешем текущего окна текста, а само окно «прокатывает» по тексту за константу с помощью полиномиального хеша. В среднем это даёт при разумном выборе модуля, и хотя в худшем случае алгоритм деградирует до , его настоящая сила - в естественном обобщении на поиск множества образцов и на задачи вроде детекции плагиата, где десятки хешей сравниваются за один проход.
Идея: сравнивать хеши, а не строки
Наивный поиск подстроки сдвигает образец по тексту и в каждой из позиций сравнивает до символов - это . Рабин и Карп заметили: если у двух строк разные хеши, то строки заведомо различны, и сравнивать их посимвольно не нужно. Хеш - это число, его сравнение стоит .
План такой: один раз посчитать хеш образца , затем для каждого окна посчитать его хеш и сравнить с . Если хеши не совпали - окно точно не образец, идём дальше. Если совпали - это кандидат, его проверяют посимвольно (потому что хеши разных строк иногда совпадают). Вся экономия в том, что хеш окна пересчитывается не за , а за - за счёт полиномиального хеша со скользящим окном.
Полиномиальный хеш
Строку кодируют как значение многочлена по основанию по модулю :
Здесь - основание (обычно простое чуть больше размера алфавита, например или ), - большой простой модуль (например ), а - числовой код символа. По модулю хеш умещается в машинное слово, и арифметика остаётся константной. Образец и каждое окно текста кодируются одной и той же формулой, поэтому равные строки гарантированно дают равный хеш.
Скользящее окно за O(1)
Главный трюк - пересчитать хеш соседнего окна из предыдущего без повторного суммирования. Пусть известен хеш окна . Чтобы получить хеш окна , нужно: убрать вклад ушедшего символа , сдвинуть остаток (домножить на ) и добавить пришедший символ :
Величина считается один раз заранее. Тогда переход к следующему окну - это вычитание, два умножения и сложение по модулю, то есть . Именно это превращает пересчётов из в . Такой хеш называют скользящим (rolling hash), и он же лежит в основе rsync и алгоритма Карпа-Рабина для двумерного поиска.
Полный алгоритм
h_p = hash(P) # хеш образца
h_t = hash(T[0..m-1]) # хеш первого окна
power = b^(m-1) mod q
for i in 0..n-m:
if h_t == h_p:
if T[i..i+m-1] == P: # проверка кандидата
report match at i
if i < n-m:
h_t = ((h_t - T[i]*power)*b + T[i+m]) mod q
h_t = (h_t + q) mod q # убрать возможный минус
Предвычисление хешей образца и первого окна - . Главный цикл - итераций, каждая по плюс проверка кандидатов. Если хеш хороший и совпадений мало, кандидатов почти нет, и суммарно выходит в среднем. Это та же асимптотика, что у алгоритма Кнута-Морриса-Пратта, но достигается она вероятностно, а не за счёт детерминированной префикс-функции.
Ложные срабатывания и модуль
Хеш отображает строки длины в числа из диапазона , поэтому разные строки иногда дают один хеш - коллизия, или ложное срабатывание (spurious hit). При совпадении хешей обязательна посимвольная проверка кандидата, иначе алгоритм будет находить несуществующие вхождения.
При случайном тексте и модуле вероятность коллизии в одной позиции - порядка . Ожидаемое число ложных срабатываний на весь текст - около . С модулем на тексте в миллион символов это доли единицы: проверки кандидатов почти не происходят, и среднее остаётся линейным.
Без посимвольной проверки кандидата Рабин-Карп некорректен: совпадение хешей не гарантирует совпадения строк. Проверка - обязательная часть алгоритма, а не оптимизация.
Для защиты от намеренных коллизий (когда противник подбирает текст под ваш хеш) применяют двойное хеширование - два независимых модуля ; вероятность одновременной коллизии падает до . Случайный выбор и из набора простых ещё надёжнее.
Худший случай и анти-хеш тесты
Если хеш-функция выбрана неудачно или текст подобран злонамеренно, хеши совпадают в каждой позиции, посимвольная проверка запускается всюду, и алгоритм деградирует до . Классический пример - текст из одного повторяющегося символа и образец из того же символа: каждое окно совпадает, и проверка кандидата стоит .
Поэтому фиксированный публичный модуль (, ) уязвим: на спортивных соревнованиях существуют анти-хеш тесты, валящие именно такие константы. Лечится рандомизацией: и выбираются случайно во время выполнения, и заранее построить ломающий тест становится невозможно. Детерминированной линейной гарантии у Рабина-Карпа нет - за ней идут к KMP или Z-функции.
Поиск множества образцов
Настоящее преимущество Рабина-Карпа - когда образцов несколько, но одинаковой длины . Хеши всех образцов кладут в хеш-множество, а текст прокатывают одним скользящим окном. На каждой позиции проверяют, есть ли хеш окна в множестве - это . Один проход находит вхождения всех образцов за в среднем, без множителя в главном цикле.
Если же образцы разной длины, единого окна не получится, и для множественного поиска удобнее алгоритм Ахо-Корасик: он строит один автомат на боре и за линейный проход находит все вхождения паттернов любой длины. Рабин-Карп остаётся выбором, когда длины совпадают (детекция дубликатов фиксированных блоков) или когда хеши и так нужны для других целей.
Применения
- Детекция плагиата и дубликатов. Тексты режут на перекрывающиеся -граммы, для каждой считают скользящий хеш, и совпадающие хеши указывают на заимствованные фрагменты. Отбор по хешам (winnowing, MOSS) построен на rolling hash Рабина-Карпа.
- rsync и дедупликация. Скользящий хеш по блокам файла позволяет найти совпадающие куски между старой и новой версией и передать по сети только различия.
- Двумерный поиск образца. Поиск прямоугольного шаблона в матрице/изображении: сначала хешируют строки, потом столбцы хешей - обобщение Карпа-Рабина на два измерения.
- Поиск повторяющихся подстрок. Бинпоиск по длине повтора + хеширование всех окон этой длины даёт для самой длинной повторяющейся подстроки.
Частые ошибки
- Забывают проверку кандидата при совпадении хешей. Совпадение хешей - это лишь повод сравнить строки, а не доказательство равенства. Без проверки алгоритм выдаёт ложные вхождения на каждой коллизии.
- Пересчитывают хеш окна за . Если на каждой позиции хеш считается заново суммированием, теряется весь смысл - выходит . Хеш соседнего окна обязан получаться из предыдущего за через формулу скользящего окна.
- Не приводят хеш к неотрицательному по модулю. После вычитания значение может стать отрицательным; в языках с «математическим» остатком нужно добавить и взять модуль ещё раз, иначе хеши перестанут совпадать.
- Берут слишком маленький модуль. При порядка тысяч коллизии происходят постоянно, проверки кандидатов запускаются всюду и среднее время уезжает к квадратичному. Модуль должен быть большим простым.
- Полагаются на фиксированные и там, где есть противник. Публичные константы ломаются анти-хеш тестами. Где важны гарантии - рандомизировать параметры или брать детерминированный KMP.
FAQ
Чем алгоритм Рабина-Карпа отличается от KMP? KMP детерминированно гарантирует за счёт префикс-функции и никогда не деградирует. Рабин-Карп даёт только в среднем (в худшем случае ), зато проще обобщается на множество образцов одной длины и на хеш-задачи вроде детекции плагиата. Для одного образца с жёсткими гарантиями берут KMP; где нужны хеши или много одинаковых по длине паттернов - Рабин-Карп.
Зачем нужен модуль , если можно хешировать без него? Без модуля значение полинома растёт экспоненциально по и быстро выходит за машинное слово - арифметика перестаёт быть константной. Модуль удерживает хеш в пределах слова, сохраняя на операцию; цена - редкие коллизии, которые отлавливает посимвольная проверка.
Можно ли искать сразу несколько образцов? Да, если все образцы одной длины : их хеши кладут в хеш-множество и за один проход скользящим окном проверяют принадлежность хеша окна множеству - в среднем. Для образцов разной длины единое окно не работает, и удобнее Ахо-Корасик с бором и суффиксными ссылками.
Коротко
Алгоритм Рабина-Карпа ищет образец длины в тексте длины , сравнивая полиномиальные хеши вместо самих строк. Хеш окна пересчитывается из соседнего за скользящим окном , поэтому в среднем алгоритм работает за . Совпадение хешей - лишь кандидат: его обязательно проверяют посимвольно, иначе коллизии дают ложные вхождения. В худшем случае или на анти-хеш тестах алгоритм деградирует до , что лечится рандомизацией и либо двойным хешированием. Главная ниша - поиск множества образцов одинаковой длины, детекция плагиата, rsync и двумерный поиск, где скользящий хеш сравнивает десятки фрагментов за один проход.
Читайте также

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

Алгоритм Бойера-Мура: почему он ищет подстроку так быстро
Разбираем алгоритм Бойера-Мура: зачем сравнивать паттерн справа налево, как работают эвристики плохого символа и хорошего суффикса и почему поиск выходит сублинейным.

Алгоритм Ахо-Корасик: поиск множества образцов в тексте
Разбираем алгоритм Ахо-Корасик: как из бора паттернов и суффиксных ссылок собрать автомат и найти все вхождения множества образцов в тексте за один линейный проход.