1. 动态规划专题精讲
今天咱们来啃动态规划这块硬骨头,重点突破四个经典问题:最长公共子序列(LCS)、不相交的线、最大子数组和以及判断子序列。这几个问题看似独立,实则暗藏玄机,都是动态规划思想在不同场景下的经典应用。作为刷过300+动态规划题的过来人,我把这些问题的解题套路和易错点都给你整理好了。
1.1 问题共性分析
这四个问题都具备动态规划的两个关键特征:
- 最优子结构:问题的最优解包含子问题的最优解
- 重叠子问题:递归算法会反复计算相同的子问题
以1143.最长公共子序列为例,当我们比较"abcde"和"ace"时:
- 最后一个字符'e'相同 → LCS长度 = "abcd"和"ac"的LCS + 1
- 最后一个字符不同 → LCS长度 = max("abcde"和"ac"的LCS, "abcd"和"ace"的LCS)
这种自顶向下的分解方式,正是动态规划的精髓所在。
2. 最长公共子序列(LCS)实战
2.1 状态定义与转移方程
对于字符串text1和text2,定义dp[i][j]表示text1[0..i-1]和text2[0..j-1]的LCS长度。转移方程分两种情况:
python复制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])
注意:这里i,j表示长度,所以对应字符索引要减1,这是新手最容易出错的地方
2.2 代码实现与优化
标准实现时间复杂度O(mn),空间复杂度O(mn)。可以通过滚动数组优化到O(min(m,n)):
python复制def longestCommonSubsequence(text1: str, text2: str) -> int:
m, n = len(text1), len(text2)
dp = [[0]*(n+1) for _ in range(2)] # 只需两行
for i in range(1, m+1):
for j in range(1, n+1):
if text1[i-1] == text2[j-1]:
dp[i%2][j] = dp[(i-1)%2][j-1] + 1
else:
dp[i%2][j] = max(dp[(i-1)%2][j], dp[i%2][j-1])
return dp[m%2][n]
3. 不相交的线问题解析
3.1 问题转化技巧
1035.不相交的线看起来是个几何问题,实则就是LCS的变种。因为连线不相交的条件等价于保持相对顺序,这正是子序列的定义。
示例:
code复制nums1 = [1,4,2]
nums2 = [1,2,4]
最大连线数就是LCS([1,4,2], [1,2,4]) = 2(连线1-1和4-4或1-1和2-2)
3.2 实现差异点
虽然解法与LCS相同,但要注意输入是数字数组而非字符串:
python复制def maxUncrossedLines(nums1: List[int], nums2: List[int]) -> int:
m, n = len(nums1), len(nums2)
dp = [[0]*(n+1) for _ in range(m+1)]
for i in range(1, m+1):
for j in range(1, n+1):
if nums1[i-1] == nums2[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]
4. 最大子数组和问题
4.1 贪心与动态规划的选择
53.最大子数组和可以用贪心或动态规划解决。贪心解法更直观:
python复制def maxSubArray(nums: List[int]) -> int:
max_sum = current_sum = nums[0]
for num in nums[1:]:
current_sum = max(num, current_sum + num)
max_sum = max(max_sum, current_sum)
return max_sum
但为了保持动态规划专题的一致性,我们看DP解法:
4.2 动态规划解法
定义dp[i]为以nums[i]结尾的最大子数组和:
python复制def maxSubArray(nums: List[int]) -> int:
dp = [0] * len(nums)
dp[0] = nums[0]
for i in range(1, len(nums)):
dp[i] = max(nums[i], dp[i-1] + nums[i])
return max(dp)
关键点:dp[i]只与dp[i-1]有关,因此可以优化空间到O(1),实际上就是前面的贪心解法
5. 判断子序列问题
5.1 双指针解法
392.判断子序列最简单的解法是双指针:
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 动态规划预处理
当需要多次检查不同的s时,可以预处理t,建立字符位置索引:
python复制from bisect import bisect_right
from collections import defaultdict
class SubsequenceChecker:
def __init__(self, t: str):
self.char_indices = defaultdict(list)
for i, char in enumerate(t):
self.char_indices[char].append(i)
def isSubsequence(self, s: str) -> bool:
prev = -1
for char in s:
indices = self.char_indices[char]
i = bisect_right(indices, prev)
if i == len(indices):
return False
prev = indices[i]
return True
这种方法预处理O(n),每次查询O(mlogn),适合s短t长且多次查询的场景。
6. 动态规划问题对比分析
| 问题编号 | 问题名称 | 状态定义 | 转移方程特点 | 空间优化方式 |
|---|---|---|---|---|
| 1143 | 最长公共子序列 | dp[i][j]表示LCS长度 | 字符匹配时斜向转移 | 滚动数组 |
| 1035 | 不相交的线 | 同1143 | 同1143 | 同1143 |
| 53 | 最大子数组和 | dp[i]表示以i结尾的最大和 | 只与前一个状态有关 | 单变量替代 |
| 392 | 判断子序列 | 通常不用DP | 双指针或二分查找 | 预处理字符位置索引 |
7. 常见错误与调试技巧
-
索引越界:在LCS问题中,dp数组大小应为(len1+1)*(len2+1),但访问字符时要记得-1
-
初始化错误:dp[0][j]和dp[i][0]都应初始化为0,表示空字符串的LCS长度为0
-
状态转移混淆:最大子数组和问题中,dp[i]必须包含nums[i],这与LCS不同
-
空间优化陷阱:使用滚动数组时,模运算要正确,建议打印中间结果验证
调试时可以打印dp表格:
python复制def print_dp(dp, text1, text2):
print(" " + " ".join([' ']+list(text2)))
for i in range(len(dp)):
row = [str(x) for x in dp[i]]
prefix = ' ' if i==0 else text1[i-1]
print(prefix + " " + " ".join(row))
8. 进阶挑战与扩展思考
-
输出具体子序列:修改LCS代码,不仅返回长度,还返回具体的子序列
-
空间优化极限:对于LCS问题,能否优化到O(min(m,n))空间?
-
不相交线变种:如果允许k次交叉,如何修改算法?
-
最大子数组和变种:如何返回子数组的起止位置?
以输出LCS具体序列为例:
python复制def longestCommonSubsequenceWithPath(text1: str, text2: str) -> str:
m, n = len(text1), len(text2)
dp = [[0]*(n+1) for _ in range(m+1)]
path = [[0]*n for _ in range(m)]
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-1][j-1] = 1 # 斜向
else:
if dp[i-1][j] > dp[i][j-1]:
dp[i][j] = dp[i-1][j]
path[i-1][j-1] = 2 # 向上
else:
dp[i][j] = dp[i][j-1]
path[i-1][j-1] = 3 # 向左
# 回溯路径
res = []
i, j = m-1, n-1
while i >=0 and j >=0:
if path[i][j] == 1:
res.append(text1[i])
i -= 1
j -= 1
elif path[i][j] == 2:
i -= 1
else:
j -= 1
return ''.join(reversed(res))
这个实现虽然增加了空间复杂度,但能直观展示动态规划的回溯过程。在实际面试中,根据问题要求选择是否输出具体路径。