Manacher算法: 求解字符串的最长回文子串的长度
1、Manacher算法
-
Manacher算法中的概念:
- 1、回文子串:指的是一个字符串从左到右和从右到左读是完全相同的字符串,例如"aba"、"abba"等。
- 2、回文子串的长度:指的是回文子串的字符数量,例如"aba"的长度为3,"abba"的长度为4。
- 3、回文半径:指的是回文子串的中心到回文子串的边界的距离,奇数长度的回文半径为其回文长度的一半加1,偶数长度的回文半径为其回文长度的一半。
- 例如"aba"的回文半径为2,"abba"的回文半径为2。
- 4、回文半径数组:指的是每个位置的回文半径所组成的数组,例如"aba"的回文半径数组为[1,2,1],"abba"的回文半径数组为[1,2,2,1]。
- 可以看出,奇数长度的回文子串在计算某个位置的回文时,只需要考虑其左右两边对称位置是否相同,
- 而偶数长度的回文子串在计算某个位置的回文时,还需要考虑其自身与下个位置或者上个位置是否相同,相比较而言比奇数的更加复杂。
- Mancher算法就是通过统一奇偶处理,将无论是奇数还是偶数长度的字符,都转为了奇数长度来处理的。
-
Manacher算法的核心思想:
- Mancher算法的核心思想主要有两个,一个是统一奇偶处理,一个是利用对称性进行优化计算方法。
- 下面单独分析每一个思想方法。
-
统一奇偶处理:
- 为了方便处理奇数长度和偶数长度的回文子串,Manacher算法通过在原始字符串的每个字符之间插入一个特殊字符(如'#'),
- 统一将所有字符串都转换为奇数长度。例如,原始字符串"aba"转换为"#a#b#a#",原始字符串"abba"转换为"#a#b#b#a#"。
- 这样做的好处是,无论是奇数长度还是偶数长度的回文子串,都可以通过统一的计算方法来处理,无需单独考虑。
- 此时,计算出来的回文字符的长度就是加入字符后的回文长度/2的结果。
- 同时,因为这样处理完以后,都是奇数的长度了,而加入的字符恰好都是在转换后的字符的奇数位置上,所以在对比的时候,都是加入的字符和加入的字符之间的对比,
- 原来的字符和原来的字符做对比,所以即使加入的字符在原来的字符中包含,也不会影响最终的结果。
-
利用对称性进行优化计算方法:
-
Manacher算法利用回文子串的对称性,通过记录已经计算过的回文半径,避免重复计算
-
(因为无论是统一奇偶处理后的字符串,还是原来是奇数长度的字符串都是一样的逻辑,这里在举例的时候我们就用原来是奇数长度的字符串做例子,要不然加入很多#看起来不太清楚)。
-
具体的做法就是从左往右依次处理字符串中的每一个字符,
-
新增一个回文半径数组pArr,用来记录每个字符的回文半径。
-
新建一个变量R,用来记录计算中遇到的回文右边界的最右边的位置(对应整个字符串,并不是每一个字符串都有对应一个R)。
-
新建一个变量C,用来记录对应R的中心的字符串位置(对应整个字符串,和R是对应的)。
-
在这个时候,如果处理字符串的i位置,就会有以下几种情况:
-
1、i在R的右边:
-
此时,因为i在R的右边,所以i的回文半径至少是1(也就是它本身),此时i已经超过了我们记录的回文右边界R,无法进行优化,
-
所以我们需要从i位置开始,向左右两边扩展,计算i位置的回文半径,并更新R和C的值,记录下i位置的回文右边界的最右边的位置R和i位置的中心位置C,
-
并计算出i位置的回文半径pArr[i]。
-
2、i在R的左边:
-
此时i在R的左边,也就是说我们在计算i前面位置的时候,已经扩展到了R的位置,是可以进行优化的,具体要分情况来讨论。
-
首先,此时的C记录的是对应R的中心位置,此时在C的左边必然有一个R相对于C的对称位置,我们记为L;
-
因为i以前已经算扩展到了R的位置,所以在C的左边也必然有一个i相对于C的对称位置,我们记为i',并且,i'的回文半径我们已经计算过了,就是pArr[i'];
-
因为i'的回文半径我们已经计算过了,所以i'的回文必然是有一个左右边界的,我们记为a和b,那么a和b是相对于i'对称的,
-
此时对于i'的左边界a,就有3中情况,我们分开讨论:
-
2.1、如果i'的左边界a在L的右边,也就是被L包含,此时i位置的回文半径和i'位置的回文半径是相同的,即pArr[i] = pArr[i']。
-
因为i'的回文是被L包含的,整个L和R也是相对于C对称的,i和i'也是相对于C对称的,所以i的回文字符是和i'的回文字符是相同的。
-
比如(字符串不包含空格,空格只是为了对应位置比较直观)

-
加入L在最左侧位置,R已经是最右侧,L和R包含了整个字符串,那么中间位置就是t对应的位置,
-
加入此时要计算倒数第一个d对应的字符的回文,此时对应的点i'就是从左侧起第一个d的位置,其包含在L的范围内,此时i'的回文就是cdc,对应i的回文也就是cdc。
-
2.2、如果i'的左边界a在L的左边,也就是i'的回文超出了L的范围,此时i位置的回文半径就是i到R的距离。
-
因为i和i'是对应于C对称的,L和R也是相对于C对称的,既然R无法再往右扩了,也就是L-1位置的字符和R+1位置的是不一样的,
-
又因为a和b是关于i'对称的,a又小于L,即L位置在i'的回文里面,其关于i'的对称位置记为L',同时算出R关于i的对称点R',
-
此时L'+1的位置和L-1的是相同的,R'-1的位置和L'+1也是相同的(关于C对称),而L-1的位置和R+1又是不同的,所以R'-1和R+1是不同的。
-
即此时的i位置的回文半径就是i到R的距离。
-
比如(字符串不包含空格,空格只是为了对应位置比较直观)

-
2.3、如果i'的左边界a和L的边界重合,此时i位置的回文半径是不确定的,需要从i位置开始往左右扩。
-
比如(字符串不包含空格,空格只是为了对应位置比较直观)

-
此时X位置和Y位置的字符串可能相等,也可能不相等,都是能成立的。
-
-
总结:
- 首先,我们分各种情况来讨论只是为了优化,并不是说到了i位置,就不能用暴力方法往左右两边扩了。还是可以扩的。
- 讨论的意义在于能优化就优化,不能优化就暴力扩展。
- 从上面的情况1和情况2,已经2.1、2.2、2.3的几种情况综合来说,情况1和2.3是不能优化的,可以直接从i位置扩展,
- 情况2.1和2.2是可以优化的,而且2.2是包含在2.1里面的,所以我们在写代码的时候,就可以方便的从两种情况中取到最小值,
- 然后从i位置按照条件往右扩即可,这样有了优化,也不至于遗漏掉情况,也省去了繁杂的计算条件。
- 当然,在实际的写代码的过程中,我们一般会让R位置是C位置对称点的下一个位置,也就是不包含R的位置的字符,这样计算C到R的距离直接用R-C即可。
3、Manacher算法的实现
java
/**
* 利用Manacher算法求字符串s的最长回文子串的长度
*/
public static int manacher(String s) {
if (s == null || s.length() == 0) {
return 0;
}
// 获取统一奇偶处理后的字符串数组
char[] charArr = manacherString(s);
// 回文半径数组
int[] pArr = new int[charArr.length];
// 中心位置
int C = -1;
// 回文右边界的右边的位置
int R = -1;
// 最大回文半径
int max = Integer.MIN_VALUE;
for (int i = 0; i < charArr.length; i++) {
// 首先取到情况2.1和2.2的最小值
pArr[i] = R > i ? Math.min(pArr[2 * C - i], R - 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] > R) {
R = i + pArr[i];
C = i;
}
max = Math.max(max, pArr[i]);
}
// 回文中都是奇数,所以回文长度是charArr.length/2,
// 回文半径是charArr.length/2+1
// max是最大的回文半径,所以回文长度是max-1
return max - 1;
}
/**
* 获取统一奇偶处理后的字符串数组
*/
private 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;
}
整体代码和测试如下:
java
/**
* Manacher算法: 求解字符串的最长回文子串的长度
* Manacher算法中的概念:
* 1、回文子串:指的是一个字符串从左到右和从右到左读是完全相同的字符串,例如"aba"、"abba"等。
* 2、回文子串的长度:指的是回文子串的字符数量,例如"aba"的长度为3,"abba"的长度为4。
* 3、回文半径:指的是回文子串的中心到回文子串的边界的距离,奇数长度的回文半径为其回文长度的一半加1,偶数长度的回文半径为其回文长度的一半。
* 例如"aba"的回文半径为2,"abba"的回文半径为2。
* 4、回文半径数组:指的是每个位置的回文半径所组成的数组,例如"aba"的回文半径数组为[1,2,1],"abba"的回文半径数组为[1,2,2,1]。
* 可以看出,奇数长度的回文子串在计算某个位置的回文时,只需要考虑其左右两边对称位置是否相同,
* 而偶数长度的回文子串在计算某个位置的回文时,还需要考虑其自身与下个位置或者上个位置是否相同,相比较而言比奇数的更加复杂。
* Mancher算法就是通过统一奇偶处理,将无论是奇数还是偶数长度的字符,都转为了奇数长度来处理的。
* <br>
* Manacher算法的核心思想:
* Mancher算法的核心思想主要有两个,一个是统一奇偶处理,一个是利用对称性进行优化计算方法。
* 下面单独分析每一个思想方法。
* <br>
* 统一奇偶处理:
* 为了方便处理奇数长度和偶数长度的回文子串,Manacher算法通过在原始字符串的每个字符之间插入一个特殊字符(如'#'),
* 统一将所有字符串都转换为奇数长度。例如,原始字符串"aba"转换为"#a#b#a#",原始字符串"abba"转换为"#a#b#b#a#"。
* 这样做的好处是,无论是奇数长度还是偶数长度的回文子串,都可以通过统一的计算方法来处理,无需单独考虑。
* 此时,计算出来的回文字符的长度就是加入字符后的回文长度/2的结果。
* 同时,因为这样处理完以后,都是奇数的长度了,而加入的字符恰好都是在转换后的字符的奇数位置上,所以在对比的时候,都是加入的字符和加入的字符之间的对比,
* 原来的字符和原来的字符做对比,所以即使加入的字符在原来的字符中包含,也不会影响最终的结果。
* <br>
* 利用对称性进行优化计算方法:
* Manacher算法利用回文子串的对称性,通过记录已经计算过的回文半径,避免重复计算
* (因为无论是统一奇偶处理后的字符串,还是原来是奇数长度的字符串都是一样的逻辑,这里在举例的时候我们就用原来是奇数长度的字符串做例子,要不然加入很多#看起来不太清楚)。
* 具体的做法就是从左往右依次处理字符串中的每一个字符,
* 新增一个回文半径数组pArr,用来记录每个字符的回文半径。
* 新建一个变量R,用来记录计算中遇到的回文右边界的最右边的位置(对应整个字符串,并不是每一个字符串都有对应一个R)。
* 新建一个变量C,用来记录对应R的中心的字符串位置(对应整个字符串,和R是对应的)。
* 在这个时候,如果处理字符串的i位置,就会有以下几种情况:
* 1、i在R的右边:
* 此时,因为i在R的右边,所以i的回文半径至少是1(也就是它本身),此时i已经超过了我们记录的回文右边界R,无法进行优化,
* 所以我们需要从i位置开始,向左右两边扩展,计算i位置的回文半径,并更新R和C的值,记录下i位置的回文右边界的最右边的位置R和i位置的中心位置C,
* 并计算出i位置的回文半径pArr[i]。
* 2、i在R的左边:
* 此时i在R的左边,也就是说我们在计算i前面位置的时候,已经扩展到了R的位置,是可以进行优化的,具体要分情况来讨论。
* 首先,此时的C记录的是对应R的中心位置,此时在C的左边必然有一个R相对于C的对称位置,我们记为L;
* 因为i以前已经算扩展到了R的位置,所以在C的左边也必然有一个i相对于C的对称位置,我们记为i',并且,i'的回文半径我们已经计算过了,就是pArr[i'];
* 因为i'的回文半径我们已经计算过了,所以i'的回文必然是有一个左右边界的,我们记为a和b,那么a和b是相对于i'对称的,
* 此时对于i'的左边界a,就有3中情况,我们分开讨论:
* 2.1、如果i'的左边界a在L的右边,也就是被L包含,此时i位置的回文半径和i'位置的回文半径是相同的,即pArr[i] = pArr[i']。
* 因为i'的回文是被L包含的,整个L和R也是相对于C对称的,i和i'也是相对于C对称的,所以i的回文字符是和i'的回文字符是相同的。
* 比如(字符串不包含空格,空格只是为了对应位置比较直观)
* 字符串: a b c d c k s t s k c d c b a
* 对应位置:L a i'b C i R
* 加入L在最左侧位置,R已经是最右侧,L和R包含了整个字符串,那么中间位置就是t对应的位置,
* 加入此时要计算倒数第一个d对应的字符的回文,此时对应的点i'就是从左侧起第一个d的位置,其包含在L的范围内,此时i'的回文就是cdc,对应i的回文也就是cdc。
* 2.2、如果i'的左边界a在L的左边,也就是i'的回文超出了L的范围,此时i位置的回文半径就是i到R的距离。
* 因为i和i'是对应于C对称的,L和R也是相对于C对称的,既然R无法再往右扩了,也就是L-1位置的字符和R+1位置的是不一样的,
* 又因为a和b是关于i'对称的,a又小于L,即L位置在i'的回文里面,其关于i'的对称位置记为L',同时算出R关于i的对称点R',
* 此时L'+1的位置和L-1的是相同的,R'-1的位置和L'+1也是相同的(关于C对称),而L-1的位置和R+1又是不同的,所以R'-1和R+1是不同的。
* 即此时的i位置的回文半径就是i到R的距离。
* 比如(字符串不包含空格,空格只是为了对应位置比较直观)
* 字符串: a b c d e d c b a t s t a b c d e d c f
* 对应位置: a L i' L' b C R' i R
* 2.3、如果i'的左边界a和L的边界重合,此时i位置的回文半径是不确定的,需要从i位置开始往左右扩。
* 比如(字符串不包含空格,空格只是为了对应位置比较直观)
* 字符串: X a b c b a s t s a b c b a Y
* 对应位置: L(a) i' C i R
* 此时X位置和Y位置的字符串可能相等,也可能不相等,都是能成立的。
* 总结:
* 首先,我们分各种情况来讨论只是为了优化,并不是说到了i位置,就不能用暴力方法往左右两边扩了。还是可以扩的。
* 讨论的意义在于能优化就优化,不能优化就暴力扩展。
* 从上面的情况1和情况2,已经2.1、2.2、2.3的几种情况综合来说,情况1和2.3是不能优化的,可以直接从i位置扩展,
* 情况2.1和2.2是可以优化的,而且2.2是包含在2.1里面的,所以我们在写代码的时候,就可以方便的从两种情况中取到最小值,
* 然后从i位置按照条件往右扩即可,这样有了优化,也不至于遗漏掉情况,也省去了繁杂的计算条件。
* 当然,在实际的写代码的过程中,我们一般会让R位置是C位置对称点的下一个位置,也就是不包含R的位置的字符,这样计算C到R的距离直接用R-C即可。
*/
public class Manacher {
/**
* 利用Manacher算法求字符串s的最长回文子串的长度
*/
public static int manacher(String s) {
if (s == null || s.length() == 0) {
return 0;
}
// 获取统一奇偶处理后的字符串数组
char[] charArr = manacherString(s);
// 回文半径数组
int[] pArr = new int[charArr.length];
// 中心位置
int C = -1;
// 回文右边界的右边的位置
int R = -1;
// 最大回文半径
int max = Integer.MIN_VALUE;
for (int i = 0; i < charArr.length; i++) {
// 首先取到情况2.1和2.2的最小值
pArr[i] = R > i ? Math.min(pArr[2 * C - i], R - 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] > R) {
R = i + pArr[i];
C = i;
}
max = Math.max(max, pArr[i]);
}
// 回文中都是奇数,所以回文长度是charArr.length/2,
// 回文半径是charArr.length/2+1
// max是最大的回文半径,所以回文长度是max-1
return max - 1;
}
/**
* 获取统一奇偶处理后的字符串数组
*/
private 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;
}
// for test
public static int comparator(String s) {
if (s == null || s.length() == 0) {
return 0;
}
char[] str = manacherString(s);
int max = 0;
for (int i = 0; i < str.length; i++) {
int L = i - 1;
int R = i + 1;
while (L >= 0 && R < str.length && str[L] == str[R]) {
L--;
R++;
}
max = Math.max(max, R - L - 1);
}
return max / 2;
}
public static void main(String[] args) {
int possibilities = 5;
int strSize = 50;
int testTimes = 5000000;
System.out.println("test begin");
for (int i = 0; i < testTimes; i++) {
String str = getRandomString(possibilities, strSize);
int manacher = manacher(str);
int comparator = comparator(str);
if (manacher != comparator) {
System.out.println("测试失败!");
System.out.printf("字符串:%s,manacher:%d,comparator:%d\n", str, manacher, comparator);
break;
}
}
System.out.println("test finish");
}
// for test
public static String getRandomString(int possibilities, int size) {
char[] ans = new char[(int) (Math.random() * size) + 1];
for (int i = 0; i < ans.length; i++) {
ans[i] = (char) ((int) (Math.random() * possibilities) + 'a');
}
return String.valueOf(ans);
}
}
3、题目:添加字符后成回文
- 题目:添加字符后成回文
- 给定一个字符串str,只能在str的后面添加字符,想让str整体变成回文串,返回至少要添加几个字符
- 思路:
- 在字符串str后面添加字符,让str整体变成回文串,就是从左到右找每个字符的回文串,找到一个字符包含了最右侧的字符,然后将其补全即可。
- 结合Mancher算法,就是找到最右侧的R包含str最后一个字符的C,然后将0到L的字符逆序补全到str后面即可。
整体代码如下:
java
/**
* 题目:添加字符后成回文
* 给定一个字符串str,只能在str的后面添加字符,想让str整体变成回文串,返回至少要添加几个字符
*/
public class Q1_AddShortestEnd {
/**
* 思路:
* 在字符串str后面添加字符,让str整体变成回文串,就是从左到右找每个字符的回文串,找到一个字符包含了最右侧的字符,然后将其补全即可。
* 结合Mancher算法,就是找到最右侧的R包含str最后一个字符的C,然后将0到L的字符逆序补全到str后面即可。
*/
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 C = -1;
// 回文右边界的下一个位置,回文不包含R位置
int R = -1;
// 最大包含最右侧字符的回文半径
int maxContainsEnd = -1;
for (int i = 0; i < charArr.length; i++) {
// 获取到i位置的最小值
pArr[i] = R > i ? Math.min(pArr[2 * C - i], R - 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] > R) {
R = i + pArr[i];
C = i;
}
// R到了最右侧,更新最大的回文半径
if (R == charArr.length) {
// 最大的回文半径就是当前i位置的回文半径
maxContainsEnd = pArr[i];
// 找到后不再寻找,直接退出
break;
}
}
// 已经找到了经过处理后的回文半径,此时str中回文串的长度就是maxContainsEnd-1,
// 所以此时要添加的长度就是str.length()-(maxContainsEnd-1)= str.length() - maxContainsEnd + 1
// 只需要将0到L-1的字符逆序补全到str后面即可,从charArr中取,就是取偶数位置的字符,因为奇数位置都是填充的#
char[] res = new char[str.length() - maxContainsEnd + 1];
for (int i = 0; i < res.length; i++) {
// res从后往前放,所以charArr从前往后取
res[res.length - 1 - i] = charArr[i * 2 + 1];
}
return String.valueOf(res);
}
/**
* 获取统一奇偶处理后的字符串数组
*/
private 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;
}
public static void main(String[] args) {
String str1 = "abcd123321";
System.out.println(shortestEnd(str1));
}
}
后记
个人学习总结笔记,不能保证非常详细,轻喷