Поиск подстроки в строке с использованием хеширования. Алгоритм Рабина-Карпа — различия между версиями

Материал из Викиконспекты
Перейти к: навигация, поиск
(Новая страница: «Алгоритм Рабина — Карпа — это алгоритм поиска строки, который ищет шаблон, то есть подстр…»)
 
(Метод хеширования)
 
(не показано 117 промежуточных версий 15 участников)
Строка 1: Строка 1:
Алгоритм Рабина Карпа — это алгоритм поиска строки, который ищет шаблон, то есть подстроку, в тексте используя хеширование.
+
Алгоритм Рабина-Карпа предназначен для [[Наивный алгоритм поиска подстроки в строке#Постановка задачи| поиска подстроки в строке]].
 +
==Метод хеширования==
  
Давайте сначала определимся с методом хеширования.
+
[[Наивный алгоритм поиска подстроки в строке]] работает за <tex>O\left(n^2\right)</tex> в худшем случае — слишком долго. Чтобы ускорить этот процесс, можно воспользоваться методом [[Хеш-таблица#Хеширование|хеширования]].
 +
{{Определение
 +
|definition = Пусть дана строка <tex>s[0..n-1]</tex>. Тогда '''полиномиальным хешем''' (англ. ''polynomial hash'') строки <tex>s</tex> называется число <tex>h = \mathrm{hash}(s[0..n-1]) =  p^{0} s[0] + ... + p^{n - 1} s[n-1]</tex>, где <tex>p</tex> — некоторое простое число, а <tex>s[i]</tex> <tex>{-}</tex> код <tex>i</tex>-ого символа строки <tex>s</tex>.
 +
}}
 +
Проблему переполнения при вычислении хешей довольно больших строк можно решить так <tex>{-}</tex> считать хеши по модулю <tex>r=2^{64}</tex> (или <tex>2^{32}</tex>), чтобы модуль брался автоматически при переполнении типов.
  
Выберем полиномиальный хеш - <tex>hash(s[1..n]) = (p^{n - 1} s[1] + ... + p^{0} s[n])</tex> mod <tex>r</tex>, где <tex>p</tex> - это некоторое простое число, а <tex>r</tex> - некоторое большое число, чтобы было меньше коллизий (обычно берётся <tex>2^{32}</tex> или <tex>2^{64}</tex>, чтобы модуль брался автоматически - при переполнении типов;). Заметим, что если 2 строчки имеют одинаковый хэш, то они с большой вероятностью равны.
+
Для работы алгоритма потребуется считать хеш подстроки <tex>s[i..j]</tex>. Делать это можно следующим образом:
  
Научимся переходить от одного хеша к другому за <tex>O(1)</tex>:
+
Рассмотрим хеш <tex>s[0..j]</tex>:
  
<tex>hash(s[i + 1..i + m - 1]) = hash(s[i..i + m - 1]) - p^{m - 1} s[i]</tex>.
+
<tex>\mathrm{hash}(s[0..j]) = s[0] + p s[1] +...+ p^{i-1} s[i-1] + p^{i} s[i] +...+ p^{j-1} s[j-1] + p^{j} s[j]</tex>
  
<tex>hash(s[i + 1..i + m]) = p \cdot hash(s[i + 1..i + m]) + s[i + m]</tex>.
+
Разобьем это выражение на две части:
  
Получается : <tex>hash(s[i + 1..i + m]) = p \cdot hash(s[i..i + m - 1]) - p^{m} s[i] + s[i + m]</tex>.
+
<tex>\mathrm{hash}(s[0..j]) = (s[0] + p s[1] +...+ p^{i-1} s[i-1]) + (p^{i} s[i] +...+ p^{j-1} s[j-1] + p^{j} s[j])</tex>
  
Теперь к самому алгоритму.
+
Вынесем из последней скобки множитель <tex>p^{i}</tex>:
  
У нас сеть шаблон - <tex>p[1..m]</tex>. У нас есть строка - <tex>s[1..n]</tex>. Мы хотим найти все вхождения шаблона в строку.
+
<tex>\mathrm{hash}(s[0..j]) = (s[0] + p s[1] +...+ p^{i-1} s[i-1]) + p^{i}(s[i] +...+ p^{j-i-1} s[j-1] + p^{j-i} s[j])</tex>
  
Давайте посчитаем <tex>hash(s[1..m])</tex> и <tex>hash(p[1..m])</tex>.
+
Выражение в первой скобке есть не что иное, как хеш подстроки <tex>s[0..i-1]</tex>, а во второй — хеш нужной нам подстроки <tex>s[i..j]</tex>. Итак, мы получили, что:
  
И для <tex>i \in [1..n - m + 1]</tex> считаем <tex>hash(s[i..i + m - 1]</tex> - сравниваем с <tex>hash(p[1..m])</tex>. Если они получаются равными - то мы считаем, что подстрока <tex>p</tex> входит в строку <tex>s</tex> (начиная с позиции <tex>i</tex>;).
+
<tex>\mathrm{hash}(s[0..j]) = \mathrm{hash}(s[0..i-1]) + p^{i}\mathrm{hash}(s[i..j])</tex>
  
Следует с предподсчитать - <tex>p^{m}</tex>.
+
Отсюда получается следующая формула для <tex>\mathrm{hash}(s[i..j])</tex>:
  
Псевдо-код:
+
<tex>\mathrm{hash}(s[i..j]) = (1/p^{i})(\mathrm{hash}(s[0..j]) - \mathrm{hash}(s[0..i-1]))</tex>
  '''1:''' function RabinKarp(string s[1..n], string p[1..m])
 
  '''2:'''  hp := hash(p[1..m])
 
  '''3:'''  h := hash(s[1..m])ы
 
  '''4:'''  for i from 1 to (n-m+1)
 
  '''5:'''      if h = hp
 
  '''6:'''          add i
 
  '''7:'''      h := p <tex>\cdot</tex> h - <tex>p^{m}</tex>s[i] + s[i + m]
 
  '''8:'''  return not found
 
  
7 строка была получена с помощью быстрого пересчёта хеша. Мы считаем, что <tex>s[n + 1]</tex> - пустой символ.
+
Однако, как видно из формулы, чтобы уметь считать хеш для всех подстрок начинающихся с <tex>i</tex>, нужно предпосчитать все <tex>p^{i}</tex> для <tex>i \in [0..n - 1]</tex>. Это займет много памяти. Но поскольку нам нужны только подстроки размером <tex>m</tex> <tex>{-}</tex> мы можем подсчитать хеш подстроки <tex>s[0..m-1]</tex>, а затем пересчитывать хеши для всех <tex>i \in [0..n - m]</tex> за <tex>O(1)</tex> следующим образом:
  
Посчитаем время работы.
+
<tex>\mathrm{hash}(s[i + 1..i + m - 1]) = (\mathrm{hash}(s[i..i + m - 1]) - p^{m - 1} s[i]) \bmod r</tex>.
  
Изначальный подсчёт хешей - <tex>O(m)</tex>. В цикле всего <tex>n - m + 1</tex> итераций - каждая выполняется за <tex>O(1)</tex>. Итого - <tex>O(n + m)</tex>.
+
<tex>\mathrm{hash}(s[i + 1..i + m]) = (p \cdot \mathrm{hash}(s[i + 1..i + m - 1]) + s[i + m]) \bmod r</tex>.
 +
 
 +
Получается : <tex>\mathrm{hash}(s[i + 1..i + m]) = (p \cdot \mathrm{hash}(s[i..i + m - 1]) - p^{i} s[i] + s[i + m]) \bmod r</tex>.
 +
 
 +
==Решение==
 +
 
 +
===Алгоритм===
 +
 
 +
Алгоритм начинается с подсчета <tex>\mathrm{hash}(s[0..m-1])</tex> и <tex>\mathrm{hash}(p[0..m-1])</tex>, а также с подсчета <tex>p^{m}</tex>, для ускорения ответов на запрос.
 +
 
 +
Для <tex>i \in [0..n - m]</tex> вычисляется <tex>\mathrm{hash}(s[i..i + m - 1])</tex> и сравнивается с <tex>\mathrm{hash}(p[0..m-1])</tex>. Если они оказались равны, то образец <tex>p</tex> скорее всего содержится в строке <tex>s</tex> начиная с позиции <tex>i</tex>, хотя возможны и ложные срабатывания алгоритма. Если требуется свести такие срабатывания к минимуму или исключить вовсе, то применяют сравнение некоторых символов из этих строк, которые выбраны случайным образом, или применяют явное сравнение строк, как в [[Наивный алгоритм поиска подстроки в строке|наивном алгоритме поиска подстроки в строке]]. В первом случае проверка произойдет быстрее, но вероятность ложного срабатывания, хоть и небольшая, останется. Во втором случае проверка займет время, равное длине образца, но полностью исключит возможность ложного срабатывания.
 +
 
 +
Если требуется найти индексы вхождения нескольких образцов, или сравнить две строки <tex>{-}</tex> выгоднее будет предпосчитать все степени <tex>p</tex>, а также хеши всех префиксов строки <tex>s</tex>.
 +
 
 +
===Псевдокод===
 +
Приведем пример псевдокода, который находит все вхождения строки <tex>w</tex> в строку <tex>s</tex> и возвращает массив позиций, откуда начинаются вхождения.
 +
'''vector<int>''' rabinKarp (s : '''string''', w : '''string'''):
 +
    '''vector<int>''' answer
 +
    '''int''' n = s.length
 +
    '''int''' m = w.length
 +
    '''int''' hashS = hash(s[0..m - 1])
 +
    '''int''' hashW = hash(w[0..m - 1])
 +
    '''for''' i = 0 '''to''' n - m
 +
        '''if''' hashS == hashW
 +
              answer.add(i)
 +
        hashS = (p * hashS - p<tex>^{m}</tex> * hash(s[i]) + hash(s[i + m])) '''mod''' r <font color=green>// r — некоторое большое число, p — некоторое просто число</font>
 +
    '''return''' answer
 +
 
 +
Новый хеш <tex>hashS</tex> был получен с помощью быстрого пересчёта. Для сохранения корректности алгоритма нужно считать, что <tex>s[n + 1]</tex> {{---}} пустой символ.
 +
 
 +
===Время работы===
 +
 
 +
Изначальный подсчёт хешей выполняется за <tex>O(m)</tex>.  
 +
 
 +
Каждая итерация выполняется за <tex>O(1)</tex>, В цикле всего <tex>n - m + 1</tex> итераций.
 +
 
 +
Итоговое время работы алгоритма <tex>O(n + m)</tex>.
 +
 
 +
Однако, если требуется исключить ложные срабатывания алгоритма полностью, т.е. придется проверить все полученные позиции вхождения на истинность, то в худшем случае итоговое время работы алгоритма будет <tex>O(n</tex> <tex>\cdot</tex> <tex>m)</tex>.
 +
 
 +
== Сравнение с другими алгоритмами ==
 +
Преимущества:
 +
* Быстрая скорость работы — <tex>O(n + m)</tex>, где <tex>n</tex> — длина строки, <tex>m</tex> — длина образца;
 +
* Простая и понятная реализация;
 +
 
 +
Недостатки:
 +
* Возможно подобрать входные данные так, что количество ложных срабатываний будет недопустимо большим;
 +
 
 +
== См. также ==
 +
*[[Наивный алгоритм поиска подстроки в строке]]
 +
*[[Поиск наибольшей общей подстроки двух строк с использованием хеширования]]
 +
 
 +
== Источники информации ==
 +
* ''Кормен, Томас Х., Лейзерсон, Чарльз И., Ривест, Рональд Л., Штайн Клиффорд'' '''Алгоритмы: построение и анализ''', 3-е издание. Пер. с англ. — М.:Издательский дом "Вильямс", 2014. — 1328 с.: ил. — ISBN 978-5-8459-1794-2 (рус.) — страницы 1036–1041.
 +
 
 +
[[Категория:Алгоритмы и структуры данных]]
 +
[[Категория:Поиск подстроки в строке]]
 +
[[Категория: Хеширование]]
 +
[[Категория:Точный поиск]]

Текущая версия на 21:27, 24 февраля 2021

Алгоритм Рабина-Карпа предназначен для поиска подстроки в строке.

Метод хеширования[править]

Наивный алгоритм поиска подстроки в строке работает за [math]O\left(n^2\right)[/math] в худшем случае — слишком долго. Чтобы ускорить этот процесс, можно воспользоваться методом хеширования.

Определение:
Пусть дана строка [math]s[0..n-1][/math]. Тогда полиномиальным хешем (англ. polynomial hash) строки [math]s[/math] называется число [math]h = \mathrm{hash}(s[0..n-1]) = p^{0} s[0] + ... + p^{n - 1} s[n-1][/math], где [math]p[/math] — некоторое простое число, а [math]s[i][/math] [math]{-}[/math] код [math]i[/math]-ого символа строки [math]s[/math].

Проблему переполнения при вычислении хешей довольно больших строк можно решить так [math]{-}[/math] считать хеши по модулю [math]r=2^{64}[/math] (или [math]2^{32}[/math]), чтобы модуль брался автоматически при переполнении типов.

Для работы алгоритма потребуется считать хеш подстроки [math]s[i..j][/math]. Делать это можно следующим образом:

Рассмотрим хеш [math]s[0..j][/math]:

[math]\mathrm{hash}(s[0..j]) = s[0] + p s[1] +...+ p^{i-1} s[i-1] + p^{i} s[i] +...+ p^{j-1} s[j-1] + p^{j} s[j][/math]

Разобьем это выражение на две части:

[math]\mathrm{hash}(s[0..j]) = (s[0] + p s[1] +...+ p^{i-1} s[i-1]) + (p^{i} s[i] +...+ p^{j-1} s[j-1] + p^{j} s[j])[/math]

Вынесем из последней скобки множитель [math]p^{i}[/math]:

[math]\mathrm{hash}(s[0..j]) = (s[0] + p s[1] +...+ p^{i-1} s[i-1]) + p^{i}(s[i] +...+ p^{j-i-1} s[j-1] + p^{j-i} s[j])[/math]

Выражение в первой скобке есть не что иное, как хеш подстроки [math]s[0..i-1][/math], а во второй — хеш нужной нам подстроки [math]s[i..j][/math]. Итак, мы получили, что:

[math]\mathrm{hash}(s[0..j]) = \mathrm{hash}(s[0..i-1]) + p^{i}\mathrm{hash}(s[i..j])[/math]

Отсюда получается следующая формула для [math]\mathrm{hash}(s[i..j])[/math]:

[math]\mathrm{hash}(s[i..j]) = (1/p^{i})(\mathrm{hash}(s[0..j]) - \mathrm{hash}(s[0..i-1]))[/math]

Однако, как видно из формулы, чтобы уметь считать хеш для всех подстрок начинающихся с [math]i[/math], нужно предпосчитать все [math]p^{i}[/math] для [math]i \in [0..n - 1][/math]. Это займет много памяти. Но поскольку нам нужны только подстроки размером [math]m[/math] [math]{-}[/math] мы можем подсчитать хеш подстроки [math]s[0..m-1][/math], а затем пересчитывать хеши для всех [math]i \in [0..n - m][/math] за [math]O(1)[/math] следующим образом:

[math]\mathrm{hash}(s[i + 1..i + m - 1]) = (\mathrm{hash}(s[i..i + m - 1]) - p^{m - 1} s[i]) \bmod r[/math].

[math]\mathrm{hash}(s[i + 1..i + m]) = (p \cdot \mathrm{hash}(s[i + 1..i + m - 1]) + s[i + m]) \bmod r[/math].

Получается : [math]\mathrm{hash}(s[i + 1..i + m]) = (p \cdot \mathrm{hash}(s[i..i + m - 1]) - p^{i} s[i] + s[i + m]) \bmod r[/math].

Решение[править]

Алгоритм[править]

Алгоритм начинается с подсчета [math]\mathrm{hash}(s[0..m-1])[/math] и [math]\mathrm{hash}(p[0..m-1])[/math], а также с подсчета [math]p^{m}[/math], для ускорения ответов на запрос.

Для [math]i \in [0..n - m][/math] вычисляется [math]\mathrm{hash}(s[i..i + m - 1])[/math] и сравнивается с [math]\mathrm{hash}(p[0..m-1])[/math]. Если они оказались равны, то образец [math]p[/math] скорее всего содержится в строке [math]s[/math] начиная с позиции [math]i[/math], хотя возможны и ложные срабатывания алгоритма. Если требуется свести такие срабатывания к минимуму или исключить вовсе, то применяют сравнение некоторых символов из этих строк, которые выбраны случайным образом, или применяют явное сравнение строк, как в наивном алгоритме поиска подстроки в строке. В первом случае проверка произойдет быстрее, но вероятность ложного срабатывания, хоть и небольшая, останется. Во втором случае проверка займет время, равное длине образца, но полностью исключит возможность ложного срабатывания.

Если требуется найти индексы вхождения нескольких образцов, или сравнить две строки [math]{-}[/math] выгоднее будет предпосчитать все степени [math]p[/math], а также хеши всех префиксов строки [math]s[/math].

Псевдокод[править]

Приведем пример псевдокода, который находит все вхождения строки [math]w[/math] в строку [math]s[/math] и возвращает массив позиций, откуда начинаются вхождения.

vector<int> rabinKarp (s : string, w : string):
   vector<int> answer
   int n = s.length
   int m = w.length
   int hashS = hash(s[0..m - 1])
   int hashW = hash(w[0..m - 1])
   for i = 0 to n - m
        if hashS == hashW
             answer.add(i)
        hashS = (p * hashS - p[math]^{m}[/math] * hash(s[i]) + hash(s[i + m])) mod r // r — некоторое большое число, p — некоторое просто число
   return answer

Новый хеш [math]hashS[/math] был получен с помощью быстрого пересчёта. Для сохранения корректности алгоритма нужно считать, что [math]s[n + 1][/math] — пустой символ.

Время работы[править]

Изначальный подсчёт хешей выполняется за [math]O(m)[/math].

Каждая итерация выполняется за [math]O(1)[/math], В цикле всего [math]n - m + 1[/math] итераций.

Итоговое время работы алгоритма [math]O(n + m)[/math].

Однако, если требуется исключить ложные срабатывания алгоритма полностью, т.е. придется проверить все полученные позиции вхождения на истинность, то в худшем случае итоговое время работы алгоритма будет [math]O(n[/math] [math]\cdot[/math] [math]m)[/math].

Сравнение с другими алгоритмами[править]

Преимущества:

  • Быстрая скорость работы — [math]O(n + m)[/math], где [math]n[/math] — длина строки, [math]m[/math] — длина образца;
  • Простая и понятная реализация;

Недостатки:

  • Возможно подобрать входные данные так, что количество ложных срабатываний будет недопустимо большим;

См. также[править]

Источники информации[править]

  • Кормен, Томас Х., Лейзерсон, Чарльз И., Ривест, Рональд Л., Штайн Клиффорд Алгоритмы: построение и анализ, 3-е издание. Пер. с англ. — М.:Издательский дом "Вильямс", 2014. — 1328 с.: ил. — ISBN 978-5-8459-1794-2 (рус.) — страницы 1036–1041.