Manacher算法(马拉车算法)的个人理解
Manacher(马拉车)算法的个人理解
-
背景
Manacher算法是Manacher在1975年提出的在线性时间复杂度内求得最长回文子串问题的算法。
求回文子串长度的朴素思路是中心扩展方法,即针对每一个字符同时向左右扩展尝试找到以它为中心的最长回文子串。例如针对字符串 "bcacaacb",在处理第一个字符 'a' 时往左右扩展能找到以该字符为中心的最长回文子串为 "cac",但是从这个例子也能看出这个基础思路会遗漏后面 "caac" 这种长度为偶数的回文子串,同时这种暴力方法的时间复杂度是O(n^2)很明显无法满足太长字符串的高效处理。
-
主要思路
首先针对需要同时考虑奇数和偶数长度的子串问题, Manacher提出的方法是在原始字符串首尾以及每个字符之间都插入某个相同字符(即使取原始字符串中包含的字符也不影响结果,但为了方便讨论/理解,一般统一插入特殊字符 '#'),经过处理的扩展字符长度必定为奇数且以插入字符为中心进行扩展就是对应原始字符串的偶数长度子串。
算法还提出几个关键含义的变量:
-
回文半径
扩展字符串中以某个字符为中心向左右扩展得到的最长回文子串长度的一半,即该字符到符合回文的最远字符的距离(包含该字符和最远字符)。
例如 "#a#b#c#b#a#a#b#" 中以 'c' 为中心时得到最长回文子串为 "#a#b#c#b#a#" ,回文半径的值就是6。
一般用数组len记录每个下标对应的回文半径的值,简单推理可以得到的结论是,扩展字符中最长回文子串在原始字符串中的真实回文子串长度值是 len[i] - 1 ,且该子串末尾的下一个下标是 (i + len[i]) / 2 ,这两个值在最后返回原始字符串的最长回文子串会用到。
![image]()
-
回文最右边界
遍历扩展串时每个字符都有它对应的最长回文子串的右边界,在处理过程中记录所有右边界的最大值,记作 r 。
-
回文中心
当前回文最右边界对应的中心字符下标位置,如果有多个字符的回文边界与最右边界重合,回文中心指的是最早扩展到该边界的字符中心(即回文中心只有在更新回文最右边界时才会同步更新),记作 c 。
回文中心结合回文最右边界就是距离待处理字符最近且最大的已知回文范围,能最大概率利用回文特性进行加速处理。
![image]()
基于上述三个关键变量,可以利用回文特性在处理后续字符时减少重复扩展判断,从而在线性时间复杂度范围内求得最长回文子串:
- 如果当前下标 i 已经超出回文最右边界,需要直接从当前位置开始向左右扩展,在符合回文条件的情况下会更新回文最右边界及回文中心;
- 当前下标 i < r 时,先找到基于回文中心的对称下标 i' ,根据 i - c = c - i' 可得 i' = 2*c - i,对称位置的回文半径已经记录过了;
![image]()
- 如果 i' 扩展的最长回文子串范围没有超过回文中心扩展得到的最长回文范围,根据回文对称特性可以直接得出当前下标 i 的回文半径值就等于 len[i'] ;
![image]()
- 如果 i' 扩展的最大回文子串左边界与回文中心扩展得到的最长回文范围左边界重合,即 i' - len[i'] == c - len[c] ,与上一个场景同理可证当前回文半径至少是 len[i'] 且可能会进一步扩展到更大的回文范围(证明和数据示例不再重复,核心逻辑都是回文对称);
- 如果 i' 扩展的最大回文子串范围超出了回文中心扩展得到的最大回文范围,同理易得当前回文半径至少是 r - i 且需要进一步扩展判断能否得到更大的回文范围。
- 总结后面这三种情况,如果 r - i < len[i'] 就确定回文半径初始值是 r - i 并且需要继续扩展判断是否有更大回文半径,否则回文半径的值就不会小于 len[i'] ,也就是说回文半径初始值可以设为 len[i'] 并继续扩展,所以代码中只需要设置不同的回文半径初始值再统一执行扩展判断即可,最终代码是很简洁的。
-
-
时间复杂度分析
只有对称点回文半径完全包含在当前回文中心对应的最大回文范围内时,可以直接确定当前下标的回文半径并且不会更新回文最右边界。
另外三种扩展的情况下每次判断要么初始下标就不满足回文直接确定答案,要么在继续扩展的同时必定伴随着最右边界的延伸,而回文最右边界只会变大不会回退变小,所以整个字符串依次进行中心扩展其实只涉及回文最右边界的扩张而不存在回缩重复,即时间复杂度是 O(n)。
-
代码示例
private static final Character INSERT = '#'; public String longestPalindrome(String s) { char[] cs = s.toCharArray(); int n = cs.length; if (n < 2) { return s; } char[] arr = expandStr(cs); n = arr.length; int[] len = new int[n]; int r = 0, c = 0, maxLen = 1, maxStart = 0; for (int i = 0; i < n; i++) { // 提炼四种情况下的回文半径初始值 int offset = r > i ? Math.min(r - i, len[(c << 1) - i]) : 1; // 从初始值对应范围边界开始尝试继续扩展 int left = i - offset, right = i + offset; while (left >= 0 && right < n && arr[left] == arr[right]) { left--; right++; } len[i] = right - i; if (len[i] > maxLen) { // 如果当前回文半径是更大的回文子串长度,更新在原始字符串中对应的回文子串长度和起始下标 maxLen = len[i] - 1; maxStart = (right >> 1) - maxLen; } if (right > r) { // 如果回文右边界变大了就同步更新回文最右边界和回文中心的值 r = right; c = i; } } return s.substring(maxStart, maxStart + maxLen); } /** * 为了兼容奇数和偶数长度的回文子串,在字符串首位和每个字符中间都插入同一个字符 */ private char[] expandStr(char[] cs) { int n = cs.length; char[] res = new char[(n << 1) + 1]; res[0] = INSERT; for (int i = 0, j = 1; i < n; i++) { res[j++] = cs[i]; res[j++] = INSERT; } return res; }
本文表述基于作者主观理解,如有错漏或歧义之处,欢迎评论指出沟通交流




浙公网安备 33010602011771号