一、LeetCode 5. 最长回文子串
题目介绍:
给你一个字符串 s
,找到 s
中最长的回文子串。
示例:
输入:s = "babad" 输出:"bab" 解释:"aba" 同样是符合题意的答案。
基本思考:什么是回文字符串?
首先在处理这个问题的时候,我们要先考虑什么样的字符串时回文字符串,总结一下特点:
回文字符串长度 | 示例 | 特点 |
1 | "a" | 一个字符肯定是,不用猜 |
2 | "aa" | 两个字符一模一样,肯定是 |
> 2 | "abcba" | 从中心点看,左右对称 |
现在我们假定某个字符串s
是回文字符串,该字符串的长度可以使用从索引i到索引j来表示(即从索引i开始到索引j结束的一个字符串)。
/** * @method isPalindrome * @description 判断字符串s的索引i到索引j是否是回文字符串 * @param {number} i * @param {number} j */ const isPalindrome = (i, j) => { // 1. 是否是一个字符 if (i === j) return true; // 2. 是否是首尾一致,首尾不一致肯定不是回文字符串 if (s[i] !== s[j]) return false; // 3. 判断索引i和索引j的关系, // 基于上面的2条件,走到条件3说明 s[i]和s[j]中间就只有一个字符了,所以这就是一个回文字符串 if (i + 1 === j) return true; // 如果以上条件都不满足,则继续向中间逼近,递归处理 return isPalindrome(i + 1, j - 1); }
使用递归的方式来实现,算法的时间复杂度是 O(n)O(n)O(n)
接下来我们思考,如何查询出字符串s
中的最长回文子串呢?
老实人的想法:暴力枚举
老实人再次上线,可以枚举出字符串s
中所有的子串组合,取最大值即可。
上代码!
/** * @param {string} s * @return {string} */ var longestPalindrome = function(s) { /** * @param {number} i * @param {number} j */ const isPalindrome = (i, j) => { // 1. 是否是一个字符 if (i === j) return true; // 2. 是否是首尾一致,首尾不一致肯定不是回文字符串 if (s[i] !== s[j]) return false; // 3. 判断索引i和索引j的关系, // 基于上面的2条件,走到条件3说明 s[i]和s[j]中间就只有一个字符了,所以这就是一个回文字符串 if (i + 1 === j) return true; // 如果以上条件都不满足,则继续向中间逼近,递归处理 return isPalindrome(i + 1, j - 1); } // 缓存字符串s的长度,不必在执行循环时,每次都计算一次。 const len = s.length; // 设置起始索引indexI let indexI = 0; // 设置结束索引为indexJ let indexJ = 0; // 两层循环,将每一种子串都枚举 for (let i = 0; i < len; i++) { for (let j = i + 1; j < len; j++) { // 判断是否是回文字符串 if (isPalindrome(i, j)) { // 如果是回文子串,则将当前长度与历史长度进行比较,取二者的最大值 if (j - i > indexJ - indexI) { // 记录此时的i、j indexI = i; indexJ = j; } } } } // 注意审题,返回的是回文子串,不是长度 return s.substring(indexI, indexJ + 1); };
提交代码!
网络异常,图片无法展示
|
执行结果是:超出时间限制
,看起来似乎有问题,也好像没啥问题,实际就是算法效率不高!
再次看下这个算法实现时的时间复杂度是多少?两层for循环是O(n2)O(n^2)O(n2),在加上最里面判断是否是回文字符串的isPalindrome
方法O(n)O(n)O(n),最终的复杂度是 O(n3)O(n^3)O(n3)。
递归优化经典策略 - 缓存优化
每一个递归的实现都是可以进行缓存优化的,在函数isPalindrome
中,我们可以使用缓存cache[i][j]
来记录对应i
和j
位置的结果,不必每次都重新计算了。
小小调整下:
// 定义二维数组,缓存结果 const cache = []; const isPalindrome = (i, j) => { // 如果不存在某个一维数组则进行初始化 if (cache[i] === undefined) { cache[i] = []; } // 如果对应[i][j]存在,则直接返回结果 if (cache[i][j] !== undefined) { return cache[i][j]; } // 1. 是否是一个字符 if (i === j) return true; // 2. 是否是首尾一致,首尾不一致肯定不是回文字符串 if (s[i] !== s[j]) return false; // 3. 判断索引i和索引j的关系, // 基于上面的2条件,走到条件3说明 s[i]和s[j]中间就只有一个字符了,所以这就是一个回文字符串 if (i + 1 === j) return true; // 如果以上条件都不满足,则继续向中间逼近,递归处理 cache[i][j] = isPalindrome(i + 1, j - 1); return cache[i][j]; }
再次提交,看看
网络异常,图片无法展示
|
执行结果是通过了,但是时间复杂度还是O(n3)O(n^3)O(n3)性能上还是非常不好看,接下来我们可以尝试换一种角度来看看这个问题。
动态规划看问题
我们假定在索引i
和索引j
之间是回文字符串,j在前,i在后
,使用dp[i][j]
来表示。那么dp[i][j]
的值相当于中间的回文字符串dp[i - 1][j + 1]
结果加上边界值s[i] === s[j]
的结果。
看下代码的实现
/** * @param {string} s * @return {string} */ var longestPalindrome = function(s) { const len = s.length; const dp = []; let [ indexJ, indexI ] = [0, 0]; for (let i = 0; i < len; i++) { if (!dp[i]) { dp[i] = [] } dp[i][i] = true; // 注意是从j到i,so... for (j = 0; j < i; j++) { // 在这里要注意dp[i - 1][j + 1]的边界问题 // 如 cbb,当i=2,j=1时,dp[i - 1][j + 1]的值是dp[1][2]是不存在的 undefined,但是bb确实符合回文字符串 // 所以要确定 dp[i - 1][j + 1]是否是已经存在的值 dp[i][j] = s[i] === s[j] ? dp[i - 1]?.[j + 1] ?? true : false; // 判断是否是符合条件,并且长度大于历史最大值 if (dp[i][j] && (i - j) > (indexI - indexJ)) { [indexJ, indexI] = [j, i] } } } return s.substring(indexJ, indexI + 1) };
这里的时间复杂度已经是O(n2)O(n^2)O(n2)了呦
算法-从菜鸟开始,而无止境。与君共勉!