最长回文子串(Longest Palindromic Substring)和最长回文子序列(Longest Palindromic Subsequence)是字符串算法中的经典孪生问题——名字仅一字之差,核心逻辑却因“子串连续”和“子序列不连续”的本质差异,导致代码设计、状态转移、遍历顺序都有显著不同。
本文将从「代码实现」「核心逻辑拆解」「关键差异对比」三个维度,用最直观的方式分清二者的异同。
一、先上完整代码(两种遍历方式,便于对比)
1. 最长回文子串(两种遍历方式)
方式1:按子串长度L遍历(短→长)
class Solution:
def longestPalindromeSubstring(self, s: str) -> int:
n = len(s)
if n == 0:
return 0
# dp[i][j]:s[i..j]是否是回文子串(布尔型)
dp = [[False] * n for _ in range(n)]
max_len = 1 # 最小回文子串长度为1(单个字符)
# 初始化:长度为1的子串都是回文
for i in range(n):
dp[i][i] = True
# 按子串长度L从2到n遍历(短→长)
for L in range(2, n + 1):
for i in range(n - L + 1):
j = i + L - 1 # 子串结束索引
# 核心判断:首尾相等 + (长度2或中间子串是回文)
if s[i] == s[j] and (L == 2 or dp[i+1][j-1]):
dp[i][j] = True
max_len = max(max_len, L) # 更新最长长度
return max_len
方式2:直接以i-j为标准遍历(i逆序+j正序)
class Solution:
def longestPalindromeSubstring(self, s: str) -> int:
n = len(s)
if n == 0:
return 0
# dp[i][j]:s[i..j]是否是回文子串(布尔型)
dp = [[False] * n for _ in range(n)]
max_len = 1 # 默认最长回文子串长度为1(单个字符)
# 核心遍历:i从后往前(n-1 → 0),j从i往后(i → n-1)
for i in range(n-1, -1, -1):
# j必须≥i,从i+1开始(i==j已初始化)
for j in range(i+1, n):
# 状态转移:首尾相等 + (长度2或中间子串是回文)
if s[i] == s[j] and (j - i <= 1 or dp[i+1][j-1]):
dp[i][j] = True
# 更新最长长度(子串长度=j-i+1)
max_len = max(max_len, j - i + 1)
return max_len
2. 最长回文子序列(两种遍历方式)
方式1:按子串长度L遍历(短→长)
class Solution:
def longestPalindromeSubseq(self, s: str) -> int:
n = len(s)
# dp[i][j]:s[i..j]的最长回文子序列长度(数值型)
dp = [[0] * n for _ in range(n)]
# 初始化:长度为1的子序列长度为1
for i in range(n):
dp[i][i] = 1
# 按子串长度L从2到n遍历(短→长)
for L in range(2, n + 1):
for i in range(n - L + 1):
j = i + L - 1 # 子串结束索引
# 核心判断:首尾相等则+2,否则取两种情况的最大值
if s[i] == s[j]:
dp[i][j] = dp[i+1][j-1] + 2
else:
dp[i][j] = max(dp[i+1][j], dp[i][j-1])
return dp[0][n-1]
方式2:直接以i-j为标准遍历(i逆序+j正序)
class Solution:
def longestPalindromeSubseq(self, s: str) -> int:
n = len(s)
if n == 0:
return 0
# dp[i][j]:s[i..j]的最长回文子序列长度(数值型)
dp = [[0] * n for _ in range(n)]
# 初始化:长度为1的子序列长度为1(i==j)
for i in range(n):
dp[i][i] = 1
# 核心遍历:i从后往前(n-1 → 0),j从i往后(i → n-1)
for i in range(n-1, -1, -1):
# j从i+1开始(i==j已初始化)
for j in range(i+1, n):
# 状态转移:首尾相等则+2,否则取子问题最大值
if s[i] == s[j]:
dp[i][j] = dp[i+1][j-1] + 2
else:
dp[i][j] = max(dp[i+1][j], dp[i][j-1])
# 最终结果:整个字符串的最长回文子序列长度
return dp[0][n-1]
二、核心逻辑拆解(逐个对比关键模块)
1. 状态定义:布尔型 vs 数值型(最本质差异)
最长回文子串(子串连续)
- 状态定义:
dp[i][j]表示 s[i..j]是否是回文子串(True/False); - 原因:子串是连续的,判断“是否为回文”是核心——只有确认是回文,才需要更新最长长度;
- 额外变量:需要
max_len记录最长回文子串的长度(因为DP数组不存储长度,只存储“是否为回文”)。
最长回文子序列(子序列不连续)
- 状态定义:
dp[i][j]表示 s[i..j]的最长回文子序列长度(整数); - 原因:子序列不连续,无需先判断“是否为回文”——DP数组直接存储最优解(最长长度),无需额外变量记录,最终结果就是
dp[0][n-1]。
2. 初始化:都处理长度为1的情况,但意义不同
最长回文子串
- 初始化:
dp[i][i] = True(单个字符是回文子串); - 配套操作:
max_len = 1(单个字符的长度是1,是默认最长值)。
最长回文子序列
- 初始化:
dp[i][i] = 1(单个字符的最长回文子序列就是自己,长度1); - 配套操作:无额外变量,DP数组直接存储长度。
3. 状态转移方程:依赖“连续性”的判断差异
最长回文子串(子串连续)
- 核心前提:子串连续,所以 首尾相等是必要条件,但还需确保中间部分也是连续的回文;
- 转移方程(两种遍历方式一致):
if s[i] == s[j] and (j - i == 1 or dp[i+1][j-1]): # L遍历用L==2,i-j遍历用j-i==1 dp[i][j] = True- 拆解:
s[i] == s[j]:连续回文的基础(首尾必须相等);j - i == 1(或L==2):长度为2的子串(如“aa”“bb”),首尾相等就是回文,无需依赖中间;dp[i+1][j-1]:长度≥3的子串,中间部分(s[i+1..j-1])必须是回文(连续),整体才是回文。
- 拆解:
最长回文子序列(子序列不连续)
- 核心前提:子序列不连续,首尾相等时可直接累加长度,不相等时取最优子问题解;
- 转移方程(两种遍历方式完全一致):
if s[i] == s[j]: dp[i][j] = dp[i+1][j-1] + 2 # 首尾加入子序列,长度+2 else: dp[i][j] = max(dp[i+1][j], dp[i][j-1]) # 取“跳过i”或“跳过j”的最大值- 拆解:
s[i] == s[j]:首尾字符可同时加入回文子序列,长度=中间子序列长度+2;s[i] != s[j]:首尾不能同时加入,选择“跳过s[i]”或“跳过s[j]”的最长值。
- 拆解:
4. 遍历顺序:两种方式的核心逻辑与对比
两种问题都支持“按L遍历”和“按i-j遍历”,核心原则都是保证短子串先处理,长子串后处理(因为都依赖dp[i+1][j-1]等短子串状态)。
两种遍历方式详细对比
| 遍历方式 | 核心逻辑 | 代码写法特点 | 可读性 | 适用场景 |
|---|---|---|---|---|
| 按长度L遍历 | 直接控制子串长度,从2到n递增 | 需计算j = i+L-1,控制i的范围(n-L+1) | 高 | 新手入门,直观理解“短→长”依赖 |
| 按i-j遍历 | i逆序(n-1→0)、j正序(i→n-1) | 无需计算j,直接遍历,逻辑更简洁 | 中 | 简化代码,避免长度计算 |
遍历顺序的必要性验证(以s="cbbd"为例)
- 按L遍历顺序:L=2(i=0,j=1;i=1,j=2;i=2,j=3)→ L=3(i=0,j=2;i=1,j=3)→ L=4(i=0,j=3);
- 按i-j遍历顺序:i=3(无j)→ i=2(j=3)→ i=1(j=2;j=3)→ i=0(j=1;j=2;j=3);
- 两种顺序均满足“短子串先处理”,最终结果完全一致。
5. 结果获取:额外变量 vs 直接取DP值
最长回文子串
- 结果:
max_len(遍历过程中不断更新,确认是回文子串后才更新长度); - 若要返回具体子串(而非长度),还需记录最长回文子串的起始索引和长度,最后截取。
最长回文子序列
- 结果:
dp[0][n-1](整个字符串的最长回文子序列长度,直接从DP数组获取); - 若要返回具体子序列,需回溯DP数组(根据状态转移路径反推选择的字符)。
三、关键差异汇总表(一目了然)
| 对比维度 | 最长回文子串(连续) | 最长回文子序列(不连续) |
|---|---|---|
| 状态定义 | dp[i][j]:s[i..j]是否是回文子串(布尔) |
dp[i][j]:s[i..j]的最长回文子序列长度(整数) |
| 核心目标 | 判断“是否为回文”,再记录最长长度 | 直接计算“最长长度”,无需单独判断 |
| 额外变量 | 需要 max_len 记录最长长度 |
无需额外变量,结果为 dp[0][n-1] |
| 状态转移核心 | 首尾相等 + 中间是连续回文 | 首尾相等则+2,否则取子问题最大值 |
| 转移方程复杂度 | 条件判断更简单(仅需确认“是否”) | 逻辑更灵活(需选择最优子问题) |
| 遍历方式 | 支持L遍历和i-j遍历,核心一致 | 支持L遍历和i-j遍历,核心一致 |
| 结果获取 | 遍历中更新 max_len |
直接取DP数组最终状态 |
| 拓展性(返回具体串) | 需记录起始索引,截取字符串 | 需回溯DP数组,反推字符选择 |
四、为什么会有这些差异?(本质原因)
所有差异的根源都来自 “子串连续” vs “子序列不连续”:
- 子串连续:必须保证整个区间是“完整的回文”——所以先判断“是否为回文”,再记录长度;
- 子序列不连续:无需保证区间完整,只需在区间内选择字符形成回文——所以直接计算“最长长度”,允许跳过不满足条件的字符。
举个直观例子(s="bbbab"):
- 最长回文子串:连续的“bbb”(长度3);
- 最长回文子序列:不连续的“bbbb”(长度4,跳过中间的“a”);
- 对应代码逻辑:
- 子串:“bbbb”不是连续的,所以
dp[0][4]是False,不会更新max_len; - 子序列:
dp[0][4] = dp[1][3] + 2(首尾“b”+中间“bba”的最长子序列长度2),结果为4。
- 子串:“bbbb”不是连续的,所以
五、面试常见考点与易错点
考点1:状态定义的选择
- 问:为什么最长回文子串不用“数值型DP”(直接存储长度)?
- 答:可以,但没必要——布尔型DP更简洁,只需判断“是否为回文”,再更新长度;若用数值型,需额外处理“非回文子串”的长度(设为0),逻辑更繁琐。
考点2:遍历顺序的灵活选择
- 问:两种遍历方式(L遍历 vs i-j遍历)可以互换吗?
- 答:可以!只要保证“短子串先处理”,两种方式完全等价——面试中可根据自己的习惯选择,若面试官追问,需能解释清楚“为什么这么遍历”(保证依赖状态已计算)。
易错点1:最长回文子串忘记处理长度为2的情况
- 错误写法:
if s[i] == s[j] and dp[i+1][j-1](忽略长度为2的情况); - 后果:长度为2的回文子串(如“aa”)会被误判为非回文(因为
dp[i+1][j-1]是dp[i+1][i],默认是False)。
易错点2:最长回文子序列首尾相等时忘记+2
- 错误写法:
dp[i][j] = dp[i+1][j-1](少加2); - 后果:长度计算错误(首尾两个字符未计入)。
易错点3:i-j遍历时光顾i逆序,忽略j的起始位置
- 错误写法:
j从0开始遍历(导致j < i,出现无效子串); - 正确写法:
j从i+1开始遍历(确保j ≥ i,子串有效)。
六、总结:一句话区分两种代码+遍历方式
- 最长回文子串(连续):先判回文,再记长度(布尔DP+
max_len,支持L遍历和i-j遍历); - 最长回文子序列(不连续):直接算长度,选最优解(数值DP,支持L遍历和i-j遍历);
- 遍历核心:短子串先处理,要么按L递增,要么i逆序+j正序。
核心记忆:连续看“是否”,不连续看“长度”;遍历看“依赖”,短子先处理——抓住这个关键点,就能在面试中快速区分两种问题,灵活选择遍历方式,写出正确代码。
浙公网安备 33010602011771号