在LeetCode字符串类题目中,「最长回文子串」是入门级经典题,也是动态规划、中心扩展法的典型应用场景。本文将从题目解析出发,详细讲解两种主流解法(动态规划+中心扩展),拆解思路、代码逻辑、避坑要点,兼顾新手理解与实战应用,帮助大家举一反三解决同类问题。
一、题目核心解析
1. 题目描述
给你一个字符串 s,找到 s 中最长的回文子串。
2. 关键概念区分
-
回文子串:正读和反读完全相同的连续子串(如 "bab"、"bb")。
-
回文子序列:正读和反读完全相同的非连续子序列(如 "babad" 的子序列 "bab",可跳过中间字符),本文重点聚焦「子串」。
3. 边界与示例
-
边界情况:空字符串返回 "";单个字符返回其本身(如 "a" → "a")。
-
示例1:输入 "babad" → 输出 "bab" 或 "aba"(两种均为最长回文子串)。
-
示例2:输入 "cbbd" → 输出 "bb"(唯一最长回文子串)。
二、解法一:动态规划法(易懂通用版)
动态规划(DP)的核心思路是「复用子串状态,避免重复计算」,适合新手入门,思路可迁移到同类子串问题(如最长回文子序列)。
1. 核心思路拆解
(1)DP数组定义
定义 dp[i][j] 表示:字符串 s 中,从索引 i 到索引 j(闭区间)的子串 s[i..j] 是否是回文子串(true 为是,false 为否)。
(2)状态转移方程
判断 s[i..j] 是否为回文,核心依赖两个条件,分3种情况推导:
-
子串长度为 1(i = j):单个字符必然是回文,故 dp[i][i] = true。
-
子串长度为 2(j = i+1):首尾字符相等则为回文,即 s[i] === s[j] 时,dp[i][j] = true。
-
子串长度 > 2(j > i+1):首尾字符相等 且 内部子串 s[i+1..j-1] 是回文,即 s[i] === s[j] && dp[i+1][j-1] = true 时,dp[i][j] = true。
(3)遍历顺序
由于 dp[i][j] 依赖 dp[i+1][j-1](内部子串状态),若按 i 或 j 直接遍历,会导致内部子串未计算就先判断外部,因此需按「子串长度」从小到大遍历:
-
先初始化所有长度为 1 的子串(dp[i][i] = true)。
-
再依次处理长度为 2 到 n 的子串,遍历所有可能的左边界 left,计算右边界 right = left + len - 1,判断是否为回文。
(4)结果记录
用两个变量记录最长回文子串的信息,避免遍历结束后再查找:
-
maxLen:最长回文子串的长度(初始为 1,覆盖单个字符的默认情况)。
-
start:最长回文子串的起始索引(初始为 0)。
2. 完整代码(TypeScript)
typescript
function longestPalindrome(s: string): string {
const n = s.length;
// 边界处理:空字符串或单个字符直接返回
if (n <= 1) return s;
// 初始化DP数组:n行n列,默认值为false
const dp = Array.from({ length: n }, () => new Array(n).fill(false));
let maxLen = 1;
let start = 0;
// 初始化长度为1的子串(所有单个字符都是回文)
for (let i = 0; i < n; i++) {
dp[i][i] = true;
}
// 遍历长度为2到n的子串
for (let len = 2; len <= n; len++) {
for (let left = 0; left < n; left++) {
const right = left + len - 1;
// 右边界超出字符串长度,终止当前循环
if (right >= n) break;
// 核心判断:首尾字符相等
if (s[left] === s[right]) {
// 长度为2直接是回文,长度>2依赖内部子串
if (len === 2) {
dp[left][right] = true;
} else {
dp[left][right] = dp[left + 1][right - 1];
}
}
// 更新最长回文子串信息
if (dp[left][right] && len > maxLen) {
maxLen = len;
start = left;
}
}
}
// 截取最长回文子串(substring左闭右开)
return s.substring(start, start + maxLen);
};
3. 逐行解析与避坑要点
避坑核心:
-
边界处理:先判断 n ≤ 1 的情况,避免后续 DP 数组初始化报错(如 n=0 时无法创建 n×n 数组)。
-
右边界判断:left 遍历中,right = left + len - 1 可能超出 n-1(字符串最大索引),需及时 break,避免数组越界。
-
状态转移:长度 >2 时,必须依赖 dp[left+1][right-1],不可直接设为 true(如 "abcba",需判断中间 "bcb" 是回文)。
-
DP数组初始化:用 Array.from 创建 n 行 n 列的二维数组,默认填充 false,确保初始状态统一。
-
长度为1的子串初始化:循环赋值 dp[i][i] = true,覆盖所有单个字符的情况。
-
子串遍历:外层循环控制长度 len,内层循环控制左边界 left,计算右边界后判断首尾字符,再根据长度更新 dp 状态。
-
结果截取:substring 方法是左闭右开区间,因此 end 为 start + maxLen,无需减1。
4. 复杂度分析
-
时间复杂度:O(n^2),两层循环(len 从 2 到 n,left 从 0 到 n-len),每次判断为 O(1)。
-
空间复杂度:O(n^2),需开辟 n×n 的 DP 数组存储子串回文状态。
三、解法二:中心扩展法(空间优化版)
动态规划法的空间复杂度较高,中心扩展法利用「回文子串中心对称」的特点,将空间优化至 O(1),执行效率更优,适合实战中追求空间性能的场景。
1. 核心思路
回文子串的本质是「中心对称」,因此可围绕两种中心向两边扩散,判断扩散后的子串是否为回文,同时记录最长回文信息:
-
奇数长度回文:中心为单个字符(如 "aba",中心是 "b")。
-
偶数长度回文:中心为两个相邻字符(如 "bb",中心是 "b" 和 "b")。
-
辅助函数复用:定义 expandAroundCenter 函数,接收左右边界,返回该中心对应的最长回文子串的「起始索引」和「长度」,简化代码。
2. 完整代码(TypeScript)
typescript
// 中心扩展法实现(空间优化版)
function longestPalindromeCenterExpand(s: string): string {
const n = s.length;
// 边界处理:空字符串或单个字符直接返回
if (n <= 1) return s;
let maxLen = 1;
let start = 0;
// 辅助函数:从left和right向两边扩散,返回[起始索引, 回文长度]
const expandAroundCenter = (left: number, right: number): [number, number] => {
// 左右边界不越界,且首尾字符相等,继续扩散
while (left >= 0 && right < n && s[left] === s[right]) {
left--;
right++;
}
// 扩散结束后,有效回文边界为[left+1, right-1],计算长度和起始索引
const length = right - left - 1;
const startIdx = left + 1;
return [startIdx, length];
};
// 遍历所有可能的中心(奇数+偶数)
for (let i = 0; i < n; i++) {
// 奇数长度回文:中心为i(单个字符)
const [start1, len1] = expandAroundCenter(i, i);
// 偶数长度回文:中心为i和i+1(两个相邻字符)
const [start2, len2] = expandAroundCenter(i, i + 1);
// 更新最长回文子串信息
const currentMaxLen = Math.max(len1, len2);
if (currentMaxLen > maxLen) {
maxLen = currentMaxLen;
// 确定当前最长回文的起始索引
start = currentMaxLen === len1 ? start1 : start2;
}
}
// 截取并返回最长回文子串
return s.substring(start, start + maxLen);
};
3. 逐行解析与避坑要点
避坑核心:
-
辅助函数边界回退:扩散结束后,left 和 right 已超出有效回文边界,需回退一位(left+1、right-1),因此长度为 right - left - 1。
-
中心不遗漏:需遍历所有奇数和偶数中心,共 2n-1 个(n 个奇数中心 + n-1 个偶数中心),避免遗漏最长回文子串。
-
边界处理:与 DP 法一致,先判断 n ≤ 1 的情况,避免后续扩散时越界。
-
辅助函数设计:将扩散逻辑封装,避免重复代码,提高可读性和可维护性。
-
中心遍历:循环变量 i 覆盖所有奇数中心,i 和 i+1 覆盖所有偶数中心,确保无遗漏。
-
结果更新:每次扩散后对比长度,及时更新 maxLen 和 start,避免遍历结束后再查找,提升效率。
4. 复杂度分析
-
时间复杂度:O(n^2),每个中心最多扩散 n 次,共 2n-1 个中心,整体为 O(n×n)。
-
空间复杂度:O(1),仅使用常数个变量存储回文信息,无需额外开辟数组。
四、两种解法测试用例验证
为确保两种解法的正确性,以下测试用例分别验证两种方法,覆盖边界、常规、特殊场景:
1. 动态规划法测试
-
测试用例1:s = "babad" → 输出 "bab"(start=0,maxLen=3,截取 s[0,3))。
-
测试用例2:s = "cbbd" → 输出 "bb"(len=2,left=1,right=2,dp[1][2]=true)。
-
测试用例3:s = "" → 输出 ""(边界处理生效)。
-
测试用例4:s = "a" → 输出 "a"(初始 maxLen=1)。
2. 中心扩展法测试
-
测试用例1:s = "babad" → 输出 "bab" 或 "aba"(奇数中心 i=1 扩散得到)。
-
测试用例2:s = "cbbd" → 输出 "bb"(偶数中心 i=1、i+1=2 扩散得到)。
-
测试用例3:s = "ac" → 输出 "a" 或 "c"(最长回文长度为1)。
-
测试用例4:s = "ccc" → 输出 "ccc"(奇数中心 i=1 扩散得到,长度3)。
五、两种解法对比总结
| 对比维度 | 动态规划法 | 中心扩展法 |
|---|---|---|
| 核心思路 | 复用子串回文状态,避免重复计算 | 利用中心对称,向两边扩散判断 |
| 时间复杂度 | O(n^2) | O(n^2) |
| 空间复杂度 | O(n^2)(需n×n DP数组) | O(1)(仅用常数变量) |
| 优势 | 思路易懂,可迁移到同类子串/子序列问题 | 空间最优,执行效率更高,适合实战 |
| 适用场景 | 新手入门、同类问题迁移(如最长回文子序列) | 实战优化、空间受限场景 |
六、总结与实战建议
LeetCode 5. 最长回文子串的核心是「回文子串的对称性」和「子问题复用」,两种解法各有侧重:
-
若你是新手,优先掌握「动态规划法」,理解状态定义和转移逻辑,打好子问题复用的基础,后续可轻松迁移到 LeetCode 516. 最长回文子序列等题目。
-
若你追求实战效率,优先使用「中心扩展法」,空间优化至 O(1),在面试中更易体现代码功底。
补充技巧:解题时可先判断边界情况(n ≤ 1),再执行核心逻辑,避免不必要的计算;同时可通过调试工具查看 DP 数组状态、中心扩散过程,加深对思路的理解。