Начал я на днях читать книгу про обработку строк и буквально с первых страниц, прихлебывая чаечек я начал поражаться тому, что за пять лет работы программистом я смотрел на строки только как на последовательность букв и ничего большее. Про то, что строки можно анализировать, чтобы как-то с ними продуктивно работать я вообще не задумывался.
Ну и я решил проверить на практике, так ли эти алгоритмы быстры и хороши как в книге описывается. Не то чтобы я имею повод сомневаться в авторе, но ведь всегда лучше один раз увидеть, чем сто раз прочитать в книге.
Скажу сразу, что данный материал написан для таких же обывателей как я, и что я понимаю, что на Хабре была написаны некоторые статьи по поводу алгоритмов обработки строк и была даже пара статей про КМП. Но со своей стороны я хочу максимально просто разжевать устройство этого алгоритма, замерить его производительность и посмотреть где, когда и для чего его можно использовать. В общем, я считаю мне есть что сказать.
Код, примеры и термины будут местами из ранее упомянутой книги С. Окулова "Алгоритмы обработки строк", так что, если какой-то термин будет упомянут не правильно, то милости прошу, поправляйте и я внесу коррективы.
Устройство алгоритма
Грани строки
Начать стоит с того, что у строки есть грани. Гранью строки называется любой префикс строки, который равен ее суффиксу.
Например, у строки qwertyqwe есть грань qwe, потому что строка и начинается, и заканчивается на qwe. Важно заметить, что грань не может быть равна самой строке.
Таким образом для строки aaaaa, грани будут a, aa, aaa, aaaa, но aaaaa гранью не будет.
К чему собственно разгон: с помощью вычисления массива граней (он же таблица префиксов и суффиксов) реализуется главная фишка алгоритма — таблица сдвигов паттерна поиска.
Таблица сдвигов паттерна поиска
Есть например у нас паттерн поиска abaaba.
- Берем первый символ строки (а) и ищем для него грань. Так как для него грани быть не может (потому что грань не может быть равна строке) то грань — 0.
- Берем теперь два первых символа (ab) и ищем грань для них. Так как нет суффикса, который равен префиксу, то грань снова 0.
- Берем подстроку aba и ищем максимальную грань для нее. Так как из префиксов и суффиксов совпадают только буквы «а», то максимальная грань — 1.
Повторять до конца.
export function findBorders(text: string): number[] {
const borders = [0];
for (let i = 1, length = text.length; i < length; i++) {
const border = findMaxBorder(text, i + 1);
borders.push(border);
}
return borders;
}
function findMaxBorder(text: string, length: number): number {
let max = 0;
for (let i = 0; i < length - 1; i++) {
if (isPartsEqually(text, 0, length - i - 1, i + 1)) {
max = i + 1;
}
}
return max;
}
function isPartsEqually(text: string, firstIndex: number, secondIndex: number, length: number): boolean {
for(let i = 0; i < length; i++) {
if (text[firstIndex + i] !== text[secondIndex + i]) {
return false;
}
}
return true;
}
Результат должен быть вот такой:
Для чего были нужны все эти манипуляции? Сейчас поясню.
Массив граней это градообразующее предприятие всего алгоритма: его задача сказать нам, откуда продолжать сравнивать, если вдруг наш процесс сравнения строки собьется.
Ход поиска
Что здесь происходит:
Мы сравниваем наш текст T с паттерном P. Первые два символа текста совпали с паттерном, но третий символ T и P не совпали (T[2] != P[2]
). Значит смотрим в наш массив граней br. Ага, грань br[2]
имеет длину 0. Значит сдвигаем P на 2 символа вправо (длина совпавшего участка за вычетом длины грани плюс сдвиг на единичку: 2 - 1 + 1
).
Передвинули, проверяем символ еще раз:T[2] != P[0]
, хорошо, тогда сдвигаем паттерн еще на 0 - 0 + 1
.
Дальше совпадает еще три символа и так далее.
Таким образом, зная структуру нашего паттерна мы можем оптимизировать количество сравнений и не возвращаться к уже проверенным участкам строки.
export function getSubstringKMP(text: string, pattern: string): number[] {
const borders = findBorders(pattern);
const result = [];
let compareIndex = 0;
for (let i = 0; i < text.length; i++) {
while (compareIndex > 0 && text[i] !== pattern[compareIndex]) {
compareIndex = borders[compareIndex - 1];
}
if (text[i] === pattern[compareIndex]) {
compareIndex++;
}
if (compareIndex === pattern.length) {
result.push(i - compareIndex + 1);
compareIndex = borders[pattern.length - 1];
}
}
return result;
}
Замеры производительности
Сравним производительность наивного алгоритма и нашей реализации КМП.
Текст:
GTAGTGTGTCTACGTCTTTCTTTGACAGTACCGCGTAATTTGCAGTGCTATAAATCATCTCTAACGCTGGCTGTGCACCGCCACCGTAGTGTGTCTACGTCTTTCTTTGACAGTACCGCGTAATTTGCAGTGCTATAAATCATCTCTAACGCTGGCTGTGCACCGCCACCCCAGCGGGAAGCCCATTTTTCCACTACCTCTGTTCCTGGTATAGTGCACTATATCGCCCGTAACCGATGTCTCGCCAAGATTTTGGCAACTTCCCGAGCAATCAGGTGGAGTCAGACCGATAGCTCTAATGGTTTACGTGAATGCATGGCGCCTATAGCTATGGGCAGAAACCAGCGGGAAGCCCATTTTTCCACTACCTCTGTTCCTGGTATAGTGCACTATATCGCCCGGTAGTGTGTCTACGTCTTTCTTTGACAGTACCGCGTAATTTGCAGTGCTATAAATCATCTCTAACGCTGGCTGTGCACCGCCACCCCAGCGGGAAGCCCATTTTTCCACTACCTCTGTTCCTGGTATAGTGCACTATATCGCCCGTAACCGATGTCTCGCCAAGATTTTGGCAACTTCCCGAGCAATCAGGTGGAGTCAGACCGATAGCTCTAATGGTTTACGTGAATGCATGGCGCCTATAGCTATGGGCAGAAATAACCGATGTCTCGCCAAGATTTTGGCAACGTAGTGTGTCTACGTCTTTCTTTGACAGTACCGCGTAATTTGCAGTGCTATAAATCATCTCTAACGCTGGCTGTGCACCGCCACCCCAGCGGGAAGCCCATTTTTCCACTACCTCTGTTCCTGGTATAGTGCACTATATCGCCCGTAACCGATGTCTCGCCAAGATTTTGGCAACTTCCCGAGCAATCAGGTGGAGTCAGACCGATAGCTCTAATGGTTTACGTGAATGCATGGCGCCTATAGCTATGGGCAGAAATTCCCGAGCAATCAGGTGGAGTCAGACCGATAGCTCTAATGGTTTACGTGAATGCATGGCGCCTATAGCTATGGGCAGAAA
Паттерн:
GTAGTGTGTCTACGTCTTTCTTTGACAGTACCGCGTA
Замер происходит для всех четырех вхождений.
getSubstringNaive x 5,939 ops/sec ±1.57% (88 runs sampled)
getSubstringKMP x 103,425 ops/sec ±0.48% (93 runs sampled)
Выходит так, что КМП быстрее почти в 21 раз.
Напомню, что наивный алгоритм сравнивает последовательность в строке с паттерном полностью, то есть, если в какой-то момент он видит, что происходит несовпадение, то он все равно сравнивает до конца, надеясь на чудо. Ну выходит, что это тупой алгоритм, а не наивный.
export function getSubstringNaive(text: string, pattern: string): number[] {
const result = [];
for (let i = 0; i <= text.length - pattern.length; i++) {
let flag = true;
// Ну о-о-о-очень наивный алгоритм
for (let j = 0; j < pattern.length; j++) {
if (text[i + j] !== pattern[j]) {
flag = false;
}
if (j === pattern.length - 1 && flag) {
result.push(i);
}
}
}
return result;
}
По этому я предлагаю не избивать младенцев, а все таки сделать некоторую оптимизацию наивного алгоритма, и прерывать сравнение при первом расхождении последовательностей.
export function getSubstringNotSoNaive(text: string, pattern: string): number[] {
const result = [];
for (let i = 0; i <= text.length - pattern.length; i++) {
for (let j = 0; j < pattern.length; j++) {
if (text[i + j] !== pattern[j]) {
break;
}
if (j === pattern.length - 1) {
result.push(i);
}
}
}
return result;
}
Повторим наши замеры:
getSubstringNaive x 6,019 ops/sec ±0.75% (97 runs sampled)
getSubstringKMP x 103,850 ops/sec ±0.20% (94 runs sampled)
getSubstringNotSoNaive x 218,057 ops/sec ±0.52% (96 runs sampled)
И вот мы в два раза обогнали КМП алгоритм.
Сравнение продуктивности
Хорошо, допустим КМП работает медленнее, но давайте посчитаем количество сравнений каждого алгоритма. Для этого заведем глобальные счетчики и посчитаем каждое сделанное сравнение.
Код для всего этого безобразия будет выглядеть
export class Counter {
public get count(): number {
return this._count;
}
public readonly name: string;
private _count: number = 0;
public constructor(name: string) {
this.name = name;
}
public increase(): void {
this._count++;
}
}
const counterMap = new Map<string, Counter>();
export function createCounter(name: string): Counter {
const counter = new Counter(name);
counterMap.set(name, counter);
return counter;
}
export function getAllCounters(): [string, Counter][] {
return Array.from(counterMap.entries());
}
export function compare<T>(first: T, second: T, counter: Counter): boolean {
counter.increase();
return first === second;
}
Засунем везде функцию compare и будем считать каждое фактическое сравнение символов.
Результат:
getSubstringNaive: 36,556
getSubstringKMP: 1,422
getSubstringNotSoNaive: 1,434
У КМП результат по количеству сравнений наименьший, ему в спину дышит не очень наивный алгоритм с разницей всего в 12 сравнений, а полный аутсайдер — наивный алгоритм.
Хорошо, мы сравнили скорость алгоритмов на одной конкретной строке, сравнили их продуктивность по сравнениям, а теперь давайте сравним это все на реальном тексте. Внимательный читатель, должен был заметить, что строка из предыдущего примера подозрительно похожа на последовательность ДНК, так вот, это потому что КМП алгоритм вообще ценился, а может быть и до сих пор ценится в биоинформатике, в плане поиска нуклеотидных последовательностей (Я даже нашел пару научных публикаций на этот счет).
Но как мне кажется среди людей читающих эту статью вряд ли попадется хоть сколько-нибудь значимое количество биоинформатиков, так что предлагаю проверить алгоритм на реальном тексте, чтобы оценить его прикладное значение для рядового программиста.
Проверять будем на отрывке из Войны и мира про злосчастный дуб.
На краю дороги стоял дуб. Вероятно, в десять раз старше берез, составлявших лес, он был в десять раз толще, и в два раза выше каждой березы. Это был огромный, в два обхвата дуб, с обломанными, давно, видно, суками и с обломанной корой, заросшей старыми болячками. С огромными своими неуклюже, несимметрично растопыренными корявыми руками и пальцами, он старым, сердитым и презрительным уродом стоял между улыбающимися березами. Только он один не хотел подчиняться обаянию весны и не хотел видеть ни весны, ни солнца.
«Весна, и любовь, и счастие! — как будто говорил этот дуб. — И как не надоест вам все один и тот же глупый бессмысленный обман! Все одно и то же, и все обман! Нет ни весны, ни солнца, ни счастья. Вон смотрите, сидят задавленные мертвые ели, всегда одинакие, и вон и я растопырил свои обломанные, ободранные пальцы, где ни выросли они — из спины, из боков. Как выросли — так и стою, и не верю вашим надеждам и обманам» .
Князь Андрей несколько раз оглянулся на этот дуб, проезжая по лесу, как будто он чего-то ждал от него. Цветы и трава были и под дубом, но он все так же, хмурясь, неподвижно, уродливо и упорно, стоял посреди их.
«Да, он прав, тысячу раз прав этот дуб, — думал князь Андрей, — пускай другие, молодые, вновь поддаются на этот обман, а мы знаем жизнь, — наша жизнь кончена! » Целый новый ряд мыслей безнадежных, но грустно-приятных в связи с этим дубом возник в душе князя Андрея. Во время этого путешествия он как будто вновь обдумал всю свою жизнь и пришел к тому же прежнему, успокоительному и безнадежному, заключению, что ему начинать ничего было не надо, что он должен доживать свою жизнь, не делая зла, не тревожась и ничего не желая.
Найдем все упоминания слов:
- дуб
- Андрей
- обломанн
Результаты
Ops/sec
Алгоритм | дуб | Андрей | обломанн |
---|---|---|---|
getSubstringNaive | 55,051 | 28,830 | 20,774 |
getSubstringKMP | 120,206 | 142,387 | 114,619 |
getSubstringNotSoNaive | 184,652 | 205,846 | 164,068 |
Количество сравнений
Алгоритм | дуб | Андрей | обломанн |
---|---|---|---|
getSubstringNaive | 5,013 | 10,008 | 13,328 |
getSubstringKMP | 1,741 | 1,688 | 1,832 |
getSubstringNotSoNaive | 1,739 | 1,683 | 1,828 |
Очевидные и не очень выводы
Первый вывод и он же очевидный: чем больше сравнений, тем меньше скорость алгоритма.
Второй: на реальном тексте КМП реализация существенно приблизилась к не очень наивному алгоритму (на ДНК строке отставание было в 2 раза).
Третий: КМП и не очень наивный алгоритм имеют практически одинаковое количество сравнений, но при этом разница в скорости примерно 35%.
Давайте поподробнее
Длина текста — 1673 символа. Мы фактически подтвердили сложность алгоритмов (хотя это от нас и не требовалось и не то чтобы кто-то в ней сомневался, но мне все равно приятно), потому что у наивного алгоритма количество сравнений изменяется примерно как *n m, а у остальных алгоритмов оно с определенной погрешностью держится около n**, как и задумано.
Разница между КМП и не очень наивным алгоритмом такая большая оттого, что у КМП есть препроцессинг строки, а у его оппонента его нет. Кроме того, у КМП есть еще и накладные расходы на проверки массива граней, на входы и выходы из различных циклов и if'ов, но так как почти во всех случаях массив граней пустой, то получается, что порядок сравнений у обоих алгоритмов практически идентичный.
На реальном тексте КМП себя показал лучше. При наличии таблицы непустой таблицы префиксов у алгоритма возрастает количество накладных расходов, вот и получается, что вроде бы он и используется в биоинформатике, но там себя показывает хуже (!), чем на реальном тексте.
Но, внезапный твист: главная фишка этого алгоритма не скорость, а то, что он не делает повторных сравнений уже пройденных символов. Если вы сравните алгоритмы, то наивная и не очень реализации сравнивают строки посимвольно и при несовпадении передвигают индекс сравнения вперед и идут по новой смотреть уже проверенные символы, а КМП видит несовпадение и перекидывает индекс сравнения в соответствии с массивом граней и идет себе дальше.
Это значит, что если у нас есть огромный файл генома и условно старый компьютер с ограниченным размером оперативной памяти (а КМП был опубликован в 1977 году, когда компьютер стоил как ракета, а оперативной памяти в них было с гулькин нос), то мы можем постепенно считывать этот файл (или перфоленту) и в реальном времени сравнивать поступающие данные и нужной последовательностью.
Вывод
Алгоритм очень занятный, но в прикладном значении он скорее всего бесполезен для 95% программистов, только если они не разрабатывают систему реального времени на каких-либо слабеньких устройствах.
Но в его основе лежит хитроумная идея, которая весьма наглядно показывает, что не стоит просто кидаться на амбразуру, а иногда нужно провести разведку и ознакомиться с тем, что имеешь.
Ссылка на репозиторий, если вдруг кто захочет поиграться сам.