Manacher算法详解:O(n)时间求解最长回文子串的C++实现
2026/7/12 12:33:49 网站建设 项目流程

1. 项目概述:为什么我们需要Manacher算法?

如果你刷过LeetCode或者参加过算法竞赛,肯定遇到过“最长回文子串”这道经典题目。最直观的解法是枚举所有可能的子串中心,然后向两边扩展,时间复杂度是O(n²)。对于较短的字符串,这或许可以接受,但一旦字符串长度达到10⁵甚至10⁶级别,O(n²)的算法就会立刻超时。这时,Manacher算法就登场了——它能在O(n)的线性时间内,找出一个字符串中所有可能的回文子串信息。

我第一次接触Manacher是在准备一场关键的算法面试时,当时觉得这个算法名字听起来很“唬人”,原理似乎也很绕。但真正理解并亲手用C++实现一遍后,才发现它的设计极其精妙,核心思想是利用了回文串的对称性来避免重复计算。这篇文章,我就从一个C++开发者的实战角度,带你彻底吃透Manacher算法,从暴力解法到算法优化,再到手把手实现,最后分析时间空间复杂度。无论你是为了面试刷题,还是为了提升算法内功,这篇近万字的详解都能让你一次搞懂。

2. 核心思路拆解:从暴力到智慧的跨越

2.1 问题定义与暴力解法

首先明确我们要解决的问题:给定一个长度为n的字符串s,找出其所有回文子串,或者至少找出最长的回文子串。一个字符串是回文串,意味着它正着读和反着读是一样的,比如 “aba”, “abba”。

最朴素的暴力解法有两种:

  1. 枚举所有子串:双重循环枚举所有起始位置i和结束位置j,判断s[i…j]是否是回文。判断回文需要O(j-i+1)时间,总复杂度高达O(n³)。
  2. 中心扩展法:这是更常见的优化。遍历每个可能的“中心点”(共有n个奇数长度中心,n-1个偶数长度中心),从中心向两边扩展,直到字符不匹配。这种方法的时间复杂度是O(n²)。

中心扩展法的C++代码大概长这样:

int expandAroundCenter(const string& s, int left, int right) { while (left >= 0 && right < s.size() && s[left] == s[right]) { left--; right++; } return right - left - 1; // 返回回文串长度 } // 主函数中需要分别处理奇偶中心

这个算法在面试中常作为热身题,但它O(n²)的复杂度在面对大数据时依然不够看。我们需要思考:在中心扩展的过程中,是否存在大量重复计算?

2.2 Manacher算法的核心洞察:对称性与状态复用

Manacher算法的天才之处在于,它发现并利用了回文串的一个关键性质:对称性

假设我们已知一个以位置center为中心、右边界为right的很长的回文串。现在我们要计算位置iicenter右侧且在right左侧)的回文半径。由于回文串的对称性,i关于center的对称点是j = 2 * center - i

关键推理:如果以j为中心的回文串完全被包含在以center为中心的大回文串内部(即j的回文左边界没有超过大回文串的左边界),那么根据对称性,i的回文半径至少等于j的回文半径。因为大回文串内部的镜像区域是完全相同的。

这个“至少”非常重要,它让我们能直接获得一个安全的起始扩展半径,而不是每次都从1开始扩展。如果j的回文左边界超出了大回文串的范围,那么i的回文半径最多只能扩展到right - i的位置,因为超出部分无法利用对称性保证。

这就是Manacher算法的核心:维护一个当前已知的、右边界最靠右的回文串,利用它的对称性来加速后续中心点的计算

2.3 奇偶统一处理:插入分隔符的技巧

中心扩展法需要分别处理奇偶长度回文串,这增加了代码复杂度。Manacher采用了一个非常巧妙的预处理技巧:在原始字符串的每个字符之间以及首尾插入一个相同的、不会在原串中出现的分隔符(常用#)。

例如,原始字符串s = “abba”,预处理后变成t = “#a#b#b#a#”

这样做有两个巨大好处:

  1. 统一奇偶性:新字符串t的长度总是奇数(2*n + 1)。这样,无论是原始字符串中的奇数长度回文(如”aba”)还是偶数长度回文(如”abba”),在t中都对应一个奇数长度的回文串(分别对应”#a#b#a#”和”#a#b#b#a#”)。我们只需要处理奇数长度回文这一种情况。
  2. 简化边界判断:由于我们在首尾也插入了分隔符,在扩展时无需特殊处理边界,因为边界字符一定是#,不可能匹配,循环会自动终止。

预处理后,算法的目标就变成了:对于新字符串t,计算一个数组p,其中p[i]表示以t[i]为中心的最长回文子串的半径长度(包含中心点)。例如,对于”#a#b#a#”,中心b处的p值为4(回文串是”#a#b#a#”,半径是4)。

3. 算法流程与C++实现详解

理解了核心思想,我们来看具体的算法步骤和C++实现。我会逐行解释,并附上完整的、可运行的代码。

3.1 算法步骤分解

假设我们已经得到了预处理后的字符串t,长度为mm = 2*n + 1)。我们需要计算数组p[0…m-1]

我们维护两个关键变量:

  • center: 当前已知的、右边界最靠右的回文串的中心位置。
  • right: 该回文串的右边界索引(开区间,即实际右边界是right-1)。

算法从i=0遍历到i=m-1

  1. 初始化p[i]
    • 如果i >= right,说明当前位置i不在任何已知回文串的“保护”范围内,我们没有任何先验信息,只能从1开始朴素扩展。所以设p[i] = 1
    • 如果i < right,说明i在某个已知回文串内部。找到i关于center的对称点j = 2*center - i。那么p[i]至少可以取min(p[j], right - i)。这是因为:
      • p[j]是以j为中心的回文半径。如果以j为中心的回文串完全包含在大回文串内,那么p[i]可以直接等于p[j]
      • right - ii到大回文串右边界的距离。即使j的回文串超出了大回文串左边界,i的回文串也不可能超过right(因为超出部分无法保证对称性)。 取两者最小值,是安全的起始半径。
  2. 中心扩展:在确定了p[i]的初始值后,我们尝试向两边扩展。只要满足i - p[i] >= 0 && i + p[i] < m && t[i - p[i]] == t[i + p[i]],就说明可以继续扩展,p[i]++
  3. 更新维护的回文串:扩展完成后,如果以i为中心的回文串的右边界i + p[i]超过了当前的right,说明我们找到了一个更靠右的回文串。更新center = iright = i + p[i]

3.2 完整C++代码实现

下面是我在项目中常用的、经过优化的C++实现。代码包含了预处理、核心算法以及后处理获取原始字符串最长回文子串的功能。

#include <iostream> #include <string> #include <vector> #include <algorithm> using namespace std; class Manacher { public: // 预处理:将原字符串s转换为添加分隔符的新字符串t // 例如 "abba" -> "^#a#b#b#a#$" // 这里在首尾添加了'^'和'$'作为哨兵,可以避免边界检查 string preProcess(const string& s) { int n = s.length(); if (n == 0) return "^$"; string t = "^"; for (int i = 0; i < n; i++) { t += "#"; t += s[i]; } t += "#$"; return t; } // 核心Manacher算法,返回预处理字符串t的半径数组p vector<int> manacher(const string& t) { int m = t.length(); vector<int> p(m, 0); int center = 0, right = 0; for (int i = 1; i < m - 1; i++) { // 跳过首尾哨兵 int i_mirror = 2 * center - i; // i关于center的对称点 // 关键步骤:利用对称性确定p[i]的初始值 if (right > i) { p[i] = min(right - i, p[i_mirror]); } else { p[i] = 0; // 不在已知回文串保护范围内,从0开始 } // 尝试中心扩展 while (t[i + p[i] + 1] == t[i - p[i] - 1]) { p[i]++; } // 如果扩展后的右边界超过了当前right,更新center和right if (i + p[i] > right) { center = i; right = i + p[i]; } } return p; } // 获取原始字符串的最长回文子串 string longestPalindrome(const string& s) { if (s.empty()) return ""; string t = preProcess(s); vector<int> p = manacher(t); // 找到半径最大的中心点 int maxLen = 0; int centerIndex = 0; for (int i = 1; i < t.length() - 1; i++) { if (p[i] > maxLen) { maxLen = p[i]; centerIndex = i; } } // 将预处理字符串中的中心位置映射回原始字符串 // 在t中,原始字符出现在奇数索引位置:1, 3, 5, ... // 半径maxLen对应原始字符串的长度就是maxLen int start = (centerIndex - maxLen) / 2; return s.substr(start, maxLen); } // 获取所有回文子串的数量(可选功能) int countPalindromes(const string& s) { if (s.empty()) return 0; string t = preProcess(s); vector<int> p = manacher(t); int total = 0; // 对于预处理字符串t中的每个位置i(跳过哨兵) // p[i]表示以t[i]为中心的最长回文半径(包含中心) // 那么以t[i]为中心的回文子串数量就是p[i](半径是多少,就有多少个不同半径的回文串) for (int i = 1; i < t.length() - 1; i++) { total += p[i]; } // 注意:这里计算的是预处理字符串中的回文子串数量 // 如果需要原始字符串中的数量,需要根据奇偶性调整 return total; } }; // 测试函数 int main() { Manacher solver; // 测试用例1:普通情况 string s1 = "babad"; cout << "原始字符串: " << s1 << endl; cout << "最长回文子串: " << solver.longestPalindrome(s1) << endl; cout << "回文子串总数: " << solver.countPalindromes(s1) << endl; cout << endl; // 测试用例2:偶数长度回文 string s2 = "cbbd"; cout << "原始字符串: " << s2 << endl; cout << "最长回文子串: " << solver.longestPalindrome(s2) << endl; cout << endl; // 测试用例3:全相同字符 string s3 = "aaaa"; cout << "原始字符串: " << s3 << endl; cout << "最长回文子串: " << solver.longestPalindrome(s3) << endl; cout << "回文子串总数: " << solver.countPalindromes(s3) << endl; return 0; }

3.3 代码逐行解析与关键点

  1. 预处理函数preProcess

    • 我选择了^$作为首尾哨兵。这比单纯用#更好,因为^$通常不会出现在输入字符串中,而且它们明确标记了字符串的边界,在扩展时while循环遇到它们会自动停止,无需额外的索引检查。
    • 预处理后的字符串格式为:^#a#b#b#a#$。注意,原始字符a出现在索引1, 3, 5…的位置(奇数索引),而#出现在偶数索引(除了首尾的^$)。
  2. 核心算法函数manacher

    • vector<int> p(m, 0)p[i]表示以t[i]为中心的最长回文子串的半径(从中心到一边的字符数,包含中心自身)。例如,对于回文串”#a#b#a#”,中心bp值为3(半径包含b、左边的#a和右边的#a,但注意我们计算的是半径长度,不是直径)。
    • int i_mirror = 2 * center - i:计算对称点。这是利用中点公式:center = (i + i_mirror) / 2
    • if (right > i):判断i是否在[center - radius, center + radius]这个当前最右回文串的范围内。注意right是开区间,所以是>而不是>=
    • p[i] = min(right - i, p[i_mirror]):这是算法的精髓。right - ii到右边界的距离,p[i_mirror]是对称点的半径。取最小值确保了我们的初始扩展不会超出已知的“安全区域”。
    • while循环:进行中心扩展。注意我写的是t[i + p[i] + 1] == t[i - p[i] - 1],这是因为p[i]初始值可能为0,我们从中心向左右各扩展一位开始比较。这种写法比先赋值再while循环内p[i]++更清晰。
    • 更新centerright:只有当i + p[i] > right时才更新,保证了right是单调不减的,这是算法O(n)复杂度的关键。
  3. 后处理与映射

    • longestPalindrome函数中,我们找到p数组中的最大值maxLen及其索引centerIndex
    • 关键映射公式:原始字符串中的起始位置start = (centerIndex - maxLen) / 2。为什么?因为在预处理字符串t中,原始字符的索引都是奇数。假设t中回文中心在索引i,半径为maxLen,那么这个回文串在t中的范围是[i-maxLen, i+maxLen]。这个范围内的奇数索引对应原始字符串的字符。第一个奇数索引是i-maxLen+1(如果i-maxLen是偶数)或i-maxLen(如果i-maxLen是奇数)。通过观察和推导,可以证明(i - maxLen) / 2就是原始字符串中的起始位置。
    • 回文串在原始字符串中的长度就是maxLen。因为预处理字符串中,每个原始字符前后都有一个#,所以半径maxLen对应的原始字符串长度就是maxLen(你可以用几个例子验证一下)。

4. 时间复杂度与空间复杂度分析

4.1 为什么是O(n)?

这是Manacher算法最令人惊叹的地方。表面上看,我们有一个外层循环遍历n个位置,内层还有一个while循环进行扩展,似乎是O(n²)。但关键在于,right这个变量是单调不减的,且每次while循环成功扩展都会使right增加。

我们可以这样思考:算法中的字符比较只发生在while循环的t[i + p[i] + 1] == t[i - p[i] - 1]这一行。每次比较成功,p[i]增加1,同时right(即i + p[i])也至少增加1。而right最多从0增长到m(预处理字符串长度)。因此,整个算法过程中,字符比较的总次数是O(m)的,也就是O(n)。

更形式化的证明可以采用摊还分析:每个字符最多被成功比较一次(扩展right),也可能在p[i]被赋初值时“跳过”而未被比较。所以总操作次数是线性的。

4.2 空间复杂度

我们需要一个长度为mm = 2n + 3)的数组p来存储半径信息,所以空间复杂度是O(n)。预处理字符串t也需要O(n)空间。总体是O(n)。

5. 边界情况与注意事项

在实际编码和调试中,我踩过不少坑,这里总结几个关键点:

5.1 预处理字符串的哨兵选择

我选择^$作为首尾哨兵,而不是像很多教程那样只用#。这样做有两个好处:

  1. 避免边界检查:在while循环中,当i + p[i] + 1i - p[i] - 1超出字符串范围时,访问t的哨兵字符^$,它们不可能相等,循环自然终止。否则你需要写i + p[i] + 1 < m && i - p[i] - 1 >= 0这样的条件,代码更冗长。
  2. 统一索引计算p数组的长度是m,我们遍历从1到m-2,避开了哨兵。这样p[i]的值就是准确的半径,不需要在映射时做±1的调整。

5.2 奇偶回文的统一处理验证

为了确保你真正理解了预处理的作用,可以手动验证几个例子:

  • 奇数回文”aba”->”^#a#b#a#$”。在t中,以b(索引4)为中心,p[4]=4,对应原始字符串中长度为3的回文”aba”
  • 偶数回文”abba”->”^#a#b#b#a#$”。在t中,两个b之间的#(索引5)是中心,p[5]=4,对应原始字符串中长度为4的回文”abba”

你会发现,无论原始回文是奇是偶,在t中都对应一个奇数长度的回文,且p[i]的值正好是原始回文串的长度。

5.3 数组下标与半径的定义

这里最容易混淆。在我的实现中:

  • p[i]半径,表示从中心t[i]向一侧扩展的字符数(包含中心自身)。所以回文串的总长度是2 * p[i] - 1(在预处理字符串中)。
  • 为什么while循环中是t[i + p[i] + 1] == t[i - p[i] - 1]?因为当前已经确认了半径为p[i]的回文,我们要检查的是半径再扩展1是否还满足回文条件。所以比较的是中心向外第p[i]+1个字符。

如果你看到其他实现中p[i]定义不同(比如表示直径或半长),那么循环条件和映射公式都会不同。关键是要自洽,理解并坚持一种定义方式。

5.4 性能优化小技巧

  1. 避免字符串拼接:在preProcess中,我使用了+=来构建新字符串。对于超长字符串,可以预分配空间:string t; t.reserve(2 * n + 3);,然后使用t.push_back(),效率更高。
  2. 减少除法运算:在映射回原始字符串时,(centerIndex - maxLen) / 2涉及除法。如果性能极其敏感,可以确保centerIndexmaxLen都是整数,且注意整数除法的特性。
  3. 并行计算考虑:Manacher算法本质是顺序的,因为i依赖之前计算的centerright。所以不适合并行化。如果要在多核环境下处理超长字符串,可能需要考虑其他思路或算法变种。

6. 常见问题与调试技巧

6.1 为什么我的程序输出不对?

首先检查预处理字符串是否正确。打印出t看看是不是你期望的格式。例如对于输入”a”t应该是”^#a#$”

其次,在核心算法中,最可能出错的是while循环的条件和p[i]的更新。确保:

  • 初始赋值p[i]时,当i < right,取min(right - i, p[i_mirror])
  • while循环比较的是t[i + p[i] + 1]t[i - p[i] - 1],注意是+1-1,不是p[i]
  • 更新right时,是i + p[i],不是i + p[i] - 1

6.2 如何处理空字符串或单字符字符串?

我的实现在preProcess中已经处理了空字符串情况(返回”^$”)。对于单字符字符串,算法也能正确工作。但要注意,在longestPalindrome中,如果输入为空,我直接返回空字符串,避免后续访问越界。

6.3 如何获取所有回文子串而不仅仅是最长的?

p数组包含了所有信息。对于预处理字符串t中的每个位置i(中心),以它为中心的回文子串有p[i]个(半径从1到p[i])。但注意,这些回文子串在t中,要映射回原始字符串,需要根据中心位置是原始字符还是#来区分奇偶。

一个更直接的方法是:在得到p数组后,遍历原始字符串的每个可能中心(包括字符位置和字符之间的位置),根据p值计算出对应的回文串在原串中的起始和结束位置。具体来说:

  • 对于奇数中心(对应原始字符位置i),在t中的索引是2*i+2(考虑我们的预处理方式^#a#b#$,第一个字符at的索引2)。那么半径r = p[2*i+2],对应原始字符串中从i - r/2i + r/2的回文串。
  • 对于偶数中心(对应字符间位置),计算类似但索引不同。

6.4 内存使用能更优吗?

理论上,我们不需要同时存储整个p数组。因为算法只依赖p[i_mirror],而i_mirror总是小于i(当iright左侧时)。但为了代码清晰和后续查询(比如找所有回文),通常还是保留整个数组。如果只找最长回文,可以只记录最大值及其索引,不存整个p数组。

7. 算法变体与应用场景

7.1 查找所有回文子串数量

如我之前代码所示,countPalindromes函数可以统计预处理字符串中所有回文子串数量(每个中心i贡献p[i]个)。但要注意,这统计的是t中的回文,包括那些中心是#的。如果只想统计原始字符串中的回文数量,需要过滤掉那些完全由#组成的回文(实际上就是偶数长度回文被重复计算的部分)。更准确的计算公式是:原始字符串中回文子串总数 = Σ(ceil(p[i]/2)),其中i遍历t中所有原始字符对应的位置。

7.2 判断任意子串是否为回文

如果我们预处理了整个字符串并得到了p数组,就可以在O(1)时间内回答任意查询[l, r]是否是回文。方法是将原始区间[l, r]映射到预处理字符串中的中心c和半径len,然后检查p[c]是否足够大。具体来说:

  • 对于奇数长度查询(lr同奇偶),中心c = l + r(在原始索引下),然后检查p[c] >= (r-l)/2 + 1
  • 对于偶数长度查询,需要小心处理,因为中心在两个字符之间。

7.3 在流数据中的应用

经典Manacher需要整个字符串已知。如果数据是流式的(例如从网络逐段接收),需要支持在字符串末尾添加字符并动态维护回文信息,这就是“在线Manacher”问题。这通常需要结合后缀自动机或其他数据结构,复杂度会更高。

7.4 与其他算法的对比

  • 动态规划:用dp[i][j]表示s[i…j]是否是回文,时间复杂度O(n²),空间复杂度O(n²)(可优化到O(n))。比中心扩展法更差,通常不用于此问题。
  • 后缀数组/后缀自动机:可以解决但过于复杂,O(n log n)或O(n)但常数大。
  • 哈希+二分:通过字符串哈希可以在O(1)时间内比较子串是否相等,对每个中心二分搜索最大半径,总复杂度O(n log n)。比Manacher慢但实现简单。

在实际面试或竞赛中,Manacher通常是首选,因为它既高效又相对容易实现(一旦理解)。

8. 实战练习与测试用例

光说不练假把式。这里提供一些测试用例,你可以用它们验证你的实现:

void testManacher() { Manacher solver; struct TestCase { string input; string expected; int expectedCount; // 回文子串总数(预处理字符串中) }; vector<TestCase> tests = { {"", ""}, // 空字符串 {"a", "a"}, // 单字符 {"aa", "aa"}, // 全相同,偶数长度 {"ab", "a"}, // 无回文,取第一个字符(或"b"也可) {"aba", "aba"}, // 标准奇数回文 {"abba", "abba"}, // 标准偶数回文 {"babad", "bab"}, // 注意"aba"也是最长,但算法返回找到的第一个 {"cbbd", "bb"}, // 最长回文是"bb" {"abcde", "a"}, // 无回文,单字符 {"aaaa", "aaaa"}, // 全相同,多个最长回文 {"racecar", "racecar"}, // 经典回文 {"abacdfgdcaba", "aba"}, // 注意不是整个串回文 }; for (const auto& test : tests) { string result = solver.longestPalindrome(test.input); if (result != test.expected) { cout << "测试失败: 输入=\"" << test.input << "\", 期望=\"" << test.expected << "\", 实际=\"" << result << "\"" << endl; } else { cout << "测试通过: 输入=\"" << test.input << "\", 结果=\"" << result << "\"" << endl; } } }

运行这些测试,确保你的实现在各种边界情况下都能正确工作。特别是空字符串、单字符、全相同字符这些边缘情况,往往是面试官考察的重点。

9. 从理论到实践:我踩过的那些坑

最后分享一些我在实际使用Manacher算法时积累的经验:

  1. 索引从0开始还是1开始:这可能是最让人头疼的。我强烈建议在纸上画图,用一个小例子(比如”aba”)手动模拟算法过程,标出每个索引和p值。一旦你理清了索引关系,代码就清晰了。我的经验是:坚持使用0-based索引,但在预处理时添加明确的哨兵,这样逻辑最清晰。

  2. while循环的越界检查:如果你不用哨兵,那么while循环必须检查索引是否越界:while (i - p[i] >= 0 && i + p[i] < n && s[i-p[i]] == s[i+p[i]])。注意这里是i ± p[i],不是i ± p[i] ± 1,取决于你的p[i]定义。用哨兵可以简化这个条件。

  3. 更新right的时机:一定要在扩展完成后再更新right。有些初学者会在while循环内更新,这是错误的,因为p[i]在循环中可能增加多次。

  4. 对称点的计算i_mirror = 2 * center - i这个公式要记牢。当centeri都是整数时,i_mirror可能是小数吗?不会,因为icenter的奇偶性相同(都在预处理字符串的奇数索引或偶数索引上),所以i_mirror也是整数。

  5. 性能测试:对于长度10⁶的随机字符串,我的C++实现在现代CPU上能在几十毫秒内完成。如果发现性能不符合预期,检查是否有不必要的字符串拷贝(比如传值而不是传引用),或者是否在循环中调用了length()这样的函数(应该事先存到变量中)。

Manacher算法是一个典型的“想通了就很简单,想不通就一头雾水”的算法。我第一次学习时花了整整一个下午才完全理解。但一旦掌握,它就成为你解决回文问题的利器。在面试中,如果你能清晰地解释Manacher的原理并写出正确代码,绝对是一个大大的加分项。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询