leetcode #5 最长回文子串【暴力法、动态规划、Manacher算法】 | 刷题之路第二站——动态规划类问题

题号 5

题目描述

给定一个字符串 s,找到 s 中最长的回文子串。你可以假设 s 的最大长度为 1000。

示例1:

输入: "babad"
输出: "bab"
注意: "aba" 也是一个有效答案。

示例2:

输入: "cbbd"
输出: "bb"

链接:leetcode #5 最长回文子串

 

解题思路1——暴力法

1- 找出给定字符串的所有子串

2- 按照子串的长度由大到小依次判断该子串是否是回文子串,如果找到一个回文子串,则该回文子串就是要找的最长的回文子串。

时间复杂度:O(n^3)

空间复杂度:O(1)

备注:显然这种解法太过于繁琐。 

 

解题思路2——动态规划

Step1:描述问题的最优解结构特征

观察给定字符串s的某个子串 S( i, j ),其中 0<= i <= j <= s.length(),使用 P( i, j ) 记录子串 S( i, j ) 是否为回文子串,若是,则 P( i, j) 为1,否则为0。

1- 如果 j == i , 那么子串 S( i, i ) 只有一个字符,显然是回文子串,所以所有的 P( i , i ) = 1; 

2- 如果 j == i+1,那么子串 S( i, i+1) 包含两个字符

    (1)如果 S[ i ] == S[ i+1 ],则该子串为回文子串,令 P( i, i+1 ) = 1,并且如果该子串长度大于目前找到的回文子串的最大长度 maxLength ,则记录下该子串的始末位置以及该子串长度;

    (2)如果 S[ i ] != S[ i+1 ],则该子串不是回文子串,令 P( i, i+1 ) =0即可。

3- 对于 j > i+1 的情况:

i i + 1 …… j - 1 j
    ……    

     (1)如果 S( i+1, j-1 ) 是回文子串,即 P( i+1, j-1 ) == 1 时:

               a. 如果 S[ i ] == S [ j ],则子串 S( i, j ) 为回文子串,令 P( i, j ) = 1,并且如果该子串长度大于目前找到的回文子串的最大长度 maxLength ,则记录下该子串的始末位置以及该子串长度;

               b. 如果 S[ i ] != S[ j ],则子串 S( i, j ) 不是回文子串,令 P( i, j ) =0。

    (2)如果 S( i+1, j-1 ) 不是回文子串,则显然 S( i, j ) 更加不是回文子串。

    对于第三点需要注意的一点时,当我们对 三字符子串 进行判断是否为回文子串时,需要用到 两字符子串 是否是回文子串,因此必须按照子串的长度由小到大的顺序进行处理。

关系如下图所示:

简化来说,对于 j > i+1 的情况,P( i, j ) = P( i+1, j-1 ) ∩ ( S[ i ] == S[ j ] )

 

leetcode #5 最长回文子串【暴力法、动态规划、Manacher算法】 | 刷题之路第二站——动态规划类问题_第1张图片

 

Step2:递归定义最优解值

令 P( i, j ) 为子串 S( i, j ) 是否为回文子串的表示,其中 P( i, i ) 只包含一个字符,显然是回文子串;

定义 maxLength 存放当前已经找到的最大回文子串的长度,初始情况下 maxLength =0;

定义 maxL 和 maxR 表示当前已经找到的最大回文子串在给定字符串中的始末位置。

当我们每找到一个回文子串,就判断该子串长度是否大于 maxLength,若大于,则对 maxL,maxR 和 maxLength进行更新。

初始值:P( i , i ) = 1

当 j == i+1 时,P( i, j ) = ( S[ i ] == S[ j ] )

当 j > i+1 时,P( i, j ) = P( i+1, j-1 )  ∩  ( S[ i ] ∩ S[ j ] )

 

Step3:自底向上计算最优解值

class Solution {
public:
	string longestPalindrome(string s) {
		int l = s.length();
    	if (l==NULL||l == 0) return "";    //如果给定字符串为空或者长度为0,则返回空串
		int **p = new int *[l];    //以给定字符串的长度申请动态二维数组P[i][j],用于标识子串S(i, j)是否为回文子串
		for (int i = 0; i < l; i++)
			p[i] = new int[l];
		int maxL=0, maxR=0, maxLength=0;    //定义记录最长回文子串的三个标识量
		for (int i = 0; i < s.length(); i++)    //设定初始值
			p[i][i] = 1;
		int count = l - 1;
		int j = 1;
		while (count) {    //按照子串长度由小到大的顺序依次设定P[i][j]的值
			int i = 0;
			while ((i + j) < l) {    
				if (j == 1) {    //如果是两字符子串,则只需判断首末字符是否相等即可
					if (s[i] == s[i+j]) {
						p[i][i+j] = 1;
						if ((j+1) > maxLength) {
							maxL = i;
							maxR = i + j;
							maxLength = j + 1;
						}

					}
					else
						p[i][i+j] = 0;
				}
				else {    //对于大于两字符的子串,需要对P[i+1][j-1]和首末字符是否相等同时进行判断
					if (p[i + 1][i+j - 1] == 1 && s[i] == s[i+j]) {
						p[i][i+j] = 1;
						if ((j + 1) > maxLength) {
							maxL = i;
							maxR = i+j;
							maxLength = j  + 1;
						}
					}
					else {
						p[i][i+j] = 0;
					}
				}
				i++;
			}
			j++;
			count--;
		}
		string ans;    //定义ans存放结果数组
		for (int i = maxL; i <= maxR; i++)
			ans.push_back(s[i]);
		for (int i = 0; i < l; i++) {    //释放动态二维数组的空间
			delete[] p[i];
		}
		delete[] p;
		return ans;
	}
};

 

Step4:(不需要输出最优解)

时间复杂度:O(n^2)

空间复杂度:O(n^2)

 

解题思路3——动态规划【对思路2的优化】

解题思路和代码参考:动态规划算法求最长回文子串

整体思路与解题思路2相似,不过对其中的处理方式和代码做了进一步的优化。

使用数组 dp[ j ][ i ] 表示给定字符串 s 中下标从 j 到 i 的子串是否为回文子串,dp[ j ][ i ] 的推导规律如下:

1- 如果子串只包含一个字符,则一定是回文子串,令 dp[ j ][ i ] 为 true;

2- 如果子串中包含两个字符,则比较这两个字符是否相同就可知道该子串是否为回文子串;

3- 如果子串中包含三个及三个以上字符,则取决于首尾字符是否相同以及首尾字符外的字符串是否是回文子串。

备注:

(1)第 1、2 种情况可以合并进行处理,因为当子串中只包含一个字符时,j 和 i 指向同一个字符,一定是相同的,也即意味着一定是回文子串;

(2)在对第 1、2 种情况的处理过程中,实际上就是实现了动态规划中对于底的处理;

(3)为什么使用 dp[ j ][ i ] 而不是常规的 dp[ i ][ j ] ?

以“ababa”为例, 如果使用 dp[i][j],且循环代码为:

for( int i = 0; i < s.size(); i++) {
    for( int j = i; j < s.size(); j++) {
        …………
    }
}

则依次处理的子串为:(顺序为从左到右,从上到下)

[0,0]  [0,1]  [0,2]  [0,3]  [0,4]

[1,1]  [1,2]  [1,3]  [1,4]

[2,2]  [2,3]  [2,4] 

[3,3]  [3,4]

[4,4]

以子串 [0,4] 为例,当我们计算 dp[0][4] 时,需要用到 dp[3][3],显然 dp[3][3] 在之后才被计算到,而此时使用该值去计算 dp[0][4] 显然是不正确的。

 

而如果使用 dp[ j ][ i ],且循环代码为:

for( int i = 0; i < s.size(); i++) {
    for( int j = 0; j <= i; j++) {
        …………
    }
}

依旧以“ababa”为例,依次处理的子串为:(顺序为从左到右,从上到下)

[0,0]

[0,1]  [1,1]

[0,2]  [1,2]  [2,2]

[0,3]  [1,3]  [2,3]  [3,3]

[0,4]  [1,4]  [2,4]  [3,4]  [4,4] 

可以发现对后一个子串进行处理时,使用到的都是之前已经处理过的子串的值,因此不会出现上面的问题。

时间复杂度:O(n^2)

空间复杂度:O(n^2)

 

实现代码:

class Solution {
public:
	string longestPalindrome(string s) {
	    int n = s.size();
	    bool dp[1000][1000];
	    fill_n(&dp[0][0], n*n, false);
	    int maxLength = 1;
	    int start = 0;
	    for (int i = 0; i < s.size(); i++) {
		    for (int j = 0; j <=i; j++) {
			    if (i - j < 2)
				    dp[j][i] = (s[i] == s[j]);
			    else
				    dp[j][i] = (s[i] == s[j] && dp[j + 1][i - 1]);
			    if (dp[j][i] && (i - j + 1) > maxLength) {
				    maxLength = i - j + 1;
				    start = j;
			    }
		    }
	    }
	    return s.substr(start, maxLength);
    }
};

 

解题思路4——Manacher's Algorithm 马拉车算法

思路和代码参考:王大咩的图书馆——Manacher's Algorithm ----马拉车算法

马拉车算法是查找一个字符串的最长回文子串的线性方法

1、改造给定字符串S,使其变为新字符串T

改造方法如下:

在字符串S的字符之间和首尾都插入一个分隔符 '#',使得给定字符串S的长度都变为奇数。【这样不需要再对字符串长度分奇偶处理】

举个栗子:

S=“abba"    T="#a#b#b#a#"    新字符串T的长度为 9

S="abcba"    T="#a#b#c#b#a#"    新字符串T的长度为 11

为了防止出现越界,我们在新字符串T的首尾再加上两个不同的字符‘$'和'^',只在头部加'$'也可以实现,因为尾部有’\0',相当于尾部已加过不同的字符。

2、将新字符串 T[ i ] 处的回文半径存储到数组 P[ ] 中

(1)P[ i ] 为新字符串 T[ i ] 处的回文半径:表示新字符串T中以字符 T[ i ] 为中心的最长回文子串的最右端字符到 T[ i ] 的长度;

举个栗子:若以 T[ i ] 为中心的最长回文子串为 T[ l, r ],则 P[ i ] = r - l + 1

(2)若 P[ i ] = 1,表示以 T[ i ] 为中心的最大回文子串就是 T[ i ] 本身。

举个栗子:

index 0 1 2 3 4 5 6 7 8 9 10 11 12
S a b a a b a              
T # a # b # a # a # b # a #
P[ ] 1 2 1 4 1 2 7 2 1 4 1 2 1

数组P的性质:以 T[ i ] 为中心的最长回文子串 在原字符串S中的长度为:P[ i ] - 1

P[i] T S
P[0] # /
P[1] #a# a
P[2] # /
P[3] #a#b#a# aba
P[4] # /
P[5] #a# a
…… …… ……

证明:

          由观察可知,在转换得到的字符串T中,所有回文子串的长度都是奇数,

          所以,对于以 T[ i ] 为中心的最长回文子串,其长度为 2 × P[ i ] - 1。

          又因为字符串T的所有回文子串中,分隔符‘#‘的数量一定比其他字符的数量多一,

          令分隔符’#‘的数量为x,其他字符的数量为y:

          x + y = 2 × P[ i ] - 1, x - y = 1

          x = P[ i ], y = P[ i ] -1

          所以有 P[ i ] 个分隔符‘#’,剩下的 P[ i ] - 1 个字符均来自原字符串S

          因此,该回文子串在原字符串S中的长度为 P[ i ] - 1。

3、遍历 P[ ],取其中的最大值即可

4、如何计算数组 P[ ]的值?

从左往右依次计算数组P[ ],使用 Mi 表示前一个位置的最大回文子串的中心位置,L、R分别表示前一个位置的最大回文子串最左端和最右端的位置。

(1)如果 i <= R,则:

         点 i 关于 Mi 的对称点 j = 2 × Mi - i

         a. 如果 P[ j ] < R - i,即 以点 j 为中心的最长回文子串没有超过范围 [L, R],如下图所示

             当我们计算P[ i ]的值时,蓝色部分表示以 T[ i - 1 ] 为中心的最大回文子串,左侧橙色部分表示 以 T[ j ] 为中心的最大回文子串,由回文子串的对称性可知,以 T[ i ] 为中心的最大回文子串与以 T[ j ] 为中心的最大回文子串相同

             所以,P[ i ] = P[ j ]。

         b. 如果 P[ j ] >= R - i,即 以点 j 为中i性能的最长回文子串超过范围 [L, R],如下图所示

             显然,以 T[ j ] 为中心的最大回文子串的左端点超过了以 Mi 为中心的最大回文子串的左端点,由对称性可知,对于[L , j] 部分的子串与[ i, R] 部分想对应,所以 P[ i ] 的长度至少为 R-i。

             之后再从 R+1 开始依次与 i 左侧的对称位置的字符进行匹配,即下图中的红色部分,直到发生不匹配为止,更新 P[ i ] 的值即可。

             

    

(2)如果 i > R,则:

         一一进行匹配。

时间复杂度:O(n)

空间复杂度:O(n)

 

代码实现:

class Solution {
public:
	string longestPalindrome(string s) {
		string T = "$#";    		//构造新字符串T
		for (int i = 0; i < s.size(); ++i) {
			T += s[i];
			T += "#";
		}

		vector P(T.size(), 0);    		//使用数组P[]存放新字符串T[i]处的回文半径
		int Mi = 0, R = 0;    		//Mi和R分别表示上一个最长回文子串的中心位置和最右侧位置
		int maxLength = 0, maxPoint = 0;    		//maxLength和maxPoint分别记录截止到目前为止找到的最长回文子串的回文半径和其中心位置
		for (int i = 1; i < T.size(); ++i) {    
			P[i] = R > i ? min(P[2 * Mi - i], R - i) : 1;    			//如果R>i,则P[i]可能等于P[j],也有可能等于R-i;如果R<=i,则令P[i]=1
			while (T[i + P[i]] == T[i - P[i]])    			//以i为中心对i左右两侧的字符一一进行匹配
				++P[i];

			if (R < i + P[i]) {    			//更新R和Mi值
				R = i + P[i];
				Mi = i;
			}
			if (maxLength < P[i]) {    			//更新最长回文子串的回文半径值和中心位置
				maxLength = P[i];
				maxPoint = i;
			}
		}
		return s.substr((maxPoint - maxLength) / 2, maxLength - 1);
	}
};

 

你可能感兴趣的:(算法设计与分析)