Алгоритм Бойера-Мура
Читать дальше
Статья Р. Бойера и Дж С. Мура "A Fast String Searching Algorithm" появилась в журнале Communications of the ACM в 1977 году. Замысел авторов был прямо противоположен замыслу КМП: не извлекать больше пользы из совпавшей части, а как можно раньше убедиться, что совпадения нет, и потому начинать сравнение с конца образца.
Упрощение, которое разбирают чаще всего, принадлежит Р. Н. Хорспулу: в 1980 году в журнале Software: Practice and Experience он показал, что от правила хорошего суффикса можно отказаться без заметной потери скорости, если сдвигать окно по символу текста напротив последней позиции образца. Именно этот вариант приведен в коде ниже и лежит в основе большинства практических реализаций.
С оценками получилось не сразу. Полная версия с обоими правилами работает за линейное время даже в худшем случае, но строгое доказательство этого факта заняло годы: точные границы числа сравнений получил Р. Коул в начале 1990-х. Упрощенная версия таких гарантий не дает - на неудачном сочетании текста и образца она возвращается к O(N * M), - и тем не менее на естественных текстах с большим алфавитом обгоняет и КМП, и полную версию.
Те же два автора известны и за пределами поиска строк: Бойер и Мур создали одну из первых систем автоматического доказательства теорем (Nqthm, позже ACL2) и придумали алгоритм поиска большинства голосов, который находит элемент, встречающийся более чем в половине массива, за один проход и с одной ячейкой памяти.
Как работает алгоритм Бойера-Мура
В отличие от наивного метода и КМП, которые проверяют символы образца слева направо, Бойер-Мур прикладывает образец слева, но проверяет совпадение символов с конца образца к началу. Для европейской письменности ход непривычный - в учебниках его так и называют, семитским чтением, - но именно он дает алгоритму право прыгать через непрочитанные куски текста.
Если во время сравнения обнаружено несовпадение, алгоритм обращается к заранее построенной таблице "последних вхождений" символов в образец (эвристика "плохого символа"). Если символ из текста, на котором произошла ошибка, вообще не встречается в нашем образце - значит, нет смысла сдвигать образец на 1 или 2 позиции, он все равно "напорется" на этот плохой символ. Алгоритм совершает огромный скачок, сдвигая все окно поиска сразу за плохой символ.
На сквозном примере модуля это видно сразу. Ищем образец ABABAC в тексте ABABADABABAC: последний символ окна - D, а в образце есть только A, B и C. Сравнение срывается на первом же шаге, и окно перелетает через всю длину образца.
ABABAC: буква стоит тем ближе к концу, чем меньше сдвиг, а буква, которой в образце нет вовсе, дает прыжок сразу на все шесть позиций.Тренажер: прыжки по плохому символу
Ищем образец ABABAC в тексте ABABADABABAC: сначала таблица сдвигов, потом сравнение с конца окна.
Реализация на языках C и C++ (Бойер-Мур-Хорспул)
Существует несколько вариаций алгоритма. Оригинальный Бойер-Мур использует две эвристики - плохой символ и хороший суффикс, - но и в учебниках, и на практике чаще разбирают упрощенную версию: только правило плохого символа, без правила хорошего суффикса. Ее же называют модификацией Бойера-Мура-Хорспула (BMH). Она проще в написании и на обычных текстах часто работает даже быстрее полной.
Сдвиг в упрощенной версии всегда берется по одному и тому же символу текста - тому, что стоит напротив последней позиции образца, независимо от того, на каком символе сорвалось сравнение. Таблица для этого нужна ровно одна: для каждой буквы алфавита в ней лежит расстояние от ее самого правого вхождения в образец до конца образца, а для буквы, которой в образце нет, - вся длина M.
Второе правило, хорошего суффикса, работает почти как КМП, только с конца: если хвост совпал, а дальше сорвалось, образец сдвигают до следующего такого же хвоста внутри себя. Комбинированная стратегия считает оба сдвига и берет больший - оба правила гарантируют, что меньший сдвиг вхождение не пропустит. Сами Бойер и Мур предлагали пойти дальше и объединить свою стратегию с КМП, но Н. Вирт заметил на это, что усложнение построения таблиц и самого поиска вряд ли окупится выигрышем. На практике так и вышло: закрепилась упрощенная версия.
Бойер-Мур-Хорспул: код целиком
#include <stdio.h>
#include <string.h>
#define ALPHABET_SIZE 256
// Функция поиска (Бойер-Мур-Хорспул)
void searchBMH(const char *txt, const char *pat) {
int m = strlen(pat);
int n = strlen(txt);
int badChar[ALPHABET_SIZE]; // Таблица сдвигов
// 1. Инициализация таблицы сдвигов максимальной длиной
for (int i = 0; i < ALPHABET_SIZE; i++) {
badChar[i] = m;
}
// 2. Запись реальных сдвигов (кроме последней буквы)
// Мы считаем расстояние от символа до конца образца
for (int i = 0; i < m - 1; i++) {
badChar[(unsigned char)pat[i]] = m - 1 - i;
}
// 3. Поиск по тексту
int shift = 0; // Смещение окна относительно начала текста
while (shift <= (n - m)) {
int j = m - 1;
// Проверяем совпадение СПРАВА НАЛЕВО
while (j >= 0 && pat[j] == txt[shift + j]) {
j--;
}
if (j < 0) {
printf("Совпадение на индексе: %d\n", shift);
// Если нужно искать дальше, сдвигаем окно
shift += m;
} else {
// Эвристика плохого символа: прыгаем по таблице!
// Смотрим на последний символ текущего окна в тексте
shift += badChar[(unsigned char)txt[shift + m - 1]];
}
}
}
Приведенный выше код на C собирается и компилятором C++ без единой правки - это проверено сборкой в режиме C++17. Но если вы пишете на C++, привычнее другая запись: std::string и std::array вместо указателей и ручного выделения памяти. Отсутствие вхождения обозначается привычным для C++ значением std::string::npos.
Бойер-Мур-Хорспул на C++: код целиком
#include <array>
#include <string>
// Упрощенный Бойер-Мур: только правило плохой литеры
std::size_t searchBMH(const std::string& txt, const std::string& pat) {
const std::size_t m = pat.size();
const std::size_t n = txt.size();
if (m == 0 || m > n) return std::string::npos;
std::array<std::size_t, 256> shift;
shift.fill(m); // чужой буквы нет в образце: прыжок на всю длину
for (std::size_t i = 0; i + 1 < m; ++i)
shift[static_cast<unsigned char>(pat[i])] = m - 1 - i;
std::size_t pos = 0;
while (pos + m <= n) {
std::size_t j = m;
while (j > 0 && pat[j - 1] == txt[pos + j - 1]) --j; // сравнение справа налево
if (j == 0) return pos;
// сдвиг по символу текста напротив последней позиции образца
pos += shift[static_cast<unsigned char>(txt[pos + m - 1])];
}
return std::string::npos; // не найдено
}
Алгоритм воскресного дня: другая точка опоры
В 1990 году Д. Санди предложил еще одно упрощение той же идеи; в англоязычных источниках оно известно как Quick Search. Отличие от Хорспула ровно одно - по какому символу текста считается сдвиг.
Хорспул берет символ напротив последней позиции образца, то есть последний символ текущего окна. Санди берет символ, стоящий сразу за окном. Рассуждение простое: сдвиг, который еще может привести к совпадению, не превосходит длины образца, а значит этот символ после сдвига обязательно окажется под образцом. Если его в образце нет вовсе, то и перешагнуть можно вместе с ним - на M + 1 позицию.
Из-за этого меняется и таблица: она строится по всем символам образца, включая последний, а величина сдвига равна расстоянию от самого правого вхождения буквы до позиции сразу за образцом. Для ABABAC обе таблицы выглядят так:
| Правило | A | B | C | любая другая |
|---|---|---|---|---|
| Хорспул | 1 | 2 | 6 | 6 |
| Санди | 2 | 3 | 1 | 7 |
Прыжки у Санди в среднем на единицу длиннее, и на обычных текстах это заметно. Но выигрыш не гарантирован, и сквозной пример модуля - как раз тот случай, когда лучше оказывается Хорспул. При поиске ABABAC в ABABADABABAC напротив конца первого окна стоит D, которой в образце нет, и окно сразу перелетает через шесть позиций; Санди же смотрит на символ за окном, а там A, и сдвигает всего на две. В итоге Хорспул проходит два окна и 7 сравнений символов, Санди - четыре окна и 18 сравнений.
Точка опоры у этих правил разная, поэтому и проигрывают они на разных данных. На практике их нередко объединяют - и именно такое сочетание работает внутри Python, о чем речь ниже.
FastSearch) используется внутри операторов in и .find() в Python. На реальных человеческих текстах с большим алфавитом вероятность встретить "плохой символ" очень велика, и алгоритм летит по строке гигантскими шагами, перепрыгивая целые слова. С версии 3.10 к этому добавилась страховка: на длинных образцах Python переключается на алгоритм two-way, у которого линейное время гарантировано, - чтобы на специально подобранных строках поиск не вырождался в квадратичный.
Коротко: главное
- Направление: Образ прикладывается слева, но проверка символов внутри окна всегда идет с конца образа к его началу.
- Плохой символ: Если наткнулись на несовпадение и такого символа в образе вообще нет - мы можем смело перешагнуть этот символ всем образом.
- Упрощенная версия: только правило плохого символа, без хорошего суффикса, сдвиг по символу текста напротив последней позиции образца. Именно ее разбирают в учебниках, она же Бойер-Мур-Хорспул.
- Парадокс скорости: В лучшем случае алгоритм работает за O(N/M) - чем длиннее строка поиска, тем бóльшими кусками алгоритм перепрыгивает текст!
Проверьте себя
Какой алгоритм в лучшем случае способен найти подстроку за время O(N/M) (т.е. время поиска уменьшается с ростом длины образца)?