题目 - 【源于LeetCode - 5】(中等)
给定一个字符串 s,找到 s 中最长的回文子串,假设 s 的最大长度不超过1000。
tips: 回文字符串:正读反读都一样
示例 1:
输入: "babad"
输出: "bab"
注意: "aba" 同样是符合题意的答案。
示例 2:
输入: "cbbd"
输出: "bb"
正文
这道经典的题目在 LeetCode 中被标记为中等难度,只要能理解题目意思做出来难度不大,但是,想写出一个最优算法并不容易。按照我自己的解题思路,总结了3种:
方法一:暴力枚举
我的老师曾说过:“世界上没有2个for循环解决不了的算法”,基于冒泡排序的理念,我最先想到的也是最暴力的方法,就是枚举出所有的子字符串,最终确定最长的那个回文数串。
public String longestPalindrome(String s){
if (s.isEmpty()) return null;
if (s.length() == 1) return s;
String ret = s.substring(0,1);
for (int i = 0; i < s.length(); i++) {
for (int j = i+1; j < s.length(); j++) {
String temp = s.substring(i,j+1);
String rev = new StringBuilder(temp).reverse().toString();
// 如果是回文串,且长度大于ret,替换
if (temp.equals(rev) && temp.length() > ret.length()) {
ret = temp;
}
}
}
return ret;
}
执行了一下...... LeetCode 程序显示“超出时间限制”,果然太暴力的解法不被允许啊!!
大家可以再IDEA中运行看看,方法是没问题的。
方法二:动态规划
四大常用算法:分治、贪心、回溯、动态规划,其中,动态规划是最应该第一个被掌握的,借着这道算法题,我们一起来学习一下。
动态规划过程:每次决策依赖于当前状态,又随即引起状态的转移。一个决策序列就是在变化的状态中产生出来的,所以,这种多阶段最优化决策解决问题的过程就称为动态规划。
基本思想与分治法类似,也是将待求解的问题分解为若干个子问题(阶段),按顺序求解子阶段,前一子问题的解,为后一子问题的求解提供了有用的信息。
本题中,对于字符串str,我们新建一个二位数组 dp[][] arr 来作为备忘录,记录子问题的结果,其中 dp[i][j] 表示字符串第 i 到 j 是否为回文, true 表示是, false 表示不是。假设 dp[i, j] = true ,那么必定存在 dp[i+1, j-1] = true。这样最长回文子串就能分解成一系列子问题,可以利用动态规划求解了。这也是动态规划的精髓所在,不过这种做法的算法复杂度也是O(n^2)
首先构造状态转移方程:
上面的状态转移方程表示,当 str[i] = str[j] 时:
- 如果 str[i+1...j-1] 是回文串,则 str[i...j] 也是回文串;
- 如果 str[i+1...j-1] 不是回文串,则 str[i...j] 也不是回文串。
public String longestPalindrome(String s) {
if (s.isEmpty()) return s;
int len = s.length();
boolean[][] dp = new boolean[len][len];
int left = 0, right = 0;
// 倒序向前遍历
for (int i = len - 2; i >= 0; i--) {
for (int j = i + 1; j < len; j++) {
// 小于3一定是回文,aa或aba
dp[i][j] = s.charAt(i) == s.charAt(j) && ( j-i < 3 || dp[i+1][j-1]);
// 更新左右边界
if(dp[i][j] && right-left < j-i){
left = i;
right = j;
}
}
}
return s.substring(left,right+1);
}
执行了一下...... 结果还不错。
细心的同学会发现,上面的算法是倒序便利的,正序原理也是一样的,如下:
public String longestPalindrome_1(String s){
if (s.isEmpty()) return null;
if (s.length() == 1) return s;
int len = s.length();
boolean[][] arr = new boolean[len][len];
int left = 0, right = 0;
// 正序向后遍历,注意i和j的位置
for (int i = 1; i < len; i++) {
for (int j = i - 1; j >= 0; j--) {
arr[i][j] = s.charAt(i) == s.charAt(j) && ( i-j < 3 || arr[i-1][j+1]);
if (arr[i][j] && (i - j > right - left)) {
right = i;
left = j;
}
}
}
return s.substring(left, right+1);
}
方法三:递归
自从见证过一次线上OOM事故以后,我就很少使用递归来解决问题,看似简洁,实际上不仅耗时而且耗内存。但是,在下面的方法中,我只是运用了递归的思想将方法一进行了优化,出发点就是“减少不必要的查询和判断,以缩短运算时间”。
基本思路:外层循环遍历一次,判断以目标位置 i 对称位置的元素是否相等,若相等 while 循环向两边取到边界,保存左右边界值;不相等则继续遍历,以此来减少不必要的取值操作。
class Solution {
private int stt = 0;
private int end = 0;
public String longestPalindrome_3(String s) {
char[] str = s.toCharArray();
find(str, 0);
return s.substring(stt, end);
}
private void find(char[] str, int i) {
if(i >= str.length) return;
int left = i - 1;
int right = i + 1;
// 处理右侧连续的相同字符
while(right < str.length && str[i] == str[right]) right++;
// 重新定义下次循环的起点
i = right;
// 查找左右边缘
while(left >= 0 && right < str.length && str[left] == str[right]) {
left--;
right++;
}
// 左边恢复到回文字符串端点
left++;
// 刷新记录
if(right - left > end - stt) {
stt = left;
end = right;
}
find(str, i);
}
}
执行了一下...... 嚯嚯嚯,没想到居然打败了100%的网友。这种写法只是时间上最短,内存消耗并没有优化。