好的,我已经记住了你已学过的题目列表。这次我为你讲解一个线性动态规划领域中非常经典,但可能在你列表中尚未出现过的题目。
题目:最长回文子串 (Longest Palindromic Substring) —— 动态规划解法
题目描述
给定一个字符串 s,请你找出 s 中最长的回文子串(连续的字符序列)。
回文串是指正着读和反着读都一样的字符串。
示例
输入:s = "babad"
输出:"bab" 或 "aba"(都有效)
输入:s = "cbbd"
输出:"bb"
约束条件
- 1 <= s.length <= 1000
s仅由数字和英文字母组成。
解题过程详解(动态规划思路)
我们要找的是最长回文子串,这是一个“子串”问题,意味着结果必须是原字符串中连续的一段。动态规划非常适合解决这种“最优子结构”问题。
第一步:定义状态
我们需要一个二维数组 dp[i][j] 来表示某个子串是否是回文。
dp[i][j]表示:以索引i为起始,以索引j为结束的子串s[i..j]是否是回文串。- 其值是一个布尔值:
True代表是回文,False代表不是回文。 - 我们的最终目标就是找出所有
dp[i][j] == True的子串中,j - i + 1(长度)最大的那个。
第二步:寻找状态转移方程
如何由已知的小问题推导出大问题呢?核心思路是:一个长串是不是回文,取决于它的“核心”和两端的字符。
-
基本情况(最小子问题):
- 单个字符:对于任意
i,子串s[i..i]只有一个字符,当然是回文。所以dp[i][i] = True。 - 两个字符:子串
s[i..i+1]是回文,当且仅当这两个字符相等。所以dp[i][i+1] = (s[i] == s[i+1])。
- 单个字符:对于任意
-
一般情况(长度 >= 3 的子串):
- 对于子串
s[i..j](其中j - i >= 2),它要成为回文,必须满足两个条件:
a. 它两端的字符必须相等:s[i] == s[j]
b. 去掉两端字符后剩下的“核心”部分s[i+1..j-1]也必须是回文串,即dp[i+1][j-1] == True - 因此,状态转移方程为:
dp[i][j] = (s[i] == s[j]) and dp[i+1][j-1]
- 对于子串
第三步:确定填表顺序
观察状态转移方程 dp[i][j] = (s[i] == s[j]) and dp[i+1][j-1]。
要计算 dp[i][j],我们需要知道 dp[i+1][j-1] 的结果,也就是左下方格子的值。
这意味着我们不能按简单的 i 从 0 到 n,j 从 0 到 n 的顺序来填表,因为 i+1 行可能还没算。
一个有效的填表顺序是:
- 按子串长度
L从小到大进行枚举。 - 对于每个固定长度
L,枚举所有可能的起始位置i,那么结束位置j = i + L - 1。 - 因为计算
dp[i][j]时,用到的dp[i+1][j-1]对应的是长度L-2的子串,这个值在我们按长度递增的顺序计算时,肯定已经计算出来了。
这种顺序保证了依赖的子问题总是先被解决。
第四步:初始化与边界情况
- 初始化一个
n x n的二维数组(n为字符串长度),所有值先设为False。 - 长度为1的子串(对角线)初始化为
True:dp[i][i] = True。 - 长度为2的子串(
j = i+1)根据s[i] == s[i+1]来初始化。这一步可以融入到主循环中处理,也可以单独处理。
第五步:记录答案
在填充 dp 表的过程中,我们随时追踪当前找到的最长回文子串的起始位置和长度。
一旦发现 dp[i][j] == True,就检查当前子串长度 L = j - i + 1 是否大于已知的最大长度。如果是,就更新记录。
第六步:算法流程(伪代码)
def longestPalindrome(s: str) -> str:
n = len(s)
if n < 2:
return s
# 步骤1:初始化dp表
dp = [[False] * n for _ in range(n)]
# 记录最长回文的起始位置和长度
start = 0
max_len = 1
# 步骤2:初始化长度为1的子串
for i in range(n):
dp[i][i] = True
# 步骤3:按子串长度 L 从小到大枚举 (L从2到n)
for L in range(2, n + 1):
# 枚举起始位置 i
for i in range(n):
# 计算结束位置 j
j = i + L - 1
# 如果 j 越界,直接跳出内层循环
if j >= n:
break
# 检查两端字符
if s[i] != s[j]:
dp[i][j] = False
else:
# 核心转移方程
if L == 2: # 长度为2的特殊情况
dp[i][j] = True
else: # 长度大于2
dp[i][j] = dp[i + 1][j - 1]
# 步骤4:更新答案
if dp[i][j] and L > max_len:
max_len = L
start = i
# 步骤5:根据记录的起始位置和最大长度返回子串
return s[start: start + max_len]
第七步:复杂度分析
- 时间复杂度:O(n²)。我们有两层循环,外层枚举长度 O(n),内层枚举起点 O(n),内部操作为 O(1)。
- 空间复杂度:O(n²)。我们需要一个 n x n 的二维 DP 表来存储中间状态。
第八步:示例推演 (s = “babad”)
- 初始化
n=5,dp表对角线为True。max_len=1,start=0。 - 长度 L=2:
- i=0, j=1: s[0]=’b’, s[1]=’a’ 不等 ->
dp[0][1]=False - i=1, j=2: ‘a’ != ‘b’ -> False
- i=2, j=3: ‘b’ == ‘a’? No -> False
- i=3, j=4: ‘a’ == ‘d’? No -> False
无更新。
- i=0, j=1: s[0]=’b’, s[1]=’a’ 不等 ->
- 长度 L=3:
- i=0, j=2: s[0]=’b’, s[2]=’b’ 相等,且 L=3 >2,看核心
dp[1][1]=True->dp[0][2]=True。长度3 > 1,更新max_len=3,start=0。 - i=1, j=3: ‘a’ == ‘a’ 相等,核心
dp[2][2]=True->dp[1][3]=True。长度3等于当前max_len,不更新起始位置(或者可以更新,取决于题目要求,通常返回任意一个即可)。 - i=2, j=4: ‘b’ != ‘d’ -> False。
- i=0, j=2: s[0]=’b’, s[2]=’b’ 相等,且 L=3 >2,看核心
- 长度 L=4, L=5:遍历后发现没有更长的回文子串。
- 最终返回
s[0:0+3] = “bab”。算法同样能找到”aba”,取决于内层循环顺序和更新策略。
总结:这个动态规划解法通过定义子问题 dp[i][j],利用回文串的“两边相等且内部也是回文”的性质,建立了状态转移方程。通过按子串长度递增的顺序填表,我们能够从最小的回文串(单个字符)开始,逐步构造出更长的回文串,并最终找到最长的那个。这个思路清晰,是理解和掌握区间DP(一种二维线性DP)的绝佳例题。