E098. Z-функция строки и её вычисление
emaxx algorithm
Task
Источник: e-maxx.ru/algo, страница PDF 287.
Пусть дана строка
длины
. Тогда Z-функция ("зет-функция") от этой строки — это массив длины
,
-ый
элемент которого равен наибольшему числу символов, начиная с позиции
, совпадающих с первыми символами строки
.
Иными словами,
— это наибольший общий префикс строки
и её
-го суффикса. Примечание. В данной статье, во избежание неопределённости, мы будем считать строку 0-индексированной — т.
е. первый символ строки имеет индекс
, а последний —
.
Первый элемент Z-функции,
, обычно считают неопределённым. В данной статье мы будем считать, что он равен нулю (хотя ни в алгоритме, ни в приведённой реализации это ничего не меняет). В данной статье приводится алгоритм вычисления Z-функции за время
, а также различные применения
этого алгоритма.
Примеры
Приведём для примера подсчитанную Z-функцию для нескольких строк:
●
:
●
:
●
:
Тривиальный алгоритм
Формальное определение можно представить в виде следующей элементарной реализации за :
vector<int> z_function_trivial (string s) {
int n = (int) s.length();
vector<int> z (n);
for (int i=1; i<n; ++i)
while (i + z[i] < n && s[z[i]] == s[i+z[i]])
++z[i];
return z;
}
Мы просто для каждой позиции
перебираем ответ для неё
, начиная с нуля, и до тех пор, пока мы не
обнаружим несовпадение или не дойдём до конца строки. Разумеется, эта реализация слишком неэффективна, перейдём теперь к построению эффективного алгоритма.
Эффективный алгоритм вычисления Z-функции
Чтобы получить эффективный алгоритм, будем вычислять значения
по очереди — от
до
, и при
этом постараемся при вычислении очередного значения
максимально использовать уже вычисленные значения.
Назовём для краткости подстроку, совпадающую с префиксом строки
, отрезком совпадения.
Например, значение искомой Z-функции
— это длиннейший отрезок совпадения, начинающийся в позиции
(и заканчиваться он будет в позиции
).
Для этого будем поддерживать координаты
самого правого отрезка совпадения, т.е. из
всех обнаруженных отрезков будем хранить тот, который оканчивается правее всего. В некотором смысле, индекс
—
это такая граница, до которой наша строка уже была просканирована алгоритмом, а всё остальное — пока ещё не известно. Тогда если текущий индекс, для которого мы хотим посчитать очередное значение Z-функции, — это
, мы имеем один
из двух вариантов:
●
— т.е. текущая позиция лежит за пределами того, что мы уже успели обработать.
Тогда будем искать
тривиальным алгоритмом, т.е. просто пробуя значения
,
, и т.
д. Заметим, что в итоге, если
окажется
, то мы будем обязаны обновить координаты самого правого
отрезка
— т.к.
гарантированно окажется больше
.
●
— т.е. текущая позиция лежит внутри отрезка совпадения
. Тогда мы можем использовать уже подсчитанные предыдущие значения Z-функции, чтобы
проинициализировать значение
не нулём, а каким-то возможно бОльшим числом.
Для этого заметим, что подстроки
и
совпадают. Это означает, что в качестве
начального приближения для
можно взять соответствующее ему значение из отрезка
, а
именно, значение
.
Однако значение
могло оказаться слишком большим: таким, что при применении его к позиции
оно
"вылезет" за пределы границы
. Этого допустить нельзя, т.к. про символы правее
мы ничего не знаем, и они
могут отличаться от требуемых. Приведём пример такой ситуации, на примере строки:
Когда мы дойдём до последней позиции (
), текущим самым правым отрезком будет
. Позиции
с
учётом этого отрезка будет соответствовать позиция
, ответ в которой равен
. Очевидно, что
таким значением инициализировать
нельзя, оно совершенно некорректно. Максимум, каким значением мы
могли проинициализировать — это
, поскольку это наибольшее значение, которое не вылазит за пределы отрезка .
Таким образом, в качестве начального приближения для
безопасно брать только такое выражение:
Проинициализировав
таким значением
, мы снова дальше действуем тривиальным алгоритмом
— потому что после границы
, вообще говоря, могло обнаружиться продолжение отрезка совпадение, предугадать которое одними лишь предыдущими значениями Z-функции мы не могли. Таким образом, весь алгоритм представляет из себя два случая, которые фактически различаются
только начальным значением
: в первом случае оно полагается равным нулю, а во втором
— определяется по предыдущим значениям по указанной формуле. После этого обе ветки алгоритма сводятся к выполнению тривиального алгоритма, стартующего сразу с указанного начального значения. Алгоритм получился весьма простым. Несмотря на то, что при каждом
в нём так или иначе выполняется
тривиальный алгоритм — мы достигли существенного прогресса, получив алгоритм, работающий за линейное время. Почему это так, рассмотрим ниже, после того, как приведём реализацию алгоритма.
Реализация
Реализация получается весьма лаконичной:
vector<int> z_function (string s) {
int n = (int) s.length();
vector<int> z (n);
for (int i=1, l=0, r=0; 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)
l = i, r = i+z[i]-1;
}
return z;
} Прокомментируем эту реализацию. Всё решение оформлено в виде функции, которая по строке возвращает массив длины — вычисленную Z-функцию.
Массив
изначально заполняется нулями. Текущий самый правый отрезок совпадения полагается равным , т.
е. заведомо маленький отрезок, в который не попадёт ни одно
.
Внутри цикла по
мы сначала по описанному выше алгоритму определяем начальное значение
— оно либо останется нулём, либо вычислится на основе приведённой формулы. После этого выполняется тривиальный алгоритм, который пытается увеличить значение
настолько, насколько
это возможно. В конце выполняется обновление текущего самого правого отрезка совпадения
, если, конечно, это
обновление требуется — т.е. если
.
Асимптотика алгоритма
Докажем, что приведённый выше алгоритм работает за линейное относительно длины строки время, т.е. за . Доказательство очень простое.
Нас интересует вложенный цикл
— т.к. всё остальное — лишь константные операции, выполняемые
раз.
Покажем, что каждая итерация этого цикла
приведёт к увеличению правой границы
на единицу. Для этого рассмотрим обе ветки алгоритма:
●
В этом случае либо цикл
не сделает ни одной итерации (если
), либо же сделает несколько
итераций, продвигаясь каждый раз на один символ вправо, начиная с позиции
, а после этого — правая граница
обязательно обновится.
Поскольку
, то мы получаем, что действительно каждая итерация этого цикла увеличивает новое значение
на единицу.
●
В этом случае мы по приведённой формуле инициализируем значение
некоторым числом
. Сравним это
начальное значение
с величиной
, получаем три варианта: