EssayAI
Блог
Блог
Математика и алгоритмы

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

4 февраля 2026Время чтения: 9 минут
#алгоритмы#строки#палиндромы#длиннейшая палиндромная подстрока
Алгоритм Манакера: поиск всех палиндромов за O(n)

Алгоритм Манакера (Manacher, 1975) находит все максимальные палиндромные подстроки строки длины nn за честное O(n)O(n) - линейно, без логарифмов, без амортизаций «в среднем». Тот же результат позволяет за один проход решить классическую задачу о длиннейшей палиндромной подстроке (longest palindromic substring, LeetCode 5), на которую наивный алгоритм тратит O(n2)O(n^2), а решение через хеши - O(nlogn)O(n \log n) или O(n)O(n) только в среднем. Манакер же даёт детерминированную верхнюю оценку при минимальных накладных расходах.

Зачем нужен отдельный алгоритм для палиндромов

Палиндромные подстроки устроены сложнее обычных. У них два типа - нечётные (центр в символе, как aba\text{aba}) и чётные (центр между символами, как abba\text{abba}). Перебор всех центров с расширением вправо-влево даёт O(n2)O(n^2) в худшем случае (на строке aaaaaa\text{aaaaaa}). Хеширование с бинарным поиском по радиусу - O(nlogn)O(n \log n), но всё равно работает «слепо», не используя тот факт, что палиндромы в строке наводятся друг на друга через симметрию.

Манакер заметил: если мы уже нашли длинный палиндром с центром cc и правым краем rr, любая позиция i[c,r]i \in [c, r] имеет «зеркало» i=2cii' = 2c - i внутри уже исследованной области. Палиндром в зеркале даёт нижнюю оценку на палиндром в ii почти бесплатно. Дальше нужно только доуточнять явным расширением, а суммарное число таких расширений за весь прогон n\leq n. Отсюда линейность.

Унификация чётных и нечётных палиндромов

Чтобы не писать два почти одинаковых цикла, перед запуском между всеми символами вставляют разделитель, который не встречается в алфавите - обычно #. Строку s=abbas = \text{abba} превращают в

s=#a#b#b#a#.s' = \#a\#b\#b\#a\#.

Длина новой строки - 2n+12n + 1. Любой палиндром в ss, чётный или нечётный, становится в ss' нечётным с центром на символе или на разделителе. Центр на букве - это исходный нечётный палиндром, центр на # - исходный чётный. Удобно: дальше работаем только с нечётными.

Граничные символы часто оборачивают в пару сторожей вида ^ и $, чтобы не проверять выход за границы явно - расширение остановится само, потому что ^$.

Массив радиусов p[i]p[i]

Главная структура алгоритма - массив p[i]p[i] длины s=2n+1|s'| = 2n + 1, где p[i]p[i] - радиус максимального палиндрома с центром в ii (не считая сам центр). То есть подстрока s[ip[i]i+p[i]]s'[i - p[i] \ldots i + p[i]] - палиндром, а s[ip[i]1]s[i+p[i]+1]s'[i - p[i] - 1] \neq s'[i + p[i] + 1].

Если p[i]=kp[i] = k в строке с разделителями, то в исходной строке палиндром имеет длину ровно kk (это не опечатка: за счёт расщепления символов разделителями радиус в ss' численно равен длине палиндрома в ss).

Зная весь p[]p[\cdot], можно ответить на любой вопрос о палиндромах: длиннейший - maxp[i]\max p[i] с восстановлением позиции; проверка «есть ли палиндром длины LL» - за один линейный проход.

Зеркальный трюк: использование cc и rr

Алгоритм поддерживает два указателя на лету:

  • cc - центр того палиндрома среди найденных, у которого правая граница максимальна;
  • r=c+p[c]r = c + p[c] - сама правая граница (индекс самого правого символа внутри этого палиндрома).

Когда мы дошли до позиции ii и хотим вычислить p[i]p[i], рассматриваем два случая.

Случай i<ri < r. Внутри известного палиндрома с центром cc зеркальная позиция i=2cii' = 2c - i. Палиндром, центрированный в ii', уже вычислен - у него радиус p[i]p[i']. По симметрии относительно cc палиндром той же длины помещается и в ii, если не выходит за правую границу rr. Стартовое значение:

p[i]=min(ri, p[2ci]).p[i] = \min(r - i,\ p[2c - i]).

Случай iri \geq r. Полезной информации из зеркала нет, стартуем с p[i]=0p[i] = 0.

Дальше - явное расширение: пока s[ip[i]1]=s[i+p[i]+1]s'[i - p[i] - 1] = s'[i + p[i] + 1], увеличиваем p[i]p[i] на 1. После расширения, если i+p[i]>ri + p[i] > r, обновляем cic \leftarrow i, ri+p[i]r \leftarrow i + p[i] - теперь правая граница «съехала» дальше.

Псевдокод с инвариантом

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]

Инвариант перед итерацией: rr - самая правая позиция, которую покрывает уже найденный палиндром с центром в cc. После итерации ii инвариант сохраняется, потому что мы обновляем (c,r)(c, r) только если i+p[i]>ri + p[i] > r - то есть когда нашли палиндром, чей правый край ещё правее.

Почему это O(n)O(n)

Ключевой аргумент - амортизация числа явных расширений. Каждое успешное расширение на шаге ii увеличивает значение rr как минимум на единицу: если расширение прошло, это значит, что мы вышли за прежний правый край (иначе зеркальный трюк дал бы максимум rir - i и расширение не понадобилось бы). Переменная rr монотонно неубывающая и ограничена сверху s=2n+1|s'| = 2n + 1. Значит, суммарное число успешных расширений за весь прогон не превосходит 2n+12n + 1.

Неуспешное расширение (то, на котором цикл while остановился) - ровно одно на итерацию, итого 2n+1\leq 2n + 1. Все остальные операции - константа на итерацию. Итого O(n)O(n) операций, O(n)O(n) дополнительной памяти под массив pp.

Большинство «линейных» алгоритмов на строках (KMP, Z-функция) амортизируется через монотонно растущий указатель - Манакер из той же семьи доказательств.

Сравнение с другими подходами

Наивный O(n2)O(n^2). Перебор всех центров с расширением. На строках длины до 104\sim 10^4 работает мгновенно, но ломается на aaaaa\text{aaaa\ldots a}: каждый центр даёт радиус n/2\sim n / 2, итого Θ(n2)\Theta(n^2).

Хеши + бинарный поиск по радиусу. Для каждого центра двоичным поиском по длине палиндрома проверяем равенство префикса и его реверса через полиномиальный хеш, O(nlogn)O(n \log n) суммарно. Требует аккуратной работы с коллизиями (double hashing) и не даёт детерминированного результата.

Eertree (palindromic tree, Rubinstein-Shur, 2014). Хранит все различные палиндромные подстроки в специальной структуре: O(n)O(n) узлов и построения, поддерживает онлайн-добавление символа. Богаче Манакера - можно считать частоты, искать вторые максимумы, - но реализация заметно сложнее. На одиночном прогоне Манакер выигрывает по константе.

Применения

  • Longest palindromic substring (LeetCode 5). Каноническая задача собеседования. Манакер даёт O(n)O(n) - лучшая известная асимптотика. После прогона i=argmaxp[i]i^* = \arg\max p[i] и подстрока s[ip[i]i+p[i]]s'[i^* - p[i^*]\ldots i^* + p[i^*]], очищенная от #, - ответ.
  • Биоинформатика, палиндромы в ДНК. Молекулярные палиндромы - участки, где последовательность совпадает со своим обратным комплементом (ATA \leftrightarrow T, CGC \leftrightarrow G). После замены каждого нуклеотида на пару с комплементом задача сводится к обычным палиндромам, и Манакер находит сайты рестрикции за один линейный проход по геному.
  • Сжатие. Палиндромные периоды используются в специализированных схемах сжатия повторяющихся текстов и в обнаружении дубликатов с реверсной симметрией.
  • Подзадача в более сложных алгоритмах. Манакер вызывается как чёрный ящик в задачах «разрезать строку на минимальное число палиндромов» (palindrome partitioning DP), «посчитать палиндромные подстроки», «есть ли палиндром длины L\geq L с началом в позиции ii».

Частые ошибки

  • Пишут два цикла отдельно для чётных и нечётных палиндромов. Удваивает код и повышает вероятность бага. Унификация через # стоит трёх строк.
  • Делают p[i]=p[2ci]p[i] = p[2c - i] без min(ri,)\min(r - i, \cdot). Получается переоценка радиуса: палиндром в зеркале может выходить за левую границу палиндрома с центром cc, и тогда симметрия не гарантирует совпадения за пределами rr. Алгоритм начнёт «выдумывать» палиндромы.
  • Не проверяют выход за границы строки в while. Без сторожевых символов или явной проверки получаем IndexError на палиндромах до края. Обёртка ^ и $ решает это элегантнее условия в цикле.
  • Используют Манакер на двоичных строках, ожидая выигрыша против eertree. Для большого числа однотипных запросов eertree лучше - он кэширует все палиндромы. Манакер хорош для одиночного прогона.

FAQ

Чем алгоритм Манакера отличается от наивного поиска палиндромов? Наивный для каждого центра запускает расширение независимо, получая O(n2)O(n^2) на строках с длинными палиндромами. Манакер использует уже найденные палиндромы: если позиция ii лежит внутри известного палиндрома с центром cc, её зеркало даёт стартовое значение радиуса бесплатно. Суммарное число явных расширений ограничено 2n2n, отсюда O(n)O(n) против O(n2)O(n^2).

Зачем вставлять # между символами? Чтобы унифицировать чётные и нечётные палиндромы. Без разделителей пришлось бы вести два массива podd[i]p_{\text{odd}}[i] и peven[i]p_{\text{even}}[i] и писать два цикла; с разделителями любой палиндром в ss' оказывается нечётным с центром либо на символе (исходный нечётный), либо на # (исходный чётный). Длина в ss' численно равна длине в ss.

Можно ли построить Манакер онлайн, добавляя символы по одному? Базовый алгоритм - нет: при добавлении символа справа правый край rr может сдвинуться, и значения p[i]p[i] для уже обработанных ii остаются актуальными, но новые палиндромы могут пересекать уже посчитанную область странным образом. Для онлайн-режима используют eertree - он специально спроектирован под потоковое добавление.

Коротко

Алгоритм Манакера находит все максимальные палиндромные подстроки строки длины nn за O(n)O(n). Идея: вставка разделителей # унифицирует чётные и нечётные палиндромы, массив радиусов p[i]p[i] заполняется с использованием зеркальной симметрии относительно текущего центра cc и правого края rr - стартовое значение p[i]=min(ri,p[2ci])p[i] = \min(r - i, p[2c - i]), дальше доуточнение явным расширением. Линейность доказывается через монотонность rr: суммарное число успешных расширений не превосходит длины строки. На LeetCode 5, поиске сайтов рестрикции в ДНК и подзадачах palindrome partitioning Манакер - стандартный инструмент; альтернативный eertree богаче, но сложнее в реализации.

Доверьте текст нейросети EssayAI

Открыть EssayAI

Бесплатно, на русском языке и без VPN

Читайте также