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

Алгоритм Бойера-Мура-Хорспула (Boyer-Moore-Horspool, BMH) - упрощённая модификация классического Бойера-Мура, предложенная Найджелом Хорспулом в 1980 году в заметке «Practical fast searching in strings». Хорспул заметил, что для большинства реальных задач полная схема BM с двумя эвристиками избыточна: достаточно одной эвристики «плохого символа», но привязанной не к месту несовпадения, а к символу текста, стоящему под последним символом окна. Получился короткий, кешевой-дружелюбный код, который на естественных текстах работает почти так же быстро, как полный BM, а иногда и быстрее за счёт меньших констант.
Почему понадобилось упрощать BM
Полный алгоритм Бойера-Мура использует две эвристики: «плохой символ» и «хороший суффикс». Каждая требует своей таблицы; good suffix строится нетривиально, через аналог префикс-функции справа налево. На коротких паттернах накладные расходы на вычисление двух сдвигов и взятие максимума съедают выигрыш по числу прочитанных символов.
Хорспул предложил радикальное упрощение. Вместо «на сколько подвинуть, если несовпадение случилось на позиции », задаём вопрос «на сколько подвинуть, исходя из того, какой символ стоит под последней позицией окна». Этот символ известен независимо от того, где именно паттерн рассыпался при сравнении. Хороший суффикс вообще не считается; bad character - всегда по последнему символу окна.
Сама идея - сдвиг по последнему символу окна
Пусть мы прикладываем образец длины к тексту длины так, что левая граница окна - позиция , правая - позиция . Сравниваем символы справа налево. Если все символов совпали - нашли вхождение. Если на каком-то шаге не совпало - Хорспул смотрит на символ , тот самый, что стоит под последней позицией окна, и сдвигает окно вправо так, чтобы самое правое строгое вхождение в оказалось выровнено под этим в тексте.
«Строгое» - потому что само при построении таблицы не учитывается: если бы оно учитывалось, при получалось бы , и окно не двигалось бы вообще. Если в нет - окно сдвигается сразу на , целиком за пределы текущей позиции.
Построение shift-таблицы
Таблица сдвигов индексируется символами алфавита и заполняется одним проходом по :
for c in alphabet:
shift[c] = m
for k in 0..m-2:
shift[P[k]] = m - 1 - k
То есть для каждого символа, встречающегося в первых позициях паттерна, мы храним расстояние от его последнего вхождения до конца паттерна. Для всех остальных символов - . Время построения - , где - размер алфавита; памяти - .
Пример. Для ():
| символ | E | X | A | M | P | L | прочие |
|---|---|---|---|---|---|---|---|
| 6 | 5 | 4 | 3 | 2 | 1 | 7 |
Обратите внимание: для буквы значение , а не , хотя последнее вхождение - это сам . Дело в том, что при построении мы игнорируем, и берётся вхождение из , дающее .
Основной цикл поиска
i = 0
while i <= n - m:
j = m - 1
while j >= 0 and P[j] == T[i + j]:
j -= 1
if j < 0:
report match at i
i += shift[T[i + m - 1]]
Главное отличие от полного BM: сдвиг считается по символу , причём независимо от того, на каком случилось несовпадение и нашли ли мы вообще вхождение. Никакого взятия максимума с good-suffix - таблица одна, формула одна. Это и есть весь алгоритм.
Пошаговый пример
Поищем в ().
Шаг 1. , против - несовпадение. , .
Шаг 2. . , . .
Шаг 3. . Совпало справа налево до против . , .
Шаг 4. . - все 7 символов совпадают, найдено вхождение на позиции 14. Двигаем по концу окна: .
Шаг 5. , окно выходит за границу. Конец. Итого четыре сдвига, около 12 сравнений.
Сложность
- Средний случай - для случайного текста на алфавите : с высокой вероятностью под концом окна стоит символ, которого в нет, поэтому средний сдвиг близок к .
- Худший случай - . Контрпример: , - таблица даёт , окно ползёт по одному символу, и на каждом окне сравнивается весь паттерн.
- Предобработка - время, память.
В отличие от полного BM, у Хорспула нет простой модификации, дающей гарантированное - на adversarial-данных его не используют без дополнительных проверок.
Сравнение с BM, KMP и Sunday
- Vs полный Boyer-Moore. BMH имеет ту же асимптотику в среднем, но меньшие константы. На больших алфавитах часто обгоняет полный BM на 10-20%; на малых (ДНК) и adversarial-входах полный BM с good suffix лучше.
- Vs KMP. KMP даёт гарантированное , но читает каждый символ текста. BMH в среднем читает символов - быстрее в среднем, но без гарантии в худшем. В потоковом чтении KMP удобнее.
- Vs Sunday (1990). Sunday сдвигает по символу - за пределами окна. Это позволяет двигаться до за шаг. Чуть быстрее BMH на длинных паттернах, но требует лишнего символа справа.
BMH: i += shift[T[i + m - 1]] // символ под концом окна Sunday: i += shift[T[i + m]] // символ сразу за окном
Где используется
- редакторы кода и текстовые редакторы (поиск в Vim, Emacs
isearchдля длинных паттернов); - антивирусные сканеры - поиск сигнатур вредоносного кода в файлах, скорость на длинных паттернах решает всё;
- реализации
String.indexOfв стандартных библиотеках (HotSpot JVM для длинных образцов); - утилиты вроде
grep- как один из fallback-методов наряду с BM и SIMD-ускоренными вариантами.
Типовые учебные задачи
- Построить shift-таблицу для заданного и объяснить, почему последний символ паттерна не учитывается.
- Прогнать поиск пошагово, указав сравнения, сдвиги и число операций.
- Построить и , на которых BMH деградирует до .
- Сравнить число сравнений для одного и того же алгоритмами BMH, BM, KMP и наивным.
Частые ошибки
- Учитывают при построении shift. Тогда , окно зависает, поиск зацикливается.
- Берут сдвиг по символу несовпадения, как в полном BM. В BMH сдвиг - всегда по , под концом окна.
- Не сдвигают окно после успешного матча. В BMH после вхождения тоже двигаются по таблице - на .
- Хранят таблицу как hash-map на больших алфавитах. Для ASCII массив на 256 элементов быстрее любого hash-доступа.
- Удивляются, что BMH медленнее BM в худшем случае. У Хорспула слабее эвристика; если нужна гарантия - используйте полный BM с модификацией Galil или KMP.
FAQ
В чём принципиальное отличие BMH от полного Бойера-Мура? В BMH нет эвристики хорошего суффикса, а bad character упрощён: сдвиг считается всегда по символу под концом окна, а не по позиции несовпадения. Одна таблица размером вместо двух, более короткий код и в большинстве задач - та же или лучшая скорость, чем у полного BM, за счёт меньших констант.
Когда Horspool явно проигрывает? На коротком алфавите (ДНК, бинарные данные) - там полный BM с good suffix или KMP лучше. На adversarial-данных BMH деградирует до , а KMP остаётся .
Чем Horspool отличается от Sunday? Sunday (1990) сдвигает окно по символу - за окном, а не под его концом. Это позволяет двигаться до за шаг и даёт 5-15% выигрыша на длинных паттернах, но требует лишнего символа справа от окна.
Коротко
Алгоритм Бойера-Мура-Хорспула - полный Бойер-Мур, упрощённый до одной таблицы сдвигов по последнему символу окна. , где - позиция самого правого вхождения в , или , если вхождения нет. Среднее время - , худший случай - , предобработка - . Несмотря на отсутствие гарантии линейности, BMH часто быстрее полного BM на естественных текстах за счёт меньших констант и стоит в основе поиска в редакторах, антивирусных сканерах и многих библиотечных indexOf.
Читайте также

Z-функция строки за O(n): построение и применение
Что такое Z-функция строки, как построить массив за линейное время O(n) с помощью Z-блока, чем она отличается от префикс-функции и какие задачи решает на практике.

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

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