分析
说明:
- 以下解法中「暴力算法」是基础,「动态规划」必须掌握,「中心扩散」方法要会写;
- 「Manacher 算法」仅用于扩宽视野,绝大多数的算法面试中,面试官都不会要求写这个方法(除非面试者是竞赛选手)。
方法一:暴力匹配 (Brute Force)
- 根据回文子串的定义,枚举所有长度大于等于 22 的子串,依次判断它们是否是回文;
- 在具体实现时,可以只针对大于“当前得到的最长回文子串长度”的子串进行“回文验证”;
- 在记录最长回文子串的时候,可以只记录“当前子串的起始位置”和“子串长度”,不必做截取。这一步我们放在后面的方法中实现。
说明:暴力解法时间复杂度高,但是思路清晰、编写简单。由于编写正确性的可能性很大,可以使用暴力匹配算法检验我们编写的其它算法是否正确。优化的解法在很多时候,是基于“暴力解法”,以空间换时间得到的,因此思考清楚暴力解法,分析其缺点,很多时候能为我们打开思路。
参考代码 1:Java 代码正常运行,C++ 代码超出内存限制,Python 代码超时。
public class Solution {
public String longestPalindrome(String s) {
int len = s.length();
if (len < 2) {
return s;
}
int maxLen = 1;
int begin = 0;
// s.charAt(i) 每次都会检查数组下标越界,因此先转换成字符数组
char[] charArray = s.toCharArray();
// 枚举所有长度大于 1 的子串 charArray[i..j]
for (int i = 0; i < len - 1; i++) {
for (int j = i + 1; j < len; j++) {
if (j - i + 1 > maxLen && validPalindromic(charArray, i, j)) {
maxLen = j - i + 1;
begin = i;
}
}
}
return s.substring(begin, begin + maxLen);
}
/**
* 验证子串 s[left..right] 是否为回文串
*/
private boolean validPalindromic(char[] charArray, int left, int right) {
while (left < right) {
if (charArray[left] != charArray[right]) {
return false;
}
left++;
right--;
}
return true;
}
}
复杂度分析:
- 时间复杂度: O ( N 3 ) O(N^3) O(N3),这里 N是字符串的长度,枚举字符串的左边界、右边界,然后继续验证子串是否是回文子串,这三种操作都与 N 相关;
- 空间复杂度:O(1),只使用到常数个临时变量,与字符串长度无关。
方法二:动态规划
下面是「动态规划』问题的思考路径,供大家参考。
特别说明:
- 以下「动态规划」的解释只帮助大家了解「动态规划」问题的基本思想;
- 「动态规划」问题可以难到非常难,在学习的时候建议不要钻到特别难的问题中去;
- 掌握经典的动态规划问题的解法,理解状态的定义的由来、会列出状态转移方程;
- 然后再配合适当难度的问题的练习;
- 有时间和感兴趣的话可以做一些不太常见的类型的问题,拓宽视野;
- 「动态规划」讲得比较好的经典书籍是《算法导论》。
提示:右键「在新便签页打开图片」可查看大图。
1、思考状态(重点)
- 状态的定义,先尝试「题目问什么,就把什么设置为状态」;
- 然后思考「状态如何转移」,如果「状态转移方程」不容易得到,尝试修改定义,目的依然是为了方便得到「状态转移方程」。
「状态转移方程」是原始问题的不同规模的子问题的联系。即大问题的最优解如何由小问题的最优解得到。
2、思考状态转移方程(核心、难点)
- 状态转移方程是非常重要的,是动态规划的核心,也是难点;
- 常见的推导技巧是:分类讨论。即:对状态空间进行分类;
- 归纳「状态转移方程」是一个很灵活的事情,通常是具体问题具体分析;
- 除了掌握经典的动态规划问题以外,还需要多做题;
- 如果是针对面试,请自行把握难度。掌握常见问题的动态规划解法,理解动态规划解决问题,是从一个小规模问题出发,逐步得到大问题的解,并记录中间过程;
- 「动态规划」方法依然是「空间换时间」思想的体现,常见的解决问题的过程很像在「填表」。
3、思考初始化
初始化是非常重要的,一步错,步步错。初始化状态一定要设置对,才可能得到正确的结果。
- 角度 1:直接从状态的语义出发;
- 角度 2:如果状态的语义不好思考,就考虑「状态转移方程」的边界需要什么样初始化的条件;
- 角度 3:从「状态转移方程」方程的下标看是否需要多设置一行、一列表示「哨兵」(sentinel),这样可以避免一些特殊情况的讨论。
4、思考输出
有些时候是最后一个状态,有些时候可能会综合之前所有计算过的状态。
5、思考优化空间(也可以叫做表格复用)
- 「优化空间」会使得代码难于理解,且是的「状态」丢失原来的语义,初学的时候可以不一步到位。先把代码写正确是更重要;
- 「优化空间」在有一种情况下是很有必要的,那就是状态空间非常庞大的时候(处理海量数据),此时空间不够用,就必须「优化空间」;
- 非常经典的「优化空间」的典型问题是「0-1 背包」问题和「完全背包」问题。
(下面是这道问题「动态规划」方法的分析)
这道题比较烦人的是判断回文子串。因此需要一种能够快速判断原字符串的所有子串是否是回文子串的方法,于是想到了「动态规划」。
「动态规划」的一个关键的步骤是想清楚「状态如何转移」。事实上,「回文」天然具有「状态转移」性质。
- 一个回文去掉两头以后,剩下的部分依然是回文(这里暂不讨论边界情况);
依然从回文串的定义展开讨论:
- 如果一个字符串的头尾两个字符都不相等,那么这个字符串一定不是回文串;
- 如果一个字符串的头尾两个字符相等,才有必要继续判断下去。
- 如果里面的子串是回文,整体就是回文串;
- 如果里面的子串不是回文串,整体就不是回文串。
即:在头尾字符相等的情况下,里面子串的回文性质据定了整个子串的回文性质,这就是状态转移。因此可以把「状态」定义为原字符串的一个子串是否为回文子串。
第 1 步:定义状态
dp[i][j]
表示子串 s[i..j]
是否为回文子串,这里子串 s[i..j]
定义为左闭右闭区间,可以取到 s[i]
和 s[j]
。
第 2 步:思考状态转移方程
在这一步分类讨论(根据头尾字符是否相等),根据上面的分析得到:
dp[i][j] = (s[i] == s[j]) and dp[i + 1][j - 1]
说明:
- 「动态规划」事实上是在填一张二维表格,由于构成子串,因此
i
和j
的关系是i <= j
,因此,只需要填这张表格对角线以上的部分。 - 看到
dp[i + 1][j - 1]
就得考虑边界情况。
边界条件是:表达式 [i + 1, j - 1]
不构成区间,即长度严格小于 2
,即 j - 1 - (i + 1) + 1 < 2
,整理得 j - i < 3
。
这个结论很显然:j - i < 3
等价于 j - i + 1 < 4
,即当子串 s[i..j]
的长度等于 2
或者等于 3
的时候,其实只需要判断一下头尾两个字符是否相等就可以直接下结论了。
- 如果子串
s[i + 1..j - 1]
只有 1 个字符,即去掉两头,剩下中间部分只有 11 个字符,显然是回文; - 如果子串
s[i + 1..j - 1]
为空串,那么子串s[i, j]
一定是回文子串。
因此,在 s[i] == s[j]
成立和 j - i < 3
的前提下,直接可以下结论,dp[i][j] = true
,否则才执行状态转移。
第 3 步:考虑初始化
初始化的时候,单个字符一定是回文串,因此把对角线先初始化为 true
,即 dp[i][i] = true
。
事实上,初始化的部分都可以省去。因为只有一个字符的时候一定是回文,dp[i][i]
根本不会被其它状态值所参考。
第 4 步:考虑输出
只要一得到 dp[i][j] = true
,就记录子串的长度和起始位置,没有必要截取,这是因为截取字符串也要消耗性能,记录此时的回文子串的「起始位置」和「回文长度」即可。
第 5 步:考虑优化空间
因为在填表的过程中,只参考了左下方的数值。事实上可以优化,但是增加了代码编写和理解的难度,丢失可读和可解释性。在这里不优化空间。
注意事项:总是先得到小子串的回文判定,然后大子串才能参考小子串的判断结果,即填表顺序很重要。
大家能够可以自己动手,画一下表格,相信会对「动态规划」作为一种「表格法」有一个更好的理解。
参考代码 2:
public class Solution {
public String longestPalindrome(String s) {
// 特判
int len = s.length();
if (len < 2) {
return s;
}
int maxLen = 1;
int begin = 0;
// dp[i][j] 表示 s[i, j] 是否是回文串
boolean[][] dp = new boolean[len][len];
char[] charArray = s.toCharArray();
for (int i = 0; i < len; i++) {
dp[i][i] = true;
}
for (int j = 1; j < len; j++) {
for (int i = 0; i < j; i++) {
if (charArray[i] != charArray[j]) {
dp[i][j] = false;
} else {
if (j - i < 3) {
dp[i][j] = true;
} else {
dp[i][j] = dp[i + 1][j - 1];
}
}
// 只要 dp[i][j] == true 成立,就表示子串 s[i..j] 是回文,此时记录回文长度和起始位置
if (dp[i][j] && j - i + 1 > maxLen) {
maxLen = j