先把定位说清楚,免得你白紧张:Manacher是面试加分项,不是常规要求 。
绝大多数面试里,答出"中心扩散O(n²)"就已经过关了;但如果你能在白板上把Manacher讲明白,那是实打实的亮点。
更值得你带走的是Manacher背后的思想------它和 KMP是同一条脉络:
已经算出来的信息,绝不再算第二遍。
KMP靠"最长相等前后缀"让主串指针不回退;Manacher靠"回文的对称性"让扩散半径不从头开始。
两者都是用一份预处理信息,把重复劳动一次性省掉。
📦 题目速览 LeetCode 5(30秒读懂)
给你一个字符串
s,找到s中最长的回文子串。示例:
"babad"→"bab"("aba"也合法)示例:
"cbbd"→"bb"示例:
"a"→"a"约束: n ≤ 1000,仅数字和英文字母。
"回文"就是正读反读都一样:"aba"是,"abba"也是。注意前者长度是奇数(中心是单个字符b),后者是偶数(中心是bb之间的空隙)------奇偶中心不同,是所有回文解法的第一个绊脚石。
🧠 核心思路:从暴力到Manacher的进化链
① 暴力枚举 :枚举所有子串O(n²)个,每个判回文O(n) → O(n³)。
② 动态规划 :dp[i][j]表示s[i..j]是否回文,转移dp[i][j] = dp[i+1][j-1] && s[i]==s[j] → O(n²) 时间 + O(n²)空间。
③ 中心扩散 :枚举每个可能的中心(n个奇数中心 + n-1个偶数中心),各向两边扩散 → O(n²) 时间、O(1) 空间。这是"够用解"。
④ Manacher :中心扩散的瓶颈在于------每个中心的半径都从0开始重新数 。
回文有对称性:如果我已知一个以C为中心、右边界到R的大回文,那么对于C右侧的某个位置i,它关于C的镜像点mirror = 2C - i的半径已经被算过了 ,可以直接拿来当i的初始半径。
Manacher的三个组件
- 预处理 :在字符间插入分隔符(如
#),把奇偶长度统一成奇数------"aba"变#a#b#a#,"abba"变#a#b#b#a# - 维护最右边界 :记录当前已探明的、右边界最靠右的那个回文的中心
center与右边界right - 镜像复用 :若
i < right,则p[i]的初始值可以取min(right - i, p[mirror]),不必从1开始扩散
那个min里的两个含义要分清 :p[mirror]是镜像点的半径(能复用多少),right - i是"别超出已知大回文的边界"(超出部分未经验证,不能用)。
一句话核心:用分隔符统一奇偶 → 用最右边界记住"探明区域" → 用镜像把新位置的半径初始化,从而让扩散总量变成线性。
🖼️ 图解算法(手把手走一遍)
以s = "abba"为例。预处理后t = "#a#b#b#a#",长度n = 9:
| 下标 | 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 |
|---|---|---|---|---|---|---|---|---|---|
| 字符 | # | a | # | b | # | b | # | a | # |
逐位推进(p[i]为以i为中心的回文半径,含中心;right为最右已探明边界):
| i | ti | 在边界内? | 镜像点 | pmirror | 初始pi | 扩散后pi | 更新后 (center, right) |
|---|---|---|---|---|---|---|---|
| 0 | # | 否 | --- | --- | 1 | 1 | (0, 1) |
| 1 | a | 否 | --- | --- | 1 | 2 | (1, 3) |
| 2 | # | 是(2<3) | 0 | 1 | min(1,1)=1 | 1 | (1, 3) |
| 3 | b | 否(3=3) | --- | --- | 1 | 2 | (3, 5) |
| 4 | # | 是(4<5) | 2 | 1 | min(1,1)=1 | 5 | (4, 9) |
| 5 | b | 是 | 3 | 2 | min(4,2)=2 | 2 | (4, 9) |
| 6 | # | 是 | 2 | 1 | min(3,1)=1 | 1 | (4, 9) |
| 7 | a | 是 | 1 | 2 | min(2,2)=2 | 2 | (4, 9) |
| 8 | # | 是 | 0 | 1 | min(1,1)=1 | 1 | (4, 9) |
关键看i = 4 :它落在已知回文(center=3, right=5)内,镜像点mirror = 2×3-4 = 2,p[2] = 1,于是初始半径直接给到1。随后扩散一路匹配到p[4] = 5,覆盖了t[0..8],对应原串"abba"------中心回文被一次性发现。
还原答案 :p[i]是在t中的半径,转回原串有固定公式:
起始下标start = (i - p[i] + 1) / 2
原串长度len = p[i] - 1
i=4 时:start = (4-5+1)/2 = 0,len = 5-1 = 4 → s[0:4] = "abba" ✓
💻 代码实现(Python + Java)
Python版
python
class Solution:
# ---------- 够用解:中心扩散O(n²) / O(1) ----------
def longestPalindrome_expand(self, s: str) -> str:
n = len(s)
if n < 2:
return s
start, max_len = 0, 1
for i in range(n):
# 奇数长度回文:中心是单个字符
l, r = i, i
while l >= 0 and r < n and s[l] == s[r]:
l -= 1
r += 1
if r - l - 1 > max_len: # 越界后r-l-1才是真实长度
max_len, start = r - l - 1, l + 1
# 偶数长度回文:中心是两个字符之间
l, r = i, i + 1
while l >= 0 and r < n and s[l] == s[r]:
l -= 1
r += 1
if r - l - 1 > max_len:
max_len, start = r - l - 1, l + 1
return s[start:start + max_len]
# ---------- 最优解:ManacherO(n) ----------
def longestPalindrome(self, s: str) -> str:
if not s:
return ""
t = '#' + '#'.join(s) + '#' # 插入分隔符统一奇偶
n = len(t)
p = [1] * n # p[i]:半径(含中心)
center = right = 0 # 最右回文的中心与右边界
best_i = 0
for i in range(n):
if i < right: # 在已知边界内 → 镜像复用
mirror = 2 * center - i
p[i] = min(right - i, p[mirror])
while (i - p[i] >= 0 and i + p[i] < n
and t[i - p[i]] == t[i + p[i]]):
p[i] += 1 # 从已有半径继续扩散
if i + p[i] > right: # 刷新最右边界
center, right = i, i + p[i]
if p[i] > p[best_i]:
best_i = i
start = (best_i - p[best_i] + 1) // 2
return s[start:start + p[best_i] - 1]
Java版
java
class Solution {
public String longestPalindrome(String s) {
if (s == null || s.length() == 0) return "";
StringBuilder sb = new StringBuilder("#");
for (char c : s.toCharArray()) sb.append(c).append('#');
String t = sb.toString();
int n = t.length();
int[] p = new int[n];
for (int i = 0; i < n; i++) p[i] = 1;
int center = 0, right = 0;
int bestI = 0;
for (int i = 0; i < n; i++) {
if (i < right) {
int mirror = 2 * center - i;
p[i] = Math.min(right - i, p[mirror]);
}
while (i - p[i] >= 0 && i + p[i] < n
&& t.charAt(i - p[i]) == t.charAt(i + p[i])) {
p[i]++;
}
if (i + p[i] > right) {
center = i;
right = i + p[i];
}
if (p[i] > p[bestI]) bestI = i;
}
int start = (bestI - p[bestI] + 1) / 2;
return s.substring(start, start + p[bestI] - 1);
}
}
⚠️ 防坑提醒(必看):
p的初值必须是 1(中心自身就是一个长度为1的回文),不是0。min(right - i, p[mirror])里的right - i不能省------它保证初始半径不越过"已探明区域"。- 转回原串的公式:
start = (i - p[i] + 1) // 2,len = p[i] - 1。
⏱️ 复杂度分析(面试必问)
- 时间O(n) :这是Manacher最反直觉的地方------明明有内层
while循环,为什么是线性?
因为每当扩散成功一次,right就至少右移一格,而right最多右移n次;
加上镜像复用时直接跳过扩散,总扩散次数被摊还成O(n)。
这属于摊还分析的结论(和双栈队列的均摊O(1)是同一类思想)。 - 空间O(n) :
p数组 + 预处理串t。
| 解法 | 时间 | 空间 |
|---|---|---|
| 暴力枚举 | O(n³) | O(1) |
| 区间DP | O(n²) | O(n²) |
| 中心扩散 | O(n²) | O(1) |
| Manacher | O(n) | O(n) |
实测:O(n)的保证值多少钱?
串长4000(CPython 3.13):
| 输入 | 最长回文长度 | Manacher | 中心扩散 | 结论 |
|---|---|---|---|---|
随机ab串 |
26 | 0.0027s | 0.0018s | 中心扩散快1.5× |
a/b/aa混合 |
40 | 0.0036s | 0.0031s | 中心扩散略快 |
全a(回文极长) |
4000 | 0.0023s | 0.7293s | Manacher快314倍 |
随机串上,Manacher反而慢 ------因为回文平均长度只有26,中心扩散接近O(n),而Manacher要先构造2n+1的分隔串、再走2n+1个中心,预处理常数白白付了出去。
但回文一旦变长,中心扩散立刻崩 :全a时它是实打实的O(n²)(每个中心都要扩2000次),耗时从0.0018s涨到 0.7293s,涨了400倍 ;而Manacher因为镜像复用,耗时反而略微下降。
这就是理论保证的价值:中心扩散的性能取决于数据,Manacher的性能不取决于数据。 生产环境的输入往往不是随机的------文本里可能出现"aaaa...aaa"、回文结构密集的HTML/XML片段、或用户恶意构造的退化输入。买Manacher,买的不是"平均更快",而是"任何输入都不会退化"。
🚀 隐藏价值:p数组还能一行统计回文子串数(LC.647)
p[i]不只是"半径",它直接等于以t[i]为中心、在原串里的回文子串个数:
python
def countSubstrings(s: str) -> int:
t = '#' + '#'.join(s) + '#'
n = len(t)
p = [1] * n
center = right = 0
for i in range(n):
if i < right:
mirror = 2 * center - i
p[i] = min(right - i, p[mirror])
while i - p[i] >= 0 and i + p[i] < n and t[i - p[i]] == t[i + p[i]]:
p[i] += 1
if i + p[i] > right:
center, right = i, i + p[i]
return sum(x // 2 for x in p) # 每个中心贡献 p[i]//2 个回文
公式是sum(p[i] // 2):逐个整除再求和 ,不能写成sum(p) // 2(先求和再除会多算,因为每个中心的取整损失被累积放大了)。
用s = "aaa"核一遍:t = "#a#a#a#",跑出p = [1, 2, 3, 4, 3, 2, 1],逐个整除求和0+1+1+2+1+1+0 = 6;而暴力枚举"aaa"的回文子串是"a"×3 + "aa"×2 + "aaa"×1 = 6 ✓(若误写成 sum(p) // 2会得到8,错在两个半截回文被凑成了一整个)。
这是Manacher的隐藏价值 :跑完一遍p数组,整串的回文结构信息就全在里面了。
🚀 举一反三:5道高频变体题
| 题号 | 题目 | 与本题的关系 |
|---|---|---|
| LC.647 | 回文子串(计数) | p数组直接求和,见上 |
| LC.516 | 最长回文子序列 | 注意是子序列,用区间DP,Manacher不适用 |
| LC.214 | 最短回文串 | KMP的应用,与 Manacher不同路 |
| LC.131 | 分割回文串 | 回文判定 + 回溯 |
| LC.5 | 最长回文子串 | 本题 |
💬 面试追问模拟(提前准备,惊艳全场)
Q1:为什么p[i]要取min(right - i, p[mirror])?
两个上界含义不同:
p[mirror]是"镜像点能复用多少半径",right - i是"不能超过已探明区域的右边界"。只有p[mirror]完全落在边界内时才完全复用;一旦超出,超出的部分没有对称性保证,必须老实扩散。
Q2:预处理插入#的作用是啥?
统一奇偶。奇回文(如
aba)中心是字符,偶回文(如abba)中心是空隙;插入分隔符后,两种回文在
t中都变成"以某个字符为中心的奇回文",代码只需处理一种情况。
Q3:Manacher和KMP有什么共通之处?
都是"已算过的信息不再重算 ":KMP用next数组记住最长前后缀,失配时模式串滑到该位置;
Manacher用p数组记住半径,新位置从镜像半径起步。两者都把O(n²)或更差的重复劳动压到O(n)。
Q4:工程里用得多吗?
不算高频。但它出现在一些特定领域:生物信息学的回文序列检测、文本处理的对称结构分析。面试中的价值主要是展示你对摊还分析和对称性利用的理解深度。
🧩 实战小技巧(刷题党必备)
- 口诀:插分隔符统奇偶,最右边界记探明,镜像复用省扩散。
- 模板:Manacher = 预处理 + 最右边界 + 镜像复用 + 扩散。
- 防坑 :
p初值必须为1;right - i不能省;还原公式别记错。
📈 实际应用场景(不止是刷题)
- 生物信息学:DNA回文序列识别(限制性内切酶切点)
- 文本处理:对称结构分析
- 数据压缩:对称模式检测
🎁 今日思考题
Manacher的
p数组其实还能直接算出"字符串里一共有多少个回文子串"(LC.647)。提示 :
p[i]是半径,半径内的每一层都是一个回文。你能想到怎么从
p[i]推出以i为中心的回文子串个数吗?