1. 动态规划专题精讲
今天咱们来啃动态规划领域的几块硬骨头——子序列相关的高频面试题。这些题目在各大厂的算法面试中出场率极高,但很多同学面对这类问题时总感觉无从下手。我将结合自己刷题和面试官的经验,带大家用动态规划的视角重新审视这些经典问题。
动态规划解题的核心在于状态定义和转移方程,而字符串问题往往需要二维DP数组来表示子问题的解。
1.1 题目概览与解题思路
我们先快速过一遍三个题目的基本要求:
-
115.不同的子序列:给定字符串s和t,计算s的子序列中等于t的个数。例如s="rabbbit",t="rabbit",返回3。
-
583. 两个字符串的删除操作:给定两个单词word1和word2,返回使word1和word2相同所需的最小删除次数。例如word1="sea",word2="eat",返回2。
-
72. 编辑距离:给定两个单词word1和word2,计算将word1转换成word2所需的最少操作次数(插入、删除、替换)。例如word1="horse",word2="ros",返回3。
虽然题目表述各异,但它们的核心都是字符串匹配与编辑问题。解决这类问题的通用思路是:
- 定义dp数组的含义(通常为二维)
- 确定base case(初始条件)
- 找出状态转移方程
- 确定遍历顺序
- 举例推导验证
2. 不同的子序列问题解析
2.1 问题重述与示例分析
题目要求计算字符串s中等于t的子序列数量。子序列的定义是:不改变字符相对顺序的情况下删除某些字符得到的新字符串。
以示例s="rabbbit",t="rabbit"为例:
- 删除s[3]得到"rabbit"
- 删除s[4]得到"rabbit"
- 删除s[5]得到"rabbit"
因此返回3。
2.2 DP数组定义与初始化
我们定义dp[i][j]表示s[0..i-1]的子序列中等于t[0..j-1]的个数。这样定义可以方便处理空字符串的情况。
初始化条件:
- dp[i][0] = 1:空字符串是任何字符串的子序列
- dp[0][j] = 0 (j>0):非空字符串不可能是空字符串的子序列
2.3 状态转移方程推导
考虑s[i-1]和t[j-1]的关系:
- 当s[i-1] == t[j-1]时:
- 可以选择使用s[i-1]匹配t[j-1],此时数量为dp[i-1][j-1]
- 也可以选择不使用s[i-1]匹配t[j-1],此时数量为dp[i-1][j]
- 因此dp[i][j] = dp[i-1][j-1] + dp[i-1][j]
- 当s[i-1] != t[j-1]时:
- 只能选择不使用s[i-1],因此dp[i][j] = dp[i-1][j]
2.4 代码实现与优化
python复制def numDistinct(s: str, t: str) -> int:
m, n = len(s), len(t)
dp = [[0] * (n + 1) for _ in range(m + 1)]
for i in range(m + 1):
dp[i][0] = 1
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] + dp[i-1][j]
else:
dp[i][j] = dp[i-1][j]
return dp[m][n]
空间优化:可以使用一维数组优化空间复杂度到O(n)。
注意:当输入规模很大时,结果可能超过普通整型范围,但在力扣的测试用例中不需要特别处理。
3. 两个字符串的删除操作
3.1 问题转化思路
这道题可以转化为求两个字符串的最长公共子序列(LCS)长度,然后用两个字符串的长度之和减去两倍的LCS长度。
例如word1="sea",word2="eat":
- LCS为"ea",长度2
- 删除次数 = (3 + 3) - 2*2 = 2
3.2 直接DP解法
也可以直接定义dp[i][j]为使word1[0..i-1]和word2[0..j-1]相同所需的最小删除次数。
状态转移方程:
- 当word1[i-1] == word2[j-1]时:dp[i][j] = dp[i-1][j-1]
- 否则:dp[i][j] = min(dp[i-1][j], dp[i][j-1]) + 1
初始化:
- dp[i][0] = i(删除word1的所有字符)
- dp[0][j] = j(删除word2的所有字符)
3.3 代码实现对比
python复制# 方法一:通过LCS转换
def minDistanceLCS(word1: str, word2: str) -> int:
m, n = len(word1), len(word2)
dp = [[0] * (n + 1) for _ in range(m + 1)]
for i in range(1, m + 1):
for j in range(1, n + 1):
if word1[i-1] == word2[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 m + n - 2 * dp[m][n]
# 方法二:直接DP
def minDistanceDirect(word1: str, word2: str) -> int:
m, n = len(word1), len(word2)
dp = [[0] * (n + 1) for _ in range(m + 1)]
for i in range(m + 1):
dp[i][0] = i
for j in range(n + 1):
dp[0][j] = j
for i in range(1, m + 1):
for j in range(1, n + 1):
if word1[i-1] == word2[j-1]:
dp[i][j] = dp[i-1][j-1]
else:
dp[i][j] = min(dp[i-1][j], dp[i][j-1]) + 1
return dp[m][n]
两种方法的时间复杂度都是O(mn),空间复杂度可以通过滚动数组优化到O(min(m,n))。
4. 编辑距离问题深度剖析
4.1 问题定义与操作说明
编辑距离是字符串相似度度量的重要指标,允许三种操作:
- 插入一个字符
- 删除一个字符
- 替换一个字符
每种操作的成本为1,要求计算最小总成本。
4.2 DP数组设计与状态转移
定义dp[i][j]为将word1[0..i-1]转换为word2[0..j-1]的最小编辑距离。
状态转移方程:
- 当word1[i-1] == word2[j-1]时:
dp[i][j] = dp[i-1][j-1](不需要操作) - 否则:
dp[i][j] = min(
dp[i-1][j] + 1, # 删除word1[i-1]
dp[i][j-1] + 1, # 插入word2[j-1]
dp[i-1][j-1] + 1 # 替换word1[i-1]为word2[j-1]
)
初始化:
- dp[i][0] = i(删除word1的所有字符)
- dp[0][j] = j(插入word2的所有字符)
4.3 代码实现与空间优化
python复制def minDistance(word1: str, word2: str) -> int:
m, n = len(word1), len(word2)
dp = [[0] * (n + 1) for _ in range(m + 1)]
for i in range(m + 1):
dp[i][0] = i
for j in range(n + 1):
dp[0][j] = j
for i in range(1, m + 1):
for j in range(1, n + 1):
if word1[i-1] == word2[j-1]:
dp[i][j] = dp[i-1][j-1]
else:
dp[i][j] = min(dp[i-1][j], dp[i][j-1], dp[i-1][j-1]) + 1
return dp[m][n]
空间优化版本(滚动数组):
python复制def minDistanceOpt(word1: str, word2: str) -> int:
m, n = len(word1), len(word2)
dp = [0] * (n + 1)
for j in range(n + 1):
dp[j] = j
for i in range(1, m + 1):
pre = dp[0]
dp[0] = i
for j in range(1, n + 1):
temp = dp[j]
if word1[i-1] == word2[j-1]:
dp[j] = pre
else:
dp[j] = min(dp[j], dp[j-1], pre) + 1
pre = temp
return dp[n]
5. 常见错误与调试技巧
5.1 边界条件处理
-
空字符串情况:
- 在初始化阶段要正确处理dp[0][0]、dp[i][0]和dp[0][j]
- 测试用例:s="", t="a";s="a", t=""
-
字符完全匹配情况:
- 确保当s[i-1]==t[j-1]时,状态转移正确包含dp[i-1][j-1]
5.2 遍历顺序问题
-
双重循环的顺序:
- 通常i在外层,j在内层
- 确保在计算dp[i][j]时,dp[i-1][j]、dp[i][j-1]和dp[i-1][j-1]已经计算完成
-
空间优化时的顺序:
- 使用滚动数组时,注意保存pre的值
- 内层循环可以从左到右或从右到左,但要保持一致
5.3 大数溢出问题
在"不同的子序列"问题中,当s和t很长且匹配方式很多时,结果可能非常大:
- Python不需要特别处理
- 在Java/C++中可能需要使用long类型
- 力扣的测试用例通常不会导致溢出
6. 实战应用与扩展思考
6.1 实际应用场景
-
文本相似度计算:
- 编辑距离可用于拼写检查、抄袭检测
- 在搜索引擎中用于查询建议
-
生物信息学:
- DNA序列比对
- 蛋白质序列分析
-
版本控制系统:
- 计算文件差异
- 合并冲突检测
6.2 题目变种与扩展
-
带权重的编辑距离:
- 不同操作(插入、删除、替换)有不同的成本
- 状态转移方程中的+1改为+weight
-
限制操作次数的编辑距离:
- 在DP状态中增加操作次数维度
- 用于近似字符串匹配
-
多字符串编辑距离:
- 扩展到三个或更多字符串的比较
- 需要更高维的DP数组
6.3 性能优化进阶
-
空间优化:
- 使用滚动数组将空间复杂度从O(mn)降到O(min(m,n))
- 在编辑距离问题中,可以进一步优化到O(n)
-
剪枝策略:
- 当当前最小操作次数已经超过已知最小值时提前终止
- 适用于有操作次数限制的情况
-
并行计算:
- 对角线计算法可以利用并行性
- 适用于超长字符串的比较
7. 总结与个人心得
经过这三个问题的系统训练,我总结出字符串DP问题的几个关键点:
-
状态定义要清晰明确,通常用dp[i][j]表示处理到某个位置时的最优解
-
初始化条件要全面考虑,特别是空字符串的情况
-
状态转移方程要分类讨论,考虑所有可能的操作选择
-
遍历顺序要确保子问题先于当前问题解决
在实际面试中,我建议按照以下步骤解题:
- 明确问题要求
- 举例说明理解题意
- 定义DP数组
- 推导状态转移方程
- 处理边界条件
- 编写代码并测试
最后分享一个调试技巧:在推导DP表格时,可以手动填写一个小规模的例子,这样能直观地验证状态转移方程的正确性。比如对于编辑距离问题,可以手动填写word1="horse"和word2="ros"的DP表格,逐步验证每个状态的计算过程。