Алгоритм Рабина — Карпа

Алгоритм Рабина — Карпа — это алгоритм поиска строки, который ищет шаблон, то есть подстроку, в тексте, используя хеширование. Он был разработан в 1987 году Михаэлем Рабином и Ричардом Карпом.

Алгоритм редко используется для поиска одиночного шаблона, но имеет значительную теоретическую важность и очень эффективен в поиске совпадений множественных шаблонов одинаковой длины. Для текста длины n и шаблона длины m его среднее и лучшее время исполнения равно O(n) при правильном выборе хеш-функции (смотрите ниже), но в худшем случае он имеет эффективность O(nm), что является одной из причин того, почему он не слишком широко используется. Для приложений, в которых допустимы ложные срабатывания при поиске, то есть, когда некоторые из найденных вхождений шаблона на самом деле могут не соответствовать шаблону, алгоритм Рабина — Карпа работает за гарантированное время O(n) и при подходящем выборе рандомизированной хеш-функции (смотрите ниже) вероятность ошибки можно сделать очень малой. Также алгоритм имеет уникальную особенность находить любую из заданных k строк одинаковой длины в среднем (при правильном выборе хеш-функции) за время O(n) независимо от размера k.

Одно из простейших практических применений алгоритма Рабина — Карпа состоит в определении плагиата. Скажем, например, что студент пишет работу по Моби Дику. Коварный профессор находит различные исходные материалы по Моби Дику и автоматически извлекает список предложений в этих материалах. Затем алгоритм Рабина — Карпа может быстро найти в проверяемой статье примеры вхождения некоторых предложений из исходных материалов. Для устранения чувствительности алгоритма к небольшим различиям можно игнорировать детали, такие как регистр или пунктуация, при помощи их удаления. Поскольку количество строк, которые мы ищем, k, очень большое, обычные алгоритмы поиска одиночных строк становятся неэффективными.

Поиск подстрок сдвигом и конкурирующие алгоритмы 
Основной задачей алгоритма является нахождение строки длины m, называемой образцом, в тексте длины n. Один из простейших алгоритмов для этой задачи просто ищет подстроку во всех возможных местах:

  1 function NaiveSearch(string s[1..n], string sub[1..m])
  2     for i from 1 to n-m+1
  3         for j from 1 to m
  4             if s[i+j-1] ≠ sub[j]
  5                 перейти к следующей итерации внешнего цикла
  6         return i
  7     return not found

Этот алгоритм хорошо работает во многих практических случаях, но совершенно неэффективен, например, на поиске строки из 10 тысяч символов «a», за которыми следует «b», в строке из 10 миллионов символов «a». В этом случае он показывает своё худшее время исполнения Θ(mn).

Алгоритм Кнута — Морриса — Пратта уменьшает это время до Θ(n), единожды используя предвычисления для каждого символа текста; Алгоритм Бойера — Мура пропускает не один символ, а столько, сколько максимально возможно для того, чтобы поиск удался, эффективно уменьшая количество итераций через внешний цикл, поэтому количество символов, с которыми производится сравнение, может быть сравнимо с n/m в лучшем случае. Алгоритм Рабина — Карпа вместо этого фокусируется на ускорении действия строк 3-6, что будет рассмотрено в следующем разделе.

Использование хеширования для поиска подстрок сдвигом 
Вместо того, чтобы использовать более умный пропуск, алгоритм Рабина — Карпа пытается ускорить проверку эквивалентности образца с подстроками в тексте, используя хеш-функцию. Хеш-функция — это функция, преобразующая каждую строку в числовое значение, называемое хеш-значением (хеш); например, мы можем иметь хеш от строки «hello» равным 5. Алгоритм использует тот факт, что если две строки одинаковы, то и их хеш-значения также одинаковы. Таким образом, всё, что нам нужно, это посчитать хеш-значение искомой подстроки и затем найти подстроку с таким же хеш-значением.

Однако существуют две проблемы, связанные с этим. Первая состоит в том, что, так как существует очень много различных строк, между двумя различными строками может произойти коллизия — совпадение их хешей. В таких случаях необходимо посимвольно проверять совпадение самих подстрок, что занимает достаточно много времени, если данные подстроки имеют большую длину (эту проверку делать не нужно, если ваше приложение допускает ложные срабатывания). При использовании достаточно хороших хеш-функций (смотрите далее) коллизии случаются крайне редко, и в результате среднее время поиска оказывается невелико.

Пример алгоритма (исходного кода приложения):

  1 function RabinKarp(string s[1..n], string sub[1..m])
  2     hsub := hash(sub[1..m])
  3     hs := hash(s[1..m])
  4     for i from 1 to (n-m+1)
  5         if hs = hsub
  6             if s[i..i+m-1] = sub
  7                 return i
  8         hs := hash(s[i+1..i+m])
  9     return not found

Строки 2, 3, и 6 затрачивают для исполнения время  каждая. Однако строки 2 и 3 исполняются только один раз, а строка 6 выполняется только когда хеш-значения совпадают, что происходит нечасто. Строка 5 выполняется  раз, но всегда требует постоянного времени.

Вторая проблема заключается в пересчитывании хеша. При наивном пересчёте хеш-значения подстроки s[i+1..i+m] затрачивается время , и, так как это делается в каждом цикле, алгоритм будет затрачивать время , то есть такое же, какое тратят и наиболее простые алгоритмы. Метод решения данной проблемы состоит в предположении того, что переменная hs уже содержит хеш-значение подстроки s[i..i+m-1]. Если использовать его для подсчёта следующего хеш-значения за постоянное время, тогда проблема будет решена.

Это достигается использованием так называемого кольцевого хеша. Самым простым примером кольцевого хеша является добавление значений каждого следующего символа в подстроке и последующее использование данной формулы для подсчёта каждого следующего хеш-значения за фиксированное время:
  s[i+1..i+m] = s[i..i+m-1] - s[i] + s[i+m]
Такая формула не даёт никаких гарантий нечастого возникновения коллизий, и действительно несложно убедиться, что в большинстве приложений при её использовании выражение в 6 строке будет выполняться чаще, чем при использовании других, более «умных» кольцевых хеш-функций.

Заметим, что если мы очень неудачливы или имеем очень плохую хеш-функцию, например, такую, как постоянную функцию (hash=const), строка 6 с высокой вероятностью будет выполняться  раз, то есть при каждой итерации цикла. Так как она затрачивает время , сам алгоритм будет требовать время .

Используемая хеш-функция 
Ключами к производительности алгоритма Рабина — Карпа являются низкая вероятность коллизий и эффективное вычисление хеш-значения последовательных подстрок текста. Рабин и Карп предложили использовать так называемый полиномиальный хеш (хотя любой другой кольцевой хеш также подойдёт). Для данного шаблона  такой хеш определён следующим образом:
 
где  — некоторое простое число, а  — число от  до . Хеш-значения последовательных подстрок  и  для полиномиального хеша вычисляются следующим образом (заметим, что для эффективности число  считается перед основной процедурой поиска алгоритма Рабина — Карпа):
 .
Например, пусть ,  произвольно, и мы имеем текст «abracadabra» и ищем образец длины 3. Мы можем рассчитать хеш подстроки «bra» из хеша подстроки «abr» (предыдущая подстрока), вычитая число, добавленное для первой буквы 'a' из «abr», то есть  ( — ASCII для 'a'), умножая на основание  и, наконец, добавляя последнее число для «bra», то есть . Чтобы избежать переполнения целых чисел, в большинстве реализаций после каждой из этих четырёх операций (умножение при вычислении  — это отдельная операция) нужно брать результат по модулю .

Рабин и Карп доказали, что если  (то есть  фиксируется) и простое число  выбирается случайно из диапазона , то вероятность коллизии при поиске шаблона в тексте длины  не превосходит . Но у такой хеш-функции два существенных недостатка: во-первых, алгоритм выбора случайного простого числа достаточно громоздкий, а во-вторых, модульная арифметика делает такой хеш очень медленным на практике (отметим, что вся арифметика в формуле для хешей последовательных подстрок должна быть по модулю , то есть взятие модуля выполнится четыре раза).

Современная модификация полиномиального хеша, предложенная Дитзфелбингером и др., лишена этих недостатков. Отличие этого варианта в том, что простое число  фиксируется, а число  случайно выбирается из диапазона от  до  перед началом работы алгоритма (при этом  совсем не обязательно должно быть простым). Доказано, что для такой хеш-функции вероятность коллизии при поиске шаблона в строке  при  для какого-то  не превосходит , при естественном условии что  для всех . Для ускорения модульной арифметики  можно выбирать равным степени двойки минус один (так называемые простые числа Мерсенна): для 32-битовых машин лучше всего подходит , для 64-битовых — ; взятие по модулю  для таких значений  вычисляется с помощью быстрых побитовых операций. Другой возможный выбор — значения  или , для которых тоже существуют быстрые алгоритмы взятия остатка от деления на  (при этом диапазон допустимых значений  немного сужают). Можно выбирать  лишь один раз при старте программы, а затем использовать его во всех хешах.

Заблуждения о полиномиальном хеше 
Ещё раз отметим, что предоставляемые полиномиальным хешем гарантии отсутствия коллизий весьма сильны: даже если кто-то, зная , но не зная , специально будет подбирать шаблон и строку длины  для поиска так, чтобы алгоритм Рабина — Карпа с полиномиальным хешем давал как можно больше коллизий, всё равно, при  для какого-то  (то есть при достаточно большом  и не сверхбольшом ) и если  выбирается действительно случайно, вероятность даже одной коллизии будет не больше , то есть очень мала. Для достижения этого результат важно, что  является простым числом. Например, частая ошибка — полагать  или  (то есть вообще не использовать модульную арифметику); примером строки, в которой можно найти много коллизий полиномиального хеша для таких , причём независимо от выбора числа , является последовательность Морса — Туэ.

Имеет популярность следующая интерпретация полиномиального хеша: каждая строка представляется числом с основанием  и затем это число берётся по модулю . Такая интерпретация не добавляет ясности в природу эффективности данного хеша, в то время как интерпретация полиномиального хеша как собственно полинома с коэффициентами, равными значениям символов, достаточно просто приводит к доказательству малой вероятности коллизии при случайном выборе : рассмотрим две различные строки  и ; полиномиальные хеши этих строк равны тогда и только тогда, когда ; но из теоремы Безу следует, что нетождественный нулю полином  степени  в поле вычетов по модулю  ( выбирается простым, именно чтобы превратить кольцо вычетов в поле) имеет не более  корней, а значит, вероятность коллизии  и  при случайном выборе  не превосходит ; поэтому если  для какого-то , вероятность коллизии двух различных строк длины  не превосходит  (отсюда, в частности, получается вероятность ошибки  для поиска шаблона в строке).

Также иногда можно встретить рекомендацию использовать простое число в качестве , но, по-видимому, кроме эмпирических наблюдений на некоторых весьма ограниченных объёмах данных такие советы ничем более не обоснованы.

Рабин — Карп и поиск множества образцов 
Из-за медленного поведения в худшем случае алгоритм Рабина — Карпа хуже алгоритма Кнута — Морриса — Пратта, алгоритма Бойера — Мура и других быстрых алгоритмов поиска строк. Тем не менее, алгоритм Рабина — Карпа можно использовать для поиска набора образцов за линейное время в лучшем случае и квадратичное в труднодостижимом худшем случае; хотя и здесь он проигрывает в худшем случае алгоритму Ахо — Корасик, имеющему линейное время работы.

Если мы хотим найти в данном тексте любой образец из большого набора, скажем, k образцов фиксированной одинаковой длины, мы можем модифицировать алгоритм Рабина — Карпа, используя хеш-таблицу или любую другую структуру данных для проверки того, что хеш данной строки принадлежит набору хеш-значений образцов, которые мы ищем:

  function RabinKarpSet(string s[1..n], set of string subs, m) {
      set hsubs := 
      for each sub in subs
          hsubs := hsubs  {hash(sub[1..m])}
      hs := hash(s[1..m])
      for i from 1 to (n-m+1)
          if hs ∈ hsubs
              if s[i..i+m-1] = string из subs с хешем hs
                  return i
          hs := hash(s[i+1..i+m])
      return не найдено
  }

Другие алгоритмы могут искать одиночный образец за время O(n), и следовательно, они могут быть использованы для поиска k образцов за время O(n k). В противоположность им, вариант алгоритма Рабина — Карпа выше может найти все k образцов за ожидаемое время O(n+k), потому что хеш-таблица, используемая для проверки случая, когда хеш подстроки равен хешу любого из образцов, использует O(1) времени. На практике из-за относительной простоты реализации и быстроты работы этот вариант нередко может оказаться предпочтительнее алгоритма Ахо — Корасик.

См. также 
 Алгоритм Бойера — Мура
 Алгоритм Кнута — Морриса — Пратта
 Алгоритм Ахо — Корасик

Примечания

Литература 
 
 
 
 
 

Рабина — Карпа
Рабина — Карпа
Хеширование