4. Longest Palindromic Substring

«Всякое сложное геометрическое расширение начинается с правильного и точного выбора точки отсчета».
— Брайан Керниган

Поиск самого длинного палиндрома: От Expand Around Center до линейного Манакера

Задача Longest Palindromic Substring (LeetCode 5) — фундаментальный классический фильтр на алгоритмическую и инженерную зрелость. Она проверяет не только умение писать циклы без ошибок смещения на единицу (off-by-one), но и понимание того, как срезы строк живут в памяти, когда динамическое программирование проигрывает прямому обходу и как работает предел оптимизации строковых алгоритмов.

TEXT
Вход:  s = "babad"
Выход: "bab" (или "aba")

Вход:  s = "cbbd"
Выход: "bb"
flowchart TD
    classDef entry   fill:#1e3a5f,stroke:#38bdf8,stroke-width:2px,color:#f8fafc
    classDef process fill:#1e293b,stroke:#38bdf8,stroke-width:1px,color:#f8fafc
    classDef accent  fill:#1e1b4b,stroke:#818cf8,stroke-width:1px,color:#f8fafc
    classDef warn    fill:#78350f,stroke:#fbbf24,stroke-width:1px,color:#f8fafc
    classDef success fill:#064e3b,stroke:#34d399,stroke-width:1px,color:#f8fafc

    Task["Поиск длиннейшего палиндрома"]:::entry --> Choice{"Выбор стратегии"}:::process

    Choice --> Brute["Перебор всех подстрок:<br/>O(N^3) время — ТАБУ на интервью"]:::warn
    Choice --> DP["Таблица DP dp[i][j]:<br/>O(N^2) время, O(N^2) память"]:::process
    Choice --> Expand["Expand Around Center:<br/>O(N^2) время, O(1) память"]:::success
    Choice --> Manacher["Алгоритм Манакера:<br/>O(N) время, O(N) память"]:::accent

    Expand --> Standard["Индустриальный стандарт для 99% собеседований"]:::success
    Manacher --> Hardcore["Олимпиадный предел для N > 100 000"]:::accent
  

1. Базовый индустриальный подход: Expand Around Center

Наивный перебор всех пар $(i, j)$ с последующей проверкой палиндрома занимает кубическое время $O(N^3)$.
Динамическое программирование требует двумерную матрицу dp[N][N] типа bool, которая при $N = 10\,000$ потребует около 100 МБ памяти в куче и породит частые промахи кэша.

Подход Expand Around Center (Расширение от центра) использует фундаментальное геометрическое свойство палиндрома: зеркальную симметрию относительно своей оси.

В строке длины $N$ существует ровно $2N - 1$ возможных центров:

  • $N$ центров нечетной длины (одиночный символ s[i]).
  • $N - 1$ центров четной длины (виртуальный зазор между s[i] и s[i+1]).
TEXT
Строка:   a   b   a   b   a
Центры:  [0]  |  [1]  |  [2]  |  [3]  |  [4]
         Нечет Чет Нечет Чет Нечет Чет Нечет Чет Нечет

Формула восстановления начального индекса

Если расширение от центра дало палиндром максимальной длины currLen:

  • Для нечетного центра $i$ длина $2k+1$: начало находится в $i - k = i - \frac{\text{currLen}-1}{2}$.
  • Для четного центра между $i$ и $i+1$ длина $2k$: начало находится в $i - k + 1 = i - \frac{\text{currLen}-1}{2}$ (благодаря целочисленному делению).

Единая формула для обоих случаев:
$$\text{start} = i - \frac{\text{currLen} - 1}{2}$$


Идиоматичная реализация на Go

Go
package palindrome

// LongestPalindrome находит наибольшую палиндромную подстроку за O(N^2) времени и O(1) памяти.
func LongestPalindrome(s string) string {
    if len(s) < 2 {
        return s
    }

    start, maxLen := 0, 1

    for i := 0; i < len(s); i++ {
        // Нечетный палиндром: центр s[i] (например, "aba")
        len1 := expandAroundCenter(s, i, i)
        // Четный палиндром: центр между s[i] и s[i+1] (например, "abba")
        len2 := expandAroundCenter(s, i, i+1)

        currLen := max(len1, len2)
        if currLen > maxLen {
            maxLen = currLen
            start = i - (currLen-1)/2
        }
    }

    // Срез строки в Go не копирует байты — O(1) возврат 16-байтового заголовка
    return s[start : start+maxLen]
}

// expandAroundCenter возвращает длину палиндрома с заданным центром
func expandAroundCenter(s string, left, right int) int {
    for left >= 0 && right < len(s) && s[left] == s[right] {
        left--
        right++
    }
    // Длина между (left+1) и (right-1) включительно:
    // (right - 1) - (left + 1) + 1 = right - left - 1
    return right - left - 1
}

2. Адаптация под Unicode (UTF-8)

Если строка содержит символы не из набора ASCII (кириллица, эмодзи, CJK), побайтовое сравнение s[left] == s[right] разрушит многобайтовые руны.

Go
// LongestPalindromeUnicode корректно обрабатывает произвольные руны Unicode.
// Временная сложность: O(N^2)
// Пространственная сложность: O(N) — аллокация среза []rune
func LongestPalindromeUnicode(s string) string {
    if len(s) < 2 {
        return s
    }

    runes := []rune(s)
    n := len(runes)
    start, maxLen := 0, 1

    for i := 0; i < n; i++ {
        len1 := expandRunes(runes, i, i, n)
        len2 := expandRunes(runes, i, i+1, n)

        currLen := max(len1, len2)
        if currLen > maxLen {
            maxLen = currLen
            start = i - (currLen-1)/2
        }
    }

    return string(runes[start : start+maxLen])
}

func expandRunes(runes []rune, left, right, n int) int {
    for left >= 0 && right < n && runes[left] == runes[right] {
        left--
        right++
    }
    return right - left - 1
}

3. Линейный алгоритм Манакера: $O(N)$ Время и $O(N)$ Память

Когда длина строки $N \ge 10^5$, алгоритм Expand Around Center выполняет порядка $10^{10}$ операций и не проходит по Time Limit.
Алгоритм Манакера устраняет избыточные сравнения, переиспользуя ранее вычисленные радиусы палиндромов благодаря осевой симметрии.

flowchart LR
    classDef process fill:#1e293b,stroke:#38bdf8,stroke-width:1px,color:#f8fafc
    classDef accent  fill:#1e1b4b,stroke:#818cf8,stroke-width:1px,color:#f8fafc
    classDef success fill:#064e3b,stroke:#34d399,stroke-width:1px,color:#f8fafc

    Input["Исходная строка: 'aba'"]:::process --> Transform["1. Унификация: '^#a#b#a#$'<br/>Границы ^ и $ защищают от выхода"]:::process
    Transform --> ArrayP["2. Массив радиусов P[]<br/>Поддержка центра C и правой границы R"]:::accent
    ArrayP --> Loop["3. Итерация по ячейкам i:<br/>P[i] = min(R - i, P[2*C - i])"]:::process
    Loop --> Expand["4. Дорасширение при необходимости"]:::process
    Expand --> Update["5. Обновление C и R при i + P[i] > R"]:::success
  

Полная реализация алгоритма Манакера на Go

Go
// LongestPalindromeManacher находит палиндром за строго линейное время O(N).
func LongestPalindromeManacher(s string) string {
    if len(s) < 2 {
        return s
    }

    // 1. Преобразование строки: "aba" -> "^#a#b#a#$"
    // Специальные символы '^' и '$' исключают проверки выхода за границы среза
    t := make([]byte, 0, len(s)*2+3)
    t = append(t, '^')
    for i := 0; i < len(s); i++ {
        t = append(t, '#', s[i])
    }
    t = append(t, '#', '$')

    n := len(t)
    p := make([]int, n) // p[i] — радиус палиндрома вокруг t[i]
    c, r := 0, 0        // c — центр самого правого палиндрома, r — его правая граница

    for i := 1; i < n-1; i++ {
        mirror := 2*c - i // Зеркальная позиция i относительно центра c

        if i < r {
            p[i] = min(r-i, p[mirror])
        }

        // Дорасширение палиндрома (выполняется амортизировано O(1) раз)
        for t[i+1+p[i]] == t[i-1-p[i]] {
            p[i]++
        }

        // Если палиндром с центром i вышел за правую границу r — сдвигаем окно
        if i+p[i] > r {
            c = i
            r = i + p[i]
        }
    }

    // Поиск максимального радиуса
    maxLen, centerIndex := 0, 0
    for i := 1; i < n-1; i++ {
        if p[i] > maxLen {
            maxLen = p[i]
            centerIndex = i
        }
    }

    // Восстановление исходных координат в оригинальной строке s
    start := (centerIndex - maxLen) / 2
    return s[start : start+maxLen]
}

Сравнительный анализ подходов

Метод Время Память Аллокации Сложность реализации Рекомендация для интервью
Expand Around Center $O(N^2)$ $O(1)$ $0$ Простая ($\sim 20$ строк) Выбирать по умолчанию. Отличная кэш-локальность.
Динамическое программирование $O(N^2)$ $O(N^2)$ Высокая Средняя ($\sim 30$ строк) Только если задача требует вернуть матрицу всех подпалиндромов.
Алгоритм Манакера $O(N)$ $O(N)$ $2$ слайса Высокая ($\sim 50$ строк) Описывать концепцию устно. Писать код только по прямому требованию $O(N)$.

Вопросы для собеседования


Резюме

  • Expand Around Center — практический эталон решения задачи поиска палиндромов: $O(N^2)$ времени, строго $O(1)$ памяти и ноль аллокаций.
  • Корректный расчет стартового индекса: $\text{start} = i - \lfloor(\text{currLen}-1)/2\rfloor$.
  • Для поддержки полного Unicode требуется предварительная конвертация в []rune.
  • Алгоритм Манакера обеспечивает теоретический оптимум $O(N)$ времени за счет сохранения радиусов и зеркальной симметрии, устраняя повторные сравнения.

В следующей лекции мы перейдем к эффективной группировке множеств строк: 5. Group Anagrams, где разберем канонические ключи, работу с хэш-таблицами и оптимизацию аллокаций в Go.