问题引入
回文子串查找是字符串处理中的基础问题。给定一个字符串,要求找出其中最长的回文子串。暴力解法需要O(N^3)时间,中心扩展法优化到O(N^2),而Manacher算法(中文常译为马拉车算法)能在O(N)时间内完成求解,且实现简洁。本文用Java完整实现马拉车算法,并深入讲解其对称扩展的核心思想。
算法原理
预处理
原始字符串中回文子串的长度可能是奇数也可能是偶数。马拉车算法通过在每个字符间插入一个不会出现在原串中的分隔符(如#),将问题统一转化为寻找奇数长度回文。例如 "abba" 变为 "#a#b#b#a#",原串中的偶回文对应新串中的奇回文。
对称扩展与镜像复用
维护一个当前已知最右回文子串的右边界right及其中心center。对于位置i,如果i < right,则i关于center的对称点mirror = 2 * center - i的回文半径p[mirror]可以直接复用,因为对称性保证了i周围至少存在长度为p[mirror]的回文。随后只需要从right继续向外扩展验证即可。如果i >= right,则只能暴力扩展。
完整Java实现
package algorithm.manacher;
/**
* 马拉车算法(Manacher)
* 线性时间O(N)查找最长回文子串
*/
public class ManacherAlgorithm {
/**
* 查找最长回文子串
* @param s 输入字符串
* @return 最长回文子串
*/
public String longestPalindrome(String s) {
if (s == null || s.length() < 2) {
return s;
}
// 预处理:插入分隔符,统一为奇数长度回文
char[] t = preprocess(s);
int n = t.length;
int[] p = new int[n]; // p[i]表示以i为中心的回文半径(含中心)
int center = 0; // 当前最右回文的中心
int right = 0; // 当前最右回文的右边界(不含)
int maxLen = 0; // 最大回文长度
int centerIndex = 0; // 最大回文的中心索引
for (int i = 0; i < n; i++) {
// 利用对称性确定初始回文半径
int mirror = 2 * center - i;
if (i < right) {
// p[mirror]是镜像点的半径,但不能超过right-i的剩余长度
p[i] = Math.min(right - i, p[mirror]);
}
// 中心扩展:尝试继续扩大以i为中心的回文
int a = i + (1 + p[i]); // 右侧尝试扩展位置
int b = i - (1 + p[i]); // 左侧尝试扩展位置
while (b >= 0 && a < n && t[a] == t[b]) {
p[i]++;
a++;
b--;
}
// 更新最右回文边界
if (i + p[i] > right) {
center = i;
right = i + p[i];
}
// 记录最大回文
if (p[i] > maxLen) {
maxLen = p[i];
centerIndex = i;
}
}
// 从预处理串中还原原串的回文子串
int start = (centerIndex - maxLen) / 2;
return s.substring(start, start + maxLen);
}
/**
* 预处理字符串,在每个字符间插入'#',并在首尾添加哨兵
* 例如 "abba" -> "^#a#b#b#a#$"
*/
private char[] preprocess(String s) {
int n = s.length();
char[] t = new char[n * 2 + 3];
t[0] = '^';
t[n * 2 + 2] = '$';
for (int i = 0; i < n; i++) {
t[i * 2 + 1] = '#';
t[i * 2 + 2] = s.charAt(i);
}
t[n * 2 + 1] = '#';
return t;
}
/**
* 查找所有回文子串的数量(允许重叠)
*/
public int countPalindromicSubstrings(String s) {
if (s == null || s.length() == 0) return 0;
char[] t = preprocess(s);
int n = t.length;
int[] p = new int[n];
int center = 0, right = 0;
int count = 0;
for (int i = 1; i < n - 1; i++) {
int mirror = 2 * center - i;
if (i < right) {
p[i] = Math.min(right - i, p[mirror]);
}
while (t[i + (1 + p[i])] == t[i - (1 + p[i])]) {
p[i]++;
}
if (i + p[i] > right) {
center = i;
right = i + p[i];
}
// 每个位置贡献的回文子串数量等于回文半径
count += p[i];
}
return count;
}
/**
* 批量测试
*/
public static void main(String[] args) {
ManacherAlgorithm solver = new ManacherAlgorithm();
String[] testCases = {
"babad",
"cbbd",
"a",
"ac",
"aaaa",
"abacdfgdcaba",
"abcba"
};
System.out.println("=== 最长回文子串测试 ===");
for (String test : testCases) {
String result = solver.longestPalindrome(test);
System.out.printf("输入: %-20s -> 最长回文: %s%n", "\"" + test + "\"", result);
}
System.out.println("\n=== 回文子串计数测试 ===");
for (String test : testCases) {
int count = solver.countPalindromicSubstrings(test);
System.out.printf("输入: %-20s -> 回文数量: %d%n", "\"" + test + "\"", count);
}
}
}
复杂度分析
| 指标 | 中心扩展 | 马拉车算法 |
|---|---|---|
| 时间 | O(N^2) | O(N) |
| 空间 | O(1) | O(N) |
马拉车算法的线性时间保证来自于每个字符最多被访问常数次。当i < right时,直接利用镜像结果避免重复扩展;当需要扩展时,扩展的结果会推动right右移,而right在整个算法过程中只增不减,因此总扩展次数为O(N)。
关键技巧总结
- 分隔符预处理将偶回文和奇回文统一为奇回文,大幅简化逻辑
mirror = 2 * center - i是对称点坐标计算的核心公式p[mirror]不能直接复用,需取min(right - i, p[mirror])防止越界- 哨兵字符
^和$可避免边界检查,提升代码简洁性