1. 算法概述

Manacher算法是一种用于查找字符串中最长回文子串的线性时间复杂度算法。由Glenn Manacher于1975年提出,它巧妙地利用了回文串的对称性质来避免不必要的重复计算。

1.1 传统方法的问题

传统的中心扩展法需要 \(\mathcal{O}(n^2)\) 时间复杂度,因为它对每个字符都作为中心向两边扩展检查回文。Manacher算法通过记录之前计算的信息,将时间复杂度优化到 \(\mathcal{O}(n)\)。

2. 算法预处理

Manacher算法首先对原始字符串进行预处理,解决奇偶长度回文的问题。

2.1 插入特殊字符

我们在每个字符之间插入一个特殊字符(通常用 #,只要不混淆就行),并在首尾也插入特殊字符。

例如:

原始字符串: abba

预处理后: #a#b#b#a#

这样处理后,所有回文子串都变为奇数长度,统一了处理方式。

2.2 C++预处理实现

string preProcess(const string& s) {
int n = s.length();
if (n == 0) return "^$";
string result = "^";
for (int i = 0; i < n; i++) {
result += "#" + s.substr(i, 1);
}
result += "#$";
return result;
}

3. 核心概念

3.1 回文半径数组P

定义数组 \(P\),其中 \(P_i\) 表示以 \(i\) 为中心的回文串的半径(包括中心自身)。

例如:

1 2 3 4 5 6 7 8 9 10 11
处理后的字符串 \(T\) ^ # a # b # b # a # $
\(P\) 数组 \(0\) \(0\) \(1\) \(0\) \(1\) \(4\) \(1\) \(0\) \(1\) \(0\) \(0\)

3.2 中心C和右边界 \(R\)

  • \(C\): 当前已知的回文串的中心位置。
  • \(R\): 当前已知的回文串的右边界位置 \((C + P_C)\)。

4. 算法流程

4.1 初始化

  • C = 0, R = 0。
  • \(P\) 数组全部初始化为 \(0\)。

4.2 主要步骤

对于每个 \(i\) 从 \(1\) 到 \(n-1\):

  1. 计算 \(i\) 关于C的镜像 \(i\_mirror = 2\cdot C - i\)。
  2. 如果 \(i < R\),则 \(P_i = \min(R-i, P_{i\_mirror})\)。
  3. 尝试扩展以i为中心的回文串。
  4. 如果 \(i + P_i > R\),更新 \(C\) 和 \(R\)。

4.3 C++实现

vector<int> manacher(const string& s) {
string T = preProcess(s);
int n = T.length();
vector<int> P(n, 0);
int C = 0, R = 0;
for (int i = 1; i < n-1; i++) {
int i_mirror = 2 * C - i; // 计算i关于C的对称点
if (R > i)
P[i] = min(R - i, P[i_mirror]);
else
P[i] = 0;
// 尝试扩展以i为中心的回文
while (T[i + 1 + P[i]] == T[i - 1 - P[i]])
P[i]++;
// 如果扩展后的回文右边界超过R,更新中心和右边界
if (i + P[i] > R) {
C = i;
R = i + P[i];
}
}
return P;
}

5. 获取最长回文子串

5.1 从P数组中提取信息

  • 最长回文长度 = \(\max(P_i)\)。
  • 中心位置 = \(argmax(P_i)\)。

5.2 C++实现

string longestPalindrome(const string& s) {
vector<int> P = manacher(s);
int max_len = 0;
int center_index = 0;
for (int i = 1; i < P.size()-1; i++) {
if (P[i] > max_len) {
max_len = P[i];
center_index = i;
}
}
int start = (center_index - max_len) / 2;
return s.substr(start, max_len);
}

6. 复杂度分析

6.1 时间复杂度

  • 预处理: \(\mathcal{O}(n)\)。
  • 主算法: \(\mathcal{O}(n)\) (因为while循环的扩展操作总共不会超过 \(n\) 次)。
  • 总时间复杂度: \(\mathcal{O}(n)\)。

6.2 空间复杂度

  • 需要 \(\mathcal{O}(n)\) 空间存储P数组。
  • 预处理字符串需要 \(\mathcal{O}(n)\) 空间。
  • 总空间复杂度: \(\mathcal{O}(n)\)。

7. 算法正确性证明

Manacher 算法的正确性基于以下观察:

  1. 对称性质:回文串的对称性意味着我们可以利用已经计算过的信息。
  2. 边界控制:通过维护当前最右边界 \(R\),避免了重复计算。
  3. 线性扩展:每次扩展要么直接利用对称点信息,要么从 \(R\) 开始扩展,确保不重复。

8. 边界情况处理

8.1 空字符串

预处理后变为 ^$,\(P\) 数组为[0,0],返回空字符串。

8.2 单字符字符串

预处理后变为" ^#a#$,P数组为[0,0,1,0,0],返回该字符。

8.3 全相同字符

aaaa,算法仍能高效处理,\(P\) 数组会线性增长。

9. 实际应用示例

9.1 示例运行

输入: cbbd

预处理后: ^#c#b#b#d#$

P数组计算过程:

i=1: \(P_1=0 (^#)\)

i=2: \(P_2=1 (#c#)\)

i=3: \(P_3=0 (b)\)

i=4: \(P_4=1 (#b#b#)\)

i=5: \(P_5=0 (b)\)

i=6: \(P_6=1 (#d#)\)

i=7: \(P_7=0 (\))$

最长回文长度为 \(1\),对应原始字符串中的 bb

10. 算法优化技巧

10.1 空间优化

可以只存储当前需要的部分P值,但实现会变得复杂。

10.2 预处理优化

可以使用原始字符串的2n+1长度数组,避免字符串拼接。

10.3 并行计算

某些步骤可以并行化,但通常不必要因为算法已经是线性。

11. 常见错误与调试

11.1 数组越界

在扩展回文时要检查边界:

while (i + 1 + P[i] < n && i - 1 - P[i] >= 0 &&
T[i + 1 + P[i]] == T[i - 1 - P[i]]) {
P[i]++;
}

11.2 初始值错误

确保 \(P\) 数组初始化为 \(0\),\(C\) 和 \(R\) 初始化为 \(0\)。

11.3 预处理不当

确保预处理后的字符串首尾有不同字符(^$),避免边界检查。

12. 完整测试代码

#include <iostream>
#include <vector>
#include <algorithm> using namespace std; string preProcess(const string& s) {
int n = s.length();
if (n == 0) return "^$";
string result = "^";
for (int i = 0; i < n; i++) {
result += "#" + s.substr(i, 1);
}
result += "#$";
return result;
} vector<int> manacher(const string& s) {
string T = preProcess(s);
int n = T.length();
vector<int> P(n, 0);
int C = 0, R = 0;
for (int i = 1; i < n-1; i++) {
int i_mirror = 2 * C - i;
if (R > i)
P[i] = min(R - i, P[i_mirror]);
else
P[i] = 0;
while (i + 1 + P[i] < n && i - 1 - P[i] >= 0 &&
T[i + 1 + P[i]] == T[i - 1 - P[i]]) {
P[i]++;
}
if (i + P[i] > R) {
C = i;
R = i + P[i];
}
}
return P;
} string longestPalindrome(const string& s) {
if (s.empty()) return "";
vector<int> P = manacher(s);
int max_len = 0;
int center_index = 0;
for (int i = 1; i < P.size()-1; i++) {
if (P[i] > max_len) {
max_len = P[i];
center_index = i;
}
}
int start = (center_index - max_len) / 2;
return s.substr(start, max_len);
} int main() {
vector<string> test_cases = {
"babad",
"cbbd",
"a",
"ac",
"abb",
"abcba",
"aaaa",
""
};
for (const string& s : test_cases) {
cout << "Input: \"" << s << "\"" << endl;
cout << "Longest palindrome: \"" << longestPalindrome(s) << "\"" << endl;
cout << endl;
}
return 0;
}

13. 算法扩展

13.1 查找所有回文子串

通过遍历 \(P\) 数组,可以找到所有回文子串。

13.2 回文分割问题

可以用于优化回文分割的动态规划解法。

13.3 最长双回文子串

寻找两个不相交的回文子串,长度之和最大。

14. 与其他算法比较

14.1 与动态规划比较

动态规划需要 \(\mathcal{O}(n^2)\) 时间和空间,Manacher更优。

14.2 与后缀自动机比较

后缀自动机也可以解决但实现更复杂。

14.3 与哈希+二分比较

哈希方法通常需要 \(\mathcal{O}(n\log n)\) 且可能有哈希冲突。

15. 总结

Manacher算法以其优雅的设计和高效的性能成为解决最长回文子串问题的首选方法。通过巧妙地利用回文串的对称性质和预处理技巧,它将时间复杂度从 \(\mathcal{O}(n^2)\) 降低到 \(\mathcal{O}(n)\),是算法设计中"以空间换时间"和"利用已知信息"的典范。

[学习笔记] manacher 算法的更多相关文章

  1. 学习笔记 - Manacher算法

    Manacher算法 - 学习笔记 是从最近Codeforces的一场比赛了解到这个算法的~ 非常新奇,毕竟是第一次听说 \(O(n)\) 的回文串算法 我在 vjudge 上开了一个[练习],有兴趣 ...

  2. [ML学习笔记] XGBoost算法

    [ML学习笔记] XGBoost算法 回归树 决策树可用于分类和回归,分类的结果是离散值(类别),回归的结果是连续值(数值),但本质都是特征(feature)到结果/标签(label)之间的映射. 这 ...

  3. 学习笔记——EM算法

    EM算法是一种迭代算法,用于含有隐变量(hidden variable)的概率模型参数的极大似然估计,或极大后验概率估计.EM算法的每次迭代由两步组成:E步,求期望(expectation):M步,求 ...

  4. 数据挖掘学习笔记--AdaBoost算法(一)

    声明: 这篇笔记是自己对AdaBoost原理的一些理解,如果有错,还望指正,俯谢- 背景: AdaBoost算法,这个算法思路简单,但是论文真是各种晦涩啊-,以下是自己看了A Short Introd ...

  5. 学习笔记-KMP算法

    按照学习计划和TimeMachine学长的推荐,学习了一下KMP算法. 昨晚晚自习下课前粗略的看了看,发现根本理解不了高端的next数组啊有木有,不过好在在今天系统的学习了之后感觉是有很大提升的了,起 ...

  6. Java学习笔记——排序算法之快速排序

    会当凌绝顶,一览众山小. --望岳 如果说有哪个排序算法不能不会,那就是快速排序(Quick Sort)了 快速排序简单而高效,是最适合学习的进阶排序算法. 直接上代码: public class Q ...

  7. Java学习笔记——排序算法之进阶排序(堆排序与分治并归排序)

    春蚕到死丝方尽,蜡炬成灰泪始干 --无题 这里介绍两个比较难的算法: 1.堆排序 2.分治并归排序 先说堆. 这里请大家先自行了解完全二叉树的数据结构. 堆是完全二叉树.大顶堆是在堆中,任意双亲值都大 ...

  8. Java学习笔记——排序算法之希尔排序(Shell Sort)

    落日楼头,断鸿声里,江南游子.把吴钩看了,栏杆拍遍,无人会,登临意. --水龙吟·登建康赏心亭 希尔算法是希尔(D.L.Shell)于1959年提出的一种排序算法.是第一个时间复杂度突破O(n²)的算 ...

  9. 算法笔记--manacher算法

    参考:https://www.cnblogs.com/grandyang/p/4475985.html#undefined 模板: ; int p[N]; string manacher(string ...

  10. 学习笔记——SM2算法原理及实现

    RSA算法的危机在于其存在亚指数算法,对ECC算法而言一般没有亚指数攻击算法 SM2椭圆曲线公钥密码算法:我国自主知识产权的商用密码算法,是ECC(Elliptic Curve Cryptosyste ...

随机推荐

  1. TreeSet练习 根据字符串长度排序

    String类已经实现了Comparable接口,我们可以根据TreeSet提供的构造器传入自己的比较器. public class Set4 { public static void main(St ...

  2. 代码随想录第二十二天 | Leecode 77. 组合、216. 组合总和 III、17. 电话号码的字母组合

    Leecode 77. 组合 题目描述 给定两个整数 n 和 k,返回范围 [1, n] 中所有可能的 k个数的组合. 你可以按 任何顺序 返回答案. 示例 1: 输入:n = 4, k = 2 输出 ...

  3. Winform C#多显示器窗口控制详解

    写Winform程序的时候,有将一个窗口放置到特定的显示器,并且全屏的需求.于是借此机会,好好研究了一番这个Screen类[1],总结了一些方法. Windows的窗口逻辑 首先我们需要知道窗口定位的 ...

  4. RPC实战与核心原理之网络通信

    架构设计:涉及一个灵活的RPC框架 回顾 RPC的通信原理及RPC中各个功能组件的作用 RPC就是把拦截到的方法参数,转成可以在网络中传输的二进制,并保证服务提供方能正确还原出语义,最终实现想调用本地 ...

  5. C# 相等比较

    C# 相等比较 有两种类型的相等: 值相等:即两个值是一样的 引用相等:即引用是一样的,也就是同一个对象 默认地,对于值类型来讲,相等指的就是值相等:对于引用类型,相等就是指的引用相等. int a ...

  6. C++ ADL 与 模板

    什么是ADL ADL(Argument Dependent Lookup),参数依赖查找,明确的意思是依赖参数的函数查找,即对于函数调用,不仅会依照常规的名称查找规则,还会在函数参数所在的命名空间内查 ...

  7. React Native开发鸿蒙Next---图片浏览与保存的问题交流

    React Native开发鸿蒙Next---图片浏览与保存的问题交流 之前介绍过利用鸿蒙三方RN组件@react-native-camera-roll/camera-roll保存图片到相册. Rea ...

  8. ODOO路由里面的auth="user" 的使用

    一.controller介绍:Controller层在odoo里面可以认为是控制器,根据url地址来控制后端的业务和前端的内容展示,我们一般偏向于叫路由控制, 它相当于内网和外网之间的防火墙,外网的请 ...

  9. 4 MyBatis动态SQL之trim元素|转

    摘要:trim标签是一个格式化的标签,可以完成set或者where标签的功能. 1 MyBatis动态SQL之if 语句 2 MyBatis动态sql之where标签|转 3 MyBatis动态SQL ...

  10. 解决Spring Boot项目后端接口返回数据中文乱码问题

    摘要 解决 Spring Boot 项目中,后端返回前端的结果出现中文乱码的问题.   这几天在使用 Spring Boot 学习AOP原理的时候,通过浏览器访问后端接口的时候,响应报文总是出现中文乱 ...