Алгоритм Бойера-Мура: почему он ищет подстроку так быстро

Алгоритм Бойера-Мура (Boyer-Moore, 1977) - один из самых быстрых способов искать подстроку длины в тексте длины на практике. Наивный поиск делает сравнений, алгоритм Кнута-Морриса-Пратта даёт гарантированные , а Бойер-Мур в лучшем случае работает сублинейно - за , то есть просматривает не весь текст, а только каждую -ю позицию. Именно поэтому он лежит в основе grep, поиска в текстовых редакторах и многих реализаций String.indexOf.
Главная идея - сравнение справа налево
В наивном алгоритме паттерн прикладывают к тексту слева и сравнивают символы по порядку. У Бойера-Мура всё наоборот: паттерн прикладывают к тексту, но сравнивают справа налево. Если последний символ паттерна не совпал с текстом - мы уже за одно сравнение получили полезную информацию о куске текста длины и можем сдвинуть паттерн больше чем на 1.
Это и есть весь трюк. Все остальные детали - про то, на сколько именно можно сдвинуть. Алгоритм использует две эвристики, для каждого несовпадения считает сдвиг по обеим и берёт максимум.
Эвристика плохого символа (bad character)
Пусть мы сравнили символы справа налево и упёрлись в несовпадение: на позиции в тексте стоит символ , а в паттерне на той же позиции - что-то другое. Смотрим, где символ встречается в самом паттерне:
- Если есть в паттерне на позиции (берём самое правое вхождение левее текущей позиции сравнения) - сдвигаем паттерн так, чтобы это вхождение выровнялось с в тексте.
- Если в паттерне вообще нет - сдвигаем паттерн целиком за этот символ, то есть на позиций.
Формально сдвиг по плохому символу равен
где - текущая позиция несовпадения в паттерне, а - позиция самого правого вхождения в паттерн (или , если такого нет). Таблица строится за , где - размер алфавита.
Эвристика хорошего суффикса (good suffix)
Плохой символ хорошо работает на больших алфавитах (текст на естественном языке), но плохо - на коротких (ДНК с алфавитом ). Поэтому добавляют вторую эвристику.
Если до несовпадения мы уже успели сопоставить непустой суффикс паттерна с текстом, мы знаем не только «плохой» символ, но и кусок текста после него. Идея: найти другое вхождение этого суффикса внутри паттерна или, если его нет, найти максимальный префикс паттерна, совпадающий с собственным суффиксом. Дальше двигаем паттерн так, чтобы это вхождение встало на место уже совпавшего хвоста.
Два случая:
- Совпавший суффикс встречается в паттерне ещё раз - двигаем паттерн так, чтобы выровнять эти два вхождения. Сдвиг небольшой, но безопасный.
- Совпавший суффикс встречается только один раз (на конце паттерна) - ищем максимальный префикс паттерна, который является суффиксом этого хвоста, и выравниваем его. Если и такого нет - сдвигаем паттерн полностью на .
Таблица сдвигов по хорошему суффиксу тоже предвычисляется за - стандартная техника близка к построению префикс-функции, но обходим паттерн справа.
Объединение эвристик
На каждом несовпадении считаем оба сдвига и выбираем больший:
Это даёт корректный сдвиг: каждая эвристика сама по себе никогда не пропустит совпадение, поэтому и максимум - тоже безопасный. Обе таблицы строятся один раз перед началом поиска за . После этого основной цикл стоит:
i = 0
while i <= n - m:
j = m - 1
while j >= 0 and pattern[j] == text[i + j]:
j -= 1
if j < 0:
report match at i
i += shift_good_suffix[0]
else:
i += max(shift_bad_char(text[i + j], j),
shift_good_suffix[j])
Подробный пример
Поищем паттерн () в тексте ().
Таблица плохого символа (позиция самого правого вхождения каждого символа в ):
| символ | A | B | C | D | прочие |
|---|---|---|---|---|---|
| 4 | 5 | 2 | 6 |
Шаг 1. , прикладываем паттерн к началу: против . Сравниваем справа: , - несовпадение на .
- Плохой символ: в паттерне на позиции , сдвиг .
- Хороший суффикс: суффикс длины 0 - берём дефолтный сдвиг 1.
Выбираем максимум: . Двигаем паттерн на 4.
Шаг 2. , сравниваем против . Все 7 символов совпадают справа налево - найдено вхождение на позиции 4. Сдвигаем дальше (на или по таблице хорошего суффикса, обычно 1-2 позиции).
Шаг 3. . Сравниваем против . , - символа в паттерне нет, сдвигаем на полный .
После этого , цикл заканчивается. Итого: одно вхождение, три сравнения групп символов на 14-символьном тексте - наивный алгоритм сделал бы около сравнений.
Сложность по среднему и худшему случаям
- Лучший случай - . Достигается, когда плохой символ часто не встречается в паттерне и мы сдвигаемся на за раз. На английском тексте с паттерном длины 10-20 это типичное поведение.
- Средний случай на естественных текстах эмпирически близок к , что подтверждено многочисленными бенчмарками
grep. - Худший случай - . Возникает, когда паттерн и текст устроены так, что обе эвристики дают сдвиг 1 (классический пример: паттерн в тексте ). Без модификаций алгоритм здесь не лучше наивного.
- Модификация Galil (1979) добавляет правило перехода после успешного совпадения: запоминается уже сопоставленный префикс, чтобы не сравнивать его повторно. Это даёт гарантированную оценку при сохранении быстрого среднего случая.
Для сравнения: КМП всегда , но без сублинейного выигрыша - он смотрит каждый символ текста, тогда как Бойер-Мур многие символы вообще не читает.
Где используется в реальной жизни
- GNU
grepиgit grep- основной поиск построен на Бойере-Муре с эвристикой плохого символа, для коротких паттернов добавляется SIMD-ускорение. Именно благодаря этомуgrepобгоняетfind -name | xargs cat | matchна порядок. - Текстовые редакторы - поиск в Vim (
/pattern), Emacs (isearch), VS Code, Sublime Text для длинных паттернов использует Бойера-Мура или его упрощённые варианты. String.indexOfв некоторых JVM (HotSpot для длинных паттернов),memmemв glibc,Boost.Algorithm- стандартные библиотечные реализации.- Boyer-Moore-Horspool (1980) - упрощённая версия только с эвристикой плохого символа и сдвигом, ориентированным на крайний правый символ паттерна. Проще кодировать, чуть медленнее в худшем случае, но быстрее на практике для коротких паттернов. Используется, например, в полнотекстовом поиске SQL Server и во многих антивирусных сканерах.
- Биоинформатика - поиск мотивов в геноме, хотя на алфавите выигрыш по сравнению с КМП меньше, и часто предпочитают индексные структуры (суффиксные массивы, FM-индекс).
Частые ошибки
- Сравнивают слева направо. Тогда вся идея ломается - эвристика плохого символа теряет смысл, потому что мы не знаем «хвост» текста.
- Забывают в формуле сдвига. Если , разность отрицательная - без отсечения снизу алгоритм будет двигаться назад и зацикливаться.
- Считают, что Бойер-Мур всегда быстрее КМП. Не всегда: на алфавите из 2-4 символов и при паттерне с большим самоналожением КМП может выиграть, особенно с учётом меньших констант.
- Используют только bad character и удивляются худшему случаю . Это и есть Horspool - да, он медленнее в патологических случаях; если нужна гарантия - берите полный Бойер-Мур с модификацией Galil.
- Пишут таблицу плохого символа по всему Юникоду. При предобработка дороже самого поиска. Реализации хранят таблицу в виде хеш-таблицы или ограничиваются ASCII с fallback.
FAQ
Чем алгоритм Бойера-Мура отличается от КМП? КМП сравнивает символы слева направо и использует префикс-функцию, чтобы при несовпадении не возвращаться по тексту - гарантированно . Бойер-Мур сравнивает справа налево и за счёт эвристик пропускает целые блоки текста - в среднем , но без модификации Galil - в худшем. На больших алфавитах Бойер-Мур обычно быстрее, на малых - КМП.
Что такое Boyer-Moore-Horspool и когда брать его вместо полного? Horspool - упрощённый вариант, где остался только сдвиг по плохому символу, причём выровнённый по самому правому символу паттерна. Кодируется в 15 строк, быстрее полного на коротких паттернах из-за меньших констант. Берите его, когда нужна простая реализация и не страшен теоретический - на реальных текстах он почти не встречается.
Почему Бойер-Мур может быть быстрее линейного алгоритма? Потому что «линейный» - это по числу прочитанных символов текста, а Бойер-Мур многие символы вообще не читает. На самом деле для гарантированного нижнего предела сравнений теоремы существуют, и Бойер-Мур с правильными эвристиками этот предел достигает.
Коротко
Алгоритм Бойера-Мура ищет подстроку длины в тексте длины , сравнивая паттерн справа налево и сдвигая его на большие шаги по двум эвристикам - плохого символа и хорошего суффикса. Таблицы строятся за , основной цикл - в лучшем случае, в худшем (модификация Galil даёт гарантированное ). Стоит в основе grep, поиска в редакторах и многих библиотечных реализаций indexOf. Упрощённый вариант - Boyer-Moore-Horspool с одной эвристикой плохого символа.
Читайте также

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

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

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