对前端开发者而言,学习算法绝非为了"炫技"。它是你从"页面构建者"迈向"复杂系统设计者"的关键阶梯。它将你的编码能力从"实现功能"提升到"设计优雅、高效解决方案"的层面。从现在开始,每天投入一小段时间,结合前端场景去理解和练习,你将会感受到自身技术视野和问题解决能力的质的飞跃。
------ 算法:资深前端开发者的进阶引擎
LeetCode 5. 最长回文子串
1. 题目描述
给你一个字符串 s,找到 s 中最长的回文子串。
示例 1:
输入:s = "babad"
输出:"bab"
解释:"aba" 同样是符合题意的答案。
示例 2:
输入:s = "cbbd"
输出:"bb"
提示:
1 <= s.length <= 1000s仅由数字和英文字母组成
2. 问题分析
回文串是一个正读和反读都一样的字符串(如"上海自来水来自海上")。在前端开发中,处理字符串的场景无处不在:用户输入校验、数据格式化、富文本编辑器功能实现(如查找、高亮)等。虽然直接寻找最长回文子串的业务场景不常见,但解决此问题所运用的中心扩展 、动态规划思想,以及对于字符串操作性能的敏感度,对于构建高效、复杂的前端应用至关重要。例如,在实现一个实时语法高亮或差异对比(diff)功能时,类似的子串处理逻辑是核心。
3. 解题思路
解决"最长回文子串"问题,主要有三种经典思路:
- 暴力枚举 (Brute Force):枚举所有子串,判断是否为回文。时间复杂度为 O(n³),在 LeetCode 数据规模下必然超时,不实用。
- 动态规划 (Dynamic Programming):利用"一个回文串去掉头尾后仍然是回文串"这一特性,用空间换时间,将时间复杂度降为 O(n²),空间复杂度也为 O(n²)。
- 中心扩散法 (Expand Around Center):回文串具有对称性。我们可以遍历每一个可能的"中心",尝试向两边扩展,直到无法形成回文为止。这是该问题的最优常规解法,时间复杂度 O(n²),空间复杂度 O(1)。
- Manacher 算法:一种专门的线性时间 O(n) 算法,但理解和实现较复杂,在前端面试中通常不作要求。
最优解推荐 :对于前端面试和日常工程,中心扩散法在效率、实现复杂度和可读性上达到了最佳平衡,是必须掌握的核心解法。
4. 各思路代码实现 (JavaScript)
4.1 中心扩散法 (最优解)
javascript
/**
* @param {string} s
* @return {string}
*/
const longestPalindrome = function(s) {
if (s.length < 2) return s;
let start = 0; // 记录最长回文子串的起始索引
let maxLen = 1; // 记录最长回文子串的长度(初始为1,单个字符是回文)
/**
* 中心扩散辅助函数
* @param {number} left - 中心左边界
* @param {number} right - 中心右边界
* @returns {number} 扩散后回文串的长度
*/
const expandAroundCenter = (left, right) => {
// 当左右指针在边界内,且所指字符相等时,向两边扩展
while (left >= 0 && right < s.length && s[left] === s[right]) {
left--;
right++;
}
// 循环结束时,s[left] !== s[right] 或越界
// 因此实际回文串的索引范围是 [left + 1, right - 1]
// 回文串长度 = (right - 1) - (left + 1) + 1 = right - left - 1
return right - left - 1;
};
for (let i = 0; i < s.length; i++) {
// 情况1:以单个字符 s[i] 为中心(奇数长度回文串,如 "aba")
const len1 = expandAroundCenter(i, i);
// 情况2:以相邻字符 s[i] 和 s[i+1] 的"间隙"为中心(偶数长度回文串,如 "abba")
const len2 = expandAroundCenter(i, i + 1);
// 获取本次循环中两种中心扩散出的较长回文串长度
const currentMaxLen = Math.max(len1, len2);
// 如果当前长度大于历史最大长度,则更新起始位置和最大长度
if (currentMaxLen > maxLen) {
maxLen = currentMaxLen;
// 核心:根据中心和长度,反推回文串的起始索引
// start = i - Math.floor((maxLen - 1) / 2)
start = i - Math.floor((maxLen - 1) / 2);
}
}
// 根据起始索引和最大长度,截取并返回最长回文子串
return s.substring(start, start + maxLen);
};
// 示例步骤分解:以 s = "babad" 为例,i=1时 (字符 'a')
// len1 = expandAroundCenter(1,1): 中心为'a'
// 初始: left=1, right=1 -> s[1]='a' == s[1]='a'
// 扩展: left=0 ('b'), right=2 ('b') -> 相等
// 再扩: left=-1, right=3 -> 越界,停止。长度 = 3 - (-1) - 1 = 3
// len2 = expandAroundCenter(1,2): 中心为'a'和'b'之间
// 初始: left=1('a'), right=2('b') -> 不相等,直接停止。长度 = 2 - 1 - 1 = 0
// currentMaxLen = 3 > maxLen(1) -> 更新 maxLen=3, start = 1 - Math.floor((3-1)/2) = 0
// 最终返回 s.substring(0, 0+3) = "bab"
4.2 动态规划法
javascript
/**
* @param {string} s
* @return {string}
*/
const longestPalindromeDP = function(s) {
const n = s.length;
if (n < 2) return s;
// 1. 定义状态:dp[i][j] 表示子串 s[i..j] 是否为回文串
// 使用二维数组记录,空间复杂度 O(n²)
const dp = new Array(n).fill(false).map(() => new Array(n).fill(false));
let start = 0;
let maxLen = 1;
// 2. 初始化:所有长度为1的子串都是回文串
for (let i = 0; i < n; i++) {
dp[i][i] = true;
}
// 3. 状态转移:枚举子串长度 len,从2开始到n
for (let len = 2; len <= n; len++) {
// 枚举左边界 i
for (let i = 0; i <= n - len; i++) {
// 根据左边界和长度确定右边界 j
const j = i + len - 1;
// 核心状态转移方程:
// dp[i][j] 为 true 当且仅当:
// 1. 首尾字符相等 (s[i] === s[j])
// 2. 并且去掉首尾后的子串是回文串 (dp[i+1][j-1] 为 true)
// 或者,子串长度仅为2 (len === 2) 时,只需判断首尾字符
if (s[i] === s[j]) {
if (len === 2 || dp[i + 1][j - 1]) {
dp[i][j] = true;
// 如果当前回文串更长,则更新结果
if (len > maxLen) {
maxLen = len;
start = i;
}
}
} // 如果首尾字符不相等,dp[i][j] 默认为 false,无需操作
}
}
return s.substring(start, start + maxLen);
};
// 状态转移示例 (s="babad"),理解 dp 表的填充顺序:
// 先填 len=1 的对角线: dp[0][0], dp[1][1]... = true
// len=2: 判断所有相邻字符
// i=0,j=1: s[0]='b', s[1]='a' -> 不相等,dp[0][1]=false
// i=1,j=2: 'a' vs 'b' -> false
// i=2,j=3: 'b' vs 'a' -> false
// i=3,j=4: 'a' vs 'd' -> false
// len=3: 判断长度为3的子串
// i=0,j=2: s[0]='b', s[2]='b' 相等,且 len!=2, 看 dp[1][1]=true -> 所以 dp[0][2]=true -> 更新结果 start=0, maxLen=3
// ...以此类推。这种方法逻辑清晰,但空间占用大。
5. 各实现思路的复杂度、优缺点对比
| 方法 | 时间复杂度 | 空间复杂度 | 优点 | 缺点 | 适用场景 |
|---|---|---|---|---|---|
| 中心扩散法 | O(n²) | O(1) | 1. 空间效率极高。 2. 思路直观,代码相对简洁。 3. 性能稳定,是最优常规解。 | 1. 理论时间复杂度不是最优(但已足够)。 | 前端面试首选、大多数字符串回文问题、对内存敏感的环境。 |
| 动态规划 | O(n²) | O(n²) | 1. 思路具有通用性,是许多字符串问题的模板。 2. 状态定义清晰,易于理解和推导。 | 1. 空间占用大,在 n 较大时可能成为瓶颈。 2. 代码实现稍显繁琐。 |
适用于需要记录所有子串状态的更复杂问题(如分割回文串II)。 |
| Manacher算法 | O(n) | O(n) | 1. 理论时间复杂度最优。 | 1. 算法复杂,不易理解和记忆。 2. 实现容易出错。 3. 在前端面试中不常见。 | 对性能有极致要求的后端服务或算法竞赛。 |
6. 总结
6.1 通用解题模板/思路
对于回文类问题,可以遵循以下思考路径:
-
识别特征 :牢记回文的对称性。无论是从中心扩散,还是动态规划中"大问题依赖于小问题"的特性,都源于此。
-
优先考虑中心扩散法 :对于"最长回文子串"、"回文子串个数"等问题,中心扩散法通常是最高效、最清晰的实现方式。其核心模板如下:
javascriptfunction expand(left, right) { while (满足边界条件 && 左右字符相等) { left--; right++; } return 回文长度或子串; } for (遍历每个索引作为中心) { 处理奇数长度情况(expand(i, i)); 处理偶数长度情况(expand(i, i+1)); 更新最优结果; } -
考虑动态规划 :当问题要求的结果不仅仅是"一个",或者需要记录中间所有状态时(例如"分割回文串"需要知道所有子串是否回文),动态规划是更好的选择。其状态定义通常为
dp[i][j]表示子串s[i..j]是否回文。 -
前端关联思考:将"中心"视为UI组件的"状态",将"扩散"视为"副作用"或"更新"。理解如何高效地更新和传播状态,是构建复杂前端应用(如状态管理、响应式更新)的核心能力。
6.2 类似题目推荐
- LeetCode 647. 回文子串:计算字符串中有多少个回文子串。中心扩散法的直接应用。
- LeetCode 516. 最长回文子序列 :求最长回文子序列的长度(子序列可以不连续)。这是动态规划的经典题目,状态定义与本题类似但转移方程不同。
- LeetCode 131. 分割回文串 / 132. 分割回文串 II:需要先利用动态规划预处理,得到所有子串的回文信息,再进行深度优先搜索或二次动态规划。是综合应用的好题目。