跳转至

第 72 章 回文

配套例题:BISHI94 【模板】马拉车算法、BISHI20 回文日期 前置70-字符串处理36-哈希与字符串哈希71-字符串匹配KMP

回文串:从左往右读和从右往左读完全相同的串。

回文问题在竞赛里有一个非常清晰的「三层解法阶梯」:

需求 解法 复杂度
一个串是不是回文 s == s[::-1] \(O(n)\),C 层
找最长回文子串(\(n \le 3000\) 中心扩展 \(O(n^2)\)
找最长回文子串(\(n \le 10^6\) Manacher \(O(n)\)
\(O(1)\)任意子串 \(s[l:r]\) 是否回文 正反哈希Manacher 的 \(p\) 数组 预处理 \(O(n)\),查询 \(O(1)\)

这一章把这四层都讲透。


72.1 回文判定

s == s[::-1]                    # ✅ 最简单也最快:切片反转是 C 层 O(n)

不要手写双指针

# ❌ 慢 10 倍以上(Python 层循环)
def is_pal_slow(s):
    i, j = 0, len(s) - 1        # 两端各一个游标,向中间靠拢
    while i < j:                # i == j(正中间那个字符)不必比,它和自己天然相等
        if s[i] != s[j]:
            return False
        i += 1
        j -= 1
    return True

唯一值得手写的场景:需要提前退出且串很长又几乎必然在前几位失配—— 但 s[::-1]\(O(n)\) 复制在 C 层跑,\(n \le 10^6\) 都无所谓。

变体

需求 写法
忽略大小写 t = s.lower(); t == t[::-1]
只看字母数字 t = "".join(c for c in s.lower() if c.isalnum())
数字是否回文 str(x) == str(x)[::-1]
bytes 也一样 b == b[::-1]

72.2 中心扩展

每个回文串都有一个中心。 枚举中心,向两边扩展到不能再扩。

:回文有奇偶两种——奇长度的中心是一个字符,偶长度的中心是两个字符之间的缝。 所以要枚举 \(2n-1\) 个中心。

def longest_palindrome_expand(s):
    """中心扩展求最长回文子串,返回 (起点, 长度)。O(n^2)。"""
    n = len(s)
    if n == 0:
        return 0, 0
    best_l, best_len = 0, 1                  # 非空串至少有长度 1 的回文(单个字符)
    for c in range(n):
        for d in (0, 1):                     # d=0 奇数长度,d=1 偶数长度
            l, r = c, c + d                  # d=1 时中心是 c 与 c+1 之间那道缝
            while l >= 0 and r < n and s[l] == s[r]:
                l -= 1                       # 两端同时外扩,扩不动就退出
                r += 1
            # 退出时 l、r 各多走了一步:真正的回文是 s[l+1..r-1],长度 (r-1)-(l+1)+1 = r-l-1
            if r - l - 1 > best_len:
                best_len = r - l - 1
                best_l = l + 1
    return best_l, best_len
优点 缺点
代码短、不用想 \(O(n^2)\)\(n = 3000\) 时 Python 里已经要几秒
顺手能统计「回文子串个数」 \(n \ge 10^5\) 完全不可用

统计回文子串总数:每次扩展成功一次就是一个新的回文子串, 所以把 while 的成功次数累加起来就是答案(\(O(n^2)\))。 \(O(n)\) 的做法要用 回文自动机(PAM,Palindromic Automaton) 或 Manacher + 差分。


72.3 Manacher(马拉车)

Manacher 算法得名于提出者 Glenn Manacher,中文按发音习惯叫「马拉车」。 它一次 \(O(n)\) 扫描求出以每个位置为中心的最长回文半径, 「最长回文子串」只是顺手在这张表上取个最大值。

第一步:奇偶统一

在每两个字符之间以及首尾插入分隔符 #

s =        a   b   a   b   a
t = # a # b # a # b # a #

原串长 \(n\) → 新串长 \(2n+1\)每个回文(不论奇偶)在新串里都是奇长度

而且插入 # 有一个非常漂亮的性质:

新串中位置 \(i\) 的回文半径 \(p[i]\)(不含中心), 恰好等于原串中对应回文子串的长度。

所以答案就是 \(\max p[i]\)奇偶完全不用分类讨论

第二步:镜像预测

维护当前最靠右的回文区间 \([c-p[c],\ c+p[c]]\)(中心 \(c\),右端 \(r\))。 若当前中心 \(i < r\),设它关于 \(c\) 的镜像是 \(\text{mir} = 2c-i\),则

\[p[i] \ge \min\big(p[\text{mir}],\ r-i\big)\]

为什么要和 \(r-i\)\(\min\):镜像的回文只有在 \([c-p[c], r]\) 这个已知区间内才可靠, 超出部分完全没有信息,必须重新暴力扩展。忘了这个 min 是 Manacher 最常见的错。

模板

def manacher(s):
    """返回变换串上的回文半径数组 p(长度 2n+3,下标 1..2n+1 有效)。

    - max(p) 就是原串最长回文子串的长度;
    - 原串子串 s[l:r] 是回文  <=>  p[l + r + 1] >= r - l。

    s 传 bytes 更快(比较的是 int)。整体 O(n)。
    """
    if isinstance(s, str):
        s = s.encode()                       # 统一成 bytes:索引出来是 int,比较更快
    n = len(s)
    m = 2 * n + 1                            # 插满 '#' 后的长度:n 个原字符 + n+1 个 '#'
    # t = ^ # s0 # s1 # ... # $,两端哨兵保证扩展循环自然停止,省掉越界判断
    t = bytearray(b'^' + b'#' * m + b'$')
    # ★ 切片步长赋值,C 层一次拷贝:'^' 占 0 号位,原字符落在 2, 4, 6, ...
    t[2:2 + 2 * n:2] = s
    p = [0] * (m + 2)                        # p[i] = 变换串上以 i 为中心的回文半径(不含中心)
    c = r = 0                                # 最右回文区间的中心与右端
    for i in range(1, m + 1):                # 只扫有效区间,两端哨兵不当中心
        if i < r:                            # i 落在已知区间内,可以借镜像的结论当下界
            mir = 2 * c - i                  # i 关于中心 c 的镜像位置
            k = r - i                        # 已知区间在 i 右侧还剩这么多字符可信
            pm = p[mir]
            if pm < k:                       # k = min(p[mir], r - i)
                k = pm                       # 镜像半径伸出已知区间的部分不可信,得截断
        else:
            k = 0                            # i 在已知区间之外,没有任何可借的信息
        while t[i - k - 1] == t[i + k + 1]:  # 哨兵 ^ / $ 保证一定失配停下
            k += 1                           # 从下界继续逐位暴力扩
        p[i] = k
        if i + k > r:
            c, r = i, i + k                  # 右端更靠右了,中心必须和 r 一起换
    return p


def longest_palindrome_len(s):
    """最长回文子串的长度,O(n)。"""
    return max(manacher(s))


def make_palindrome_checker(s):
    """返回一个 O(1) 判定 s[l:r](左闭右开)是否回文的函数。预处理 O(n)。"""
    p = manacher(s)

    def is_pal(l, r):
        # s[l:r] 长 r-l,插 '#' 之后它在变换串里的半径也恰好是 r-l;
        # 中心下标 l+r+1 的推导见下方正文
        return p[l + r + 1] >= r - l         # 中心在变换串的 l+r+1 处
    return is_pal

「中心是 \(l+r+1\)」怎么来的:原串 \(s[l]\) 落在 \(t\) 的下标 \(2l+2\)\(s[r-1]\) 落在 \(2(r-1)+2 = 2r\),中点就是 \(\frac{(2l+2)+2r}{2} = l+r+1\)

三个实现细节

细节 作用
t = b'^' + b'#'*m + b'$' 两端不同的哨兵 扩展循环一定会失配停下,省掉两次越界判断
t[2:2+2n:2] = s(切片步长赋值) b'#'.join(...) 快好几倍(见 70.2
全程 bytearray / int 比较 str 快约 2 倍

本机实测(CPython 3.9,\(n = 10^6\)):

数据 耗时
随机 26 字母串 0.37 s
abab…ab 0.54 s
a(最坏结构) 0.53 s

为什么 Manacher 是 \(O(n)\):内层 while 每成功一次,\(r\) 就至少增大 1, 而 \(r\) 单调不减且 \(\le 2n+1\)。所以所有 while 的总执行次数是 \(O(n)\)。 这和 Z 函数(71.6)、单调栈的均摊论证是同一套。


72.4 哈希法:\(O(1)\) 判任意子串是否回文

思路极简单:正串的哈希 == 反串对应位置的哈希

# 依赖 36 章的 StringHash 类
def build_palindrome_checker_hash(s, StringHash):
    """返回 O(1) 判定 s[l:r] 是否回文的函数。预处理 O(n)。"""
    n = len(s)
    fwd = StringHash(s)                          # 正串的前缀哈希
    rev = StringHash(s[::-1], base=fwd.base)     # ★ 必须用同一个 base 和模数

    def is_pal(l, r):
        # 反串是整体翻转,正串下标 j 对应反串下标 n-1-j,
        # 所以区间 [l, r) 翻过去就是 [n-r, n-l);两段哈希相等即回文
        return fwd.get(l, r) == rev.get(n - r, n - l)
    return is_pal

⚠️ 正串和反串必须用同一个 base 和同一个模数,否则完全没有可比性。 base=fwd.base 那一行是全部关键。见 36-哈希与字符串哈希

Manacher vs 哈希:怎么选

Manacher 正反哈希
预处理 \(O(n)\) \(O(n)\)
\(s[l:r]\) 是否回文 \(O(1)\) \(O(1)\)
求最长回文子串 直接 max(p) ⚠️ 要二分半径,\(O(n\log n)\)
求以每个位置为中心的最长回文 ✅ 就是 \(p\) 数组 ⚠️ 每个都要二分
冲突风险 ⚠️ 有(\(2^{61}-1\) 单模已经足够安全)
能否处理「回文 + 其它子串比较」 ❌ 只管回文 万能
代码量 中等 短(有现成的类)
Python 常数 慢(大整数乘法 + 取模)

决策: - 题目只问回文 → Manacher; - 题目既要判回文又要比较任意子串 → 哈希(一套代码解决两件事); - 需要「统计本质不同的回文子串个数」 → 回文自动机 PAM(本教程不展开, 可用 Manacher + 哈希 + set 近似替代)。


72.5 常见回文题型

题型 做法
最长回文子串 Manacher
最长回文子序列 区间 DP(和回文算法无关!见 103 章
回文子串个数 Manacher 的 \(p\) 数组:以 \(i\) 为中心的回文个数 \(= \lceil p[i]/2 \rceil\)
把串分成最少几段全是回文 DP + \(O(1)\) 判回文
判断某个数/日期是否回文 转成 str[::-1](BISHI20)
最少加几个字符使全串回文 KMP:在 \(s + \texttt{sep} + s^R\) 上求 \(\pi\)71 章
回文串的构造 枚举「前一半」,后一半是它的镜像(BISHI20 的核心思路)

「枚举一半」是回文构造题的通用套路: 长度 \(L\) 的回文由前 \(\lceil L/2 \rceil\) 位唯一确定。 所以要枚举「所有长度为 8 的回文数」,只需枚举前 4 位的 \(9000\) 种可能, 而不是枚举 \(10^8\) 个 8 位数——指数直接减半


72.6 例题

BISHI94 【模板】马拉车算法(中等)

给定长度 \(n \le 10^6\) 的小写字母串 \(S\),求最长回文子串的长度。 题面见 BISHI94 原题(牛客)。 题解见 solutions/BISHI94.py(已用官方样例验证)。

\(n = 10^6\),中心扩展的 \(O(n^2)\)\(10^{12}\),必挂。只能 Manacher。

import sys


def main():
    s = sys.stdin.buffer.read().split()[0]      # split() 顺带去掉 \r\n
    n = len(s)
    m = 2 * n + 1                               # 插满 '#' 后的长度:n 个原字符 + n+1 个 '#'
    # t = ^ # s0 # s1 # ... # $,两端哨兵保证扩展循环自然停止
    t = bytearray(b'^' + b'#' * m + b'$')       # 总长 2n+3
    t[2:2 + 2 * n:2] = s                        # ★ 切片步长赋值,原字符落在偶数下标

    p = [0] * (m + 2)                           # p[i] = 以 i 为中心的回文半径(不含中心)
    c = r = 0                                   # 最右回文区间的中心与右端
    best = 0
    for i in range(1, m + 1):                   # 两端哨兵不当中心,只扫有效区间
        if i < r:                               # i 在已知区间内,借镜像位置的结论当下界
            mir = 2 * c - i                     # i 关于中心 c 的镜像位置
            k = r - i                           # 已知区间在 i 右侧还剩这么多字符可信
            pm = p[mir]
            k = pm if pm < k else k             # 下界 = min(镜像半径, 右边界剩余)
        else:
            k = 0                               # 已知区间管不到 i,只能从 0 开始扩
        while t[i - k - 1] == t[i + k + 1]:     # 哨兵保证一定会失配停下
            k += 1                              # 从下界继续逐位扩
        p[i] = k
        if i + k > r:
            c, r = i, i + k                     # 右端更靠右了,中心必须跟着一起换
        if k > best:
            best = k                            # 半径 k 就是原串中那段回文的长度
    sys.stdout.write("%d\n" % best)


main()

复杂度 \(O(n)\)本机实测 \(n = 10^6\) 约 0.4–0.55 秒,时限「其他语言 2 秒」,安全。

四个坑

  1. p[i] 的初值必须是 \(\min(r-i,\ p[\text{mir}])\)。 忘了和 \(r-i\)\(\min\),就会越过已知区间用上不可靠的信息,答案错;
  2. 更新最右区间用 if i + k > r,且要同时更新中心 c。 只更新 r 不更新 c 会让后面所有的镜像计算全错;
  3. 两端哨兵 ^$ 必须不同、且不等于 # 和任何字母。 它们的作用是让 while t[i-k-1] == t[i+k+1] 在到达边界时自然失配, 省掉两次下标越界检查——在 \(2\times10^6\) 次循环里这是实打实的时间;
  4. 读入必须 split()[0],直接 read() 会把换行符带进来,答案偏大。

答案为什么就是 max(p):变换串中半径为 \(k\) 的回文, 覆盖了 \(k\)#\(k\) 个原字符……更准确地说,插入 # 之后, 原串中长度 \(L\) 的回文在 \(t\) 中的半径恰好是 \(L\)。 可以拿 aaa 手验:\(t = \texttt{\^{}\#a\#a\#a\#\$}\), 中心在正中间的 # 上时半径是 3,正是答案 3 ✓

BISHI20 回文日期(简单)

8 位数字 YYYYMMDD 表示一个日期,若这 8 位数字本身是回文数,则称回文日期。 给定 \(a \le b\),求 \([a,b]\)真实存在的回文日期数量。 题面见 BISHI20 原题(牛客)。 题解见 solutions/BISHI20.py(已用官方样例验证)。

这题的考点是「枚举一半」,不是回文算法。

逐日枚举要跑 \(9000 \times 365 \approx 3.3\times10^6\) 天,还要写日期递增逻辑。 但注意:

8 位回文数由前 4 位(年份)唯一确定:后四位必须是年份的逆序, 即 \(\texttt{MM} = y_4y_3\)\(\texttt{DD} = y_2y_1\)

年份只有 \(1000 \sim 9999\)9000 个,逐个构造再判合法性即可。

import sys

data = sys.stdin.buffer.read().split()
a, b = int(data[0]), int(data[1])

DAYS = [0, 31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31]   # 下标 0 空出来,DAYS[m] 直接按月取

ans = 0
for y in range(1000, 10000):      # 8 位回文数由前 4 位唯一确定,只需枚举 9000 个年份
    s = str(y)
    d = int(s + s[::-1])          # ★ 该年份下唯一可能的回文日期
    if d < a or d > b:
        continue                  # 落在询问区间之外
    m, day = d // 100 % 100, d % 100   # 后四位拆开:d//100%100 是 MM,d%100 是 DD
    if not 1 <= m <= 12:          # 构造出来的月份可能是 00、20 这种非法值
        continue
    lim = DAYS[m]                 # 该月天数上限
    if m == 2 and ((y % 4 == 0 and y % 100 != 0) or y % 400 == 0):
        lim = 29                  # 闰年 2 月 29 天;只写 y%4==0 会把 1900、2100 算成闰年
    if 1 <= day <= lim:           # DD 也可能构造成 00,所以下界同样要判
        ans += 1
print(ans)

复杂度 \(O(9000)\),瞬间出结果。

四个坑

  1. 构造出的月份可能非法y = 200020000002,月份是 00y = 200220022002,月份是 20必须判 \(1 \le m \le 12\)
  2. 闰年规则要写全:「能被 4 整除但不能被 100 整除,能被 400 整除」。 只写 y % 4 == 0 会把 1900、2100 算成闰年;
  3. 日期也可能是 00y = 100010000001DD = 01 合法; 但 y = 201020100102 合法,而 y = 2000DD = 02 配上非法月份被挡掉了。 月份和日期都要判
  4. 题面说明里的「20010002」是笔误,真正的回文日期是 20011002(2001-10-02)。 遇到题面文字和「真实存在的日期」冲突时,以逻辑和样例的最终答案为准—— 样例 2 的答案是 2,正好对应 2001100220100102

本题给出的判据:所有「在一个大区间里数满足某种对称结构的数」的题, 第一反应都应该是枚举决定性的那一半,而不是枚举整个区间。 同类:回文数计数、\(\text{ABCCBA}\) 型车牌、镜像对称的数位 DP。 见 85-基础数学与递推 的数位处理。


72.7 本章速查

要点 结论
判一个串是否回文 s == s[::-1],C 层 \(O(n)\),别手写双指针
中心要枚举几个 \(2n-1\)(奇 \(n\) 个 + 偶 \(n-1\) 个)
中心扩展 \(O(n^2)\)\(n \le 3000\) 可用
\(n \ge 10^5\) 求最长回文 Manacher\(O(n)\)
Manacher 的插入符 首尾和每两字符之间插 #,新串长 \(2n+1\)
# 的妙处 新串的半径 \(p[i]\) = 原串回文的长度,奇偶不用分类
半径初值 \(\min(p[\text{mir}],\ r-i)\)——忘了 min 就错
更新最右区间 if i+k > r: c, r = i, i+k——c 必须一起更新
两端哨兵 ^$(互不相同),省掉越界判断
构造变换串 bytearray + t[2::2] = s(切片步长赋值)
复杂度证明 \(r\) 单调不减且 \(\le 2n+1\)while 总量 \(O(n)\)
实测速度 \(n = 10^6\)0.4–0.55 s
\(O(1)\)\(s[l:r]\) 回文(Manacher) p[l+r+1] >= r-l
\(O(1)\)\(s[l:r]\) 回文(哈希) 正串哈希 == 反串对应区间哈希,base 必须相同
只问回文 → Manacher
还要比较任意子串 → 哈希
最长回文子序列 区间 DP,和本章算法无关
回文构造 / 计数 枚举前一半,后一半是镜像
最少补几个字符成回文 KMP:在 \(s + \texttt{sep} + s^R\) 上求 \(\pi\)
看到什么 → 想到什么
「最长回文子串」+ \(n\) 很大
「每个位置为中心的最长回文」
「回文子串个数」
「多次询问某子串是否回文」
「分成最少几段回文」
「回文子序列
「区间里有多少回文数/日期」