题目:
Given a string S, find the longest palindromic substring in S. You may assume that the maximum length of S is 1000, and there exists one unique longest palindromic substring.
在字符串S中,找到最长回文字符串。
题目解析:
回文字符串:从左到右和从右到左,读出的数据是一样的“aba”“abccba”等
思路一:暴力法
常规思路,判断有多少个字符串,再针对每个字符串判断是否是回文字符串。n个字符的话,就有O(n^2)个字符串,针对每一个判断的话,又要有O(n)的时间。时间复杂度为O(n^3)。
具体做法:起始位置为i,让j从i+1到n遍历,针对每一个j判断i–j是否是回文字符串。
思路二:动态规划
我们尝试牺牲空间来优化算法。既然上面提到了i–j之间判断是否是回文字符串,但同样也考察了i+1 — j-1。这样会重复进行。我们可以针对i–j建立二维数组,如果是回文字符串就让P(i,j)为1。时间复杂度为O(n^2),空间复杂度为O(n^2)
P(i,j)为1时代表字符串Si到Sj是一个回文,为0时代表字符串Si到Sj不是一个回文。
P(i,j)= P(i+1,j-1)(如果S[i] = S[j],P(i,j)的状态完全依赖于P(i+1,j-1)的状态)。这是动态规划的状态转移方程。
P(i,i)= 1,P(i,i+1)= 1(如果S[i]= S[i+1])
代码如下:
int LPSubstringDP(char *str,int *index)
{
int maxlen;
int table[100][100] = {0};
int n = strlen(str);
for(int i = 0;i < n;++i){
table[i][i] = 1;
}
for(int i = 0;i < n-1;++i){
if(str[i] == str[i+1]){
table[i][i+1] = 1;
*index = i;
maxlen = 2;
}
}
for(int len = 3;len <= n;++len){ //固定长度增长,算法导论上也有一道类似的题目
for(int i = 0;i < n;++i){
int j = i + len -1; //len为[i,j]闭括号中间的个数
if(str[i] == str[j] && table[i+1][j-1]){
table[i][j] = 1; //因此len要从3开始算起!
*index = i; //这里不用再比较长度是否大于之前保存的长度,因为总循环随着len增长,一定在变大
maxlen = len;
}
}
}
return maxlen;
}
注意:
关于数组的循环,要认清整个遍历的状态。先有了P(i+1,j-1)才能判断P(i,j),所以是以长度为基准来遍历的。在算法导论上也有类似的例子。
当得到新的maxlen的时候不需要与旧值相比,因为主循环是以长度len为基准遍历的。
思路三:中心扩展法
要想得到更好的方法,就要对要查找的数据特性进行有效的分析!回文字符串,两端相等,逐渐向中间靠拢时也要相等。那么我们就可以考察以i为字符串中心时,可以向左右两边遍历多长。
但这时要分两种情况来考虑,形成的是奇数字符串还是偶数字符串。这种时间复杂度为O(n^2),空间复杂度为O(1)
本来想着用标志位来标志是偶数还是奇数,但是我们可以通过返回的长度maxlen的奇偶性直接得出。
完整代码如下(包括上面的动态规划算法)
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#define DP
int LPSubstring(char *str,int *index);
int FindLPS(char *str,int i);
int LPSubstringDP(char *str,int *index);
int main(void)
{
char *str = "dabccbaabcc";
int index,maxlen;
#ifndef DP
maxlen = LPSubstring(str,&index);
int range = maxlen/2;
printf("the LPS are(%d):",maxlen);
if(maxlen % 2){ //判断返回的长度为奇数还是偶数,来决定打印输出!
for(int i = index-range;i<=index+range;++i)
printf("%c",str[i]);
printf("\n");
}else{
for(int i = index-range+1;i <= index+range;++i)
printf("%c",str[i]);
printf("\n");
}
#else
maxlen = LPSubstringDP(str,&index);
printf("dp(%d,%d):",maxlen,index);
for(int i = index;i < index + maxlen;++i){
printf("%c",str[i]);
}
printf("\n");
#endif
return 0;
}
int LPSubstring(char *str,int *index)
{
int len = strlen(str);
int loclen = 0,maxlen = 0; //loclen表示局部长度,maxlen为整个字符串中最大的回文字符串
for(int i = 0;i < len;++i){
loclen = FindLPS(str,i);
if(loclen > maxlen){
maxlen = loclen;
*index = i;
}
}
return maxlen;
}
int FindLPS(char *str,int i)
{
int len = strlen(str);
int maxlen;
//for odd
int range = 1;
while(i-range >=0 && i+range<len){ //保证下标在有效范围内
if(str[i-range] != str[i+range])
break;
range++;
}
maxlen = 2*range-1;
//for even
range = 1;
while(i-range+1 >= 0 && i+range < len){ //只判断i和i+1相等就可以;i和i-1已经在前面遍历过了
if(str[i-range+1] != str[i+range])
break;
++range;
}
if(2*range-2 > maxlen)
maxlen = 2*range - 2;
return maxlen;
}
int LPSubstringDP(char *str,int *index)
{
int maxlen;
int table[100][100] = {0};
int n = strlen(str);
for(int i = 0;i < n;++i){
table[i][i] = 1;
}
for(int i = 0;i < n-1;++i){
if(str[i] == str[i+1]){
table[i][i+1] = 1;
*index = i;
maxlen = 2;
}
}
for(int len = 3;len <= n;++len){ //固定长度增长,算法导论上也有一道类似的题目
for(int i = 0;i < n;++i){
int j = i + len -1; //len为[i,j]闭括号中间的个数
if(str[i] == str[j] && table[i+1][j-1]){
table[i][j] = 1; //因此len要从3开始算起!
*index = i; //这里不用再比较长度是否大于之前保存的长度,因为总循环随着len增长,一定在变大
maxlen = len;
}
}
}
return maxlen;
}
思路四:Manacher算法。时间复杂度O(N)
这个算法还没太明白,来自于网络
这个算法有一个很巧妙的地方,它把奇数的回文串和偶数的回文串统一起来考虑了。这一点一直是在做回文串问题中时比较烦的地方。这个算法还有一个很好的地方就是充分利用了字符匹配的特殊性,避免了大量不必要的重复匹配。
算法大致过程是这样。先在每两个相邻字符中间插入一个分隔符,当然这个分隔符要在原串中没有出现过。一般可以用‘#’分隔。这样就非常巧妙的将奇数长度回文串与偶数长度回文串统一起来考虑了(见下面的一个例子,回文串长度全为奇数了),然后用一个辅助数组P记录以每个字符为中心的最长回文串的信息。P[id]记录的是以字符str[id]为中心的最长回文串,当以str[id]为第一个字符,这个最长回文串向右延伸了P[id]个字符。
原串: w aa bwsw f d
新串: # w # a # a # b # w # s # w # f # d #
辅助数组P:1 2 1 2 3 2 1 2 1 2 1 4 1 2 1 2 1 2 1
这里有一个很好的性质,P[id]-1就是该回文子串在原串中的长度(包括‘#’)。如果这里不是特别清楚,可以自己拿出纸来画一画,自己体会体会。当然这里可能每个人写法不尽相同,不过我想大致思路应该是一样的吧。
现在的关键问题就在于怎么在O(n)时间复杂度内求出P数组了。只要把这个P数组求出来,最长回文子串就可以直接扫一遍得出来了。
那么怎么计算P[i]呢?该算法增加两个辅助变量(其实一个就够了,两个更清晰)id和mx,其中id表示最大回文子串中心的位置,mx则为id+P[id],也就是最大回文子串的边界。
然后可以得到一个非常神奇的结论,这个算法的关键点就在这里了:如果mx > i,那么
P[i] >= MIN(P[2 * id – i], mx – i)。就是这个串卡了我非常久。实际上如果把它写得复杂一点,理解起来会简单很多:
//记j = 2 * id – i,也就是说 j 是 i 关于 id 的对称点。
if (mx – i > P[j])
P[i] = P[j];
else /* P[j] >= mx – i */
P[i] = mx – i; // P[i] >= mx – i,取最小值,之后再匹配更新。
当 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之后的部分是否对称,就只能老老实实去匹配了。
由于这个算法是线性从前往后扫的。那么当我们准备求P[i]的时候,i以前的P[j]我们是已经得到了的。我们用mx记在i之前的回文串中,延伸至最右端的位置。同时用id这个变量记下取得这个最优mx时的id值。(注:为了防止字符比较的时候越界,我在这个加了‘#’的字符串之前还加了另一个特殊字符‘$’,故我的新串下标是从1开始的)
#include<vector>
#include<iostream>
using namespace std;
const int N=300010;
int n, p[N];
char s[N], str[N];
#define _min(x, y) ((x)<(y)?(x):(y))
void kp()
{
int i;
int mx = 0;
int id;
for(i=n; str[i]!=0; i++)
str[i] = 0; //没有这一句有问题。。就过不了ural1297,比如数据:ababa aba
for(i=1; i<n; i++)
{
if( mx > i )
p[i] = _min( p[2*id-i], p[id]+id-i );
else
p[i] = 1;
for(; str[i+p[i]] == str[i-p[i]]; p[i]++)
;
if( p[i] + i > mx )
{
mx = p[i] + i;
id = i;
}
}
}
void init()
{
int i, j, k;
str[0] = '$';
str[1] = '#';
for(i=0; i<n; i++)
{
str[i*2+2] = s[i];
str[i*2+3] = '#';
}
n = n*2+2;
s[n] = 0;
}
int main()
{
int i, ans;
while(scanf("%s", s)!=EOF)
{
n = strlen(s);
init();
kp();
ans = 0;
for(i=0; i<n; i++)
if(p[i]>ans)
ans = p[i];
printf("%d\n", ans-1);
}
return 0;
}
if( mx > i)
p[i]=MIN( p[2*id-i], mx-i);
就是当前面比较的最远长度mx>i的时候,P[i]有一个最小值。这个算法的核心思想就在这里,为什么P数组满足这样一个性质呢?
(下面的部分为图片形式)