Appearance
Z-функция и префикс-функция
1. Z-функция
1.1. Определение
Пусть дана строка
Формально:
Значение
Пример:
Здесь подстрока, начинающаяся с позиции 4 (aba), совпадает с префиксом строки длины 3.
Z-функция удобна там, где нужно быстро понять, «насколько далеко» продолжается совпадение произвольного суффикса строки с её началом.
1.2. Построение за
Наивное вычисление
Идея. Будем идти по строке слева направо и поддерживать z-блок — самую правую (по правой границе) из уже найденных подстрок, совпадающих с префиксом. Обозначим его границы
Пусть мы хотим вычислить
лежит правее z-блока ( ). Тогда о содержимом строки начиная с нам ничего не известно, и мы вычисляем наивно — посимвольным сравнением с , начиная с . После этого, если новый отрезок оказался длиннее текущего z-блока, объявляем его новым z-блоком: , . лежит внутри z-блока ( ). Тогда символы на отрезке совпадают с символами префикса — мы это уже знаем, не сравнивая их заново. Значит, можно использовать ранее вычисленное значение : - Если
(то есть найденное ранее совпадение не упирается в правую границу блока), то гарантированно — увеличить это значение нельзя, потому что символ сразу за пределами блока в исходном совпадении по построению отличался от нужного. - Если
, то мы знаем только то, что (совпадение внутри блока гарантировано), а дальше, за пределами блока, нужно дополнительно сравнивать посимвольно, увеличивая значение, пока символы совпадают. После этого при необходимости обновляем z-блок.
- Если
cpp
vector<int> z_function(string s) {
int n = (int) s.size();
vector<int> z(n, 0);
int l = 0, r = 0;
for (int i = 1; i < n; i++) {
if (i <= r)
z[i] = min(r - i + 1, z[i - l]);
while (i + z[i] < n && s[z[i]] == s[i + z[i]])
z[i]++;
if (i + z[i] - 1 > r) {
r = i + z[i] - 1;
l = i;
}
}
return z;
}1.3. Доказательство
Ключевое наблюдение: правая граница z-блока
Разобьём работу алгоритма на два вида операций:
Операции с
временем — вычисление значения по формуле , когда и результат не требует досчёта. Таких операций ровно (по одной на итерацию цикла). Операции посимвольного сравнения (тело
while) — каждое такое сравнение либо заканчивается неудачей (и тогда циклwhileдля данногосразу завершается — таких неудачных сравнений не больше одного на итерацию, то есть не больше суммарно), либо заканчивается успехом и увеличивает на единицу.
Так как
Таким образом, суммарная работа алгоритма составляет
2. Префикс-функция
2.1. Определение
Префикс-функцией строки
Формально:
Например, для строки abcdabcabcdabcdab префикс-функция выдаст такой массив
2.2. Построение за
Алгоритм опирается на уже вычисленные значения
Идея. Пусть
- Если
— совпадение продлевается: , и . - Если
— нужно попробовать более короткое совпадение. Мы «откатываемся» по цепочке префикс-функции: (это следующий по длине кандидат — префикс, который сам является суффиксом текущего совпадения), и повторяем сравнение с . Откат продолжается, пока либо не найдётся совпадение, либо не станет равным . - Если после отката с
символы всё ещё не совпадают, .
cpp
vector<int> prefix_function(string s) {
int n = (int) s.size();
vector<int> pi(n, 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;
}2.3. Доказательство
Аналогично Z-функции, доказательство начинается с растущей величины — переменной
Заметим:
- На каждой итерации цикла
forпеременнаяувеличивается не более чем на 1 - Значит, суммарный прирост
за весь алгоритм не превышает , так как всего итераций внешнего цикла - Каждая итерация внутреннего цикла
whileуменьшает(переход к ) - Так как
не может стать отрицательным, а суммарно оно увеличивалось не более раз, то и уменьшиться оно может суммарно не более раз за весь проход
Отсюда суммарное число итераций внутреннего цикла while по всем внешним итерациям не превышает
3. Алгоритм Кнута–Морриса–Пратта (КМП)
Задача: дан текст
Идея: использовать префикс-функцию, чтобы при несовпадении символа не откатывать указатель по тексту назад как в наивном переборе, а сразу сдвигать образец на максимально возможную длину, используя уже накопленную информацию о совпавшей части.
Наиболее простой в реализации способ — свести поиск подстроки к вычислению префикс-функции одной составной строки:
где
Вычисляем префикс-функцию
Если на какой-то позиции
По определению,
Сложность:
Тот же результат можно получить, обходя текст
Как только
Оба варианта опираются на один и тот же факт: при несовпадении символа мы никогда не потеряем уже сопоставленную информацию — благодаря свойствам префикс-функции сдвиг образца всегда происходит на корректную величину, не пропуская ни одного возможного вхождения.
4. Сравнение с хэш-подходом: детерминированность против простоты
Z/префикс-функция и полиномиальное хэширование решают во многом одни и те же задачи — сравнение подстрок и поиск вхождений, — но опираются на разную природу вычислений, и это определяет, когда что уместнее использовать.
Начнем с очевидного, хэширование не дает детерминированного результата и всегда существует вероятность коллизий. А используя Z- и префикс-функции, совпадение, найденное этими структурами, гарантированно означает равенство подстрок, без каких-либо вероятностных допущений.
С точки зрения простоты реализации ситуация обратная. Хэширование пишется буквально в несколько строк: один проход для построения префиксных хэшей, умножение и сложение по модулю — и дальше сравнение любых двух подстрок выполняется за
Хэширование при этом гораздо универсальнее. Как только для строки посчитаны префиксные хэши, с ними можно делать что угодно: сравнивать произвольные подстроки, класть их в хэш-таблицы, использовать в бинарном поиске по длине совпадения. Z- и префикс-функция заточены именно под задачи о "самоперекрытии" строки: поиск образца, нахождение периодов, подсчёт различных подстрок.
Важно учитывать безопасность алгоритма. Z- и префикс-функция не зависят от каких-либо параметров и работают одинаково для любой строки. У хэширования же можно подобрать такие строки, вызывающие коллизии