1. 动态规划经典问题实战解析
作为一名经历过多次算法面试的老兵,我深知动态规划(DP)是大多数人的痛点。今天我想分享代码随想录训练营第43天涉及的四个经典DP问题:1143最长公共子序列、1035不相交的线、53最大子序和以及392判断子序列。这些题目看似独立,实则暗藏联系,我将用统一的DP框架带大家逐个击破。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 1143. 最长公共子序列(LCS)
2.1 问题本质剖析
给定两个字符串text1和text2,返回它们的最长公共子序列长度。子序列不同于子串,不要求连续但必须保持相对顺序。例如"ace"是"abcde"的子序列。
关键洞察:LCS是许多字符串问题的基础,如基因序列比对、代码差异分析等场景都有应用。
2.2 DP状态定义技巧
定义dp[i][j]表示text1[0..i-1]和text2[0..j-1]的LCS长度。这里使用i-1而非i是为了处理空字符串的边界情况。
python复制def longestCommonSubsequence(text1: str, text2: str) -> int:
m, n = len(text1), len(text2)
dp = [[0]*(n+1) for _ in range(m+1)]
for i in range(1, m+1):
for j in range(1, n+1):
if text1[i-1] == text2[j-1]:
dp[i][j] = dp[i-1][j-1] + 1
else:
dp[i][j] = max(dp[i-1][j], dp[i][j-1])
return dp[m][n]
2.3 空间优化策略
实际面试中常被要求优化空间复杂度。观察状态转移发现当前行只依赖上一行,可将空间降至O(n):
python复制def lcs_space_optimized(text1, text2):
if len(text1) < len(text2):
text1, text2 = text2, text1 # 让text2保持较短
prev = [0]*(len(text2)+1)
for i in range(1, len(text1)+1):
curr = [0]*(len(text2)+1)
for j in range(1, len(text2)+1):
if text1[i-1] == text2[j-1]:
curr[j] = prev[j-1] + 1
else:
curr[j] = max(prev[j], curr[j-1])
prev = curr
return prev[-1]
3. 1035. 不相交的线
3.1 问题转化艺术
题目描述:在两条平行线上绘制连接数字相同的点的直线,求最大不相交连线数。这实际上是LCS的变种——当两个数组相同时,LCS就是最大连线数。
python复制def maxUncrossedLines(nums1: List[int], nums2: List[int]) -> int:
# 完全复用LCS的解法
return longestCommonSubsequence(nums1, nums2)
3.2 实际应用场景
这种模型在电路板布线、交通路径规划中很常见。关键要识别出问题本质是求序列的某种最大匹配。
4. 53. 最大子序和
4.1 经典Kadane算法
求连续子数组的最大和。不同于前两题的二维DP,这里使用一维DP:
python复制def maxSubArray(nums: List[int]) -> int:
dp = [0]*len(nums)
dp[0] = nums[0]
max_sum = dp[0]
for i in range(1, len(nums)):
dp[i] = max(nums[i], dp[i-1]+nums[i])
max_sum = max(max_sum, dp[i])
return max_sum
4.2 空间优化版本
由于只依赖前一个状态,可以进一步优化:
python复制def maxSubArray_optimized(nums):
current_max = global_max = nums[0]
for num in nums[1:]:
current_max = max(num, current_max + num)
global_max = max(global_max, current_max)
return global_max
4.3 边界处理技巧
当所有数为负数时,最大子序和就是最大的单个负数。这个边界case常被面试官用来考察代码健壮性。
5. 392. 判断子序列
5.1 双指针解法
最简单的方法是双指针遍历:
python复制def isSubsequence(s: str, t: str) -> bool:
i = j = 0
while i < len(s) and j < len(t):
if s[i] == t[j]:
i += 1
j += 1
return i == len(s)
5.2 DP解法思路
虽然这题用DP有点杀鸡用牛刀,但为了保持DP框架统一,我们可以定义dp[i][j]表示s[0..i-1]是否是t[0..j-1]的子序列:
python复制def isSubsequenceDP(s: str, t: str) -> bool:
m, n = len(s), len(t)
dp = [[False]*(n+1) for _ in range(m+1)]
dp[0][0] = True
for j in range(n+1):
dp[0][j] = True
for i in range(1, m+1):
for j in range(1, n+1):
if s[i-1] == t[j-1]:
dp[i][j] = dp[i-1][j-1]
else:
dp[i][j] = dp[i][j-1]
return dp[m][n]
5.3 预处理优化
对于需要多次检查不同s的情况,可以预处理t建立字符位置索引,将每次查询复杂度降至O(len(s)):
python复制from bisect import bisect_left
from collections import defaultdict
class SubsequenceChecker:
def __init__(self, t):
self.char_indices = defaultdict(list)
for i, char in enumerate(t):
self.char_indices[char].append(i)
def is_subsequence(self, s):
prev = -1
for char in s:
indices = self.char_indices.get(char, [])
idx = bisect_left(indices, prev + 1)
if idx == len(indices):
return False
prev = indices[idx]
return True
6. DP问题通用解题框架
通过这四个题目,我总结出解决DP问题的通用步骤:
- 状态定义:明确dp数组的含义
- 转移方程:找出状态间的递推关系
- 初始化:处理好边界条件
- 遍历顺序:确定正确的计算顺序
- 结果提取:从dp数组中获取最终答案
对于字符串/序列类DP,90%的问题可以套用以下模板:
python复制def sequence_dp_template(s1, s2):
m, n = len(s1), len(s2)
dp = [[0]*(n+1) for _ in range(m+1)]
# 初始化
for i in range(m+1):
dp[i][0] = ... # 根据问题定制
for j in range(n+1):
dp[0][j] = ... # 根据问题定制
# 状态转移
for i in range(1, m+1):
for j in range(1, n+1):
if s1[i-1] == s2[j-1]:
dp[i][j] = ... # 相等时的转移
else:
dp[i][j] = ... # 不等时的转移
return dp[m][n] # 或根据问题返回特定值
7. 常见错误与调试技巧
在刷题和面试中,我遇到过这些典型错误:
- 数组越界:总是忘记DP数组比原字符串长度大1
- 初始化遗漏:特别是dp[0][0]的初始值设置错误
- 遍历顺序错误:有时需要反向遍历或特定顺序
- 状态转移混淆:把max/min条件写反
调试建议:
- 打印出完整的DP表格
- 先用小规模测试用例验证
- 特别注意边界情况(空字符串、全相同元素等)
8. 性能优化实战
当遇到超长字符串时(如长度>1000),需要考虑优化:
- 空间压缩:如LCS问题中提到的滚动数组技巧
- 提前终止:在某些问题中一旦达到目标可提前结束循环
- 记忆化搜索:有时递归+记忆化比迭代更直观
- 问题特性利用:如392题的双指针法比DP更高效
对于最大子序和问题,Kadane算法已经是理论最优解(O(n)时间,O(1)空间)。但在实际工程中,如果还需要知道子数组的起止位置,可以这样修改:
python复制def maxSubArray_with_indices(nums):
max_sum = current_sum = nums[0]
start = end = 0
temp_start = 0
for i in range(1, len(nums)):
if nums[i] > current_sum + nums[i]:
current_sum = nums[i]
temp_start = i
else:
current_sum += nums[i]
if current_sum > max_sum:
max_sum = current_sum
start = temp_start
end = i
return max_sum, start, end
9. 面试实战建议
根据我的面试经验,面试官常在这些地方设置考察点:
- 边界条件:空输入、全负数、超大输入等
- 空间优化:能否想到滚动数组等优化方法
- 问题变种:如要求返回具体子序列而不仅是长度
- 代码简洁性:能否写出优雅的Pythonic代码
建议在面试中:
- 先明确说出DP定义
- 边写代码边解释状态转移
- 主动讨论时间/空间复杂度
- 最后用测试用例验证
例如对于LCS问题,面试官可能会追问:"如果要返回一个具体的LCS而不仅是长度,该如何修改代码?" 这时候可以这样回答:
python复制def longestCommonSubsequence_with_path(text1: str, text2: str) -> str:
m, n = len(text1), len(text2)
dp = [[0]*(n+1) for _ in range(m+1)]
# 同时记录路径
path = [[None]*(n+1) for _ in range(m+1)]
for i in range(1, m+1):
for j in range(1, n+1):
if text1[i-1] == text2[j-1]:
dp[i][j] = dp[i-1][j-1] + 1
path[i][j] = 'diag'
else:
if dp[i-1][j] > dp[i][j-1]:
dp[i][j] = dp[i-1][j]
path[i][j] = 'up'
else:
dp[i][j] = dp[i][j-1]
path[i][j] = 'left'
# 回溯构建LCS字符串
res = []
i, j = m, n
while i > 0 and j > 0:
if path[i][j] == 'diag':
res.append(text1[i-1])
i -= 1
j -= 1
elif path[i][j] == 'up':
i -= 1
else:
j -= 1
return ''.join(reversed(res))
10. 扩展思考
这四道题展现了一个完整的DP学习路径:
- LCS(1143)展示了经典二维DP
- 不相交的线(1035)演示了问题转化
- 最大子序和(53)引入了一维DP
- 判断子序列(392)则展示了DP与双指针的选择
在LeetCode上,建议接下来尝试这些进阶题目:
-
- 编辑距离
-
- 不同的子序列
-
- 两个字符串的删除操作
-
- 最长重复子数组
动态规划的精髓在于"记住过去的结果来避免重复计算"。经过大量练习后,你会逐渐培养出对DP问题的直觉——看到新问题时能快速识别出是否适合用DP解决,以及如何定义状态。
