leetcode刷题笔记-5.最长回文子串。这么经典的题目我竟然现在才刷,懒虫一个。
参考:https://writings.sh/post/algorithm-longest-palindromic-substring
题目 https://leetcode.cn/problems/longest-palindromic-substring/description/
给你一个字符串 s,找到 s 中最长的回文子串。
回文字符串:从前往后和从后往前读的结果完全一致,比如aba;
子串:字符串s中的一部分称之为子串。与之区别的是子序列
,子序列是不改变剩余字符顺序的情况下,删除某些字符或者不删除任何字符形成的一个序列。
1 2 3 4 5 6 7 8 示例 1: 输入:s = "babad" 输出:"bab" 解释:"aba" 同样是符合题意的答案。 示例 2: 输入:s = "cbbd" 输出:"bb"
提示:
1 2 1 <= s.length <= 1000 s 仅由数字和英文字母组成
思路一:暴力 O(N^3) 基本代码 最蠢的办法就是直接暴力了,一个函数判断是否是回文,再用双层循环来枚举所有子串
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 class Solution { bool isReverseString (const string& s, int begin, int end) { for (int i = begin, j = end; i < j; i++, j--) { if (s[i] != s[j]) { return false ; } } return true ; } public : string longestPalindrome (string s) { if (s.size ()<=1 ){ return s; } int maxLength = 1 ; string ret; ret.push_back (s[0 ]); for (int i = 0 ; i < s.size () - 1 ; i++) { for (int j = i + 1 ; j < s.size (); j++) { if (isReverseString (s, i, j)) { int len = j - i + 1 ; if (len > maxLength) { maxLength = len; ret = s.substr (i, len); } } } } return ret; } };
对于本题而言,也能通过,但很明显耗时太长了,这是一个O(N^3)
时间复杂度的暴力算法。
一定优化 其实这个暴力求解法也有可优化的地方,即内层循环的枚举我们可以用maxLength进行枚举,j的初始值是i+maxLength
,这样每一次枚举都是比上一次更长的字符串 ,得到回文子串后也不需要判断长度是否大于maxLength了,能减少很多次遍历。
且在当前的写法中,每次得到一个结果,我们都调用了一次substr函数,这个函数也是O(N)
的时间复杂度。
我们可以将其改成记录结果字符串的起始下标和长度,在最终才调用substr函数来构造返回字符串,这样也能减少一定的时间复杂度。
优化后的代码如下,注意maxLength必须初始化为1。
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 class Solution { bool isReverseString (const string& s, int begin, int end) { for (int i = begin, j = end; i < j; i++, j--) { if (s[i] != s[j]) { return false ; } } return true ; } public : string longestPalindrome (string s) { if (s.size () <= 1 ) { return s; } int maxLength = 1 ; int begin = 0 ,length = 1 ; for (int i = 0 ; i < s.size () - 1 ; i++) { for (int j = i + maxLength; j < s.size (); j++) { if (isReverseString (s, i, j)) { int len = j - i + 1 ; maxLength = len; begin = i; length = len; } } } return s.substr (begin,length); } };
多次提交测试,平均耗时都是之前1000ms的十分之一了,还是有不少提升的,但是时间复杂度依旧不变,还是O(N^3)
。
思路二:动态规划 O(N^2) 二维数组 首先我们要知道,如果一个字符串是回文子串,那么它的中间一部分也一定是个回文子串。比如ABCBA
的中间部分BCB
也是一个回文子串。
这样我们就可以得出一个二维的dp数组,数组的下标(i,j)
代表s字符串中,从i到j的这个字符串是一个回文子串。且i大于j的情况是不合法的。
这样我们就能省去很多的判断步骤,如果dp[i][j]=true
,那么只需要判断i-1和j+1是否相等,就可以知道(i-1,j+1)
这个范围是不是一个回文子串了。递推公式如下
1 dp[i][j] = dp[i+1][j-1] && (s[i] == s[j])
那么dp数组要怎么遍历呢?从递归公式中可知,当前状态i,j
依赖于i+1,j-1
,即依赖于当前元素右上角 的哪一个。所以,我们需要先遍历列j,再遍历行i 。
在别的题解中你可能看到大家谈论的是左下角
,这是因为矩阵的坐标系原点 选择不同,我个人比较喜欢选左上角作为原点,然后往左是i下标,往下是j下标。
由下图可知,当我们需要知道(1,2)
这个下标位置的结果是多少的时候,我们需要先把(0,3)
给搞出来,所以应该让j作为外层循环开始遍历,这样才能保证遍历到某一个位置的时候,依赖项已经更新完成了。
继续确定dp数组的初始值,我们可以知道长度为1的时候肯定是个回文子串,即所有i和j相等的位置都可以初始化为true。长度为2的时候是否为子串需要进行判断,两个字符相同就是个回文子串。
现在就可以写代码了
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 class Solution {public : string longestPalindrome (string s) { int n = s.size (); if (n <= 1 ) { return s; } vector<vector<bool >> dp (n, vector <bool >(n, false )); for (int i = 0 ; i < n; i++) { dp[i][i] = true ; } int maxLength = 1 ; int begin = 0 ; for (int j = 0 ; j < n; j++) { for (int i = 0 ; i < j; i++) { if (j == i + 1 && s[i] == s[j]) { dp[i][j] = true ; } else if (i + 1 <= j - 1 && dp[i + 1 ][j - 1 ] == true && s[i] == s[j]) { dp[i][j] = true ; } if (dp[i][j] == true && maxLength < j - i + 1 ) { maxLength = j - i + 1 ; begin = i; } } } return s.substr (begin, maxLength); } };
代码的时间复杂度和空间复杂度都是O(N^2)
。
建议根据代码的遍历顺序画个矩阵,更方便理解。
一维数组 使用一维数组,就可以降低一层空间复杂度。对应的思路也需要改变。
数组中dp[j]
的值是下标j和j之前的最大回文子串的起始下标i; 易得初始值dp[0] = 0
; 举例字符串aabba
对应的dp数组,第一个a的最大回文子串起始地址是他自己,也就是0;第二个a之前的最大回文子串是aa,所以起始地址是0;第三个b之前的最大回文子串是它自己,因为aab不构成回文子串;第四个b之前的最大回文子串是bb,起始地址下标是2;最后一个a的最大回文子串是abba,起始下标是1。
另外我们需要知道每一位的递推值是怎么的来的。依照上面这个dp数组,假设我们需要递推最后一位a(下标为4)的dp值,那么应该是前一位b(下标为3)的最长子串起始下标的前一位 是否和当前位相等,即判断s[i]==s[dp[i-1]-1]
。
如果二者相等 ,则说明这个回文子串还可以被扩展,此时dp[i]
等于dp[i-1]-1
,子串长度是i-(dp[i-1]-1)+1
。对于当前这个例子,dp[4]=1
,回文子串长度是4-1+1=4
。
这里还有另外一个结论,即当前位i组成的最长回文子串长度一定只会比i-1能组成的最长回文子串长度多两个字符(即i-1能组成的最长回文子串左右各多一个字符)。
如果不相等 ,说明前一位的回文子串不能被扩展,但这并不代表当前位并不能组成回文子串了。如下图所示,虽然当前j和前一位j-1的回文子串不能继续扩展,但它依旧有一个长度为3的回文子串。这时候就需要从j之前通过遍历来判断是否有回文子串了。
不过我们并不需要从0开始遍历,只需要从dp[j-1]
开始遍历就可以了(即上图中下标为3的字母c开始)。前文说了,当前位i组成的最长回文子串长度一定只会比i-1能组成的最长回文子串长度多两个字符 ,不可能会有dp[j-1]-1
之前到j能组成的回文串,而(dp[j-1]-1,j)
这个子串我们已经在前面的条件中判断过不是回文了。
思路解决了,就可以写代码了
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 class Solution {public : string longestPalindrome (string s) { int n = s.size (); if (n <= 1 ) { return s; } vector<int > dp (n, 0 ) ; dp[0 ] = 0 ; int maxLength = 1 ; int begin = 0 ; for (int j = 1 ; j < n; j++) { if (dp[j - 1 ] - 1 >= 0 && s[j] == s[dp[j - 1 ] - 1 ]) { dp[j] = dp[j - 1 ] - 1 ; } else { int left = dp[j - 1 ]; int right = j; int start = left; while (left < right) { if (s[left] != s[right]) { right = j; start = left + 1 ; } else { right--; } left++; } dp[j] = start; } int len = j - dp[j] + 1 ; if (len > maxLength) { maxLength = len; begin = dp[j]; } } return s.substr (begin, maxLength); } };
虽然这么做的时间复杂度依旧是O(N^2)
,但是空间复杂度被降为了O(N)
。
思路三:中心扩散 O(N^2) 中心扩散的思路比较简单,遍历每一个下标,从这个下标开始往左往右扩散,直到找到最长的回文子串。这个思路不需要借助额外的数组,空间复杂度为O(1)
。时间复杂度依旧是O(N^2)
。
第一版本代码如下
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 class Solution {public : string longestPalindrome (string s) { int n = s.size (); if (n <= 1 ) { return s; } int maxLength = 1 ; int maxBegin = 0 ; for (int i = 0 ; i < n; i++) { int left = i - 1 , right = i + 1 ; int len = 1 ; int begin = i; while (left >= 0 && right < n) { if (s[left] != s[right]) { break ; } len = right - left + 1 ; begin = left; left--; right++; } if (len > maxLength) { maxLength = len; maxBegin = begin; } } return s.substr (maxBegin, maxLength); } };
这个代码看上去好像没有问题,但实际上我们漏掉了回文串为偶数的情况,直接从长度为3的回文串开始递推了。这是不可行的。
应该要把回文串长度为奇数,回文串长度为偶数的情况都考虑进去。下面是修改后的代码,其中使用了C++17的新特性,auto的结构化绑定来接收pair返回值,会方便一些。
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 class Solution { pair<int , int > findReverseString (const string& s, int left, int right) { int len = 1 ; int begin = left; while (left >= 0 && right < s.size ()) { if (s[left] != s[right]) { break ; } len = right - left + 1 ; begin = left; left--; right++; } return {begin, len}; } public : string longestPalindrome (string s) { int n = s.size (); if (n <= 1 ) { return s; } int maxLength = 1 ; int maxBegin = 0 ; for (int i = 0 ; i < n; i++) { auto [begin, len] = findReverseString (s, i, i + 1 ); auto [begin2, len2] = findReverseString (s, i - 1 , i + 1 ); if (len2 > len) { len = len2; begin = begin2; } if (len > maxLength) { maxLength = len; maxBegin = begin; } } return s.substr (maxBegin, maxLength); } };
The end 其他更牛逼的思路暂时就不记录了!可以参考博客开头贴出来的博客。