1. 动态规划专题精讲:从公共子序列到最大子数组
作为一名算法工程师,我经常遇到需要处理序列匹配和子数组优化的问题。今天要讨论的四个题目——1143.最长公共子序列、1035.不相交的线、53.最大子序和以及392.判断子序列,正是动态规划领域的经典案例。这些题目看似独立,实则内在联系紧密,都是解决序列相关问题的利器。
在实际工程中,最长公共子序列(LCS)算法被广泛应用于版本控制系统(如Git的diff功能)、生物信息学中的DNA序列比对等领域。而不相交的线问题则与电路板布线、日程安排等实际问题密切相关。最大子序和更是金融分析、信号处理中的基础算法。理解这些问题的本质,能帮助我们在面对复杂需求时快速找到解决方案。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 1143.最长公共子序列:二维DP的经典应用
2.1 问题定义与状态转移
给定两个字符串text1和text2,返回这两个字符串的最长公共子序列的长度。子序列是指在不改变字符相对顺序的情况下删除某些字符(也可以不删除)后组成的新字符串。
例如:
text1 = "abcde", text2 = "ace"
输出:3
解释:最长公共子序列是 "ace",长度为3。
这个问题的DP状态定义非常经典:
- dp[i][j] 表示text1[0..i-1]和text2[0..j-1]的最长公共子序列长度
- 边界条件:dp[0][j] = 0 和 dp[i][0] = 0
- 状态转移方程:
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])
2.2 代码实现与空间优化
基础实现使用O(mn)空间:
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]
空间优化版(滚动数组):
python复制def longestCommonSubsequence(text1: str, text2: str) -> int:
m, n = len(text1), len(text2)
prev = [0]*(n+1)
curr = [0]*(n+1)
for i in range(1, m+1):
for j in range(1, n+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 = curr, prev
return prev[n]
注意:在实际面试中,建议先写出基础版本,再讨论优化方案。过早优化可能影响代码可读性。
3. 1035.不相交的线:LCS的变种问题
3.1 问题转化与理解
在二维平面上,我们将两个数组中的数字分别放在上下两条平行线上,要求画出连接相同数字的直线,且这些直线不能相交。这实际上就是求两个数组的最长公共子序列!
例如:
nums1 = [1,4,2], nums2 = [1,2,4]
输出:2
解释:可以画出两条不相交的线:[1,4]或[1,2]
这个问题的解法与LCS完全相同,因为不相交的线本质上就是保持元素相对顺序的子序列。这种问题转化的能力在算法设计中非常重要。
3.2 实际应用场景
不相交的线问题在以下场景中有实际应用:
- 电路板布线:避免导线交叉造成短路
- 日程安排:将任务分配给不同时间段的资源
- 生物信息学:蛋白质序列比对
理解问题背后的实际意义,能帮助我们更好地掌握算法本质。
4. 53.最大子序和:一维DP的经典案例
4.1 Kadane算法详解
给定一个整数数组nums,找到一个具有最大和的连续子数组(至少包含一个元素),返回其最大和。
例如:
nums = [-2,1,-3,4,-1,2,1,-5,4]
输出:6
解释:连续子数组[4,-1,2,1]的和最大,为6
Kadane算法的核心思想:
- dp[i]表示以nums[i]结尾的最大子数组和
- 状态转移方程:dp[i] = max(nums[i], dp[i-1] + nums[i])
- 最终结果是max(dp)
实现代码:
python复制def maxSubArray(nums: List[int]) -> int:
curr_max = global_max = nums[0]
for num in nums[1:]:
curr_max = max(num, curr_max + num)
global_max = max(global_max, curr_max)
return global_max
4.2 边界情况处理
在实际应用中需要注意:
- 全负数数组:应该返回最大的那个负数
- 空数组:根据题目要求处理(本题保证非空)
- 多个相同最大和:通常返回第一个出现的
经验分享:Kadane算法的时间复杂度是O(n),空间复杂度是O(1),是最优解法。贪心算法的思想在这里体现得非常明显。
5. 392.判断子序列:双指针与DP解法对比
5.1 双指针解法
给定字符串s和t,判断s是否为t的子序列。
例如:
s = "abc", t = "ahbgdc"
输出:true
双指针解法直观高效:
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)
时间复杂度O(n),空间复杂度O(1),是最优解法之一。
5.2 DP解法及其适用场景
虽然本题可以用LCS的思路解决(判断LCS长度是否等于s的长度),但在实际应用中:
- 双指针解法更高效
- DP解法适用于后续有大量s需要检查的情况(预处理t)
预处理t的DP解法:
python复制def isSubsequence(s: str, t: str) -> bool:
t = ' ' + t # 添加哨兵
# 预处理t:建立每个位置的下一个字符位置表
dp = [[0]*26 for _ in range(len(t))]
for i in range(len(t)-1, -1, -1):
for c in range(26):
if i < len(t)-1 and ord(t[i+1])-ord('a') == c:
dp[i][c] = i+1
else:
dp[i][c] = dp[i+1][c] if i < len(t)-1 else 0
# 检查s
i = 0
for c in s:
i = dp[i][ord(c)-ord('a')]
if i == 0:
return False
return True
这种解法预处理O(n*26),每次查询O(m),适合大量查询场景。
6. 动态规划问题解题框架总结
通过这四个题目,我们可以总结出解决DP问题的通用思路:
- 定义状态:明确dp数组的含义
- 确定转移方程:分析状态之间的关系
- 初始化:设置边界条件
- 确定遍历顺序:保证计算当前状态时所需的前置状态已计算
- 举例验证:用简单例子验证算法正确性
对于序列问题,特别要注意:
- 子序列与子数组的区别
- 状态定义通常与位置相关
- 空间优化技巧(滚动数组)
在实际编码面试中,建议:
- 先理清思路再编码
- 写出基础解法后再考虑优化
- 用测试案例验证边界条件
动态规划的学习需要大量练习,这四个题目代表了序列DP的典型模式。掌握它们后,面对更复杂的DP问题也能游刃有余。我在实际工作中发现,很多看似复杂的问题最终都能转化为这些经典模型的变种。理解问题本质比记忆解法更重要。
