Manacher算法是经典的算法,它的作用是给定一个字符串,返回str中最长回文字串的长度。
举例,str=”123”,其中的最长回文字串是”1”、”2”、”3”,所以返回1。
str=”abc1234321ab”,其中最长的回文字串为”1234321”,所以返回7。
进阶题目分析:给定一个字符串,想通过添加字符的方式使得str整体都变成回文字符串,但要求只能在str的末尾添加字符,请返回在str后面的最短字符串。
举例,str=”12”,在str后添加”1”后str变为回文字符串,添加”21”后也是回文串,但是添加”1”的情况是所有的方案中最短的,所以返回”1”。
解答:本题重点是介绍Manachre算法,该算法是由Glenn Manacher于1975年首次发明的。Manacher解决的是在线性时间内找到一个字符串的最长回文子串,比起能够解决该问题的其他算法,Manacher算法都比较好理解和实现。
先说一个比较好理解的方法。从左到右遍历字符串,遍历到每个字符的时候,都看看以这个字符作为中心能够产生多大的回文字符串。比如str”abacaba”,以str[0]=’a’为中心的回文字符串最大长度为1,以str=’b’为中心的回文字符串最大长度为3,……其中最大的回文字串是以str[3]=’c’为中心的时候。这种方法很好理解,只要解决奇回文和偶回文寻找方式不同就可以。”121”是奇回文。”1221”是偶回文,没有确定的轴,回文的虚轴在”22”中间。但是这种方法有明显的问题,之前遍历过的字符完全无法指导后面的过程,也就是对每个字符来说都是从自己的位置出发,往左右两个方向扩出去检查。这样,对每个字符来说,往外扩的代价都是一个级别的。举一个极端的例子,字符串”aaaaaaaaaaaaaaaa”,对于每一个’a’来讲,都是扩到边界才停。所以对每一个字符扩出去检查的代价都是O(n),所以总的时间复杂度为O(N*N)。Manacher算法可以做到O(N)的时间复杂度,精髓是之前字符的”扩”的过程可以指导后面字符”扩”的过程,使得每次的”扩”的过程都不是从无开始,以下是Manacher算法的详细过程。
1,因为奇回文和偶回文在判断时比较麻烦,所以对字符串进行一个处理,在每个字符的开头、结尾和中间都加入一个特殊字符’#’,比如”bcb”,处理后变为”#b#c#b#”,然后从每个字符左右扩出去的方式找到最大回文字串就容易多了。对奇回文来说,不这么处理也能通过扩的方式找到,比如”bcb”,从’c’开始向左右两侧扩出去能找到最大回文。处理后为”#b#c#b#”,从’c’往左右两侧扩依然能找到最大回文字串。对偶回文来说,不处理而直接通过扩的方式是找不到的,比如”aa”,因为没有确定的轴,但是处理后为”#a#a#”,就可以通过从中间的’#’扩出去找到最大回文字串。所以通过这样的处理方式,最大回文字串无论是奇回文还是偶回文,都可以通过统一的”扩”的过程找到,解决了差异性的问题。同时要说的是这个特殊字符是什么无所谓,甚至可以是字符串中出现的字符,也不会影响最终的结果,就是起到一个辅助的作用。
具体的处理过程看如下的manacherString方法:
public static char[] manacherString(String str) {
char[] charArr = str.toCharArray();
char[] res = new char[str.length() * 2 + 1];
int index = 0;
for (int i = 0; i != res.length; i++) {
res[i] = (i & 1) == 0 ? '#' : charArr[index++];
}
return res;
}
2,假设str处理之后的字符串记为charArr,对每个字符(包括特殊字符)都进行”优化后”的”扩”过程。在介绍”优化后”的扩过程之前,先解释下面三个辅助变量的意义。
1),数组pArr,长度与charArr一样。pArr[i]的意义是以i位置上的字符(charArr[i])作为回文中心的情况下,扩出去得到的最大回文半径是多少。举个例子,对”#c#a#b#a#c#”来说,pArr[0..9]为[1,2,1,2,1,6,1,2,1,2,1]。我们的整个过程就是在从左到右遍历的过程中,依次计算每个位置的最大回文半径值。
2),整数pR,这个变量的意义是之前遍历的所有字符的所有回文半径中,最右即将到达的位置。还是以”#c#a#b#a#c#”为例来说,还没遍历之前,pR初始值设置为-1,charArr[0]=’#’的回文半径为1,所以目前回文半径向右只能扩到位置0,回文半径最右即将到达的位置为1(pR=1)。charArr[1]=’c’的回文半径为2,此时所有的回文半径向右能扩到位置2,所以回文半径最右即将到达的位置为3(pR=3)。依次类推,在charArr[2]位置的pR为3,charArr[3]=4,charArr[4]=4,charArr[5]=11,此时已经到达整个字符数组的结尾,之后pR不再变化了,换句话说,pR就是遍历过的所有字符中向右扩出来的最大右边界。只要右边界更往右,pR就更新。
整数index,这个变量表示最近一次pR更新时,那个回文中心的位置,以刚刚的例子来说,index就更新为1,……遍历到charArr[5]时pR更新,index就更新为5。之后的过程中,pR不再更新,所以index一直为5。
3,只要能够从左到右一次计算出数组pArr每个位置的值,最大的那个值实际上就是处理后的charArr中最大的回文半径,根据最大的回文半径,再对应回原字符串的话,整个问题就解决了。步骤3就是从左到右依次计算出pArr数组每个位置的值得过程。
1),假设现在计算到i位置,在计算过程中都会不断更新pR和index的值,即位置i之前的index这个回文中心扩出了一个目前最右的回文右边界pR。
2),如果pR-1没有包住当前的i的值,比如”#c#a#b#a#c#”,计算到charArr[1]=’c’的时,pR=1,也就是说,右边界在1的位置,1位置为最右回文半径即将到达但还没有到达的位置,所以当前的pR-1位置没有包住当前i的位置,此时和普通做法一样,从i位置字符开始,向左右扩出去检查,此时的”扩”的过程并没有获得加速。
3),如果pR-1位置包住了当前的i位置,比如”#c#a#b#a#c#”,计算到charArr[6…10]时,pR都为11,此时pR-1包住了位置6~10。这种情况下,检查过程是可以获得优化的,这也是Manacher算法的核心精髓所在。
在一个字符数组中,位置i是要计算回文半径(pArr[i])的位置。pR-1位置此时是包住i的。同时根据index的定义,index是pR更新时那个回文中心的位置,所以如果是pR-1位置以index为中心对称,那么”左大”位置到pR-1位置一定是以index为中心的回文串,我们把这个回文串叫作大回文串,同时把pR-1位置称为”右大”的位置。既然回文半径数组pArr是从左到右计算的,所以位置i之前的位置都已经计算过了回文半径。假设位置i以index为中心向左对称过去的位置为i’,那么i’位置的回文半径也是计算过的。那么以i’为中心的最大回文串大小(pArr[i’])必然只有三种情况,我们一一来分析下,假设以i’为中心的最大回文串的左边界和右边界分别记为”左小”和”右小”。
情况一,”左小”和”右小”完全在”左大”和”右大”内部,即以i’为中心的最大回文串完全在以index为中心的最大回文串的内部。
如果处在情况一下,那么以位置i为中心的最大回文串可以直接确定,就是从”右小’”到”左小’”这一段。
情况二,”左小”和”右小”左侧部分在”左大”和”右大”的外部。这种情况i位置的最大回文串是”右大’”到”右大”这一段。
情况三,”左小”和”左大”是同一个位置,即以i’为中心的最大回文串在以index为中心的最大回文串的边界上。这个要看情况。
4,按照步骤3的逻辑从左到右计算出pArr数组,计算完成后再遍历一遍pArr数组,找出最大回文半径,假设i位置的回文半径最大,即pArr[i]=max,但max只是charArr的最大回文半径,还得对应回原来的字符串,求出最大回文半径的长度(其实就是max-1)。比如原字符串为”121”,处理成charArr之后为”#1#2#1#”。在charArr中位置3的回文半径最大,最大值为4(即pArr[3]=4),对应原字符串的最大回文字串长度为4-1=3.
Manacher算法时间复杂度是O(n)的证明,虽然我们可以很明显地看到Manacher算法与普通方法相比,在扩出去检查这一行为上有明显的优化,但如何证明该算法的时间复杂度就是O(N)呢?关键之处在于估算扩出去检查这一行为发生的数量。原字符串在处理后的长度由n变为2n,从步骤3的主要逻辑来看,要么在计算第一个位置的回文半径时完全不需要扩出去检查,比如,步骤3中的3)介绍的情况一和情况二,都可以直接获得位置i的回文半径长度;要么每一次扩出去检查都会导致pR变量的更新,比如步骤3中的2)和3)介绍的情况三,扩出去检查时都让回文半径到达更右的位置,当然会使pR更新,然而pR最多是-1增加到2n(右边界),并且从来不减小,所以扩出去检查的次数就是O(n)的级别,所以Manacher算法的时间复杂度是O(n),具体参看如下的maxlength方法。
public static int maxLcpsLength(String str) {
if (str == null || str.length() == 0) {
return 0;
}
char[] charArr = manacherString(str);
int[] pArr = new int[charArr.length];
int index = -1;
int pR = -1;
int max = Integer.MIN_VALUE;
for (int i = 0; i != charArr.length; i++) {
pArr[i] = pR > i ? Math.min(pArr[2 * index - i], pR - i) : 1;
while (i + pArr[i] < charArr.length && i - pArr[i] > -1) {
if (charArr[i + pArr[i]] == charArr[i - pArr[i]])
pArr[i]++;
else {
break;
}
}
if (i + pArr[i] > pR) {
pR = i + pArr[i];
index = i;
}
max = Math.max(max, pArr[i]);
}
return max - 1;
}
进阶问题,在字符串后面的最后添加最少字符,使整个字符串是回文字符串,其实就是查找在必须包含最后一个字符的情况下,最长的回文字串是什么。那么之前不是回文字串的部分逆序过来,就是应该添加的部分。比如”abcd123321”,在必须包含最后一个字符的情况下,最长的回文字串是”123321”,之前不是最长回文字串的部分是”abcd”,所以末尾应该添加的部分就是”dcba”,那么只要把Manacher算法稍作调整就可以。具体改成,从左到右计算回文半径时,关注回文半径最右即将到达的位置(pR),一旦发现已经到达最后(pR=charArr.length),说明必须包含最后一个字符的最长回文半径已经找到,直接退出检查过程,返回该添加的字符串即可,具体参看如下代码:
public static String shortestEnd(String str) {
if (str == null || str.length() == 0) {
return null;
}
char[] charArr = manacherString(str);
int[] pArr = new int[charArr.length];
int index = -1;
int pR = -1;
int maxContainsEnd = -1;
for (int i = 0; i != charArr.length; i++) {
pArr[i] = pR > i ? Math.min(pArr[2 * index - i], pR - i) : 1;
while (i + pArr[i] < charArr.length && i - pArr[i] > -1) {
if (charArr[i + pArr[i]] == charArr[i - pArr[i]])
pArr[i]++;
else {
break;
}
}
if (i + pArr[i] > pR) {
pR = i + pArr[i];
index = i;
}
if (pR == charArr.length) {
maxContainsEnd = pArr[i];
break;
}
}
char[] res = new char[str.length() - maxContainsEnd + 1];
for (int i = 0; i < res.length; i++) {
res[res.length - 1 - i] = charArr[i * 2 + 1];
}
return String.valueOf(res);
}
以上就是Manacher算法以及衍生问题的全过程。