Алгоритм Манакера: поиск всех палиндромов за O(n)

Алгоритм Манакера (Manacher, 1975) находит все максимальные палиндромные подстроки строки длины за честное - линейно, без логарифмов, без амортизаций «в среднем». Тот же результат позволяет за один проход решить классическую задачу о длиннейшей палиндромной подстроке (longest palindromic substring, LeetCode 5), на которую наивный алгоритм тратит , а решение через хеши - или только в среднем. Манакер же даёт детерминированную верхнюю оценку при минимальных накладных расходах.
Зачем нужен отдельный алгоритм для палиндромов
Палиндромные подстроки устроены сложнее обычных. У них два типа - нечётные (центр в символе, как ) и чётные (центр между символами, как ). Перебор всех центров с расширением вправо-влево даёт в худшем случае (на строке ). Хеширование с бинарным поиском по радиусу - , но всё равно работает «слепо», не используя тот факт, что палиндромы в строке наводятся друг на друга через симметрию.
Манакер заметил: если мы уже нашли длинный палиндром с центром и правым краем , любая позиция имеет «зеркало» внутри уже исследованной области. Палиндром в зеркале даёт нижнюю оценку на палиндром в почти бесплатно. Дальше нужно только доуточнять явным расширением, а суммарное число таких расширений за весь прогон . Отсюда линейность.
Унификация чётных и нечётных палиндромов
Чтобы не писать два почти одинаковых цикла, перед запуском между всеми символами вставляют разделитель, который не встречается в алфавите - обычно #. Строку превращают в
Длина новой строки - . Любой палиндром в , чётный или нечётный, становится в нечётным с центром на символе или на разделителе. Центр на букве - это исходный нечётный палиндром, центр на # - исходный чётный. Удобно: дальше работаем только с нечётными.
Граничные символы часто оборачивают в пару сторожей вида ^ и $, чтобы не проверять выход за границы явно - расширение остановится само, потому что ^ ≠ $.
Массив радиусов
Главная структура алгоритма - массив длины , где - радиус максимального палиндрома с центром в (не считая сам центр). То есть подстрока - палиндром, а .
Если в строке с разделителями, то в исходной строке палиндром имеет длину ровно (это не опечатка: за счёт расщепления символов разделителями радиус в численно равен длине палиндрома в ).
Зная весь , можно ответить на любой вопрос о палиндромах: длиннейший - с восстановлением позиции; проверка «есть ли палиндром длины » - за один линейный проход.
Зеркальный трюк: использование и
Алгоритм поддерживает два указателя на лету:
- - центр того палиндрома среди найденных, у которого правая граница максимальна;
- - сама правая граница (индекс самого правого символа внутри этого палиндрома).
Когда мы дошли до позиции и хотим вычислить , рассматриваем два случая.
Случай . Внутри известного палиндрома с центром зеркальная позиция . Палиндром, центрированный в , уже вычислен - у него радиус . По симметрии относительно палиндром той же длины помещается и в , если не выходит за правую границу . Стартовое значение:
Случай . Полезной информации из зеркала нет, стартуем с .
Дальше - явное расширение: пока , увеличиваем на 1. После расширения, если , обновляем , - теперь правая граница «съехала» дальше.
Псевдокод с инвариантом
s' = '#' + '#'.join(s) + '#' # длина 2n + 1
p = [0] * len(s')
c, r = 0, 0 # инвариант: r = c + p[c], максимальный по r центр
for i in 1..len(s') - 1:
if i < r:
p[i] = min(r - i, p[2c - i])
while i - p[i] - 1 >= 0
and i + p[i] + 1 < len(s')
and s'[i - p[i] - 1] == s'[i + p[i] + 1]:
p[i] += 1
if i + p[i] > r:
c, r = i, i + p[i]
Инвариант перед итерацией: - самая правая позиция, которую покрывает уже найденный палиндром с центром в . После итерации инвариант сохраняется, потому что мы обновляем только если - то есть когда нашли палиндром, чей правый край ещё правее.
Почему это
Ключевой аргумент - амортизация числа явных расширений. Каждое успешное расширение на шаге увеличивает значение как минимум на единицу: если расширение прошло, это значит, что мы вышли за прежний правый край (иначе зеркальный трюк дал бы максимум и расширение не понадобилось бы). Переменная монотонно неубывающая и ограничена сверху . Значит, суммарное число успешных расширений за весь прогон не превосходит .
Неуспешное расширение (то, на котором цикл while остановился) - ровно одно на итерацию, итого . Все остальные операции - константа на итерацию. Итого операций, дополнительной памяти под массив .
Большинство «линейных» алгоритмов на строках (KMP, Z-функция) амортизируется через монотонно растущий указатель - Манакер из той же семьи доказательств.
Сравнение с другими подходами
Наивный . Перебор всех центров с расширением. На строках длины до работает мгновенно, но ломается на : каждый центр даёт радиус , итого .
Хеши + бинарный поиск по радиусу. Для каждого центра двоичным поиском по длине палиндрома проверяем равенство префикса и его реверса через полиномиальный хеш, суммарно. Требует аккуратной работы с коллизиями (double hashing) и не даёт детерминированного результата.
Eertree (palindromic tree, Rubinstein-Shur, 2014). Хранит все различные палиндромные подстроки в специальной структуре: узлов и построения, поддерживает онлайн-добавление символа. Богаче Манакера - можно считать частоты, искать вторые максимумы, - но реализация заметно сложнее. На одиночном прогоне Манакер выигрывает по константе.
Применения
- Longest palindromic substring (LeetCode 5). Каноническая задача собеседования. Манакер даёт - лучшая известная асимптотика. После прогона и подстрока , очищенная от
#, - ответ. - Биоинформатика, палиндромы в ДНК. Молекулярные палиндромы - участки, где последовательность совпадает со своим обратным комплементом (, ). После замены каждого нуклеотида на пару с комплементом задача сводится к обычным палиндромам, и Манакер находит сайты рестрикции за один линейный проход по геному.
- Сжатие. Палиндромные периоды используются в специализированных схемах сжатия повторяющихся текстов и в обнаружении дубликатов с реверсной симметрией.
- Подзадача в более сложных алгоритмах. Манакер вызывается как чёрный ящик в задачах «разрезать строку на минимальное число палиндромов» (palindrome partitioning DP), «посчитать палиндромные подстроки», «есть ли палиндром длины с началом в позиции ».
Частые ошибки
- Пишут два цикла отдельно для чётных и нечётных палиндромов. Удваивает код и повышает вероятность бага. Унификация через
#стоит трёх строк. - Делают без . Получается переоценка радиуса: палиндром в зеркале может выходить за левую границу палиндрома с центром , и тогда симметрия не гарантирует совпадения за пределами . Алгоритм начнёт «выдумывать» палиндромы.
- Не проверяют выход за границы строки в
while. Без сторожевых символов или явной проверки получаем IndexError на палиндромах до края. Обёртка^и$решает это элегантнее условия в цикле. - Используют Манакер на двоичных строках, ожидая выигрыша против eertree. Для большого числа однотипных запросов eertree лучше - он кэширует все палиндромы. Манакер хорош для одиночного прогона.
FAQ
Чем алгоритм Манакера отличается от наивного поиска палиндромов? Наивный для каждого центра запускает расширение независимо, получая на строках с длинными палиндромами. Манакер использует уже найденные палиндромы: если позиция лежит внутри известного палиндрома с центром , её зеркало даёт стартовое значение радиуса бесплатно. Суммарное число явных расширений ограничено , отсюда против .
Зачем вставлять # между символами?
Чтобы унифицировать чётные и нечётные палиндромы. Без разделителей пришлось бы вести два массива и и писать два цикла; с разделителями любой палиндром в оказывается нечётным с центром либо на символе (исходный нечётный), либо на # (исходный чётный). Длина в численно равна длине в .
Можно ли построить Манакер онлайн, добавляя символы по одному? Базовый алгоритм - нет: при добавлении символа справа правый край может сдвинуться, и значения для уже обработанных остаются актуальными, но новые палиндромы могут пересекать уже посчитанную область странным образом. Для онлайн-режима используют eertree - он специально спроектирован под потоковое добавление.
Коротко
Алгоритм Манакера находит все максимальные палиндромные подстроки строки длины за . Идея: вставка разделителей # унифицирует чётные и нечётные палиндромы, массив радиусов заполняется с использованием зеркальной симметрии относительно текущего центра и правого края - стартовое значение , дальше доуточнение явным расширением. Линейность доказывается через монотонность : суммарное число успешных расширений не превосходит длины строки. На LeetCode 5, поиске сайтов рестрикции в ДНК и подзадачах palindrome partitioning Манакер - стандартный инструмент; альтернативный eertree богаче, но сложнее в реализации.
Читайте также

Алгоритм Рабина-Карпа: поиск подстроки за O(n+m)
Разбираем алгоритм Рабина-Карпа: как полиномиальный хеш и скользящее окно ускоряют поиск подстроки до O(n+m) в среднем, почему бывают ложные совпадения и при чём тут плагиат.

Алгоритм Ахо-Корасик: поиск множества образцов в тексте
Разбираем алгоритм Ахо-Корасик: как из бора паттернов и суффиксных ссылок собрать автомат и найти все вхождения множества образцов в тексте за один линейный проход.

Алгоритм Кнута-Морриса-Пратта: поиск подстроки за O(n+m)
Алгоритм Кнута-Морриса-Пратта ищет подстроку без возвратов по тексту. Разбираем префикс-функцию по шагам, оцениваем сложность и смотрим понятный пример работы.