Бордеры и префикс-функция
Префикс, равный суффиксу. Одна лемма, из которой выводится весь алгоритм, и внутренний цикл, который выглядит квадратичным, но не является им.
4 мин
Бордер строки — это её префикс, совпадающий с суффиксом той же длины.
У строки abacaba бордеры — a, aba и вся строка целиком. Последний вариант неинтересен, поэтому дальше речь только о собственных бордерах: тех, что короче всей строки.
Префикс-функция — длина наибольшего собственного бордера префикса .
Для abacaba она равна .
Лемма, из которой всё следует
При дописывании одного символа наибольший бордер удлиняется не более чем на единицу: .
Доказательство от противного. Пусть у префикса длины нашёлся бордер длины хотя бы . Отбросим у него последний символ спереди и сзади — получится бордер префикса длины длины хотя бы . Но — наибольший. Противоречие.
Отсюда сразу видно, что делать: у нас есть кандидат , и если он не подошёл, надо перебирать кандидатов по убыванию.
Какие кандидаты перебирать
Наивно можно было бы уменьшать длину на единицу. Это работает, но медленно — и лишнее.
Ключевое наблюдение: если строка длины является бордером префикса, то следующий по величине кандидат — это , наибольший бордер самого этого бордера.
Почему при этом ничего не теряется. Пусть есть бордер длины , где . Он одновременно префикс и суффикс всей рассматриваемой строки, а раз он короче , то лежит и внутри бордера длины — и там тоже является одновременно префиксом и суффиксом. Значит, — бордер строки длины , и . Противоречие.
То есть все бордеры строки — это в точности цепочка до нуля. Перебирать что-то между её звеньями бессмысленно.
Код
vector<int> prefixFunction(const string& s) {
int n = s.size();
vector<int> pi(n, 0);
for (int i = 1; i < n; i++) {
int len = pi[i - 1]; // кандидат: бордер предыдущего префикса
while (len > 0 && s[i] != s[len]) len = pi[len - 1];
if (s[i] == s[len]) len++;
pi[i] = len;
}
return pi;
}
Десять строк. Проверено: на 40 000 случайных строк результат совпал с прямым поиском наибольшего бордера перебором длин.
Почему это линия, а не квадрат
Внутри for стоит while, который в теории может прокрутиться раз. Но суммарно по всем итерациям он сделает не больше шагов.
Следите за величиной len. Каждая итерация внешнего цикла увеличивает её не более чем на единицу — это ровно та лемма, с которой мы начали. Каждый шаг внутреннего while уменьшает её хотя бы на единицу. Уменьшить в сумме больше, чем прибавили, нельзя, а прибавили не больше .
Тот же приём, что в очереди на двух стеках и в стеке ближайших меньших: считаем не отдельную операцию, а суммарную работу.
Измерено — сколько шагов внутренний цикл делает на самом деле:
| строка | длина | шагов | доля от |
|---|---|---|---|
aaaa… |
1 000 000 | 0 | 0,00 |
| строка Фибоначчи | 1 346 269 | 27 | 0,00 |
| случайная, алфавит 26 | 1 000 000 | 38 282 | 0,04 |
| случайная, алфавит 2 | 1 000 000 | 420 611 | 0,42 |
| подобранная периодическая | 200 000 | 198 293 | 0,99 |
Оценка «не больше » — не запас, а почти точная граница: на специально подобранной периодической строке доля доходит до 0,99. Зато aaaa… и строка Фибоначчи, которые кажутся тяжёлыми, обходятся почти без откатов: там len растёт монотонно.
Что ещё сразу известно
Наименьший период строки равен .
Это тот же критерий, что в статье про период: — период тогда и только тогда, когда строка совпадает с собой, сдвинутой на . А сдвиг на совпадает ровно тогда, когда есть бордер длины . Наибольшему бордеру отвечает наименьший период.
Через хеши то же самое ищется за перебором сдвигов; префикс-функция даёт ответ за и без вероятностных допущений.
Все периоды — это минус длины всех бордеров, то есть та самая цепочка
Сколько раз каждый префикс встречается в строке считается одним обратным проходом по : прибавляем счётчик позиции к счётчику её бордера, идя справа налево.