Сегодня мы разберем хитроумный и нетривиальный алгоритм поиска подстроки в строке. Он основан не на сравнении символов, а на сравнении чисел. Я уже писал, что основная моя цель это не написать простой разбор алгоритмов, а посмотреть их эффективность, какие-то интересные места и сравнить их производительность между собой.
И сегодня есть что посмотреть. Часть 1 — Алгоритм Кнута — Морриса — Пратта
Часть 2 — Алгоритм Бойера — Мура Вообще в классической реализации используется полиномиальный хеш, но подойдет в общем-то любая кольцевая хеш-функция. Но мы возьмем полиномиальную версию. Алгоритм крайне простой: берем хеш паттерна, берем хеш куска текста равного по длине, сравниваем их. Если равны, то проверяем их посимвольно, в противном случае — идем дальше.  Все очень просто. У нас есть двоичный алфавит, где . Вычислим хеш паттерна:   Вычислим хеш подстроки .   Они не совпадают, значит двигаем паттерн на символ правее. Вычислим хеш подстроки .   Хеши подстроки и паттерна снова не совпадают, значит двигаем паттерн еще на символ правее. Вычислим хеш подстроки .   Хеши совпали, значит проверяем этот участок посимвольно. Чтобы сопоставить две строки, мы по факту превращаем их в числа простым переводом в произвольную систему счисления, где позиция символа это разряд, его код это значение разряда, а мощность алфавита это количество символов в кодировке (вообще это не обязательно, за мощность алфавита можно брать любое число больше единицы, но тогда коллизии будут встречаться чаще. Чем больше число, тем лучше, но главное не упереться в переполнение:)). В общем виде расчет хеша будет выглядеть примерно вот так:    — хеш паттерна
 — некоторое натуральное число, например мощность алфавита. Теперь давайте посчитаем хеш реальной строки. Возьмем строку Pattern и для наглядности за x возьмем мощность семибитной таблицы ASCII — 128.   Результат: 355 207 998 962 030 Выглядит многовато. Чтобы записать такое число в двоичном виде понадобится 49 разрядов, а у нас из исходных данных всего лишь паттерн длинной 7 символов и урезанная кодировка ASCII. Если мы возьмем восьмибитную версию, то будет уже переполнение даже на 64-х битных системах. Так что решить этот вопрос нам помогут схема Горнера и модульная арифметика.   Ее задача помочь нам избавиться от возведения в степень, так как возведение в степень относительно прожорливая арифметическая операция. Кроме того, без схемы Горнера алгоритм хеширования станет менее оптимальным. Так как у нас будет большое количество дублирующихся вычислений. Да и никакая модульная арифметика нам не поможет, если у нас паттерн длиной 128 ASCII-символов. В таком случае, при обычном возведении в степень, у нас для хеширования первого символа в паттерне будет использоваться число , а это, как вы понимаете, много. Для того чтобы избежать переполнения, но при этом получать корректные результаты хеширования мы для всех промежуточных результатов вычислений будем брать остаток от деления. Таким образом, наш конечный алгоритм должен выглядеть примерно вот так:   На всех промежуточных этапах мы получим значения не превышающие
. Но тут есть один нюанс: Если начать забуриваться в изыскания на тему коллизий в кольцевых хеш-функциях, то натуральным образом можно оттуда не выбраться. Если кратко, то число q должно быть большим и простым. А если поподробнее то вот: У нас есть текст  длиной . Если мы возьмем , где , то вероятность коллизии будет менее, чем  Если пробежаться по первой тысяче простых чисел и построить график , где  это количество коллизий, то будет вот:  Здесь я провел проверку на отрывке про дуб из Войны и мира, где искал строку обломанн. Да паттерн не очень большой, числа тоже не самые огромные, но зато мы можем посмотреть динамику количества коллизий. И как это не тривиально — мы получили обычную асимптоту. Ну и как мы можем заметить после 7000 самое худшее, что мы получаем это 1 коллизию. Конечно не факт, что дальше не будет какого-нибудь всплеска. Но начиная с 2000 самое худшее, что у нас было это 4 коллизии на текст 1671 символа. Все логично, чем больше простое число, тем меньше мы скукоживаем хеш, тем меньше коллизий. Ведь когда мы берем диапазон чисел , (а примерно такой хеш мы можем получить без модульной арифметики), и пытаемся его ужать в диапазон , то очевидно, что мы должны за это чем-то заплатить. И цена этому коллизии. Конкурсы все те же: Замеры, где и текст и паттерн состоят только из буквы "а". Текст: строка длинной 1024 символа
Образец: строка длинной 32 символа Алгоритм Рабина-Карпа по сложности в худшем случае имеет сложность , по времени работы ничем сильно не выделяется, но при этом делает больше сравнений. Ведь перед тем как сверять строку мы сначала должны сверить хеш. Так что здесь наш подопытный аутсайдер. Строка состоящая из 1024 символов алфавита [TGAC].  GTAGTGTGTCTACGTCTTTCTTTGACAGTACCGCGTAATTTGCAGTGCTATAAATCATCTCTAACGCTGGCTGTGCACCGCCACCGTAGTGTGTCTACGTCTTTCTTTGACAGTACCGCGTAATTTGCAGTGCTATAAATCATCTCTAACGCTGGCTGTGCACCGCCACCCCAGCGGGAAGCCCATTTTTCCACTACCTCTGTTCCTGGTATAGTGCACTATATCGCCCGTAACCGATGTCTCGCCAAGATTTTGGCAACTTCCCGAGCAATCAGGTGGAGTCAGACCGATAGCTCTAATGGTTTACGTGAATGCATGGCGCCTATAGCTATGGGCAGAAACCAGCGGGAAGCCCATTTTTCCACTACCTCTGTTCCTGGTATAGTGCACTATATCGCCCGGTAGTGTGTCTACGTCTTTCTTTGACAGTACCGCGTAATTTGCAGTGCTATAAATCATCTCTAACGCTGGCTGTGCACCGCCACCCCAGCGGGAAGCCCATTTTTCCACTACCTCTGTTCCTGGTATAGTGCACTATATCGCCCGTAACCGATGTCTCGCCAAGATTTTGGCAACTTCCCGAGCAATCAGGTGGAGTCAGACCGATAGCTCTAATGGTTTACGTGAATGCATGGCGCCTATAGCTATGGGCAGAAATAACCGATGTCTCGCCAAGATTTTGGCAACGTAGTGTGTCTACGTCTTTCTTTGACAGTACCGCGTAATTTGCAGTGCTATAAATCATCTCTAACGCTGGCTGTGCACCGCCACCCCAGCGGGAAGCCCATTTTTCCACTACCTCTGTTCCTGGTATAGTGCACTATATCGCCCGTAACCGATGTCTCGCCAAGATTTTGGCAACTTCCCGAGCAATCAGGTGGAGTCAGACCGATAGCTCTAATGGTTTACGTGAATGCATGGCGCCTATAGCTATGGGCAGAAATTCCCGAGCAATCAGGTGGAGTCAGACCGATAGCTCTAATGGTTTACGTGAATGCATGGCGCCTATAGCTATGGGCAGAAA Образец: GTAGTGTGTCTACGTCTTTCTTTGACAGTACCGCGTA. (37 символов) getSubstringNaive x 6,349
getSubstringKMP x 156,780
getSubstringNotSoNaive x 189,694
getSubstringBMBadCharacter x 199,476
getSubstringRK x 229,361 Несмотря на то, что алгоритм Рабина-Карпа на втором месте, по количеству сравнений, он опережает Бойера-Мура. Ну в общем-то БМ с эвристикой плохого символа не так эффективен на сгенерированных строках. Текст: На краю дороги стоял дуб. Вероятно, в десять раз старше берез, составлявших лес, он был в десять раз толще, и в два раза выше каждой березы. Это был огромный, в два обхвата дуб, с обломанными, давно, видно, суками и с обломанной корой, заросшей старыми болячками. С огромными своими неуклюже, несимметрично растопыренными корявыми руками и пальцами, он старым, сердитым и презрительным уродом стоял между улыбающимися березами. Только он один не хотел подчиняться обаянию весны и не хотел видеть ни весны, ни солнца. «Весна, и любовь, и счастие! — как будто говорил этот дуб. — И как не надоест вам все один и тот же глупый бессмысленный обман! Все одно и то же, и все обман! Нет ни весны, ни солнца, ни счастья. Вон смотрите, сидят задавленные мертвые ели, всегда одинакие, и вон и я растопырил свои обломанные, ободранные пальцы, где ни выросли они — из спины, из боков. Как выросли — так и стою, и не верю вашим надеждам и обманам» . Князь Андрей несколько раз оглянулся на этот дуб, проезжая по лесу, как будто он чего-то ждал от него. Цветы и трава были и под дубом, но он все так же, хмурясь, неподвижно, уродливо и упорно, стоял посреди их. «Да, он прав, тысячу раз прав этот дуб, — думал князь Андрей, — пускай другие, молодые, вновь поддаются на этот обман, а мы знаем жизнь, — наша жизнь кончена! » Целый новый ряд мыслей безнадежных, но грустно-приятных в связи с этим дубом возник в душе князя Андрея. Во время этого путешествия он как будто вновь обдумал всю свою жизнь и пришел к тому же прежнему, успокоительному и безнадежному, заключению, что ему начинать ничего было не надо, что он должен доживать свою жизнь, не делая зла, не тревожась и ничего не желая. Паттерны: Ops/sec Количество сравнений На реальном тексте алгоритм держит планку по , его производительность примерно как у КМП алгоритма и ± такое же количество сравнений. Это далеко не самый шустрый алгоритм чтения, но у него есть одна крутая фишка: он может в неточный поиск. Так как он не делает сравнения символов напрямую, то он может шустро искать в потоковом режиме примеры вхождений определенного паттерна. Значит мы можем сделать систему, которая будет удалять все знаки препинания, переводить текст в нижний регистр и искать вхождение абзаца в текст. Да, если поизголяться, то это можно сделать и без РК алгоритма, но тогда это выйдет не так оптимально по времени и памяти. Сертифицированный угнетатель ЭВМ