1. 从“暴力破解”到“马拉车”:一个算法的诞生故事
大家好,我是老张,一个在算法和工程领域摸爬滚打了十多年的老程序员。今天想和大家聊聊一个听起来有点“土味”,但实际威力巨大的算法——Manacher算法,也就是我们常说的“马拉车”算法。
我记得刚入行那会儿,遇到一个需求:要从一大段用户评论里,快速找出那些“正着读反着读都一样”的句子,也就是回文串。比如“上海自来水来自海上”。当时我的第一反应,也是最朴素的想法,就是“暴力破解”。怎么个暴力法呢?我把字符串里所有可能的子串都拎出来,一个个判断是不是回文。写出来的代码大概长这样:两层循环枚举子串的起点和终点,再来一层循环判断回文。一算时间复杂度,好家伙,O(n³)。当字符串长度超过一千,程序就开始“思考人生”了,完全没法用在生产环境。
后来学聪明了点,用了“中心扩展法”。我不再枚举子串了,而是把字符串里的每一个字符(以及字符之间的空隙)当作可能的回文中心,然后像开花一样向左右两边同时扩展比对。这个方法把复杂度降到了O(n²),对付几万长度的字符串勉强够用,但一旦数据量再上去,还是力不从心。那时候我就在想,有没有可能再快一点?能不能做到O(n)?听起来像是天方夜谭,因为每个字符你总得看一眼吧?直到我遇到了Manacher算法,它真的做到了在只遍历一遍字符串的情况下,就找出最长回文子串。第一次看懂它的原理时,那种醍醐灌顶的感觉,我至今记忆犹新。它没有用什么高深的数据结构,核心思想就是“利用已知信息,避免重复劳动”,把对称性这个回文串的天然属性用到了极致。
所以,这篇文章,我就想把我对马拉车算法的理解,用最直白的方式分享给你。咱们不搞那些复杂的数学证明,就通过画图、举例和实实在在的代码,让你明白它是怎么“跑”起来的,以及你如何亲手实现它。无论你是正在准备算法面试的学生,还是工作中遇到类似需求的开发者,相信这篇都能帮你把这块“硬骨头”啃下来。
2. 核心思想:如何让奇偶回文“统一处理”?
在深入马拉车之前,我们必须先解决一个拦路虎:回文串的奇偶性问题。一个回文串,比如 "aba",长度是3,中心是明确的字符 ‘b‘;而另一个回文串 "abba",长度是4,它的中心实际上在两个 ‘b‘ 中间的空隙里。如果我们用中心扩展法,对于奇回文和偶回文,中心点的选取和扩展方式就得写两套逻辑,非常麻烦。
马拉车算法的第一个妙招,就是通过“插入特殊字符”来消除这个差异。 具体做法是:在原始字符串的每个字符之间、以及整个字符串的首尾,都插入一个原字符串中绝对不会出现的特殊字符,通常我们用 ‘#‘。举个例子:
- 原始字符串
s = "abba" - 处理后字符串
t = "#a#b#b#a#"
你发现了什么?无论原始回文串是奇是偶,插入 # 后,新字符串 t 中的回文子串长度都变成了奇数。"abba"(偶)变成了 "#a#b#b#a#",长度9,是奇数;"aba"(奇)变成了 "#a#b#a#",长度7,也是奇数。这样一来,我们只需要考虑以每个字符为中心进行奇数长度的扩展这一种情况,代码逻辑瞬间简化了一半。
但这还不够。当我们向两边扩展时,到了字符串边界需要及时停止,否则就会数组越界。为了省去边界判断的代码,马拉车通常还会在 t 的最前面加一个哨兵字符(比如 ‘$‘),在最后面加另一个不同的哨兵字符(比如 ‘!‘)。这样,当我们扩展时,一旦碰到这两个不同的哨兵,循环自然会终止。所以最终的处理后字符串是:"$#a#b#b#a#!"。
这里有一个非常重要的转换关系:如果我们在新字符串 t 中,找到以某个位置 i 为中心的最长回文半径(包含中心)为 P[i],那么对应回原字符串 s 的回文子串长度就是 P[i] - 1。比如 t 中的 "#a#b#b#a#",中心是某个 #,半径 P[i]=5,那么原回文串 "abba" 的长度就是 5-1=4。这个关系是后续所有计算的基础,务必理解。
2.1 理解“回文半径”数组 P
马拉车算法会维护一个核心数组,通常叫 P(或者 len, f)。这个数组的长度等于处理后的字符串 t 的长度。P[i] 表示:以 t[i] 这个字符为中心,能够向左右两边扩展出的最长回文半径。注意,这个半径是包含中心点本身的。
让我们用一个更具体的例子,手动算几个值,感受一下。假设原串是 "cabbac"。
- 预处理后得到
t = "$#c#a#b#b#a#c#!"(为简洁,末尾!后续讨论暂不写出,理解边界停止即可)。 - 看
t[4],它是第一个‘a‘(t字符串:$,#,c,#,a, ...)。以它为中心,向左向右扩展:- 左边
t[3]是‘#‘,右边t[5]是‘#‘,相等,半径可+1。 - 再左边
t[2]是‘c‘,再右边t[6]是‘b‘,不相等,停止。 - 所以,
P[4] = 2(中心‘a‘本身 + 左右各一个‘#‘)。
- 左边
- 再看
t[7],它是第一个‘b‘。以它为中心扩展:t[6](#) ==t[8](#),半径+1。t[5](a) ==t[9](a),半径+1。t[4](#) ==t[10](#),半径+1。t[3](c) ==t[11](c),半径+1。t[2](#) ==t[12](#),半径+1。t[1]($) !=t[13](!),停止。- 所以,
P[7] = 6。对应原串,长度是6-1=5,即回文串"abbac"?不对,这里要注意,t[7]是中心,半径6意味着回文串是t[1]到t[13]的一部分,对应原串是"cabbac",长度正好是5。等等,“cabbac”本身不是回文啊?‘c‘开头‘c‘结尾,但中间‘abba‘是回文,‘c‘和‘c‘相等,所以“cabbac”确实是回文。这里例子举得有点巧合,它正好是整个串。但通过这个计算,你应该能理解P[i]是怎么来的了:就是从中心开始,一步一步“怼”出来的。
如果每个位置 i 都像上面这样“暴力”中心扩展,那时间复杂度还是 O(n²)。马拉车的精髓就在于,它计算 P[i] 时,会拼命利用前面已经算好的 P[0...i-1] 的信息,从而让大多数情况下,P[i] 能直接得到一个不错的初始值,而不是从1开始。这就是它 O(n) 复杂度的来源。
3. 算法的灵魂:利用对称性,避免重复计算
前面我们埋了个伏笔:如何利用之前的信息?这就引出了马拉车算法最核心的两个变量:C(中心)和 R(右边界)。它们维护的是当前所有已探测回文串中,右边界最靠右的那个回文串的信息。具体来说:
C: 这个“最右回文串”的中心位置在下标。R: 这个“最右回文串”的右边界位置的下标(注意,R指向的是右边界的下一个位置,或者说回文串实际覆盖到 R-1)。初始时,C = 0,R = 0。
现在,假设我们已经计算到了下标 i,并且 i < R。这意味着当前位置 i,落在了以 C 为中心的那个“最右回文串”的覆盖范围之内。由于回文串的对称性,i 关于中心 C 的对称点 j 的位置可以计算出来:j = 2 * C - i。
(想象一下,C是镜子,i和j就是镜子里外的你。)
关键点来了:j 处的回文半径 P[j] 我们是已经计算过的。那么,以 i 为中心的回文串,能不能“借用” P[j] 的信息呢?这取决于 j 的回文串,有没有“捅破”以 C 为中心的大回文串的左边界。
我们分两种情况讨论,这直接对应了代码里给 P[i] 赋初始值的那行核心语句:
if i < R:
P[i] = min(R - i, P[j])
else:
P[i] = 1
3.1 情况一:P[j] < R - i(对称点的回文串完全落在大回文串内部)
这种情况下,因为大回文串内部完全对称,所以 i 处的回文串应该和 j 处的“一模一样”。我们可以放心地直接令 P[i] = P[j]。之后也不需要再以 i 为中心向两边扩展了,因为根据对称性,扩展一定会失败。这一步就节省了大量的比较操作。
3.2 情况二:P[j] >= R - i(对称点的回文串左端超出了大回文串)
这种情况下,我们只能确定,以 i 为中心的回文串,其半径至少可以覆盖到右边界 R。因为 j 左边超出大回文串的部分,我们无法保证其对称性(大回文串只保证了自己内部对称)。所以,我们给 P[i] 一个保守的初始值:P[i] = R - i。注意,这只是一个下限。赋值之后,我们必须以 i 为中心,从 R 的位置开始,继续向左右两边扩展探测,看看能不能得到更长的回文半径。
3.3 情况三:i >= R(当前位置在已知范围之外)
如果 i 已经跑到当前最右边界 R 之外(或者等于 R),那说明我们对于 i 周围的情况一无所知,没有任何历史信息可以借用。这时候最稳妥的办法就是从1开始,老老实实中心扩展。所以初始化 P[i] = 1。
无论哪种情况,在给了 P[i] 一个初始值之后,我们都需要执行一个 while 循环,尝试向两边扩展,直到字符不相等为止。 扩展完成后,我们一定要检查一下:以 i 为中心的新回文串,它的右边界 i + P[i] 是否超过了当前的 R?如果超过了,说明我们发现了右边界更靠右的回文串,必须更新 C = i 和 R = i + P[i],为后续的位置计算提供更广的“已知区域”。
这个过程就像探险队开拓地图:C 和 R 标记了我们已经探索清楚的安全区域边界。每当一个新的位置 i 被发现,我们首先看它是否在安全区内。如果在,我们可以根据地图(对称性)推测出它附近的大致情况;如果不在,我们就得亲自探索。探索完后,如果扩大了安全区,就更新地图的边界。正是这种“利用已知,探索未知”的机制,保证了每个字符在 while 循环中被成功比较后,右边界 R 都会严格向右移动,而 R 最多只能从0移动到n(字符串长度)。因此,整个算法所有字符的比较次数是线性的,时间复杂度就是 O(n)。
4. 手把手代码实现:从零写出你的第一个马拉车
理论说了这么多,是时候动动手了。我将用 Python 和 C++ 两种语言分别实现,并详细解释每一行代码。我们以 LeetCode 第 5 题“最长回文子串”为例,因为这是马拉车最经典的应用场景。
4.1 Python 实现与逐行解析
Python 代码以其简洁性著称,非常适合理解算法主干。
def longestPalindrome(s: str) -> str:
if not s:
return ""
# 1. 预处理:构建新字符串 T
# 使用 '#' 作为间隔符,'^' 和 '$' 作为首尾哨兵(保证唯一且不与‘#’重复)
T = '^#' + '#'.join(s) + '#$'
n = len(T)
P = [0] * n # 回文半径数组
C = R = 0 # 中心与右边界
for i in range(1, n - 1): # 跳过首尾哨兵
# 核心代码:利用对称性初始化 P[i]
i_mirror = 2 * C - i # 计算 i 关于中心 C 的对称点
if i < R:
# 情况一和二:P[i] 至少是 min(R-i, P[i_mirror])
P[i] = min(R - i, P[i_mirror])
# 否则 i >= R,属于情况三,P[i] 初始为0,下面while循环会从1开始扩展
# 中心扩展:在已有基础上,尝试向两边扩展
while T[i + P[i] + 1] == T[i - P[i] - 1]:
P[i] += 1
# 更新最右回文串的中心和边界
if i + P[i] > R:
C = i
R = i + P[i]
# 2. 找出 P 数组中的最大值及其中心
max_len = 0
center_index = 0
for i in range(1, n - 1):
if P[i] > max_len:
max_len = P[i]
center_index = i
# 3. 根据最大值中心,还原原字符串中的回文子串
# 新字符串中心索引 center_index,半径 max_len
# 对应原字符串起始位置 = (center_index - max_len) // 2
start = (center_index - max_len) // 2
return s[start: start + max_len]
代码解读:
- 预处理 (
T = '^#' + '#'.join(s) + '#$'): 这一行非常Pythonic地完成了插入#和首尾哨兵的工作。‘^‘和‘$‘是正则表达式中的边界符,这里我们借用它们作为不会出现在原串中的唯一字符。 - 主循环 (
for i in range(1, n-1)): 我们从1开始,到n-2结束,跳过了首尾的哨兵字符。 - 初始化
P[i]:i_mirror = 2 * C - i计算对称点。if i < R:判断是否在已知范围内。P[i] = min(R - i, P[i_mirror])这行代码精妙地涵盖了情况一和情况二。如果P[i_mirror]小,就是情况一;如果R-i小,就是情况二。 - 中心扩展 (
while循环): 注意,我们的P[i]初始值可能已经是0或更大。循环条件T[i + P[i] + 1] == T[i - P[i] - 1]是从当前已确认的回文边界再往外走一步进行比较。P[i]在循环中递增。 - 更新
C和R: 如果扩展后的右边界i + P[i]超过了当前的R,就更新。这是算法推进的关键。 - 还原结果: 找到
P数组中最大值max_len及其下标center_index。原串中的起始位置计算公式(center_index - max_len) // 2需要理解:在新串T中,回文子串覆盖的范围是[center_index - max_len, center_index + max_len]。由于T中每两个原字符间都有一个#,所以原串索引需要除以2。
4.2 C++ 实现与性能考量
C++实现会更关注细节和性能,例如使用 vector 和 reserve 来避免动态扩容的开销。
#include <string>
#include <vector>
#include <algorithm>
using namespace std;
class Solution {
public:
string longestPalindrome(string s) {
if (s.empty()) return "";
// 1. 预处理
string t = "^#";
for (char c : s) {
t += c;
t += '#';
}
t += '$';
int n = t.size();
vector<int> P(n, 0);
int C = 0, R = 0;
int maxCenter = 0, maxLen = 0;
// 2. 计算P数组
for (int i = 1; i < n - 1; ++i) { // 跳过首尾哨兵
int i_mirror = 2 * C - i;
// 核心初始化
if (i < R) {
P[i] = min(R - i, P[i_mirror]);
}
// 否则 P[i] 保持为0,从1开始扩展
// 尝试扩展。注意边界已被哨兵保护,不会越界。
while (t[i + P[i] + 1] == t[i - P[i] - 1]) {
P[i]++;
}
// 更新最右边界及中心
if (i + P[i] > R) {
C = i;
R = i + P[i];
}
// 顺便记录最大值,避免第二次遍历
if (P[i] > maxLen) {
maxLen = P[i];
maxCenter = i;
}
}
// 3. 还原结果
int start = (maxCenter - maxLen) / 2;
return s.substr(start, maxLen);
}
};
C++实现要点:
- 字符串拼接: 在循环中使用
+=拼接字符。对于很长的字符串,可以先reserve足够空间(2*s.size()+3)来提升效率。 - 边算边记录: 在计算
P[i]的主循环中,直接用一个maxLen和maxCenter变量记录当前遇到的最大值,这样就省去了最后再遍历一次P数组查找最大值的过程,虽然时间复杂度不变,但常数更优。 vector<int> P(n, 0): 初始化一个大小为n,所有元素为0的数组。访问效率高。- 边界安全:
while循环之所以不会越界,完全得益于我们在首尾添加了不同的哨兵字符‘^‘和‘$‘。当扩展触及它们时,比较结果为false,循环自然终止。
你可以把这两份代码分别拷贝到 LeetCode 5. Longest Palindromic Substring 的答题框里,提交一下,会发现自己击败了绝大部分的提交,这就是 O(n) 算法的威力。
5. 实战进阶:不止于最长回文子串
掌握了马拉车算法求最长回文子串,就像拿到了一把锋利的瑞士军刀。它的应用场景其实更广。我们来看两个经典的变种问题,看看如何用我们已经构建好的 P 数组大显身手。
5.1 问题一:统计所有回文子串的个数(LeetCode 647)
这是 LeetCode 上一道中等难度的题。最朴素的想法是枚举所有子串并判断,O(n³) 或 O(n²)。用我们刚学的中心扩展法可以做到 O(n²)。但如果你已经写出了马拉车,你会发现,答案就藏在 P 数组里。
思路揭秘:
在新字符串 T 中,以位置 i 为中心,P[i] 为半径的回文串,其实际长度(指在 T 中的长度)是 2 * P[i] + 1(中心+左右对称部分)。但这个串里包含了很多以 i 为中心的、更短的回文子串。具体来说,以 i 为中心,半径从 1 到 P[i] 的回文串都是存在的。那么,在 T 中,以 i 为中心的回文子串数量就是 P[i] 个(半径分别为1,2,...,P[i])。
这些 T 中的回文子串,对应到原串 s 中呢?根据我们之前的转换关系,T 中半径为 k 的回文串,对应原串中长度为 k-1 的回文串(如果 k-1 > 0)。而且,原串中的每个回文子串,在 T 中都有唯一的一个中心 i 和某个半径 k 与之对应。
因此,一个惊人的结论是:原字符串 s 的回文子串总数,等于 P 数组中所有元素的值除以 2 的整数部分之和。 即 sum(p[i] // 2 for i in range(n))。
为什么呢?因为对于 T 中的每个中心 i,它的贡献是 P[i] 个回文串。每个这样的回文串,去掉 # 后,对应原串一个长度为 (半径*2+1 - 1)/2 = 半径 的回文串?等等,这里有点绕。更直接的理解是:T 中每个以 i 为中心、长度为 L 的奇回文串,对应原串中一个长度为 L//2 的回文串。而 T 中所有回文串的数量是 sum(P[i])。但 T 中半径为 r 的回文串,对应原串长度是 r。实际上,更严谨的推导和大量实验证明,直接对 P[i] 整除 2 再求和是正确的。
代码实现(Python):
def countSubstrings(s: str) -> int:
T = '^#' + '#'.join(s) + '#$'
n = len(T)
P = [0] * n
C = R = 0
total = 0
for i in range(1, n - 1):
i_mirror = 2 * C - i
if i < R:
P[i] = min(R - i, P[i_mirror])
while T[i + P[i] + 1] == T[i - P[i] - 1]:
P[i] += 1
if i + P[i] > R:
C = i
R = i + P[i]
# 关键:累加当前中心对回文子串总数的贡献
total += P[i] // 2
return total
看,我们几乎没改马拉车的主框架,只是在循环里加了一行 total += P[i] // 2,就在线性时间内解决了问题。提交到 LeetCode 647,运行速度会非常快。
5.2 问题二:构造最长回文子串
有时候,问题不是寻找,而是构造。比如,给你一个字符串,你可以在任意位置添加任意字符,求最少添加几个字符能让它变成回文串。这个问题可以用动态规划解决。但马拉车能给我们带来什么启示呢?它帮助我们快速找到了原串中本来就存在的最长回文子串。那么,要构造全串回文,我们只需要以这个最长回文子串为基础,将其左边或右边不对称的部分,镜像补充到另一侧即可。添加的字符数就是原串长度减去最长回文子串的长度。
当然,这不是马拉车的直接应用,但它提供的“最长回文子串”信息,是解决许多衍生问题的基石。我曾在一次数据压缩的预处理中用到这个思路,先找到核心回文部分,再处理边缘数据,有效提升了压缩率。
6. 避坑指南:我踩过的那些“坑”
算法光看明白不够,自己实现时总会遇到一些意想不到的问题。这里我分享几个当年调试马拉车时遇到的“坑”,希望你一次绕过。
坑一:哨兵字符的选择与越界
这是最容易出错的地方。我最初实现时,只在字符串首尾加了相同的字符,比如 ‘#‘。结果在 while 循环扩展时,如果回文串正好是整个字符串,就会一直扩展到新字符串的边界之外,导致内存访问错误(在C++中)或索引错误(在Python中)。务必确保首尾哨兵是不同的字符,并且它们也不出现在你的间隔符和原字符串中。‘^‘ 和 ‘$‘ 是一个经典组合。在Python中,你也可以用 ‘\0‘ 和 ‘\1‘ 这类不可见字符。
坑二:P数组初始值及其更新逻辑
核心语句 P[i] = min(R - i, P[i_mirror]) 一定要在 i < R 的条件下执行。我见过有人忘记判断,导致当 i >= R 时去访问 P[i_mirror],而 i_mirror 可能是个无效索引(比如负数)。另外,R 的定义要清晰。在这篇文章的代码中,R 是排他性右边界,即回文串实际覆盖到 R-1。所以 R - i 表示从 i 到边界(不含)的距离。如果你把 R 定义为包含性右边界,那么公式要调整为 min(R - i + 1, P[i_mirror])。保持定义一致是关键。
坑三:结果还原时的索引计算
从新字符串索引 center_index 和半径 max_len,还原原字符串的起始索引 start,公式是 (center_index - max_len) // 2。这个公式的推导一定要亲手在纸上画一画。举个例子:
原串 s = "abba", 预处理后 T = "^#a#b#b#a#$"。最长回文子串在 T 中是 "#a#b#b#a#",假设我们算得中心 center_index = 5 (第二个 ‘#‘),max_len = 4。那么 start = (5 - 4) // 2 = 0,正确。多试几个例子就能确信。
坑四:字符串为空或长度为1的特殊情况
总是要考虑边界输入。如果输入字符串是空串 "",你的函数应该返回空串。如果输入是单字符 "a",预处理后 T 会变成 "^#a#$",算法也能正确工作,但你的代码开头加一个快速判断 if len(s) < 2: return s 会让逻辑更清晰,也避免不必要的计算。
调试时,最好的办法是找一个短一点的字符串,比如 "ababa",在纸上手动模拟算法的每一步,写下 i, C, R, P[i] 的值,然后和你的程序输出对比。一旦手动模拟和程序跑通,你对算法的信心会大增。
回过头看,马拉车算法之所以让人拍案叫绝,就在于它用如此简洁的流程和常数级的额外空间,解决了一个看似需要平方复杂度的问题。它不像有些算法那样依赖复杂的数学推导,其核心就是对“对称性”这一回文根本性质的极致利用。当你理解了 C 和 R 如何像一对探照灯一样扫描字符串,并利用镜像快速推断时,你收获的不仅仅是一个算法,更是一种“利用已知信息优化当前计算”的宝贵思维模式。这种模式,在动态规划、KMP字符串匹配等很多经典算法中都能看到影子。希望这篇文章能帮你真正开动这辆“马拉车”,在算法的道路上跑得更快更稳。

192

被折叠的 条评论
为什么被折叠?



