题目
最长回文子串
提示
给你一个字符串 s,找到 s 中最长的 回文 子串。
示例 1:
1 | 输入:s = "babad" |
示例 2:
1 | 输入:s = "cbbd" |
提示:
- 1 <= s.length <= 1000
- s 仅由数字和英文字母组成
解题过程
暴力肯定可以解,求出所有子串,然后判断是否是回文,再求出最大值。然后可以在 for 里优化一下,但基本时间复杂度是 O(N^3)
。
继续想,每个单个字母的子串都是回文子串,只不过长度为1。然后每个回文子串去掉首尾后仍然是回文子串,比如 aba
去掉首尾 b
仍然是回文子串。于是编写代码:
1 | func longestPalindrome(input string) string { |
上面的代码中,以每个字符为中心,向两边扩散,直到不满足回文条件。这样就可以在 O(N^2)
的时间复杂度内求出最长的回文子串。结果发现这样并不能通过全部测试用例,因为对于偶数长度的回文子串,上面的代码无法正确处理,比如 s = "abba"
,上面的代码只能以 b
为中心扩散,无法以 bb
为中心扩散。在这里卡住之后,尝试过单独处理偶数长度的回文子串,结果发现代码会变得异常复杂。后面考虑到回文子串有一个特性,就是如果是偶数字符串的话,那中间两个字符必须相等,所以先找出相同字符的最大子串,然后再以这个子串的中点为中心,向两边扩散。这样就可以同时处理奇数和偶数长度的回文子串了。
后面看到也可以在每个字符之间也加一个虚拟字符,这样就可以将奇数和偶数长度的回文子串都处理了,一开始没想到
修改代码如下:
1 |
|
这样已经可以通过所有测试用例了,并且时间复杂度和空间复杂度都不错。后面还有一种写法,运行时间可能会稍慢,但代码更精简,就是分别以奇数字符串和偶数字符串为中心来扩散。这样就可以简化上面的代码逻辑,抽象出一个函数 expandAroundCenter
,用来处理以某个中心点向两边扩散的逻辑。这样就可以简化代码,同时也能处理奇数和偶数长度的回文子串。
代码如下:
1 | // longestPalindrome finds the longest palindromic substring in the input string. |
反思
其实细想这题是比较接近于动态规划的思想的,因为回文子串有一个特性,就是去掉首尾仍然是回文子串,这个特性可以用来构建动态规划表格。只不过这题没有使用表格的方式来存储中间结果,而是直接在字符串上进行扩散计算。复盘一下,发现动态规划类的题也是自己很大的一个思维漏洞。通过这题尝试用动态规划的思想来思考。
判断是否是动态规划
首先,要明确动态规划的定义:动态规划是将一个复杂的问题分解成简单的子问题,存储子问题的结果,以避免重复计算。回文子串可以看作是一个典型的动态规划问题,因为它满足最优子结构和无后效性。
- 最优子结构:一个回文串去掉首尾仍然是回文串。
- 无后效性:如果一个字符串是回文的,那么它的子串也可以通过相同的方法来判断是否为回文。
- 重复计算:在暴力解法中,我们会多次计算同一个子串是否为回文,这就是动态规划要避免的。
遇到动态规划的题,可以考虑通过写动态转移方程来帮助自己理清思路。用 s
从索引 i
到 j
是否是回文子串,用 ture
和 false
来表示真假。那就可以写出动态转移方程:
也就是说要判断 s[i:j]
是否是回文子串,只需要看 s[i+1:j-1]
是否是回文子串,并且 s[i]
和 s[j]
是否相等。
构建动态规划表格
基于上面转移方程,我们可以构建一个二维的 DP 表格来存储中间结果,最终求出最长的回文子串。
i \ j | 0 | 1 | 2 | 3 | 4 |
---|---|---|---|---|---|
0 | T | F | F | F | F |
1 | F | T | F | F | F |
2 | F | F | T | F | F |
3 | F | F | F | T | F |
4 | F | F | F | F | T |
上表中 T
表示 s[i:j]
是回文子串,F
表示不是。我们可以从下往上,从左往右填充这个表格。初始化时,所有的单个字符都是回文子串,所以对角线上的值都是 T
。然后根据转移方程填充表格。值得注意的是其中对角线下的值是没有意义的,因为 i
永远不可能大于 j
,所以表格中 i > j
的部分可以忽略。
分阶段计算
有了这个表格,接下来就是思考如何分阶段填充表格。这里有一个思维误区,就是先想当然的用两个循环来填充表格,外层循环控制 i
,内层循环控制 j
,这是还没有理解动态规划的本质,其本质是分阶段,从上面的转移方程也可知,P(i, j)
的值依赖于 P(i+1, j-1)
的值,所以我们应该先填充 P(i+1, j-1)
,也就是先填充长度小的子串,再填充长度大的子串。这样才能保证在计算 P(i, j)
时,P(i+1, j-1)
已经被计算过了。
基于上面的分析,我们可以使用两层循环来填充表格,外层循环控制子串的长度,内层循环控制 i
的位置,j
可以通过 i
和子串长度来计算。代码如下:
1 | func longestPalindrome(s string) string { |
部分测试用例无法通过,因为又忽略了一个细节,就是偶数个字符的回文子串,比如 abba
的时候,变化如下:
l=2,i=0,j=1,此时 s[0]
和 s[1]
不相等,所以 dp[0][1]
= false
l=2,i=1,j=2,此时 s[1]
和 s[2]
相等,所以 dp[1][2]
应该为 true,然而 if dp[i+1][j-1]
的条件判断会导致 dp[1][2]
无法被正确设置为 true,因为 dp[2][1]
为 false,所以需要单独处理长度为2的情况。
修改代码如下:
1 | func longestPalindrome(s string) string { |
这里又犯了一个错误,就是在计算 dp[i][j]
的时候,忘记了要考虑长度为2的情况。对于长度为2的回文子串,由于编写的代码 for l := 2; l < len(s); l++
就会导致跳过长度为2的情况,修改代码如下:
1 | func longestPalindrome(s string) string { |
当然上面单独处理整个字符串长度为2的情况也是可以的,因为对于长度为2的字符串,只要 s[i] == s[j]
就是回文子串。