manacher算法:马拉车算法:返回字符串s的最长回文子串长度
提示:manacher算法又是类似KMP这样需要预设信息数组的高级算法,舍弃思想,难但是很经典
据说互联网大厂的面试阶段,有很多关于回文串的考题,所以,manacher算法,一定要学透了
关于回文串的基础知识:
【1】判断链表是否是回文链表?回文结构,回文串
【2】删除字符串s中的某些字符让s成为回文串,有多少种删除方案?
上面的【2】文章,可以算是动态规划考题中变态难度级别的题目了,有兴趣看,没兴趣别看
题目
给你一个字符串s,请你返回字符串s的最长回文子串的长度max是多少?
所谓回文串,咱们之前说过了,s正念反念都相等
s的逆序=s
这种串就是回文串
回文串的左右是对称的
【链表那个章节,咱们已经学得很6了,当时叫判断链表是否回文结构?】
关于回文串的基础知识:
【1】判断链表是否是回文链表?回文结构,回文串
【2】删除字符串s中的某些字符让s成为回文串,有多少种删除方案?
上面的【2】文章,可以算是动态规划考题中变态难度级别的题目了,有兴趣看,没兴趣别看
一、审题
示例:s=1213
121就是最长的回文子串,其长度为3
s=x12213
1221就是最长的回文子串,其长度为4
暴力解o(n^2)复杂度高,要不得
如果说s的长度为奇数,好办
从s的每一个i位置,从i左右扩就完事了
比如:
但是s长度要是偶数呢,你没法搞,下图虚轴在粉色那还行,这样max=8
但是粉色i和橘色i扩都没法得到正确答案
那就这么束手就擒了吗??
填充虚轴!补上一个无关字符即可
“#”无关字符填充虚轴
在s的前面,任意俩字符中间,后面,都加上一个无关的字符“#”
你也可以随意加别的字符,没啥大不了,就是当虚轴而已
下面咱们选择加“点”
这样我画图简单,写代码随意加啥都行
显然22中间那个虚轴i的最长回文长度为17,max/2就是咱们要的结果8
每一个i位置,你都要扩,最惨的就是扩整个数组这么长
i有N个位置,每个位置扩N次
复杂度自然就是o(n^2)
要不得
虽然代码也能很容易写出来,但是复杂度太高
//暴力解
//最次,相同的字符,每一个位置i都要左右找,直到边界,这是一个等差数列的关系,自然复杂度就是o(N^2)
//暴力寻找流程
//arr N规模,头,每个数之间,尾都加入一个特殊字符,这个字符随意,只当虚轴
//对arr的每个i位置,寻找到边界,然后对比左右是否相等,等就好办,不等就就停
//得到回文长度数组backarr,长度规模2N+1,因为头尾,数中间都插入了
//backarr全体/2,取最大值就是结果
public static int getBackMaxLen(int[] arr){
int[] arr2 = new int[2 * arr.length + 1];
int index = 0;//索引arr
arr2[0] = '#';//头
arr2[arr2.length - 1] = '#';//尾
for (int i = 0; i < arr.length; i++) {
arr2[2*i+1] = arr[index++];//奇数位是arr的各个值
arr2[2*i] = '#';//偶数位全是外字符
}
int max = Integer.MIN_VALUE;//准备逐个找,然后得最值
for (int i = 0; i < arr2.length; i++) {
int num = 1;//本身就是一个
for (int j = i - 1, j2 = i + 1; j >= 0 && j2 < arr2.length; j--, j2++) {
//对称位置
if (arr2[j] == arr2[j2]) num += 2;//一下子增加俩
else break;//如果不等,不必再继续比较了
}
//每一个位置得到回文num,跟max比较
max = Math.max(max, num);
}
//整个数组都比完以后,需要max,除2才是arr的最大回文长度
return max / 2;
}
public static void test(){
int[] arr = {3,1,2,1,1,2,1,4};
// System.out.println(getBackMaxLen(arr));
char[] chars = manacherString("121");
}
测试一把:
6
这不是重点
咱们把添加虚轴符号的代码写一下
(1)生成新的字符数组,长度是原来数组M长的:2M+1,尾部要加上
(1)每逢偶数位置0 2 4 --2M-2处,都是虚轴符号“#”
(2)每逢奇数位置1 3 5–2M-1处,copy字符串s即可
(4)别忘了2M位置也要加“#”,末尾
代码手撕一下,问题不大:
//添加#到头,中,尾
public static char[] manacherString(String s){
char[] s1 = s.toCharArray();//长度M
char[] str = new char[2 * s1.length + 1];//2M+1的规模,偶数位置全是#,奇数位置是s1
int N = str.length;
str[N - 1] = '#';//一头一尾
int index = 0;//索引str的
for (int i = 0; i < N; i++) {
str[2*i + 1] = s1[index++];//奇数位置是str
str[2*i] = '#';//偶数位置是虚轴符号到2M-2
}
return str;
}
测试一把:
public static void test(){
int[] arr = {3,1,2,1,1,2,1,4};
System.out.println(getBackMaxLen(arr));
char[] chars = manacherString("121");
for(Character ch:chars) System.out.print(ch+" ");
}
# 1 # 2 # 1 #
下面manacher要用这个添加函数的
manacher马拉车算法优化到o(n)更新max结果
好,现在开始将manacher算法如何优化的
这个代码非常非精致简介,需要搞懂其中的逻辑,才能一举写出代码来,明白其中的道理
回文串的回文半径,回文区域,当前回文最右边界R,这个最右边界的回文中心C
s=x121y
添加虚轴字符处理后得到
s=#x#1#2#1#y#
图中,i是即将要去扩展的位置,最后能阔刀L–R范围,这个区域就是回文区域,R-L+1是回文直径
R是i=C这个回文中心的回文区域的最右边界
回文半径就是R-C+1
这些概念有了,咱们就可以利用一个思想加速寻找最长回文子串了
咱们定义一个回文半径数组pArr,它是s(加了“#”的)每一个i位置能得到的最大回文半径,
其中的max就是咱们要的答案,返回max-1
比如上面例子,max=4,原始s字符串(没加“#”的)的回文结构是121,长度为3,即max-1=4-1=3
现在咱们就只关心加了虚轴字符的s字符串(加了“#”的)
我们自然是一路,从i=0–N-1位置求pArr,在这个过程中,
有了0–i-1范围上的回文半径信息,能否利用他们加速求出i位置的回文半径? 而不要每次都从i开始使劲往外扩。
也就是类似KMP算法的next信息数组,
有了next[i-1]信息,看看i-1位置的字符是否等于y=next[i-1]处的字符,这样省掉了s中与match已经匹配过的前缀串,舍弃match串的j从0重头对比的暴力步骤!
【这KMP具体怎么搞得,请看文章:KMP算法:在字符串s中搜索匹配查找match字符串,如果能找到返回首个匹配位置i,否则返回-1】【看完好好理解一下舍弃的思想,应用到本文中的manacher算法,你就明白了,也更容易动我要讲得东西】
这种舍弃思想,我们也想借鉴到manacher算法中,争取利用0–i-1上的pArr信息,舍弃一些每次从i位置,都要从i这往外扩的暴力步骤!
这种舍弃思想,我们也想借鉴到manacher算法中,争取利用0–i-1上的pArr信息,舍弃一些每次从i位置,都要从i这往外扩的暴力步骤!
这种舍弃思想,我们也想借鉴到manacher算法中,争取利用0–i-1上的pArr信息,舍弃一些每次从i位置,都要从i这往外扩的暴力步骤!
所以,我们要在全程额外申请一个o(n)的空间,来存pArr数组
众所周知的事情就是,算法中优化时间和空间,向来都是反比关系,你想省空间,速度就得慢,时间复杂度高,你想速度快,那必然消耗额外空间复杂度
搞懂填写回文半径数组pArr的2个大情况,3个小情况
okay,说了这么多,你知道我为啥要准备pArr数组了吧!
now,咱们来说:填写回文半径数组pArr的2个大情况,3个小情况
每次你不是要去i那收集pArr[i]信息吗:i位置为中心,能扩出的回文半径是多少?
0–i-1范围上,当初那些i位置,扩出来的回文区域最远右边界是R,其回文中心是C
情况【1】,当i>R时:你没得选,跟暴力扩展一样,直接从i往外扩
这就如同最开始,R=-1,C=-1,你i=0,自然i>R,第一次你没得选,你就得从i开始外扩试试能有多少是回文的
情况【2】,当i<=R时:分为3种小情况
我们在情况【2】这里,关注的是i位置,与C对称的那个位置i1,它的回文边界L1–R1是啥情况
如果C当前扩到的最左右边界是L–R的话
(1)i的对称位置i1的回文区域在C的回文区域内
即L<L1时,R1自然是<R的
这时,i1的回文半径就是i的回文半径,pArr[i]=pArr[i1]
咱们来看例子:
图中i的对称位置i1的回文区域L1–R1在C的回文区域L–R内,因为C能扩到R,必然C两边都是对称的
i1的回文区域在L–R内,必然i位置的回文区域L2–R2完全和L1–R1是相等的字符串,否则C就没法扩到R了
你想想是不是?
故,pArr[i]=pArr[i1]
其实,这里你能发现啥呢?咱们来到i,有了对称位置i1的pArr信息,咱们可以省掉i从头扩的步骤,这就是舍弃思想的妙处啊!
(2)i的对称位置i1的回文区域在C的回文区域外
即L1<L时
这时,i的回文半径就是R-i+1,pArr[i]=R-i+1 ,也即是i的回文区域最远只能扩到R
我们来看例子:
图中C的回文区域L–R左右是对称的,
i的对称位置i1的回文区域L1<L了,在C回文区之外了,
咱们至少现在能保证L–R内是完全对称的,所以目前来看
至少pArr[i]=R-i+1的,也就是至少,i–R是回文的【因为i1的回文区L–R1内是回文的,对称过来R2–R也必然回文】
还能外R之外扩吗?不能!
因为当初C由于a!=y字符,所以导致C最远能扩到R,你现在也休想扩,否则就矛盾了!
故pArr[i]=R-i+1,从i最远只能扩到R处
其实,这里你能发现啥呢?咱们来到i,有了对称位置i1的pArr信息,咱们可以省掉i–R从头扩的步骤,这就是舍弃思想的妙处啊!
(3)i的对称位置i1的回文区域在C的回文区域上,即两者完全重合
即L1=L时
至少根据(2)咱们能知道目前从i最远能扩到R处,就目前至少故pArr[i]=R-i+1
那后续还能不能扩呢?暴力扩去吧,非常可能还可以扩!
咱们看例子
上图呢,L1=L了,重合,于是乎,最少咱们能把L2–R2定在上面绿色位置
由于R=1位置的字符是a,与L2-1处a相同,所以说,还能扩的,那咱们就暴力扩呗!
pArr[i]=R2-i+1
其实,这里你能发现啥呢?咱们来到i,有了对称位置i1的pArr信息,咱们可以最少能省掉i–R从头扩的步骤,这就是舍弃思想的妙处啊!
上面【1】【2】的(1)(2)(3)俩大情况,仨小情况
中,我们充分利用了舍弃思想
我们充分利用了舍弃思想
我们充分利用了舍弃思想
这就大大地加速了咱们填写pArr数组的速度,这就是manacher牛逼的地方!!!
手撕manacher算法的代码精致简介代码
有了上面收集pArr数组的过程,每次i拿到pArr[i],咱把最大值更新给max即可,最后,我们收了返回max-1就是咱要的结果
但是呢,写代码呀,咱们要有一个精简的过程
这么搞
情况【1】,当i>R时:你没得选,跟暴力扩展一样,直接从i往外扩
情况【2】,当i<=R时:分为3种小情况
我们在情况【2】这里,关注的是i位置,与C对称的那个位置i1,它的回文边界L1–R1是啥情况
如果C当前扩到的最左右边界是L–R的话
(1)i的对称位置i1的回文区域在C的回文区域内
即L<L1时,R1自然是<R的。这时,i1的回文半径就是i的回文半径,pArr[i]=pArr[i1]
(2)i的对称位置i1的回文区域在C的回文区域外
即L1<L时。这时,i的回文半径就是R-i+1,pArr[i]=R-i+1 ,也即是i的回文区域最远只能扩到R
(3)i的对称位置i1的回文区域在C的回文区域上,即两者完全重合
即L1=L时。至少根据(2)咱们能知道目前从i最远能扩到R处,就目前至少故pArr[i]=R-i+1
那后续还能不能扩呢?暴力扩去吧,非常可能还可以扩!
你瞅瞅?
咱们的精简代码是这样的流程,你明白了之前的思想,这个也就很好理解的。
1–是不是当i>R时,最次pArr[i]=1,就是i字符自己呗【情况1呗】
2–否则当i<=R时,咱们先扩1个基础步骤,取pArr[i]=min(2C-i,R-i),
说白了就是要舍弃pArr[i]这么多长度,不要重复对比了
2C-i代表啥呢?i的对称位置i1的回文半径,比较小,求法就是2C-i,你记住就行。
R-i代表啥呢?上面情况【2】的(2)(3)中i的对称位置i1的回文半径,就是R-i这么多,咱们不要1,
1–2–整合直接就是一句代码:pArr[i] = i > R ? 1 : Math.min(2 * C - i, R - i);
3–咱们在1–2–之后才试图暴力往外扩,能扩的话就让pArr[i]++
4–每次扩完,都需要更新R,C,max,每个i可能都是未来的新的更远的C
你看字糊涂没关系,咱写代码,你看代码就能理解
代码非常简介,你学完必须自己要能手撕
手撕的前提就是必须理解这个manacher算法的本质
//复习manacher算法
public static int manacherReview(String s){
if (s.equals("") || s.length() == 0) return 0;
int max = Integer.MIN_VALUE;//先默认负无穷
//s填写虚轴符号#
char[] str = tomanacherString(s);
int N = str.length;//新s的长度,也会是pArr的长度
int[] pArr = new int[N];
//初始化R,C
int C = -1;
int R = -1;//此前已经扩到的最右边界
//i从0--N-1更新max
for (int i = 0; i < N; i++) {
//1--是不是当i>R时,最次pArr[i]=1,就是i字符自己呗【情况1呗】
//2--否则当i<=R时,咱们**先扩1个基础步骤**,取pArr[i]=min(2C-i,R-i),
//**说白了就是要舍弃pArr[i]这么多长度,不要重复对比了**
//2C-i代表啥呢?i的对称位置i1的回文半径,比较小,求法就是2C-i,你记住就行。
//R-i代表啥呢?上面情况【2】的(2)(3)中i的对称位置i1的回文半径,就是R-i这么多,咱们不要1,
//以上几个基础步骤啊,直接统一成依据代码
pArr[i] = i > R ? 1 : Math.min(2 * C - i, R - i);
//3--咱们之后才**暴力往外扩,能扣就让pArr[i]++**
while (i - pArr[i] >= 0 && i + pArr[i] < N){
//i想外扩的话,这俩位置不能越界
//如果他俩相等,半径++
if (str[i - pArr[i]] == str[i + pArr[i]]) pArr[i]++;
else break;//一旦不能扩了,直接退出
}
//4--每次扩完,都需要更新R,C,max,每个i可能都是未来的新的更远的C
if (i + pArr[i] > R) {
R = i + pArr[i];
C = i;//新边界来了
}
max = Math.max(max, pArr[i]);//带有#的s的最长回文半径
}
return max - 1;//结果怎么推的看文章解释
}
测试一把,问题不大:
public static void test2(){
int[] arr = {3,1,2,1,1,2,1,4};
//System.out.println(getBackMaxLen(arr));
// char[] chars = tomanacherString("121");
System.out.println(manacher("babad"));
System.out.println(manacherReview("babad"));
}
public static void main(String[] args) {
// test();
test2();
}
3
3
是不是超级牛!
这个要是在面试中,你能手撕出来,说明你的算法优化能力还是会很强的,面试官自然能高看你!
总结
提示:重要经验:
1)回文串的基本概念,回文半径,回文区域,回文右边界,中心啥的,理解透彻
2)整个manacher算法的关键在如何舍弃不需要再对比的地方,然后填写回文半径数组pArr,最后更新给max
3)笔试求AC,可以不考虑空间复杂度,但是面试既要考虑时间复杂度最优,也要考虑空间复杂度最优。