Алгоритм Бойера — Мура

Алгоритм Бойера — Мура
Назван в честь Robert S. Boyer[вд] и J Strother Moore[вд]
Автор Robert S. Boyer[вд] и J Strother Moore[вд]
Предназначение Эффективный поиск подстроки в строке
Структура данных Строки
Худшее время
Затраты памяти или
Логотип Викисклада Медиафайлы на Викискладе

Алгоритм поиска строки Бойера — Мура — алгоритм общего назначения, предназначенный для поиска подстроки в строке. Разработан Робертом Бойером[англ.] и Джеем Муром[англ.] в 1977 году[1]. Преимущество этого алгоритма в том, что ценой некоторого количества предварительных вычислений над шаблоном (но не над строкой, в которой ведётся поиск), шаблон сравнивается с исходным текстом не во всех позициях — часть проверок пропускается как заведомо не дающая результата.

Общая оценка вычислительной сложности современного варианта алгоритма Бойера — Мура — , если не используется таблица стоп-символов (смотрите ниже), и , если используется таблица стоп-символов, где  — длина строки, в которой выполняется поиск,  — длина шаблона поиска,  — алфавит, на котором проводится сравнение[2].

Описание алгоритма

Алгоритм основан на трёх идеях.

1. Сканирование слева направо, сравнение справа налево. Совмещается начало текста (строки) и шаблона, проверка начинается с последнего символа шаблона. Если символы совпадают, производится сравнение предпоследнего символа шаблона и т. д. Если все символы шаблона совпали с наложенными символами строки, значит, подстрока найдена, и выполняется поиск следующего вхождения подстроки.

Если же какой-то символ шаблона не совпадает с соответствующим символом строки, шаблон сдвигается на несколько символов вправо, и проверка снова начинается с последнего символа.

Эти «несколько», упомянутые в предыдущем абзаце, вычисляются по двум эвристикам.

2. Эвристика стоп-символа. (Замечание: эвристика стоп-символа присутствует в большинстве описаний алгоритма Бойера — Мура, включая оригинальную статью Бойера и Мура[1], но не является необходимой для достижения оценки времени работы[2]; более того, данная эвристика для своей работы требует дополнительной памяти и дополнительного времени на этапе подготовки шаблона.)

Предположим, что мы производим поиск слова «колокол». Первая же буква не совпала — «к» (назовём эту букву стоп-символом). Тогда можно сдвинуть шаблон вправо до последней его буквы «к».

Строка:          * * * * * * к * * * * * *
Шаблон:          к о л о к о л
Следующий шаг:       к о л о к о л

Если стоп-символа в шаблоне нет, шаблон смещается за этот стоп-символ.

Строка:          * * * * * а л * * * * * * * *
Шаблон:          к о л о к о л
Следующий шаг:               к о л о к о л

В данном случае стоп-символ — «а», и шаблон сдвигается так, чтобы он оказался прямо за этой буквой. В алгоритме Бойера — Мура эвристика стоп-символа вообще не смотрит на совпавший суффикс (см. ниже), так что первая буква шаблона («к») окажется под «л», и будет проведена одна заведомо холостая проверка.

Если стоп-символ «к» оказался за другой буквой «к», эвристика стоп-символа не работает.

Строка:         * * * * к к о л * * * * *
Шаблон:           к о л о к о л
Следующий шаг:  к о л о к о л    

В таких ситуациях выручает третья идея алгоритма Бойера — Мура — эвристика совпавшего суффикса.

3. Эвристика совпавшего суффикса. Неформально, если при чтении шаблона справа налево совпал суффикс S, а символ b, стоящий перед S в шаблоне (то есть шаблон имеет вид PbS), не совпал, то эвристика совпавшего суффикса сдвигает шаблон на наименьшее число позиций вправо так, чтобы строка S совпала с шаблоном, а символ, предшествующий в шаблоне данному совпадению S, отличался бы от b (если такой символ вообще есть). Формально, для данного шаблона считается целочисленный массив suffshift[0..m], в котором suffshift[i] равно минимальному числу , такому что (если и ) и для любого , для которого выполняется и (для пояснения смотрите примеры ниже). Затем, если при чтении шаблона справа налево совпало символов , а символ не совпал, то шаблон сдвигается на suffshift[m-k] символов вправо. Например:

Строка:          * * * * * * р к а * * * * *
Шаблон:          с к а л к а л к а
Следующий шаг:               с к а л к а л к а

В данном случае совпал суффикс «ка», и шаблон сдвигается вправо до ближайшего «ка», перед которым нет буквы «л».

Строка:          * * т о к о л * * * * *
Шаблон:          к о л о к о л
Следующий шаг:           к о л о к о л

В данном случае совпал суффикс «окол», и шаблон сдвигается вправо до ближайшего «окол», перед которым нет буквы «л». Если подстроки «окол» в шаблоне больше нет, но он начинается на «кол», сдвигается до «кол», и т. д.

Внимание: несовпадение буквы перед ближайшим вхождением совпавшего суффикса является необходимым условием. Если ограничиться только сдвигом до ближайшего вхождения совпавшего суффикса, то алгоритм может работать неприемлемо медленно. Например, при поиске шаблона длины в строке , содержащей символов «a», за которыми следует строка длины , алгоритм, использующий сдвиги без учёта несовпадения символа, выполняет операций даже при использовании эвристике стоп-символов. С другой стороны доказано[2], что время работы алгоритма БМ, учитывающего несовпадение символов (то есть использующего массив suffshift, определённый выше), равно даже без использования эвристики стоп-символов (данное в книге М. Крошмора и В. Риттера[2] доказательство этого факта является модификацией доказательства Коула 1994 года[3], рассмотревшего только случай непериодических шаблонов).

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

Опишем подробнее обе таблицы.

Таблица стоп-символов

В таблице стоп-символов указывается последняя позиция в шаблоне (исключая последнюю букву) каждого из символов алфавита. Для всех символов, не вошедших в , пишем 0, если нумерация символов начинается с 1 (или −1, если нумерация начинается с 0). Например, если , таблица стоп-символов StopTable будет выглядеть так (таблица приведена для случая строки, нумеруемой с нуля; при нумерации символов с единицы нужно прибавить ко всем числам единицу):

Символ              a  b  c  d  [все остальные]
Последняя позиция   4  1  6  5  -1

Обратите внимание, для стоп-символа «d» последняя позиция будет 5, а не 7 — последняя буква не учитывается. Это известная ошибка, приводящая к неоптимальности. Для алгоритма БМ она не фатальна (спасает положение эвристика суффикса), но фатальна для упрощённой версии алгоритма БМ — алгоритма Хорспула.

Если при сравнении справа налево шаблона со строкой несовпадение произошло в позиции , а стоп-символ — , то шаблон необходимо сдвинуть на символов.

Таблица суффиксов

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

Суффикс        [пустой]         c       cc        bcc            ...   aaccbccbcc
Сдвиг               2           1        6         10            ...           10
Иллюстрация
    было            ?          ?c      ?cc       ?bcc            ...   aaccbccbcc
    стало    aaccbccbcc aaccbccbcc    aaccbccbcc     aaccbccbcc  ...             aaccbccbcc

Для «колокол»:

Суффикс      [пустой]        л         ол        кол       ...  олокол        колокол
Сдвиг              1         7          7        4         ...       4              4
Иллюстрация
    было           ?        ?л        ?ол        ?кол      ... ?олокол        колокол
    стало     колокол         колокол    колокол  колокол  ...     колокол        колокол

Существует алгоритм вычисления таблицы суффиксов suffshift[0..m] с временем работы .[2] Этот алгоритм основан на тех же идеях, что и алгоритмы вычисления префикс-функции и Z-функции строки[4][5]. Реализация данного алгоритма на C++ выглядит следующим образом:

 std::vector<int> suffshift(m + 1, m);
 std::vector<int> z(m, 0);
 for (int j = 1, maxZidx = 0, maxZ = 0; j < m; ++j) {
   if (j <= maxZ) z[j] = std::min(maxZ - j + 1, z[j - maxZidx]);
   while (j + z[j] < m && s[m - 1 - z[j]] == s[m - 1 - (j + z[j])]) z[j]++;
   if (j + z[j] - 1 > maxZ) {
     maxZidx = j;
     maxZ = j + z[j] - 1;
   }
 }
 for (int j = m - 1; j > 0; j--) suffshift[m - z[j]] = j; //цикл №1
 for (int j = 1, r = 0; j <= m - 1; j++) //цикл №2
   if (j + z[j] == m)
     for (; r <= j; r++)
       if (suffshift[r] == m) suffshift[r] = j;

В первом цикле в коде воспроизведён код вычисления так называемой Z-функции , но для перевёрнутой строки .[5] А именно, для любого , такого что , элемент содержит длину длиннейшего суффикса строки , который также является суффиксом всей строки . С помощью массива далее вычисляется искомый массив suffshift[0..m] (смотрите описание ниже). На каждой итерации последнего цикла уменьшается на 1, а на каждой итерации вложенного в него цикла уменьшается на 1. Поэтому, так как , , и алгоритм вычисления Z-функции работает за (смотрите, например, соответствующую статью, а также статью[5]), общее время работы данного алгоритма — .

Для доказательства корректности представленного кода удобно представлять себе, что анализируется строка , которая равна перевёрнутой строке . По определению suffshift, имеем suffshift[], где  — это наименьшее положительное число, такое что либо 1) строка является префиксом строки , либо 2) строка равна , а символы и отличаются. В случае 2), по определению , обязательно выполняется . Таким образом, пробегая по от до 1, цикл № 1 находит все значения suffshift, полученные по правилу 2). Для вычисления значений suffshift, полученных по правилу 1), заметим, что, во-первых, если  — префикс , то обязательно выполняется , а во-вторых, если suffshift[] = для какого-то , то обязательно . Опираясь на эти два наблюдения, цикл № 2 вычисляет оставшиеся неинициализированными значения suffshift (то есть такие что suffshift[k] = m).

Реализация алгоритма

Пусть массив сдвигов suffshift[0..m] для данного шаблона посчитан. Тогда реализация на C++ алгоритма Бойера — Мура для поиска первого вхождения в тексте за времени без применения эвристики стоп-символов выглядит следующим образом[2]:

 for (int i = 0, j = 0; i <= n - m && j >= 0; i += suffshift[j+1]) {
   for (j = m - 1; j >= 0 && s[j] == text[i + j]; j--);
   if (j < 0) report_occurrence(i);
 }

Такой алгоритм непригоден для поиска всех вхождений шаблона в текст за времени. Если убрать условие «j >= 0» из внешнего цикла, то алгоритм найдёт все вхождения, но в худшем случае, возможно, выполнит операций, в чём легко убедиться, рассмотрев строку, состоящую из одних букв «a». Для поиска всех вхождений используют следующую модификацию, которая работает времени за счёт так называемого правила Галиля[6]:

 int j, bound = 0; //всегда либо bound = 0, либо bound = m - suffshift[0]
 for (int i = 0; i <= n - m; i += suffshift[j+1]) {
   for (j = m - 1; j >= bound && s[j] == text[i + j]; j--);
   if (j < bound) {
     report_occurrence(i);
     bound = m - suffshift[0];
     j = -1; //установить j так, как будто мы прочитали весь шаблон s, а не только до границы bound
   } else {
     bound = 0;
   }
 }

Правило Галиля основано на следующем несложном наблюдении. Если вхождение найдено в позиции , то следующий поиск будет пытаться найти вхождение шаблона в позиции suffshift[0] и, по определению suffshift, уже известно, что символы совпадают с символами suffshift[0]. Значит, при выполнении поиска справа налево для определения того, есть ли вхождение шаблона в позиции , нет смысла проверять символы suffshift[0]. Именно для этого и служит переменная «bound». Доказано, что такая эвристика помогает получить времени для поиска всех вхождений шаблона в строку[6].

Для включения эвристики стоп-символов достаточно строку «i += suffshift[j+1]» заменить на следующее выражение в конце основного цикла:

  if (j < bound) i += suffshift[j+1]; 
  else i += max(suffshift[j+1], j - StopTable[text[i + j]]);

Пример работы алгоритма

Искомый шаблон — «abbad». Таблица стоп-символов будет выглядеть как (в данном примере будет удобнее пользоваться нумерацией с единицы)

Символ   a  b  d  [остальные]
Позиция  1  2  5  0

Таблица суффиксов для всех возможных суффиксов (кроме пустого) даёт максимальный сдвиг — 5.

abeccaabadbabbad
abbad

Накладываем образец на строку. Совпадения суффикса нет — таблица суффиксов даёт сдвиг на одну позицию. Для несовпавшего символа исходной строки «с» (5-я позиция) в таблице стоп-символов записан 0. Сдвигаем образец вправо на 5-0=5 позиций.

abeccaabadbabbad
     abbad

Символы 3—5 совпали, а второй — нет. Эвристика для «а» не работает (2-4=-2). Но поскольку часть символов совпала, в дело включается эвристика совпавшего суффикса, сдвигающая шаблон сразу на пять позиций!

abeccaabadbabbad
          abbad

И снова совпадения суффикса нет. В соответствии с таблицей стоп-символов сдвигаем образец на одну позицию и получаем искомое вхождение образца:

abeccaabadbabbad
           abbad

Доказательство корректности

Для доказательства корректности алгоритма достаточно показать, что если та или иная эвристика предлагает сдвиг более чем на одну позицию вправо, на пропущенных позициях шаблон не найдётся.

Итак, пусть совпал суффикс S, строка-шаблон равна PbS, стоп-символ — a (в дальнейшем малые буквы — символы, большие — строки).

Строка:     * * * * * * * a [-- S --] * * * *
Шаблон:       [--- P ---] b [-- S --]

Эвристика стоп-символа. Работает, когда в строке V символ а отсутствует. Если P=WaV и в строке V нет символа а, то эвристика стоп-символа предлагает сдвиг на |V|+1 позицию.

Строка:     * * * * * * * * * * * * a [-- S --] * * * * * * * *
Шаблон:       [- W -] a [--- V ---] b [-- S --]
Пропустить:             [- W -] a [--- V ---] b [-- S --]
Новый шаг:                  [- W -] a [--- V ---] b [-- S --]

Действительно, если в строке V нет буквы a, нечего пробовать пропущенные |V| позиций.

Если же в шаблоне нет символа а, то эвристика стоп-символа предлагает сдвиг на |P|+1 позицию — и также нет смысла пробовать пропущенные |P|.

Строка:     * * * * * * * * a [-- S --] * * * * * * * *
Шаблон:         [--- P ---] b [-- S --]
Пропустить:         [--- P ---] b [-- S --]
Новый шаг:                    [--- P ---] b [-- S --]

Эвристика совпавшего суффикса. Сама неформальная фраза — «наименьшая величина, на которую нужно сдвинуть вправо шаблон, чтобы он снова совпал с S, но символ перед данным совпадением с S (если такой символ существует) отличался бы от b» — говорит, что меньшие сдвиги бесполезны.

Варианты

Алгоритм Бойера — Мура — Хорспула

Алгоритм Бойера — Мура — Хорспула (АБМХ) работает лучше алгоритма Бойера — Мура (АБМ) на случайных текстах — для него оценка в среднем лучше.

АБМХ использует только эвристику стоп-символа; при этом за стоп-символ берётся символ входной строки, который соответствует последнему символу шаблона, независимо от того, где случилось несовпадение.

Поскольку реальные поисковые образцы редко имеют равномерное распределение, АБМХ может дать как выигрыш, так и проигрыш по сравнению с АБМ.

Алгоритм Чжу — Такаоки

На коротких алфавитах (например, при сравнении участков ДНК алфавит состоит всего из четырёх символов: A, Т, Г, Ц) эвристика стоп-символа отказывает уже на коротких суффиксах. Простейший способ улучшить работу АБМ в таких условиях — вместо одного стоп-символа строить таблицу для пары символов: несовпавшего и идущего перед ним[7]. Такой алгоритм был назван алгоритмом Чжу — Такаоки.

На предварительную обработку расходуется времени.

Алгоритм «турбо-Бойера — Мура»

Алгоритм «турбо-Бойера — Мура» разработан группой учёных во главе с М. Крошмором, предлагает другой подход к коротким алфавитам и заодно решает вторую проблему — квадратичную сложность в худшем случае.

Помимо эвристики стоп-символа и эвристики совпавшего суффикса, применяется третья эвристика — эвристика турбосдвига[8].

Пусть первый раз совпал суффикс UV (и сработала эвристика суффиксов, обеспечив полное перекрытие этого суффикса), второй раз — более короткий V (возможно, V=∅).

                                               !                     !
входная строка:      * * * * * * * * * * # [-U-] [V] * * * * * * * * # [V] * * * * * *
1. Совпало UV:         * [-U-] [V] * * * * [-U-] [V]
2. Затем совпало V:                      * [-U-] [V] * * * * * * [-U-] [V]
Сдвиг по эвристике суффиксов:                * [-U-] [V] * * * * * * [-U-] [V]
Турбосдвиг:                                    * [-U-] [V] * * * * * * [-U-] [V]

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

Алгоритм выполняет свою работу за сравнений до первого совпадения в худшем случае.

Сравнение с другими алгоритмами

Достоинства

Алгоритм Бойера — Мура на «хороших» данных очень быстр[уточнить], а вероятность появления «плохих» данных крайне мала. Поэтому он оптимален в большинстве случаев, когда нет возможности провести предварительную обработку текста, в котором проводится поиск[9]. Разве что на коротких текстах выигрыш не оправдает предварительных вычислений.

Недостатки

Алгоритмы семейства Бойера — Мура не расширяются до приблизительного поиска, поиска любой строки из нескольких.

Сравнение не является «чёрным ящиком» (только если применяется эвристика стоп-символа), поэтому при реализации наиболее быстрого поиска приходится либо рассчитывать на удачную работу оптимизатора, либо вручную оптимизировать поиск на языке ассемблера.

Если текст изменяется редко, а поиск проводится часто (например, поисковой машиной), можно провести индексацию текста. Алгоритм поиска по индексу быстрее[уточнить] алгоритма Бойера — Мура.

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

Существует ряд модификаций алгоритма Бойера — Мура, нацеленных на ещё большее ускорение — например, турбо-алгоритм, обратный алгоритм Колусси[10] и другие.

См. также

Примечания

  1. 1 2 Boyer, Moore, 1977.
  2. 1 2 3 4 5 6 Crochemore, Rytter, 2002.
  3. Cole, 1994.
  4. Гасфилд, 2003.
  5. 1 2 3 MAXimal :: algo :: Z-функция строки и её вычисление. Дата обращения: 14 марта 2017. Архивировано 26 апреля 2017 года.
  6. 1 2 Galil, 1979.
  7. Zhu-Takaoka algorithm Архивная копия от 16 декабря 2008 на Wayback Machine (англ.)
  8. Turbo-BM algorithm Архивная копия от 16 декабря 2008 на Wayback Machine (англ.)
  9. Exact string matching algorithms — Introduction Архивная копия от 16 декабря 2008 на Wayback Machine (англ.)
  10. Reverse Colussi algorithm Архивная копия от 9 марта 2016 на Wayback Machine (англ.)

Литература

  • Кормен Т. Х., Лейзерсон Ч. Е., Ривест Р. Л., Штайн К. Алгоритмы: построение и анализ = Introduction to Algorithms / под ред. С. Н. Тригуба; пер. с англ. И. В. Красиков, Н. А. Орехов, В. Н. Романов. — 2-е изд. — М.: Вильямс, 2005. — 801 с. — ISBN 5-8459-0857-4.
  • Crochemore M., Rytter W. Jewels of Stringology. — Singapore: World Publishing Scientific Co. Pte. Ltd., 2002. — 310 с. — ISBN 981-02-4782-6.
  • Boyer R. S., Moore J. S. A fast string searching algorithm // Communications of the ACM. — 1977. — Т. 20, № 10. — С. 762—772. — doi:10.1145/359842.359859.
  • Cole R. Tight bounds on the complexity of the Boyer-Moore string matching algorithm // SIAM Journal on Computing. — 1994. — Т. 23, № 5. — С. 1075—1091. — doi:10.1137/S0097539791195543.
  • Galil Z. On improving the worst case running time of the Boyer-Moore string matching algorithm // Communications of the ACM. — 1979. — Т. 22, № 9. — С. 505—508. — doi:10.1145/359146.359148.
  • Гасфилд Д. Строки, деревья и последовательности в алгоритмах: информатика и вычислительная биология = Algorithms on Strings, Trees and Sequences: Computer Science and Computational Biology / пер. с англ. И. В. Романовский. — СПб.: Невский Диалект, 2003. — 654 с. — ISBN 5-7940-0103-8.