第 1 步:预处理
第一步是对原始字符串进行预处理,也就是 添加分隔符 。
首先在字符串的首尾、相邻的字符中插入分隔符,例如 "babad"
添加分隔符 "#"
以后得到 "#b#a#b#a#d#"
。
对这一点有如下说明:
1、分隔符是一个字符,种类也只有一个,并且这个字符一定不能是原始字符串中出现过的字符;
2、加入了分隔符以后,使得“间隙”有了具体的位置,方便后续的讨论,并且新字符串中的任意一个回文子串在原始字符串中的一定能找到唯一的一个回文子串与之对应,因此对新字符串的回文子串的研究就能得到原始字符串的回文子串;
3、新字符串的回文子串的长度一定是奇数;
4、新字符串的回文子串一定以分隔符作为两边的边界,因此分隔符起到“哨兵”的作用。
五分钟学算法:原始字符串与新字符串的对应关系
第 2 步:计算辅助数组 p
辅助数组 p
记录了新字符串中以每个字符为中心的回文子串的信息。
手动的计算方法仍然是“中心扩散法”,此时记录以当前字符为中心,向左右两边同时扩散,记录能够扩散的最大步数。
以字符串 "abbabb"
为例,说明如何手动计算得到辅助数组 p
,我们要填的就是下面这张表。
char | # | a | # | b | # | b | # | a | # | b | # | b | # |
---|
index | 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 |
p |
|
|
|
|
|
|
|
|
|
|
|
|
|
第 1 行数组 char
:原始字符串加上分隔符以后的每个字符。
第 2 行数组 index
:这个数组是新字符串的索引数组,它的值是从 0 开始的索引编号。
char | # | a | # | b | # | b | # | a | # | b | # | b | # |
---|
index | 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 |
p | 0 |
|
|
|
|
|
|
|
|
|
|
|
|
以 char[0] = '#'
为中心,同时向左边向右扩散,走 1 步就碰到边界了,因此能扩散的步数为 0,因此 p[0] = 0
;
以 char[1] = 'a'
为中心,同时向左边向右扩散,走 1 步,左右都是 "#"
,构成回文子串,于是再继续同时向左边向右边扩散,左边就碰到边界了,最多能扩散的步数”为 1,因此 p[1] = 1
;
char | # | a | # | b | # | b | # | a | # | b | # | b | # |
---|
index | 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 |
p | 0 | 1 |
|
|
|
|
|
|
|
|
|
|
|
以 char[2] = '#'
为中心,同时向左边向右扩散,走 1 步,左边是 "a"
,右边是 "b"
,不匹配,最多能扩散的步数为 0,因此 p[2] = 0
;
以 char[3] = 'b'
为中心,同时向左边向右扩散,走 1 步,左右两边都是 “#”
,构成回文子串,继续同时向左边向右扩散,左边是 "a"
,右边是 "b"
,不匹配,最多能扩散的步数为 1 ,因此 p[3] = 1
;
char | # | a | # | b | # | b | # | a | # | b | # | b | # |
---|
index | 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 |
p | 0 | 1 | 0 | 1 |
|
|
|
|
|
|
|
|
|
以 char[4] = '#'
为中心,同时向左边向右扩散,最多可以走 4 步,左边到达左边界,因此 p[4] = 4
。
char | # | a | # | b | # | b | # | a | # | b | # | b | # |
---|
index | 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 |
p | 0 | 1 | 0 | 1 | 4 |
|
|
|
|
|
|
|
|
分析到这里,后面的数字不难填出,最后写成如下表格:
char | # | a | # | b | # | b | # | a | # | b | # | b | # |
---|
index | 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 |
p | 0 | 1 | 0 | 1 | 4 | 1 | 0 | 5 | 0 | 1 | 2 | 1 | 0 |
说明:有些资料将辅助数组 p
定义为回文半径数组,即 p[i]
记录了以新字符串第 i
个字符为中心的回文字符串的半径(包括第 i
个字符),与我们这里定义的辅助数组 p
有一个字符的偏差,本质上是一样的。
下面是辅助数组 p
的结论:辅助数组 p
的最大值是 5,对应了原字符串 "abbabb"
的 “最长回文子串” :"bbabb"
。这个结论具有一般性,即:
辅助数组 `p` 的最大值就是“最长回文子串”的长度
因此,我们可以在计算辅助数组 p
的过程中记录这个最大值,并且记录最长回文子串。
简单说明一下这是为什么:
如果新回文子串的中心是一个字符,那么原始回文子串的中心也是一个字符,在新回文子串中,向两边扩散的特点是:“先分隔符,后字符”,同样扩散的步数因为有分隔符 #
的作用,在新字符串中每扩散两步,虽然实际上只扫到一个有效字符,但是相当于在原始字符串中相当于计算了两个字符。
因为最后一定以分隔符结尾,还要计算一个,正好这个就可以把原始回文子串的中心算进去;
五分钟学算法:理解辅助数组的数值与原始字符串回文子串的等价性-1
如果新回文子串的中心是 #
,那么原始回文子串的中心就是一个“空隙”。在新回文子串中,向两边扩散的特点是:“先字符,后分隔符”,扩散的步数因为有分隔符 #
的作用,在新字符串中每扩散两步,虽然实际上只扫到一个有效字符,但是相当于在原始字符串中相当于计算了两个字符。
因此,“辅助数组 p
的最大值就是“最长回文子串”的长度”这个结论是成立的,可以看下面的图理解上面说的 2 点。
五分钟学算法:理解辅助数组的数值与原始字符串回文子串的等价性-2
写到这里,其实已经能写出一版代码。(注:本文的代码是结合了 LeetCode 第 5 题「最长回文子串」进行讲解)
参考代码
public class Solution {
public String longestPalindrome(String s) {
int len = s.length();
if (len < 2) {
return s;
}
String str &#61; addBoundaries(s, &#39;#&#39;);
int sLen &#61; 2 * len &#43; 1;
int maxLen &#61; 1;
int start &#61; 0;
for (int i &#61; 0; i < sLen; i&#43;&#43;) {
int curLen &#61; centerSpread(str, i);
if (curLen > maxLen) {
maxLen &#61; curLen;
start &#61; (i - maxLen) / 2;
}
}
return s.substring(start, start &#43; maxLen);
}
private int centerSpread(String s, int center) {
int len &#61; s.length();
int i &#61; center - 1;
int j &#61; center &#43; 1;
int step &#61; 0;
while (i >&#61; 0 && j < len && s.charAt(i) &#61;&#61; s.charAt(j)) {
i--;
j&#43;&#43;;
step&#43;&#43;;
}
return step;
}
private String addBoundaries(String s, char divide) {
int len &#61; s.length();
if (len &#61;&#61; 0) {
return "";
}
if (s.indexOf(divide) !&#61; -1) {
throw new IllegalArgumentException("参数错误&#xff0c;您传递的分割字符&#xff0c;在输入字符串中存在&#xff01;");
}
StringBuilder stringBuilder &#61; new StringBuilder();
for (int i &#61; 0; i < len; i&#43;&#43;) {
stringBuilder.append(divide);
stringBuilder.append(s.charAt(i));
}
stringBuilder.append(divide);
return stringBuilder.toString();
}
}
复杂度分析
科学家的工作
此时&#xff0c;计算机科学家 Manacher 出现了&#xff0c;他充分利用新字符串的回文性质&#xff0c;计算辅助数组 p。
上面的代码不太智能的地方是&#xff0c;对新字符串每一个位置进行中心扩散&#xff0c;会导致原始字符串的每一个字符被访问多次&#xff0c;一个比较极端的情况就是&#xff1a;#a#a#a#a#a#a#a#a#
。
事实上&#xff0c;计算机科学家 Manacher 就改进了这种算法&#xff0c;使得在填写新的辅助数组 p
的值的时候&#xff0c;能够参考已经填写过的辅助数组 p
的值&#xff0c;使得新字符串每个字符只访问了一次&#xff0c;整体时间复杂度由 O(N2) 改进到 O(N)。
具体做法是&#xff1a;在遍历的过程中&#xff0c;除了循环变量 i
以外&#xff0c;我们还需要记录两个变量&#xff0c;它们是 maxRight
和 center
&#xff0c;它们分别的含义如下&#xff1a;
maxRight
表示记录当前向右扩展的最远边界&#xff0c;即从开始到现在使用“中心扩散法”能得到的回文子串&#xff0c;它能延伸到的最右端的位置 。
对于 maxRight
我们说明 3 点&#xff1a;
“向右最远”是在计算辅助数组 p
的过程中&#xff0c;向右边扩散能走的索引最大的位置&#xff0c;注意&#xff1a;得到一个 maxRight
所对应的回文子串&#xff0c;并不一定是当前得到的“最长回文子串”&#xff0c;很可能的一种情况是&#xff0c;某个回文子串可能比较短&#xff0c;但是它正好在整个字符串比较靠后的位置&#xff1b;
maxRight
的下一个位置可能是被程序看到的&#xff0c;停止的原因有 2 点&#xff1a;&#xff08;1&#xff09;左边界不能扩散&#xff0c;导致右边界受限制也不能扩散&#xff0c;maxRight
的下一个位置看不到&#xff1b;&#xff08;2&#xff09;正是因为看到了 maxRight
的下一个位置&#xff0c;导致 maxRight
不能继续扩散。
为什么 maxRight
很重要&#xff1f;因为扫描是从左向右进行的&#xff0c; maxRight
能够提供的信息最多&#xff0c;它是一个重要的分类讨论的标准&#xff0c;因此我们需要一个变量记录它。
center
是与 maxRight
相关的一个变量&#xff0c;它是上述 maxRight
的回文中心的索引值。对于 center
的说明如下&#xff1a;
center
的形式化定义&#xff1a;
说明&#xff1a;x &#43; p[x]
的最大值就是我们定义的 maxRight
&#xff0c;i
是循环变量&#xff0c;0<&#61; x 表示是在 i
之前的所有索引里得到的最大值 maxRight
&#xff0c;它对应的回文中心索引就是上述式子。
maxRight
与 center
是一一对应的关系&#xff0c;即一个 center
的值唯一对应了一个 maxRight
的值&#xff1b;因此 maxRight
与 center
必须要同时更新。
下面的讨论就根据循环变量 i
与 maxRight
的关系展开讨论&#xff1a;
情况 1&#xff1a;当 i >&#61; maxRight
的时候&#xff0c;这就是一开始&#xff0c;以及刚刚把一个回文子串扫描完的情况&#xff0c;此时只能够根据“中心扩散法”一个一个扫描&#xff0c;逐渐扩大 maxRight
&#xff1b;
情况 2&#xff1a;当 i 的时候&#xff0c;根据新字符的回文子串的性质&#xff0c;循环变量关于 center
对称的那个索引&#xff08;记为 mirror
&#xff09;的 p
值就很重要。
我们先看 mirror
的值是多少&#xff0c;因为 center
是中心&#xff0c;i
和 mirror
关于 center
中心对称&#xff0c;因此 (mirror &#43; i) / 2 &#61; center
&#xff0c;所以 mirror &#61; 2 * center - i
。
根据 p[mirror]
的数值从小到大&#xff0c;具体可以分为如下 3 种情况&#xff1a;
情况 2&#xff08;1&#xff09;&#xff1a;p[mirror]
的数值比较小&#xff0c;不超过 maxRight - i
。
说明&#xff1a;maxRight - i
的值&#xff0c;就是从 i
关于 center
的镜像点开始向左走&#xff08;不包括它自己&#xff09;&#xff0c;到 maxRight
关于 center
的镜像点的步数
五分钟学算法&#xff1a;Manacher 算法分类讨论情况 2&#xff08;1&#xff09;
从图上可以看出&#xff0c;由于“以 center
为中心的回文子串”的对称性&#xff0c;导致了“以 i
为中心的回文子串”与“以 center
为中心的回文子串”也具有对称性&#xff0c;“以 i
为中心的回文子串”与“以 center
为中心的回文子串”不能再扩散了&#xff0c;此时&#xff0c;直接把数值抄过来即可&#xff0c;即 p[i] &#61; p[mirror]
。
情况 2&#xff08;2&#xff09;&#xff1a;p[mirror]
的数值恰好等于 maxRight - i
。
五分钟学算法&#xff1a;Manacher 算法分类讨论情况 2&#xff08;2&#xff09;
说明&#xff1a;仍然是依据“以 center
为中心的回文子串”的对称性&#xff0c;导致了“以 i
为中心的回文子串”与“以 center
为中心的回文子串”也具有对称性。
因为靠左边的 f
与靠右边的 g
的原因&#xff0c;导致“以 center
为中心的回文子串”不能继续扩散&#xff1b;
但是“以 i
为中心的回文子串” 还可以继续扩散。
因此&#xff0c;可以先把 p[mirror]
的值抄过来&#xff0c;然后继续“中心扩散法”&#xff0c;继续增加 maxRight
。
情况 2&#xff08;3&#xff09;&#xff1a;p[mirror]
的数值大于 maxRight - i
。
五分钟学算法&#xff1a;Manacher 算法分类讨论情况 2&#xff08;3&#xff09;
说明&#xff1a;仍然是依据“以 center
为中心的回文子串”的对称性&#xff0c;导致了“以 i
为中心的回文子串”与“以 center
为中心的回文子串”也具有对称性。
下面证明&#xff0c;p[i] &#61; maxRight - i
&#xff0c;证明的方法还是利用三个回文子串的对称性。
五分钟学算法&#xff1a;Manacher 算法分类讨论情况 2&#xff08;3&#xff09;的证明
① 由于“以 center
为中心的回文子串”的对称性&#xff0c; 黄色箭头对应的字符 c
和 e
一定不相等&#xff1b;
② 由于“以 mirror
为中心的回文子串”的对称性&#xff0c; 绿色箭头对应的字符 c
和 c
一定相等&#xff1b;
③ 又由于“以 center
为中心的回文子串”的对称性&#xff0c; 蓝色箭头对应的字符 c
和 c
一定相等&#xff1b;
推出“以 i
为中心的回文子串”的对称性&#xff0c; 红色箭头对应的字符 c
和 e
一定不相等。
因此&#xff0c;p[i] &#61; maxRight - i
&#xff0c;不可能再大。上面是因为我画的图&#xff0c;可能看的朋友会觉得理所当然。事实上&#xff0c;可以使用反证法证明&#xff1a;
如果“以 i
为中心的回文子串” 再向两边扩散的两个字符 c
和 e
相等&#xff0c;就能够推出黄色、绿色、蓝色、红色箭头所指向的 8 个变量的值都相等&#xff0c;此时“以 center
为中心的回文子串” 就可以再同时向左边和右边扩散 1 格&#xff0c;与 maxRight
的最大性矛盾。
综合以上 3 种情况&#xff0c;当 i 的时候&#xff0c;p[i]
可以参考 p[mirror]
的信息&#xff0c;以 maxRight - i
作为参考标准&#xff0c;p[i]
的值应该是保守的&#xff0c;即二者之中较小的那个值&#xff1a;
p[i] &#61; min(maxRight - i, p[mirror]);
参考代码
public class Solution {
public String longestPalindrome(String s) {
int len &#61; s.length();
if (len < 2) {
return s;
}
String str &#61; addBoundaries(s, &#39;#&#39;);
int sLen &#61; 2 * len &#43; 1;
int[] p &#61; new int[sLen];
int maxRight &#61; 0;
int center &#61; 0;
int maxLen &#61; 1;
int start &#61; 0;
for (int i &#61; 0; i < sLen; i&#43;&#43;) {
if (i < maxRight) {
int mirror &#61; 2 * center - i;
p[i] &#61; Math.min(maxRight - i, p[mirror]);
}
int left &#61; i - (1 &#43; p[i]);
int right &#61; i &#43; (1 &#43; p[i]);
while (left >&#61; 0 && right < sLen && str.charAt(left) &#61;&#61; str.charAt(right)) {
p[i]&#43;&#43;;
left--;
right&#43;&#43;;
}
if (i &#43; p[i] > maxRight) {
maxRight &#61; i &#43; p[i];
center &#61; i;
}
if (p[i] > maxLen) {
maxLen &#61; p[i];
start &#61; (i - maxLen) / 2;
}
}
return s.substring(start, start &#43; maxLen);
}
private String addBoundaries(String s, char divide) {
int len &#61; s.length();
if (len &#61;&#61; 0) {
return "";
}
if (s.indexOf(divide) !&#61; -1) {
throw new IllegalArgumentException("参数错误&#xff0c;您传递的分割字符&#xff0c;在输入字符串中存在&#xff01;");
}
StringBuilder stringBuilder &#61; new StringBuilder();
for (int i &#61; 0; i < len; i&#43;&#43;) {
stringBuilder.append(divide);
stringBuilder.append(s.charAt(i));
}
stringBuilder.append(divide);
return stringBuilder.toString();
}
}
复杂度分析
后记
Manacher 算法我个人觉得没有必要记住&#xff0c;如果真有遇到&#xff0c;查资料就可以了。