1. 动态规划专题精讲:字符串操作与编辑距离实战
作为一名刷过300+动态规划题的算法工程师,我深知字符串相关的DP问题是面试中最常出现的"拦路虎"。这次代码随想录训练营的55-57天内容,恰好覆盖了字符串处理的六大经典题型。我将结合自己在大厂面试中出题和解题的双重经验,带大家深度剖析这些题目背后的统一解题框架。
2. 核心题目解析与DP定义
2.1 判断子序列(392题)
这道题看似简单,却暗藏玄机。给定字符串s和t,判断s是否为t的子序列。双指针解法虽然直观,但DP解法才是理解后续难题的基础。
def isSubsequence(s: str, t: str) -> bool: dp = [[0]*(len(t)+1) for _ in range(len(s)+1)] for i in range(1, len(s)+1): for j in range(1, len(t)+1): if s[i-1] == t[j-1]: dp[i][j] = dp[i-1][j-1] + 1 else: dp[i][j] = dp[i][j-1] # 只能删除t的字符 return dp[-1][-1] == len(s)关键点在于dp[i][j]表示s[0:i]和t[0:j]的最长公共子序列长度。当字符不匹配时,只能选择删除t的字符(j前移),这与后续115题形成鲜明对比。
2.2 不同的子序列(115题)
这道hard题让很多同学头疼,其实只要理解状态转移的物理意义就能迎刃而解。求s中通过删除字符得到t的不同方式数目,需要特别注意初始化条件和递推关系。
def numDistinct(s: str, t: str) -> int: dp = [[0]*(len(s)+1) for _ in range(len(t)+1)] for j in range(len(s)+1): dp[0][j] = 1 # 空串是任何字符串的子序列 for i in range(1, len(t)+1): for j in range(1, len(s)+1): if t[i-1] == s[j-1]: dp[i][j] = dp[i-1][j-1] + dp[i][j-1] else: dp[i][j] = dp[i][j-1] return dp[-1][-1]关键技巧:当s[j-1]匹配t[i-1]时,可以选择匹配或者不匹配(相当于删除s的字符),这是与392题的本质区别。
3. 编辑距离系列问题
3.1 两个字符串的删除操作(583题)
这道题可以看作编辑距离的简化版,只允许删除操作。我发现很多同学会陷入一个误区——直接求最长公共子序列然后用总长度减去。其实更直观的DP定义是:
def minDistance(word1: str, word2: str) -> int: dp = [[0]*(len(word2)+1) for _ in range(len(word1)+1)] for i in range(len(word1)+1): dp[i][0] = i for j in range(len(word2)+1): dp[0][j] = j for i in range(1, len(word1)+1): for j in range(1, len(word2)+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[-1][-1]3.2 编辑距离(72题)
这是DP领域的经典问题,也是谷歌面试的高频题。完整的编辑距离需要考虑插入、删除、替换三种操作。我在面试候选人时,最看重的是能否说清楚状态转移方程的物理意义。
def minDistance(word1: str, word2: str) -> int: dp = [[0]*(len(word2)+1) for _ in range(len(word1)+1)] for i in range(len(word1)+1): dp[i][0] = i for j in range(len(word2)+1): dp[0][j] = j for i in range(1, len(word1)+1): for j in range(1, len(word2)+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], # 删除word1[i-1] dp[i][j-1], # 插入word2[j-1] dp[i-1][j-1] # 替换 ) + 1 return dp[-1][-1]实战经验:在面试白板编程时,建议先写出状态转移方程再写代码,这样即使代码有小错误,面试官也能理解你的思路。
4. 回文串专题
4.1 回文子串(647题)
这道题要求统计字符串中回文子串的数量。常规中心扩展法虽然简单,但DP解法更能锻炼思维。我推荐使用二维DP记录子串[i,j]是否为回文。
def countSubstrings(s: str) -> int: n = len(s) dp = [[False]*n for _ in range(n)] res = 0 for i in range(n-1, -1, -1): for j in range(i, n): if s[i] == s[j]: if j - i <= 1: res += 1 dp[i][j] = True elif dp[i+1][j-1]: res += 1 dp[i][j] = True return res4.2 最长回文子序列(516题)
与子串不同,子序列不要求连续。这道题的DP定义非常巧妙:dp[i][j]表示s[i..j]的最长回文子序列长度。
def longestPalindromeSubseq(s: str) -> int: n = len(s) dp = [[0]*n for _ in range(n)] for i in range(n): dp[i][i] = 1 for i in range(n-1, -1, -1): for j in range(i+1, n): if s[i] == s[j]: dp[i][j] = dp[i+1][j-1] + 2 else: dp[i][j] = max(dp[i+1][j], dp[i][j-1]) return dp[0][n-1]5. 动态规划解题方法论
通过这六道题的训练,我总结出字符串DP问题的通用解题框架:
- 明确DP数组含义:90%的困难来自于定义不清
- 确定初始条件:特别是空字符串的处理
- 分析状态转移:字符匹配/不匹配时的不同处理
- 考虑遍历顺序:i/j的递增或递减方向影响结果
- 验证边界条件:单字符、空串等特殊情况
常见错误排查表:
| 错误现象 | 可能原因 | 解决方案 |
|---|---|---|
| 数组越界 | 初始化不全 | 检查dp数组行列数是否+1 |
| 结果偏小 | 状态转移漏情况 | 重新分析字符匹配时的选择 |
| 结果偏大 | 重复计算 | 检查递推公式是否包含多余操作 |
| 超时 | 未优化空间 | 考虑状态压缩(如滚动数组) |
在面试场景中,建议按照以下步骤应对DP问题:
- 先用具体例子画出DP表格
- 口头解释状态转移逻辑
- 写出基础状态转移方程
- 讨论边界条件和初始化
- 最后编码实现
6. 进阶训练建议
掌握这些基础题型后,可以尝试以下变种题目提升能力:
- 最短回文串添加(214题)
- 回文分割II(132题)
- 正则表达式匹配(10题)
- 通配符匹配(44题)
我个人的训练心得是:每天坚持做2道DP题,一个月后会有质的飞跃。建议准备一个错题本,记录每道题的DP定义和状态转移方程,定期复习。对于字符串DP问题,要特别注意画图辅助理解,推荐使用二维表格可视化递推过程。