Manacher算法详解
Manacher算法
马拉车算法解决最长回文字串的问题
时间复杂度:O(n)
传统思想:以一个字符为中心,向两边扩大,就能返回最长回文字串,但是长度为偶数的字符串是不成立的,需要分情况讨论,而且算法时间复杂度 o(n^2) ,也可以使用动态规划,时间复杂度仍为o(n^2)
解决办法:在传统方法的基础上将每个字符两边添加字符 # ,然后再向两边扩展,这样奇数和偶数的回文串都找到了!
问题:添加的字符是不是要求字符串中没出现的字符?
- 不是,随便添加,因为无论什么时候都是实际上有的的和有的比较,新添加的和新添加的比较
Manacher算法实现
讲解:
-
首先沿用了暴力解法的中心扩展思想,从第一个字符向后遍历,为了解决偶数字符串问题,在每个字符周围插入字符 ‘#’,例如字符串“ababa”变为“#a#b#a#b#a#”,新字符串长度变为``2 * str.length() + 1`
-
算法的优点在于对向后遍历过程中优化了回文串判断的逻辑,不同于暴力算法,每个字符都从该字符开始从中心扩散,而Manacher算法类似于KMP将之前比较过的结果利用起来了,避免了反复的重复判断
-
算法核心讲解:
- 定义变量:R:最右回文右边界,C:中心点位置,arr[str.length]:用来保存每个位置为中心的回文半径(从中心点开始向边缘计数)长度
- R的意思是在整个字符串的遍历过程中保存每次i位置为中心的回文串的最右侧位置构成一个回文区间,在区间内的字符可以通过回文区间的特性,缩减判断步骤,甚至直接不用判断
- C的意思是,R为右边界的回文串的中心位置,左边则对应L
- 每次判断i位置的字符的回文串的时候都保留一下到达的最右侧位置,把最右侧位置给R,这样就能不停的扩展这个回文串,更新会文串时要同时更新中心点C
- 大概的图解
- 一共有四种情况,分为两大种
- (1)、当前遍历的i位置的字符不在回文区间中,那么这种情况就不会右优化的空间,之间按照暴力方法向后遍历
- (2)、当前遍历的i位置的字符在回文区间中(三种情况)
- 以i为中心的回文串(L....iil....ii....iir....c....il....i....ir....R)整个都在R以内,根据回文特性,i位置的回文串的长度和ii位置是完全相同的。所以arr[i] == arr[ii],i和ii是关于对称的,所以ii = 2 * c - i,所以arr[i] = arr[2 * c - i],这种情况完全不需要判断
- 以i为中心的回文串一部分在LR这个区间之外(iil..L..ii..iir..c..il..i..R..ir)那么就认为i到R这之间是不需要判断的即R - i这个长度是一定是回文的,但是R之后是否是回文还是需要判断的,同时伴随着判断结束的R的更新,一旦R右侧仍有字符构成回文,那么,R和C将同时更新
- 以i为中心的回文串压了LR的线。这种情况就是arr[i] = arr[ii] = R - i
- 至此4种情况梳理结束,循环结束后arr数组中最大值 - 1 就是最长回文子串长度(因为字符串是被我加过#字符扩展的,新字符串长度为2 * len + 1,而arr[i]是最大回文半径长度,减一以后刚好是原串中最大回文字串的长度例如“#a#b#a#”,arr[i]最大为4,串长为7,实际上串长为3,最大回文字串长度为3)
-
代码实现
package Manacher; /** * @author 孙东宇 * 创建时间:2022/04/17 * 介绍:Manacher算法,解决回文串问题 */ public class Manacher { public static void main(String[] args) { String s = "babab"; manacher(s); } public static int manacher(String s) { if (s == null || s.length() == 0) { return 0; } // 将 字符串添加‘#’字符 char[] str = manacherString(s); // 定义回文"半径"数组 int[] arr = new int[str.length]; // 回文串中心 int c = -1; // 回文右边界再往右一个位置(为了方便实现代码) int r = -1; // 扩展字符串的最大回文字串长度 int max = Integer.MIN_VALUE; // 对每个位置求回文半径,原理和暴力方法相同都是以i为中心 // 向两边扩展 for (int i = 0; i < arr.length; i++) { // 先确定每个位置最小确定的不用判断的区域,先给arr[i]赋值,减少判断量 // 解读: // 1. r > i 是判断i是否再r之内,如果不在就是第一种情况 // 2.第二种情况右3种小情况, // 2.1,i所在的区域都在r之内,那么就是arr[2 * r - c]对称过去的i // 2.2,如果有的部分不在,那么就是 r - i 为回文半径长度(但是需要判断是否需要扩大) // 2.3,如果压线,那么arr[2 * r - C] 和 r - i 是相等的 // 所有情况都是围绕arr[2 * i - c] 和 r - i 所以总结起来就是在两个之间取最小值 arr[i] = r > i ? Math.max(r - i, arr[2 * i - c]) : 1; // 将 r 向两边扩展 // 虽然只有 2.3 这一种情况需要将r向两边扩展更新,但是为了缩减代码长度,将所有都进行判断扩展 // 不能扩展到数组越界 while (i + arr[i] < str.length && i - arr[i] > -1) { if (str[i - arr[i]] == str[i + arr[i]]) { arr[i]++; } else { break; } } //更新右边界R if (i + arr[i] > r) { // r的定义、最右侧再往右一个位置 r = 1 + arr[i]; // 更新中心点 c = i; } max = Math.max(max, arr[i]); } return max - 1; } // 插入字符‘#’ private static char[] manacherString(String s) { char[] charArr = s.toCharArray(); char[] res = new char[charArr.length * 2 + 1]; int index = 0; for (int i = 0; i < res.length; i++) { res[i] = (i & 1) == 0 ? '#' : charArr[index++]; } return res; } }