提示:Manacher是解决最长回文子串问题的较优解~
[问题]
给定一个字符串str
,返回str
中最长回文子串的长度
1. Manacher预处理
把每个字符开头结尾和中间插入一个特殊字符'#'
来得到新的字符串数组
- 对于奇数回文,不处理也能找到
- 对于偶数回文,不处理是得不到的
2. 辅助变量含义
数组
pArr
-
pArr[i]
的意义是以i
位置字符作为回文中心的情况下,扩出去得到的最大回文半径长度 -
整个过程是从左到右遍历的过程中,一次计算每个位置的最大回文半径
对于"#c#a#b#a#c#"
来说 pArr[0..10]
为
[
1
,
2
,
1
,
2
,
1
,
6
,
1
,
2
,
1
,
2
,
1
]
[1,2,1,2,1,6,1,2,1,2,1]
[1,2,1,2,1,6,1,2,1,2,1]
整数
pR
- 表示之前遍历的所有字符的所有回文半径中,最右即将到达的位置
对于"#c#a#b#a#c#"
来说 ,还没遍历前初始化为
−
1
-1
−1
charArr[0]=='#'
的回文半径是
1
1
1,所以目前回文半径只能扩到
0
0
0位置,回文半径最右即将到达的位置变为
1
1
1(pR
=1)
charArr[1]=='c'
的回文半径是
2
2
2,此时所有的回文半径向右能扩到位置
2
2
2,所以回文半径最右即将到达的位置变为
3
3
3(pR
=3)
charArr[2]=='#'
的回文半径是
1
1
1,位置
2
2
2向右只能扩到位置
2
2
2,回文半径最右即将到达的位置不变(pR
=3)
charArr[3]=='a'
的回文半径是
2
2
2,位置
3
3
3向右能扩展到位置
4
4
4,回文半径最右即将到达的位置变为
5
5
5(pR
=5)
charArr[4]=='#'
的回文半径是
1
1
1,位置向右只能扩展到位置
4
4
4,回文半径最右即将到达位置不变(pR
=5)
charAee[5]=='b'
的回文半径是
6
6
6,所以位置
4
4
4向右能扩展到位置
10
10
10,回文半径最右即将到达的位置变为
11
11
11(pR
=11)
已经到达数组末尾,之后的pR不再变化
pR是遍历过的所有字符串中向右扩出来的最大右边界,只要右边界更往右,pR就更新
整数
index
表示最近更新pR
时,回文中心的位置
遍历到charArr[0]
时pR
更新,index
就更新为
0
0
0,…
3. 更新pArr
假设现在计算到i位置的字符charArr[i]
,在i
之前位置的计算中,都会不断更新pR
和index
,即位置i
之前的index
这个回文中心扩出了一个目前最右的回文边界pR
- 如果
pR-1
位置没有包住当前的i
位置
比如计算到charArr[i]='c'
时,pR
=1,右边界在
1
1
1位置,
1
1
1位置是最右回文半径即将到达但还没有到达的位置,
当前pR-1
没有包住当前i位置
从i字符开始,向左右两侧扩出去检查,此时扩的过程没有优化
- 如果当前
pR-1
位置包住当前的i位置
比如计算到charArr[6..10]
时,pR
都为11,此时pR-1
包住了位置6~10,这种情况下可以进行优化
位置i
是计算回文半径(pArr[i]
)的位置。pR-1
位置此时是包住位置i
的,index
是pR
更新时的回文中心位置
回文半径数组pArr
是从左到右计算的,位置i
之前的所有位置都已经计算过回文半径
假设位置i
以index
为中心向左对称过去的位置记为i'
,位置i'
的回文半径是计算过的,以i’
为中心的最大回文串大小只有三种情况
- 情况1
左小和右小完全在左大和右大内部,即以i’为中心的最大回文串完全在以index
为中心的最大回文串的内部
a'
是左小位置的前一个字符,b'
是右小
位置的后一个字符,b
,右小'
,左小'
,a
是以index
为中心的对称字符
此情况下,以i
为中心的最大回文串可以直接确定,即右小'
到左小'
左小
到右小
是以index
为中心,对应过去是右小'
到左小'
这段,那么右半部分的[右小'
—左小'
]是左半部分[左小
—右小
]的逆序- 由于左半部分[
左小
—右小
]是回文串,那么右半部分的[右小'
—左小'
]也是回文串 - 以i’为中心的回文串是左半部分[
左小
—右小
],那么a'≠b'
,所以以i
为中心的最大回文串是右半部分的[右小'
—左小'
]
- 情况2
a
是左大
的前一个字符,d
是右大
的后一个字符
以i为中心的最大回文串是从右大’到右大
右大'
到右大
是左大
到左大'
的逆序左小
到右小
是以i'
为中心的回文串,那么左大
到左大'
也是回文串,则右大’
到右大
也是回文串a=b
,b=c
,左大
到右大
没有扩,说明a!=d
,所以c!=d
,所以以i
为中心的回文串就是右大'
到右大
,而不会扩大
- 情况3
左小
和左大
是一个位置,即以i'
为中心的最大回文串压在以index
为中心的最大回文边界上
以i
为中心最大回文串是右大'
到右大
,但可能扩充更大
-
右大‘
到右大
这段是左小
和右小
以index
为中心对称过去的,两段互为逆序,左小
到右小
是回文串,所以右大'
到右大
也是回文串 -
扩出去过程可以优化,但无法避免扩出去的检查
总结
4. 找最大回文半径
假设i
位置的回文半径最大,pArr[i]=max
,但max
只是charArr
的最大回文半径,还得对应原字符串,求出最大回文半径(其实就是max-1)
比如字符串”121“,处理成charArr
后变成"#1#2#1#"
。在charArr
中位置3的回文半径最大,最大值为4,pArr[3]=4
,对应原字符串的最大回文子串长度为4-1=3
5. 时间复杂度分析
时间复杂度关键在于扩出去的行为发生的数量,原字符串在处理后由N变为2N,从更新pArr来看,要么计算一个位置的回文半径完全不需要扩出去检查(比如情况1、2,可以直接获得位置i的回文半径长度),要么每次扩出去检查都会导致pR变量的更新(情况3,扩出去检查时会让回文半径到达更右的位置,会使pR更新)
pR
最多从-1更新到2N(右边界),并且从来不减小,扩出去的检查是O(N级别)
因此Manacher时间复杂度为 O ( N ) O(N) O(N)
6. 伪代码实现
public static int[] manacher(String s) {
//1221 -> #1#2#2#1
s -> 处理串 str
char[] str;
int[] pArr = new int[str.length];
int R = ?;
int C = ?;
for(int i = 0; i < str.length; i++) {
if(i在R外部) {
从i开始往两边暴力扩;R变大;
} else {
if(i' 的回文区域在彻底在L..R内) {
pAAr[i] = 某个O(1)表达式;
} else if(i'回文区域有部分在L..R外) {
pArr[i] = 某个O(1)表达式;
} else { //i' 回文区域和L..R左边界压线
从R之外的字符开始,往外扩,然后确定pArr[i]的答案;
若第一步扩失败了,R不变,
否则,R变大
}
}
}
}
7. 算法实现
/**
* 预处理字符串
* @param str
* @return
*/
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;
}
public static int maxLcpsLength(String str) {
if (str == null || str.length() == 0) {
return 0;
}
//121-->#1#2#1#
char[] charArr = manacherString(str);
//回文半径数组
int[] pArr = new int[charArr.length];
//最近一次更新pR时回文中心的位置
int index = -1;
//之前遍历的所有字符的所有回文半径中,最右即将到达的位置
int pR = -1;
//扩出去的最大值
int max = Integer.MIN_VALUE;
//每个位置都求回文半径
for (int i = 0; i != charArr.length; i++) {
/**
* pArr[i]表示以i位置上的字符,作为回文中心的情况下,扩出去的最大回文半径
* pR>i 表示i在pR内部
* i在pR外部 如果pR-1位置没有包住当前的i位置,自己和自己构成回文,返回1
* i在pR内部
* pR-i表示 i到pR的距离
* 2 * index - i 表示i'的位置
*/
pArr[i] = pR > i ? Math.min(pArr[2 * index - i], pR - i) : 1;
/**
* 1.扩
* 2.三情况
* 情况1、2不扩
* 情况3 扩
*/
//左边界和有边界有效情况下
//i + pArr[i] 右大的右边一个
//i - pArr[i] 左大的左边一个
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(之前遍历的所有字符的所有回文半径中,最右即将到达的位置)
pR = i + pArr[i];
//更新回文中心
index = i;
}
max = Math.max(max, pArr[i]);
}
return max - 1;
}
8. 进阶问题
给定一个
str
,想通过添加字符串的方式使得str
整体变成回文字符串,要求只能在str
的末尾添加字符,返回str
后面添加的最短字符串
在字符串的最后添加最少字符,使得整个字符串成为回文串,其实就是查找必须包含最后一个字符的情况下,最长的回文子串是什么,那么之前不是最长回文子串部分逆序后便是应该添加的部分
比如”abcd123321“,在必须包含最后一个字符的情况下,最长回文子串是”123321“,之前不是回文字串的部分是”abcd“,所以末尾添加”dbca“
算法思想
从左到右计算回文半径时关注回文半径最右即将到达的位置pR
,一旦发现到达最后pR==charArr.length
,说明必须包含最后一个字符的最长回文半径已找到,直接退出检查过程,返回添加的字符串即可
/**
* 给定一个str,想通过添加字符串的方式使得str整体变成回文字符串,
* 要求只能在str的末尾添加字符,返回str后面添加的最短字符串
*
* @param str
* @return
*/
public static String shortestEnd(String str) {
if (str == null || str.length() == 0) {
return null;
}
//121-->#1#2#1#
char[] charArr = manacherString(str);
//回文半径数组
int[] pArr = new int[charArr.length];
//最近一次更新pR时回文中心的位置
int index = -1;
//之前遍历的所有字符的所有回文半径中,最右即将到达的位置
int pR = -1;
int maxContainsEnd = -1;
for (int i = 0; i != charArr.length; i++) {
//每个位置都求回文半径
/**
* pArr[i]表示以i位置上的字符,作为回文中心的情况下,扩出去的最大回文半径
* pR>i 表示i在pR内部
* i在pR外部 如果pR-1位置没有包住当前的i位置,自己和自己构成回文,返回1
* i在pR内部
* pR-i表示 i到pR的距离
* 2 * index - i 表示i'的位置
*/
pArr[i] = pR > i ? Math.min(pArr[2 * index - i], pR - i) : 1;
/**
* 1.扩
* 2.三情况
* 情况1、2不扩
* 情况3 扩
*/
//左边界和有边界有效情况下
//i + pArr[i] 右大的右边一个
//i - pArr[i] 左大的左边一个
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(之前遍历的所有字符的所有回文半径中,最右即将到达的位置)
pR = i + pArr[i];
//更新回文中心
index = i;
}
if (pR == charArr.length) {
maxContainsEnd = pArr[i];
break;
}
}
char[] ans = new char[str.length() - maxContainsEnd + 1];
for (int i = 0; i < ans.length; i++) {
ans[ans.length - 1 - i] = charArr[2 * i + 1];
}
return String.valueOf(ans);
}