0%

获取最长回文子串详解

我的理解

题目:给定一个字符串,求它的最长回文子串的长度。

最长回文子串,比如 aba,或者 abccba这样的。

原文解法一理解比较简单,解法二有些技巧

原文的解法一枚举法

这种是一种技巧。

我们既然要求回文字符串,而回文字符串时以字符串中间为对称的。

所以,我们可以假设在源字符串S中,假设源字符串S的每个字符都作为回文子串的中心,

在这个中心往左边和右边进行比较字符串是否相等,直到不等的时候,就是这个字符位置的

回文子串了。

举例

我们可以从给定的原文S,比如“cbcde”中,

c作为中心,它左边没有字符,所以回文子串为c。

b作为中心,它左边是c,右边是c。所以回文子串长度为cbc。

以c为中心,它左边是b,右边是d。所以回文子串为c。

。。。

原文的解法二

解法二,本质上也是需要计算出字符串每个位置的回文子串长度。

而解法二核心思想是为了在从左边位置向右边计算的过程中,记录当前最大的回文字符串,在新的位置计算回文字符串的时候,利用对称原理,跳过了部分长度来计算回文子串,减少了计算。

疑问

为什么求i的最长回文子串长度,需要用到p[j]的值?

首先 p[j]存放的是p[j]回文子串的半程(j点到以j为中心的最大回文子串的右端),也就是j+p[j] 等于p的回文子串的最右端。

这是因为i和j是以ID为对称的点,并且p[j]是已经记过计算的位置。

所以如果以j为对称中心的回文子串有一定的长度(原文图中绿色框选择的部分),再根据以ID(也是计算出来的)为中心的回文子串关系(对称ID关系),所以p[j]应该就要对称到i的附近(i的绿色部分),可能会超出mx。所以需要和mx比较取小值。因为求i点的回文子串的时候,我们只能根据已知的点的特征,去跳过已知的点对称关系。大于mx的都是不一定在以i为中心的回文子串对称的,所以需要从这个位置继续求i的回文子串了。

为什么2*id - i 就是 以id为中心的j点的位置呢?

可以看成是id + (id - i),从原文的图,可以知道

id到i的距离等于 (id - i)的绝对值,因为需要从id的位置往回走,所以以id为中心,与i对象的j点的计算是 id -|(id-i)|(也就是2*id - i)。为什么是2id-i呢,这是因为,id-i本身就是负数,被省略了。。

原文中的id,如果大于0,它是一定比i小的,为什么呢?

因为id是由i计算生成的,而i是从左到右的。

为什么需要获取p[j]和 mx-i的最小值呢?

因为求i点的回文子串的时候,我们只能根据已知的点的特征,去跳过已知的点对称关系。大于mx的都是不一定在以i为中心的回文子串对称的,所以需要从这个位置继续求i的回文子串了。

为什么插入#后一定会是奇数呢?

这是因为2n + 1一定是奇数(n为原文字符串的长度),这个高中数学我们学过。

n > 0, 2n一定是偶数,所以2n+1一定是奇数。

不过它是对源文字符串的间隙插入了n+1个#分割符号(n为原文字符串的长度),生成一个奇数的新的原文字符串

代码解析

重点来了,如果直接看原文,会有一点点难以理解它的动机。

这是一个过程计算。我们模拟它来理解

p存放的是回文子串半程,它是以某点为中心的最大回文子串的中心点到该最大回文子串的最右端的距离

  • 首先从s源文的位置1开始,也就是i=1,因为0是$符号。
    进行for循环,这里的循环意义是,为每一个位置i都计算出最大回文子串半程的值,存放在p里面。

  • 获取p[i],也就是i点的最大回文子串的值,这个值是根据已知的对称性质得知的
    进行for循环,这里的循环意义是,为每一个位置i都计算出最大回文子串半程的值,存放在p里面。

    p[i] = mx > i ? min(p[2 * id - i], mx - i) : 1;// 获取求i点最大回文子串时,根据对称原理和j点(2*id-i已经计算出的回文子串半程),i点回文子串的距离

    一开始这里p[1] = 1;

    首先,一开始我们没有计算出以ID为范围的最大回文子串,根据mx和i的比较也可以知道。

  • 根据获取到的p[i],跳过部分距离计算i点的最大回文子串
    然后我们进入while它的初始语句是,s[i + p[i]] == s[i - p[i]]。以i为中心,跳过距离来比较,如果相等,增加P[i],也就是增加i点的回文子串半程。然后继续往两边比较,更新i的最大回文子串。

    当s[i + p[i]] == s[i - p[i]] 不成立时,以i点为中心的最大回文子串的右端已经算出。

  • 更新计算id和mx
    此时比较mx 和 以i点为中心的最大回文子串的右端,看看那个大。

    如果新计算的i比原来记录的id 和mx大,那么更新id和mx。

所以id和mx是从i计算出来的。

i一定是比id大。(从左往右计算的)

回到for ,并且增加i,计算下一个位置的最大回文子串

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
//输入,并处理得到字符串s
int p[1000], mx = 0, id = 0;
memset(p, 0, sizeof(p));
for (i = 1; s[i] != '\0'; i++)
{
p[i] = mx > i ? min(p[2 * id - i], mx - i) : 1;
while (s[i + p[i]] == s[i - p[i]])
p[i]++;
if (i + p[i] > mx)
{
mx = i + p[i];
id = i;
}
}
//找出p[i]中最大的

引用原文

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
参考原文
https://wizardforcel.gitbooks.io/the-art-of-programming-by-july/content/01.05.html
最长回文子串
题目描述
给定一个字符串,求它的最长回文子串的长度。
分析与解法
最容易想到的办法是枚举所有的子串,分别判断其是否为回文。这个思路初看起来是正确的,但却做了很多无用功,如果一个长的子串包含另一个短一些的子串,那么对子串的回文判断其实是不需要的。
解法一
那么如何高效的进行判断呢?我们想想,如果一段字符串是回文,那么以某个字符为中心的前缀和后缀都是相同的,例如以一段回文串“aba”为例,以b为中心,它的前缀和后缀都是相同的,都是a。
那么,我们是否可以可以枚举中心位置,然后再在该位置上用扩展法,记录并更新得到的最长的回文长度呢?答案是肯定的,参考代码如下:
int LongestPalindrome(const char *s, int n)
{
int i, j, max,c;
if (s == 0 || n < 1)
return 0;
max = 0;

for (i = 0; i < n; ++i) { // i is the middle point of the palindrome
for (j = 0; (i - j >= 0) && (i + j < n); ++j){ // if the length of the palindrome is odd
if (s[i - j] != s[i + j])
break;
c = j * 2 + 1;
}
if (c > max)
max = c;
for (j = 0; (i - j >= 0) && (i + j + 1 < n); ++j){ // for the even case
if (s[i - j] != s[i + j + 1])
break;
c = j * 2 + 2;
}
if (c > max)
max = c;
}
return max;
}
代码稍微难懂一点的地方就是内层的两个 for 循环,它们分别对于以 i 为中心的,长度为奇数和偶数的两种情况,整个代码遍历中心位置 i 并以之扩展,找出最长的回文。
解法二、O(N)解法
在上文的解法一:枚举中心位置中,我们需要特别考虑字符串的长度是奇数还是偶数,所以导致我们在编写代码实现的时候要把奇数和偶数的情况分开编写,是否有一种方法,可以不用管长度是奇数还是偶数,而统一处理呢?比如是否能把所有的情况全部转换为奇数处理?
答案还是肯定的。这就是下面我们将要看到的Manacher算法,且这个算法求最长回文子串的时间复杂度是线性O(N)的。
首先通过在每个字符的两边都插入一个特殊的符号,将所有可能的奇数或偶数长度的回文子串都转换成了奇数长度。比如 abba 变成 #a#b#b#a#, aba变成 #a#b#a#。
此外,为了进一步减少编码的复杂度,可以在字符串的开始加入另一个特殊字符,这样就不用特殊处理越界问题,比如$#a#b#a#。
以字符串12212321为例,插入#和$这两个特殊符号,变成了 S[] = "$#1#2#2#1#2#3#2#1#",然后用一个数组 P[i] 来记录以字符S[i]为中心的最长回文子串向左或向右扩张的长度(包括S[i])。
比如S和P的对应关系:
S # 1 # 2 # 2 # 1 # 2 # 3 # 2 # 1 #
P 1 2 1 2 5 2 1 4 1 2 1 6 1 2 1 2 1
可以看出,P[i]-1正好是原字符串中最长回文串的总长度,为5。
接下来怎么计算P[i]呢?Manacher算法增加两个辅助变量id和mx,其中id表示最大回文子串中心的位置,mx则为id+P[id],也就是最大回文子串的边界。得到一个很重要的结论:
如果mx > i,那么P[i] >= Min(P[2 * id - i], mx - i)
C代码如下:
//mx > i,那么P[i] >= MIN(P[2 * id - i], mx - i)
//故谁小取谁
if (mx - i > P[2*id - i])
P[i] = P[2*id - i];
else //mx-i <= P[2*id - i]
P[i] = mx - i;
下面,令j = 2*id - i,也就是说j是i关于id的对称点。
当 mx - i > P[j] 的时候,以S[j]为中心的回文子串包含在以S[id]为中心的回文子串中,由于i和j对称,以S[i]为中心的回文子串必然包含在以S[id]为中心的回文子串中,所以必有P[i] = P[j];


当 P[j] >= mx - i 的时候,以S[j]为中心的回文子串不一定完全包含于以S[id]为中心的回文子串中,但是基于对称性可知,下图中两个绿框所包围的部分是相同的,也就是说以S[i]为中心的回文子串,其向右至少会扩张到mx的位置,也就是说 P[i] >= mx - i。至于mx之后的部分是否对称,再具体匹配。


此外,对于 mx <= i 的情况,因为无法对 P[i]做更多的假设,只能让P[i] = 1,然后再去匹配。
综上,关键代码如下:
//输入,并处理得到字符串s
int p[1000], mx = 0, id = 0;
memset(p, 0, sizeof(p));
for (i = 1; s[i] != '\0'; i++)
{
p[i] = mx > i ? min(p[2 * id - i], mx - i) : 1;
while (s[i + p[i]] == s[i - p[i]])
p[i]++;
if (i + p[i] > mx)
{
mx = i + p[i];
id = i;
}
}
//找出p[i]中最大的
此Manacher算法使用id、mx做配合,可以在每次循环中,直接对P[i]的快速赋值,从而在计算以i为中心的回文子串的过程中,不必每次都从1开始比较,减少了比较次数,最终使得求解最长回文子串的长度达到线性O(N)的时间复杂度。
参考:http://www.felix021.com/blog/read.php?2040 。另外,这篇文章也不错:http://leetcode.com/2011/11/longest-palindromic-substring-part-ii.html 。

感谢相关作者的分享

tanks

请我喝杯咖啡~

欢迎加我微信交流~