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

Материал из Википедии — свободной энциклопедии
Перейти к: навигация, поиск

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

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

Описание алгоритма[править | править вики-текст]

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

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

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

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

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

Символ              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(i + suffshift[j+1], j - StopTable[text[i + j]]);

Пример работы алгоритма[править | править вики-текст]

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

Символ   a  b  [остальные]
Позиция  4  3  0

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

abeccaabadbabbad
abbad

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

abeccaabadbabbad
     abbad

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

abeccaabadbabbad
          abbad

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

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» — говорит, что меньшие сдвиги бесполезны.


Варианты[править | править вики-текст]

Алгоритм Бойера — Мура — Хорспула[править | править вики-текст]

Этот алгоритм работает лучше Бойера-Мура на случайных текстах — для него оценка в среднем лучше.

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

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

Алгоритм Чжу — Такаоки[править | править вики-текст]

На коротких алфавитах (например, при сравнении участков ДНК алфавит состоит всего из 4 символов: 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|. В противном случае два символа, обозначенные восклицательными знаками, во входной строке разные, а в шаблоне одинаковые. В этом и заключается эвристика турбосдвига.

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

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

Достоинства[править | править вики-текст]

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

На x86 существует красивая ассемблерная реализация алгоритма БМ, основанная на командах std; rep cmpsb. После неудачного сравнения в регистре ecx остаётся индекс несовпавшего символа; указатели esi и edi указывают на соответствующие символы шаблона и входной строки. [источник не указан 1075 дней]

Недостатки[править | править вики-текст]

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

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

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

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


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

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

Примечания[править | править вики-текст]

  1. R. S. Boyer, J. S. Moore. A fast string searching algorithm // Comm. ACM 20 — 1977 — C. 762—772. (англ.)
  2. 1 2 3 4 5 6 M. Crochemore, W. Rytter. Jewels of Stringology. — Singapore: World Scientific Publishing Co. Pte. Ltd., 2002. ISBN 978-981-02-4782-9. — 310 С.
  3. R. Cole. Tight bounds on the complexity of the Boyer-Moore string matching algorithm // SIAM J. Comput. — 1994 — Т. 23, №5 — С. 1075—1091.
  4. D. Gusfield. Algorithms on Strings, Trees and Sequences: Computer Science and Computational Biology. — USA: Cambridge University Press, 1997. ISBN 0-521-58519-8. — С. 143.
  5. 1 2 3 http://e-maxx.ru/algo/z_function
  6. 1 2 Z. Galil. On improving the worst case running time of the Boyer-Moore string matching algorithm // Comm. ACM. New York, NY, USA: Association for Computing Machinery — 1979 — Т. 22, № 9 — С. 505–508.
  7. Zhu-Takaoka algorithm (англ.)
  8. Turbo-BM algorithm (англ.)
  9. Exact string matching algorithms — Introduction (англ.)
  10. Reverse Colussi algorithm (англ.)

Литература[править | править вики-текст]

  • M. Crochemore, W. Rytter. Jewels of Stringology. — Singapore: World Publishing Scientific Co. Pte. Ltd., 2002. ISBN 981-02-4782-6. — с. 310.