Если вы не знаете, что такое префикс-функция строки, не знаете, как она вычисляется, или, что самое главное, не до конца понимаете, почему алгоритм вычисления префикс-функции работает за линейное время, то эта статья для вас.
Я прошел через череду осознаний и озарений, прежде чем достичь просветления, и теперь предлагаю вам пройти этот путь вместе со мной.
Этап 1. Определение
Первое мое знакомство с префикс-функцией произошло еще в школе. Я готовился к олимпиадам по программированию, и конечно же в моем "джентльменском наборе" подготовки был алгоритм Кнута-Морриса-Пратта, который позволяет найти подстроку длины в строке длины за времени.
Итак, что такое префикс-функция? Прежде чем перейти к ответу на этот вопрос, давайте познакомимся с функцией . Эта функция отображает строку в число, которое является длиной наибольшего суффикса строки , который совпадает с ее префиксом той же длины, но при этом не совпадает со всей строкой .
Без примеров сразу может быть непонятно. Давайте рассмотрим, например, строку . У этой строки есть префиксы , , , , и суффиксы , , , , . Пересечением префиксов и суффиксов являются строки , и . Самая длинная из этих строк, которая не является исходной строкой, - это строка , ее длина равна 3. Соответственно, .
Другие примеры: , , .
Написать функцию, которая вычисляет можно, например, так (язык C++):
int pi(string s) {
int n = s.size();
for (int suffix_len = n - 1; suffix_len > 0; suffix_len--) {
string suffix = s.substr(n - suffix_len, suffix_len);
string prefix_same_len = s.substr(0, suffix_len);
if (suffix == prefix_same_len) {
return suffix_len;
}
}
return 0;
}
Мы проходим по всем суффиксам строки по убыванию их длины, для каждого суффикса находим префикс той же длины и сравниваем их. Как только встретим совпадающие суффикс и префикс - возвращаем их длину. Такая функция, очевидно, будет работать за , но нас это пока что не расстраивает, на этом этапе мы просто хотим разобраться с определением.
Теперь же, когда мы узнали и осознали, что такое , будет гораздо проще разобраться, что такое префикс-функция. Давайте обозначим ее . Так вот, префикс-функция строки - это массив чисел , где - префикс строки до -го символа включительно, .
То есть префикс-функция - это массив чисел для всех префиксов строки (количество префиксов строки совпадает с ее длиной, то есть размер массива равен длине строки).
Например , , , , .
Еще пример: .
Прежде чем двигаться дальше, убедитесь, что вы можете, глядя на строку, в уме посчитать ее префикс-функцию.
Этап 2. Применение
Как же префикс-функция может помочь находить подстроку в строке? Идея довольно красивая: давайте склеим подстроку и строку, вставив между ними символ, который гарантированно не будет встречаться ни в подстроке, ни в строке. Далее, посчитаем префикс-функцию для получившейся склеенной строки. Тогда, если в какой-то момент значение префикс-функции станет равно длине искомой подстроки, это будет означать, что мы нашли вхождение подстроки в строку.
Пример: ищем подстроку в строке . Предполагаем, что символ не встречается во входном алфавите. Склеив строки, получаем строку .
Ее префикс-функция равна . Видим, что в функции мы дважды получили , которое является длиной искомой подстроки. Следовательно, в этих позициях заканчиваются вхождения подстроки в исходную строку.
Объясню немного подробнее. Пусть длина искомой подстроки равна . Мы нашли такой префикс склеенной строки , для которого . То есть мы нашли суффикс длины , совпавший с префиксом длины . Префикс длины - это и есть наша искомая подстрока. Суффикс длины гарантированно полностью лежит в исходной строке, в которой мы ищем вхождения. Гарантирует нам это добавленный служебный символ . Благодаря нему, префиксы и суффиксы склеенной строки не пересекаются (за исключением самой строки). Следовательно, найденный суффикс является вхождением подстроки в строку.
Что ж, если мы научимся считать префикс-функцию за линейное время от длины строки, то мы получим алгоритм поиска подстроки длины в строке длины , работающий за времени.
Этап 3. Непонимание
Еще будучи в школе, я узнал, что префикс-функцию можно считать за линейное время. Без предисловий приведу алгоритм, который это делает:
vector<int> prefix_function(string s) {
int n = s.size();
vector<int> pi(n);
pi[0] = 0;
for (int i = 1; i < n; i++) {
int k = pi[i - 1];
while (k > 0 && s[i] != s[k]) {
k = pi[k - 1];
}
if (s[i] == s[k]) {
k++;
}
pi[i] = k;
}
return pi;
}
Здесь pi[i]
- это в наших обозначениях. То, что pi[0] = 0
- довольно очевидно, потому что функция от строки длины всегда равна .
Сложности в понимании происходящего начинаются, когда мы оказываемся в цикле:
По какой логике меняется значение
k
? И почему в итогеk
оказывается равноpi[i]
?Почему, несмотря на цикл
while
внутри циклаfor
, алгоритм работает за линейное время?
Когда я был юным школьником, я попытался разобраться в этих вопросах, но не преуспел, поскольку не был достаточно заинтересован и замотивирован. Только в вузе я осознал всю прелесть строгих математических доказательств и перестал принимать на веру недоказанные утверждения (если они не аксиомы). А в школе я просто поверил, что эта функция работает, работает быстро (тесты же проходят), зазубрил, как она пишется, и периодически применял при решении задач.
В студенческие времена разобраться с префикс-функцией руки у меня все никак не доходили. И только став преподавателем по программированию, в курсе которого фигурирует алгоритм Кнута-Морриса-Пратта, я нашел в себе достаточно мотивации, чтобы наконец расставить все точки над pi
.
Теперь же, если у вас не осталось никаких других вопросов, кроме двух выше написанных, давайте попробуем на них ответить.
Этап 4. Наблюдение
Я разбирался с префикс-функцией, используя две статьи: на e-maxx и на neerc.ifmo. Во многом они пересекаются и одинаковым образом выводят линейный алгоритм. Далее последует просто более подробный пересказ этих статей, где я постараюсь разобрать каждый пункт с примерами и рисунками, которые помогли в понимании лично мне.
В обеих статьях авторы делают следующее наблюдение, которое помогает вывести алгоритм:
.
Или, на языке кода: pi[i + 1] <= pi[i] + 1
.
То есть значение не может увеличиться больше чем на по сравнению со значением на предыдущем префиксе.
Доказывается это от противного. Предположим, что возможен случай, при котором
pi[i + 1] > pi[i] + 1
.
Или, другими словами, pi[i] < pi[i + 1] - 1
.
Для того, чтобы было выполнено это неравенство, pi[i + 1]
должно быть равно как минимум , иначе мы получим значение pi[i]
меньше нуля, что невозможно по определению.
Итак, пусть pi[i + 1] = k
, k >= 2
.
То есть суффикс длины k
совпадает с префиксом длины k
. Однако тогда, сделав шаг назад, можно сказать, что у предыдущего префикса суффикс длины k - 1
совпадает с префиксом длины k - 1
. На рисунке ниже: из того, что совпадают красные области, следует то, что совпадают и желтые.
Это означает, что pi[i] >= k - 1
, то есть pi[i] >= pi[i + 1] - 1
. Пришли к противоречию.
Как это наблюдение поможет нам считать префикс-функцию? Предположим, мы находимся на шаге i
, уже вычислив pi
для всех значений меньше i
. Тогда, исходя из наблюдения, у нас возможны два варианта:
pi[i] = pi[i - 1] + 1
,pi[i] <= pi[i - 1]
.
Скрытый текст
У меня в процессе написания статьи возник вопрос: а возможен ли случай, когда pi[i] == pi[i - 1]?
Немного подумав, я нашел пример:
В каком случае будет выполнено первое равенство? Только в том случае, если префикс и суффикс, совпадавшие на предыдущем шаге, увеличились и продолжили совпадать на текущем. То есть, если s[i] == s[pi[i - 1]]
. На рисунке ниже: желтая область увеличивается до красной, только если зеленые символы совпадают.
Давайте напишем код, который использует это наблюдение. Если сравниваемые символы не совпали - будем вызывать медленную функцию, написанную на первом этапе. Только теперь мы можем быть уверены, что при вызове медленной функции pi[i]
не будет превышать pi[i - 1]
. Можем передавать это число в функцию, чтобы ограничить перебор:
int pi_slow(string s, int max_suffix_len) {
int n = s.size();
for (int suffix_len = max_suffix_len; suffix_len > 0; suffix_len--) {
string suffix = s.substr(n - suffix_len, suffix_len);
string prefix_same_len = s.substr(0, suffix_len);
if (suffix == prefix_same_len) {
return suffix_len;
}
}
return 0;
}
vector<int> prefix_function(string s) {
int n = s.size();
vector<int> pi(n);
pi[0] = 0;
for (int i = 1; i < n; i++) {
int k = pi[i - 1];
if (s[i] == s[k]) {
k++;
} else {
k = pi_slow(s.substr(0, i + 1), k);
}
pi[i] = k;
}
return pi;
}
Я специально обозначил pi[i - 1]
за k
, чтобы нейминг был таким же, как в коде, к которому мы хотим прийти в конце (см. этап 3). Теперь становится понятнее, откуда в том коде взялось условие
if (s[i] == s[k]) {
k++;
}
Теперь нам осталось разобраться, как ускорить случай, когда
s[i] != s[pi[i - 1]]
.
Этап 5. Оптимизация
Итак, мы находимся в такой ситуации (зеленый и синий символы не совпали):
Теперь мы хотим найти такой максимальный префикс, который совпадет с суффиксом, заканчивающимся на s[i]
. Мы можем заметить, что такой префикс, за исключением последнего символа, будет полностью совпадать с каким-то суффиксом желтой области. На рисунке ниже: фиолетовые области совпадают, pi[i]
будет равно длине фиолетовой области вместе с синим символом.
Теперь, если мы научимся быстро находить максимальную фиолетовую область и проверять, не равен ли следующий после нее символ s[i]
(не является ли он синим), то мы существенно оптимизируем наш перебор. Как же нам быстро находить такую фиолетовую область? Попробуйте понять сами, прежде чем читать дальше.
Ключ к пониманию кроется в том, что не нужно забывать о равенстве желтых областей, нужно использовать этот факт. Мы можем дополнить рисунок следующим образом:
На прошлом рисунке было видно, что фиолетовая область есть и в начале, и в конце желтой. Теперь мы просто нарисовали ее в обеих желтых областях с двух сторон. После этого становится очевидным, что максимальная фиолетовая область будет равна функции от желтой области (по определению функции ).
Мы эту функцию уже посчитали на одном из прошлых шагов, потому что желтая область является одним из префиксов нашей строки. Желтая область, являющаяся префиксом, заканчивается на индексе pi[i - 1] - 1
, поэтому функция от нее лежит в
pi[pi[i - 1] - 1]
.
Что же нам делать, если синие символы не совпали? Искать следующую подходящую область!
Вспомним, что за k
мы обозначали pi[i - 1]
, тогда переход к следующей подходящей области будет выглядеть, как k = pi[k - 1]
. Оставлю последний рисунок, прежде чем у меня закончатся цвета:
До каких пор мы можем уменьшать k
? Пока оно не станет равным . Когда k
станет равно нулю, нужно проверить, не равен ли первый символ строки синему (s[i]
). Если равен, то тогда pi[i]
будет равно .
Итого, получаем следующий код:
vector<int> prefix_function(string s) {
int n = s.size();
vector<int> pi(n);
pi[0] = 0;
for (int i = 1; i < n; i++) {
int k = pi[i - 1];
while (k > 0) {
if (s[i] == s[k]) {
k++;
break;
}
k = pi[k - 1];
}
if (k == 0 && s[i] == s[0]) {
k = 1;
}
pi[i] = k;
}
return pi;
}
Если внимательно сравнить его с тем кодом, который я приводил в этапе 3, то можно понять, что они полностью эквиваленты:
vector<int> prefix_function(string s) {
int n = s.size();
vector<int> pi(n);
pi[0] = 0;
for (int i = 1; i < n; i++) {
int k = pi[i - 1];
while (k > 0 && s[i] != s[k]) {
k = pi[k - 1];
}
if (s[i] == s[k]) {
k++;
}
pi[i] = k;
}
return pi;
}
В итоговой версии внутри while
не происходит увеличение k
, оно вынесено в конец и объединено с проверкой в случае k == 0
.
Итак, я надеюсь, у меня получилось ответить на первый из вопросов, сформулированных в этапе 3:
По какой логике меняется значение
k
? И почему в итогеk
оказывается равноpi[i]
?
Остался последний!
Этап 6. Доказательство линейности
Почему, несмотря на цикл
while
внутри циклаfor
, алгоритм работает за линейное время?
На самом деле, ответ на этот вопрос гораздо легче, чем на первый. Однако все доказательства линейности, которые я видел, были сформулированы так, что у меня пухла голова, когда я пытался их осознать.
Сейчас я постараюсь сформулировать ту же мысль, которая есть во многих других источниках, но другими словами, более понятными лично мне. Надеюсь, вам тоже это поможет.
Итак, внутри цикла for
мы занимаемся тем, что увеличиваем и уменьшаем переменную k
. Причем увеличиваем мы ее всегда только на единицу за один проход, а уменьшаем несколько раз внутри цикла while
на неизвестную величину.
На всякий случай поясню, почему внутри while
переменная k
обязательно уменьшается: pi[k - 1] < k
, потому что по определению (функция меньше длины входной строки).
Далее заметим, что в конце тела цикла мы пишем pi[i] = k
, а в начале следующей итерации - k = pi[i - 1]
. То есть между итерациями значение k
не изменяется. Это очень важное наблюдение, отсутствие которого долго мешало мне осознать доказательство.
Из этого наблюдения мы делаем вывод, что переменная k
за все итерации увеличится максимум на , потому что всего у нас итераций, в каждой увеличение происходит максимум на , а между итерациями переменная k
не изменяется. То есть максимальное возможное значение k
- это (изначально у нас k = pi[0] = 0
).
Отсюда следует то, что уменьшиться переменная k
может максимум те же раз. Потому что в противном случае она оказалась бы меньше нуля, что невозможно.
И отсюда уже следует то, что суммарное количество итераций внутреннего цикла while
не превышает , потому что внутри каждой итерации мы гарантированно уменьшаем k
. То есть все итерации while
в сумме выполнятся за , а значит асимптотика всего алгоритма будет равна .
Что и требовалось доказать.
Этап 7. Заключение
На этом наш путь к просветлению подходит к концу.
Я надеюсь, что все написанное в статье было правдивым и понятным.
Спасибо, что дочитали до конца!
UPDATE
Как мне верно заметили в комментариях, переменную k
можно не переобъявлять на каждой итерации внешнего цикла, а объявить один раз перед циклом:
vector<int> prefix_function(string s) {
int n = s.size();
vector<int> pi(n);
pi[0] = 0;
int k = 0;
for (int i = 1; i < n; i++) {
while (k > 0 && s[i] != s[k]) {
k = pi[k - 1];
}
if (s[i] == s[k]) {
k++;
}
pi[i] = k;
}
return pi;
}
В таком виде линейность алгоритма становится ещё более наглядной.
Комментарии (24)
Zara6502
16.09.2024 05:25for (int i = 1; i < n; i++) { int k = pi[i - 1]; while (k > 0 && s[i] != s[k]) { k = pi[k - 1]; } if (s[i] == s[k]) { k++; } pi[i] = k; }
В общем читал-читал описание доказательства, и как отмечал выше оно мне непонятно в силу моих неспособностей, но вот смотрю на код и на ваше утверждение про
while
иn-1
и получается что у вас сложность O(n*n) так что вопрос линейного времени тут не самый главный.Вообще я не помню в KMP такого способа построения суффиксов, мне кажется там O(n) должно быть. Надо поискать свои старые исходники.
wataru
16.09.2024 05:25+3O(n) там. Потому что все циклы while на всех итерациях for суммарно выполнятся не более чем n раз. Потому что каждая итерация while уменьшает k но не перескакивает 0. А увеличивается k максимум n раз - по одному на каждой итерации for. Это называется амортизационный анализ.
Zara6502
16.09.2024 05:25Спасибо за разъяснение.
Подскажите еще, есть вот такая реализация KMP (C#):
/// <summary> /// Knuth–Morris–Pratt(KMP) string search implementation. /// </summary> public class Kmp { /// <summary> /// Returns the start index of first appearance /// of pattern in input string. /// Returns -1 if no match. /// </summary> public int Search(string input, string pattern) { var matchingInProgress = false; var matchIndex = new int[pattern.Length]; var j = 0; //create match index of chars //to keep track of closest suffixes in pattern that form prefix of pattern for (var i = 1; i < pattern.Length; i++) //prefix don't match suffix anymore if (!pattern[i].Equals(pattern[j])) { //don't skip unmatched i for next iteration //since our for loop increments i if (matchingInProgress) i--; matchingInProgress = false; //move back j to the beginning of last matched char j = matchIndex[j == 0 ? 0 : j - 1]; } //prefix match suffix so far else { matchingInProgress = true; //increment index of suffix //to prefix index for corresponding char matchIndex[i] = j + 1; j++; } matchingInProgress = false; //now start matching j = 0; for (var i = 0; i < input.Length; i++) if (input[i] == pattern[j]) { matchingInProgress = true; j++; //match complete if (j == pattern.Length) return i - pattern.Length + 1; } else { //reduce i by one so that next comparison won't skip current i //which is not matching with current j //since our for loop increments i if (matchingInProgress) i--; matchingInProgress = false; //jump back to closest suffix with prefix of pattern if (j != 0) j = matchIndex[j - 1]; } return -1; } }
И тут нет классической схемы как в статье, этот вариант лучше/хуже? Я понимаю что оба варианта по сложности будут O(n) но важно так же это 129*n против 5*n или нет.
wataru
16.09.2024 05:25+1Это практически тоже самое, только там цикл
while
заменен на адскую конструкцию изif
иi--
. Очень плохой подход, в этом коде весьма сложно разобраться.matchIndex
- это та же префикс функция. Тут теже самые увеличения на 1 и переход назад по префикс функции.Ну и, тут применена оптимизация: вместо подсчета префикс функции для текста "pattern$text" тут считается префикс функция только для шаблона, а потом тот же код крутится для текста, но уже ни в какой массив она не сохраняется, потому что префикс функция не может стать больше длины шаблона. Поэтому тут два последовательных цикла for. Это на скорость не влияет практически, но уменьшает потребление памяти.
Zara6502
16.09.2024 05:25Очень плохой подход, в этом коде весьма сложно разобраться
ну это часть библиотеки, её задача работать максимально оптимально. А понимать чужой код я вообще не могу (практически) так что для меня оба варианта одинаковые.
wataru
16.09.2024 05:25+1ну это часть библиотеки, её задача работать максимально оптимально.
Ну авторам библиотеки же приходилось этот код читать, хотя бы во время работы над библиотекой?
Потом, этот подход нисколько не оптимальнее. Автор сэкономил на одном цикле, казалось бы, но на самом деле там теперь на каждую "итерацию" внутреннего while аж 2 проверки: итерация в for и проверка условия if. С циклом while же там только одна проверка, а проверки итеарции по i проходят только по одному разу. Так что даже с точки зрения количества операций там все хуже.
А уж как пляски с индексом i туда-сюда путают всякие спекулятивные подгрузки и исполнение в современных процессорах - это вообще загадка. Этот код может быть на порядки медленнее.
Ну и библиотечный код обычно все-таки стараются вылизывать, ведь это не для себя, это для людей. И куча глаз будет его читать так или иначе. Библиотека не самого высокого класса.
Zara6502
16.09.2024 05:25+2Ну авторам библиотеки же приходилось этот код читать, хотя бы во время работы над библиотекой?
я не считаю что красота кода - это важное для работы библиотеки, вся суть разработки за последние 50 лет сводилась к использованию тех или иных хаков, что положительно сказывается на скорости или потреблении памяти. Красота кода - это новое современное явление, даже 10 лет назад об этом было сильно меньше разговоров чем сегодня. А авторам вообще удобно так, как им удобно, а не как тут мы на хабре решим. (и автор, насколько я помню - один)
Потом, этот подход нисколько не оптимальнее. Автор сэкономил на одном цикле, казалось бы, но на самом деле там теперь на каждую "итерацию" внутреннего while аж 2 проверки: итерация в for и проверка условия if. С циклом while же там только одна проверка, а проверки итеарции по i проходят только по одному разу. Так что даже с точки зрения количества операций там все хуже.
Ну это другой разговор, вы же раньше написали что
Это практически тоже самое
иуменьшает потребление памяти
что "немного" отличается отэтот подход нисколько не оптимальнее
хотя я изначально об этом и спрашивал.А уж как пляски с индексом i туда-сюда путают всякие спекулятивные подгрузки и исполнение в современных процессорах - это вообще загадка. Этот код может быть на порядки медленнее.
Без бенчмарков разговоры о "порядках" не имеют смысла. И я скоро выпущу статью про черепах в лабиринте, наконец-то, и там ваши слова и код сильно разнятся по финальной производительности, так что я вам в голословных утверждениях несколько не доверяю.
Ну и библиотечный код обычно все-таки стараются вылизывать, ведь это не для себя, это для людей
Ну мы по-разному понимаем это. Для меня библиотечный код - это то, куда я точно не полезу, поэтому мне нет никакой разницы через while оно сделано или через if. Я установлю библиотеку, прогоню бенчи и сделаю выбор, а сколько кодеров-перфекционистов при этом пострадало - не важно.
И куча глаз будет его читать так или иначе. Библиотека не самого высокого класса.
У автора не было цели создавать библиотеку самого высокого класса. Он её писал для себя и много лет ей пользовался, а потом оформил в виде пакета. Человек проделал огромный труд для себя и поделился с другими, можете не пользоваться, никто не принуждает же.
99% программистов-перфекционистов вообще никаких библиотек не пишут и ничего не публикуют - так какая от них польза, только что прочитать какие все плохие?
wataru
16.09.2024 05:25+1уменьшает потребление памяти
Уменьшение потребления памяти - это другая история. То же самое обычно делают и в той же реализации, что и у автора в статье, разбивая цикл for на 2 последовательных цикла. К извращению
while -> if,i--
это не имеет отношения.Zara6502
16.09.2024 05:25ну мне не сильно важно к чему это имеет отношение, ответ либо полный либо нет.
wataru
16.09.2024 05:25Я дал вам полный ответ. Там написан тот же самый алгоритм, только: извращенный control flow и вместо поиска префикс функции в "pattern$text" тут два последовательных цикла for по разным частям текста.
Zara6502
16.09.2024 05:25резюмирую - разница отсутствует, но может быть влияние на производительность на некоторых ЦПУ из-за системы предсказания выполнения.
malkovsky
16.09.2024 05:25Так вроде бы KMP != построение префикс-функции для "pattern$text". KMP -- это префикс-функция для pattern и проход без доп. памяти по text, так что два последовательных цикла как раз каноничны, а конструкция "pattern$text" используется исключительно в образовательных целях
lozhnikov Автор
16.09.2024 05:25В этой реализации ищется только первое вхождение подстроки, а не все имеющиеся
suns
16.09.2024 05:25Тут каждый шаг амортизированный
Вот как это выглядит:
Мы всегда на каждом шаге берем предыдущее значение k
Каждый заход в цикл while (k>0…){…} уменьшает текущее значение k минимум на 1
Таким образом если на шаге i мы уменьшили k на d (d<k), то следующий шаг может выполнить максимум O(k-d) операций (и далее по индукции можно доказать, что оно сходится к амортизированной O(1)
Zara6502
16.09.2024 05:25спасибо за объяснение, но я не понимаю что такое pi и почему так происходит, я смотрел только на два цикла и фразу автора про
n-1
.
Panzerschrek
16.09.2024 05:25Каждый раз, пытаясь разобраться в этом алгоритме, понимаю, что это СЛОЖНА и забиваю.
В обычных условиях поиск подстроки в строке и так достаточно быстрый, чтобы упражняться в хитромудрых алгоритмах, придуманных в древности седовласыми мудрецами. Мой Grug мозг больше склоняется к простым, но понятным, а в значительном количестве случаев более быстрым решениям.
Самый простой и понятный способ ускорить - сравнивать сразу первые 4-8 символов за раз, храня их в единственной переменной и используя хитрости битовых сдвигов. Это позволяет отфильтровать подавляющее большинство случаев, когда вхождение подстроки не найдено.
Ещё один подход - скользящее окно с XOR символов. Если этот XOR совпадает с таковым для искомой строки - можно проводить более детальное сравнение, которое в отличие от XOR учитывает порядок символов. На практике это тоже позволяет находить подстроку достаточно эффективно, т. к. совпадение XOR при несовпадении строки - достаточно редко. Дополнительно можно комбинировать этот подход с предыдущим.wataru
16.09.2024 05:25В обычных условиях поиск подстроки в строке и так достаточно быстрый, чтобы упражняться в хитромудрых алгоритмах,
Даже на коротких строках это дает ускорение в несколько раз над наивным алгоритмом. И если вы строки много ищите, даже короткие, имеет смысл применить оптимизацию. И это точно не повиснет на каких-нибудь патологических тестах, как все ваши последующие предложения.
Ещё один подход - скользящее окно с XOR символов.
Это здравая идея. Это чуть-чуть допилить, и будет метод полиномиального хеширования.
MapleBloom
16.09.2024 05:25+1Спасибо за материал!
Прошу объяснить зачем при каждом заходе в for делается присвоение
int k = pi[i - 1];
ведь значение k наследуется из предыдущей итерации. Достаточно до входа в цикл задать
int k = 0.
Это особенность плюсов?
lozhnikov Автор
16.09.2024 05:25Очень верное замечание!
Так действительно можно сделать, и это, как мне кажется, сильно упростило бы понимание линейности. Но почему-то во всех виденных мной источниках предпочитают объявлять k заново на каждой итерации.
Добавил обновление в статью с вашим замечанием. Спасибо
funny_falcon
16.09.2024 05:25+1И отсюда же следует, что для поиска первого вхождения подстроки нужен массив только для паттерна. А для итерационного нахождения всех подстрок, считать, что паттерн совпал только что при продолжении поиска.
А вообще, огромное спасибо! Очень круто всё разложено по полочкам! Вы сделали моё утро!
Zara6502
KMP люблю и сам его писал в 90-00-е еще до изучения в 2018, но совсем не понимаю теоретическую составляющую с функциями и пи. Алгоритм очень интересный и для меня всегда было загадкой почему не во всех языках он реализован в стандартных библиотеках при работе со строками.
lozhnikov Автор
Отличный повод разобраться)
Zara6502
не, я это не понимал в школе, не понимал в вузе, а на старости оно мне точно не нужно.