"动态规划像养猫,刚开始抓不住,熟了之后,喵喵喵全是套路!"
1. 判断子序列:DP 的敲门砖
问题描述
给定字符串 s 和 t,判断 s 是否为 t 的子序列。
基础解法:双指针
js
var isSubsequence = function(s, t) {
let index = 0;
for (let i = 0; i < t.length; i++) {
if (s[index] === t[i]) index++;
}
return index === s.length;
};
思维可视化
想象你在看一场电影(t),而你的小伙伴(s)只关心某些镜头。你只要顺着电影播放,帮他数他关心的镜头出现了几次,全部出现就 OK!
动态规划解法
状态定义
- dp[i][j]:s 前 i 个字符和 t 前 j 个字符的最长公共子序列长度
状态转移
- 如果 s[i-1] === t[j-1],则 dp[i][j] = dp[i-1][j-1] + 1
- 否则 dp[i][j] = dp[i][j-1]
代码实现
js
var isSubsequence = function(s, t) {
let dp = Array.from({length: s.length + 1}, () => new Array(t.length + 1).fill(0));
for (let i = 1; i <= s.length; i++) {
for (let j = 1; j <= t.length; j++) {
if (s[i-1] === t[j-1]) dp[i][j] = dp[i-1][j-1] + 1;
else dp[i][j] = dp[i][j-1];
}
}
return dp[s.length][t.length] === s.length;
};
空间优化
js
var isSubsequence = function(s, t) {
let dp = new Array(t.length + 1).fill(0);
for (let i = 1; i <= s.length; i++) {
let pre = dp[0];
for (let j = 1; j <= t.length; j++) {
let temp = dp[j];
if (s[i-1] === t[j-1]) dp[j] = pre + 1;
else dp[j] = dp[j-1];
pre = temp;
}
}
return dp[t.length] === s.length;
};
动态规划解法可视化
以 s = "abc",t = "ahbgdc" 为例,上述代码执行过程中 DP 表格的变化如下:
空 | a | h | b | g | d | c | |
---|---|---|---|---|---|---|---|
空 | 0 | 0 | 0 | 0 | 0 | 0 | 0 |
a | 0 | 1 | 1 | 1 | 1 | 1 | 1 |
b | 0 | 1 | 1 | 2 | 2 | 2 | 2 |
c | 0 | 1 | 1 | 2 | 2 | 2 | 3 |
表格说明:
- 浅蓝色单元格:初始值为 0
- 绿色单元格:字符匹配时,值为左上角单元格值 + 1,比如当 i=1、j=1 时,s [0] 与 t [0] 都是 'a',所以 dp [1][1] = dp [0][0] + 1 = 1
- 黄色单元格:字符不匹配时,值继承自左侧单元格,例如 i=1、j=2 时,s [0] 是 'a',t [1] 是 'h',不匹配,所以 dp [1][2] = dp [1][1] = 1
最终 dp[3][6] = 3 等于 s 的长度,因此 s 是 t 的子序列。
思考时刻
如果把 "子序列" 换成 "子串",你觉得状态转移会发生什么变化?(提示:子串要求连续)
2. 不同的子序列:计数型 DP
问题描述
给定 s 和 t,问 t 在 s 中作为子序列出现了多少次?
动态规划解法
状态定义
- dp[i][j]:s 前 i 个字符中出现 t 前 j 个字符的子序列个数
状态转移
- 如果 s[i-1] === t[j-1],dp[i][j] = dp[i-1][j-1] + dp[i-1][j]
- 否则 dp[i][j] = dp[i-1][j]
代码实现
js
var numDistinct = function(s, t) {
const m = s.length, n = t.length;
let dp = Array.from({length: m + 1}, () => new Array(n + 1).fill(0));
for (let i = 0; i <= m; i++) dp[i][0] = 1;
for (let j = 1; j <= n; j++) dp[0][j] = 0;
for (let i = 1; i <= m; i++) {
for (let j = 1; j <= n; j++) {
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];
};
空间优化
js
var numDistinct = function(s, t) {
const m = s.length, n = t.length;
let dp = new Array(n + 1).fill(0);
dp[0] = 1;
for (let i = 1; i <= m; i++) {
for (let j = n; j >= 1; j--) {
if (s[i-1] === t[j-1]) dp[j] = dp[j-1] + dp[j];
}
}
return dp[n];
};
动态规划解法可视化
以 s = "babgbag",t = "bag" 为例,DP 表格的填充过程如下:
空 | b | a | g | |
---|---|---|---|---|
空 | 1 | 1 | 1 | 1 |
b | 1 | 2 | 1 | 1 |
a | 1 | 2 | 3 | 1 |
b | 1 | 3 | 3 | 1 |
g | 1 | 3 | 3 | 4 |
b | 1 | 4 | 3 | 4 |
a | 1 | 4 | 7 | 4 |
g | 1 | 4 | 7 | 11 |
表格说明:
- 浅蓝色单元格:初始值,第一行始终为 1,因为空字符串在任何字符串中作为子序列出现的次数都是 1
- 绿色单元格:字符匹配时,dp[i][j] = dp[i-1][j-1] + dp[i-1][j],比如 i=2、j=2 时,s [1] 与 t [1] 都是 'a',所以 dp [2][2] = dp [1][1] + dp [1][2] = 2 + 1 = 3
- 黄色单元格:字符不匹配时,dp[i][j] = dp[i-1][j],例如 i=3、j=2 时,s [2] 是 'b',t [1] 是 'a',不匹配,所以 dp [3][2] = dp [2][2] = 3
最终结果为 11,表示 t 在 s 中作为子序列出现了 11 次。
思考时刻
如果 s = "babgbag",t = "bag",你能手动画出 DP 表的变化过程吗?(建议尝试!)
3. 两个字符串的删除操作:LCS 的妙用
问题描述
给定 word1 和 word2,每次可以删除任意一个字符,问最少几步能让两个字符串相同?
解法一:最长公共子序列(LCS)
思路
- 最长公共子序列 LCS 部分保留,其余都删掉
- 答案 = word1.length + word2.length - 2 * LCS长度
代码实现
js
var minDistance = function(word1, word2) {
const m = word1.length, n = word2.length;
let dp = Array.from({length: m + 1}, () => new Array(n + 1).fill(0));
for (let i = 1; i <= m; i++) {
for (let j = 1; j <= n; j++) {
if (word1[i-1] === word2[j-1]) dp[i][j] = dp[i-1][j-1] + 1;
else dp[i][j] = Math.max(dp[i-1][j], dp[i][j-1]);
}
}
return m + n - 2 * dp[m][n];
};
解法一:最长公共子序列(LCS)可视化
以 word1 = "sea",word2 = "eat" 为例,LCS 的 DP 表格如下:
空 | e | a | t | |
---|---|---|---|---|
空 | 0 | 0 | 0 | 0 |
s | 0 | 0 | 0 | 0 |
e | 0 | 1 | 1 | 1 |
a | 0 | 1 | 2 | 2 |
从表格中可知 LCS 长度为 2("ea"),因此最少删除次数为 3 + 3 - 2*2 = 2,这与代码计算结果一致。
解法二:直接 DP
状态定义
- dp[i][j]:word1 前 i 个字符和 word2 前 j 个字符变成相同所需的最小删除次数
状态转移
- 如果 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
代码实现
js
var minDistance = function(word1, word2) {
const m = word1.length, n = word2.length;
let dp = Array.from({length: m + 1}, () => new Array(n + 1).fill(0));
for (let i = 0; i <= m; i++) dp[i][0] = i;
for (let j = 0; j <= n; j++) dp[0][j] = j;
for (let i = 1; i <= m; i++) {
for (let j = 1; j <= n; j++) {
if (word1[i-1] === word2[j-1]) dp[i][j] = dp[i-1][j-1];
else dp[i][j] = Math.min(dp[i-1][j], dp[i][j-1]) + 1;
}
}
return dp[m][n];
};
空间优化
js
var minDistance = function(word1, word2) {
const m = word1.length, n = word2.length;
let dp = new Array(n + 1).fill(0);
for (let j = 0; j <= n; j++) dp[j] = j;
for (let i = 1; i <= m; i++) {
let pre = dp[0];
dp[0] = i;
for (let j = 1; j <= n; j++) {
let temp = dp[j];
if (word1[i-1] === word2[j-1]) dp[j] = pre;
else dp[j] = Math.min(dp[j], dp[j-1]) + 1;
pre = temp;
}
}
return dp[n];
};
解法二:直接 DP 可视化
同样以 word1 = "sea",word2 = "eat" 为例,直接 DP 的表格如下:
空 | e | a | t | |
---|---|---|---|---|
空 | 0 | 1 | 2 | 3 |
s | 1 | 2 | 3 | 4 |
e | 2 | 1 | 2 | 3 |
a | 3 | 2 | 1 | 2 |
表格说明:
- 第一行和第一列:初始化为字符串长度,因为将一个空字符串和一个长度为 k 的字符串变成相同,需要删除 k 个字符
- 绿色单元格:字符匹配时,值等于左上角单元格值,比如 i=3、j=2 时,word1 [2] 与 word2 [1] 都是 'a',所以 dp [3][2] = dp [2][1] = 1
- 黄色单元格:字符不匹配时,值为上方或左侧单元格最小值 + 1,例如 i=2、j=1 时,word1 [1] 是 'e',word2 [0] 是 'e',匹配,所以 dp [2][1] = dp [1][0] = 1
最终结果为 2,与 LCS 方法得到的答案一致。
思考时刻
你能用自己的话解释 "LCS 法" 和 "直接 DP 法" 在本题的本质区别吗?哪个更容易理解?
4. 编辑距离:动态规划的巅峰对决
问题描述
给定 word1 和 word2,可以插入、删除、替换字符,问最少几步能将 word1 变成 word2?
状态定义
- dp[i][j]:word1 前 i 个字符变成 word2 前 j 个字符的最小编辑次数
状态转移
- 如果 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], dp[i-1][j-1]) + 1
-
- dp[i-1][j]:删除
-
- dp[i][j-1]:插入
-
- dp[i-1][j-1]:替换
代码实现
js
var minDistance = function(word1, word2) {
let m = word1.length, n = word2.length;
let dp = Array.from({length: m + 1}, () => new Array(n + 1).fill(0));
for (let i = 0; i <= m; i++) dp[i][0] = i;
for (let j = 0; j <= n; j++) dp[0][j] = j;
for (let i = 1; i <= m; i++) {
for (let j = 1; j <= n; j++) {
if (word1[i-1] === word2[j-1]) dp[i][j] = dp[i-1][j-1];
else dp[i][j] = Math.min(dp[i-1][j], dp[i][j-1], dp[i-1][j-1]) + 1;
}
}
return dp[m][n];
};
空间优化
js
var minDistance = function(word1, word2) {
let m = word1.length, n = word2.length;
let dp = new Array(n + 1).fill(0);
for (let j = 0; j <= n; j++) dp[j] = j;
for (let i = 1; i <= m; i++) {
let pre = dp[0];
dp[0] = i;
for (let j = 1; j <= n; j++) {
let temp = dp[j];
if (word1[i-1] === word2[j-1]) dp[j] = pre;
else dp[j] = Math.min(dp[j], dp[j-1], pre) + 1;
pre = temp;
}
}
return dp[n];
};
动态规划解法可视化
以 word1 = "horse",word2 = "ros" 为例,编辑距离的 DP 表格如下:
空 | r | o | s | |
---|---|---|---|---|
空 | 0 | 1 | 2 | 3 |
h | 1 | 1 | 2 | 3 |
o | 2 | 2 | 1 | 2 |
r | 3 | 2 | 2 | 2 |
s | 4 | 3 | 3 | 2 |
e | 5 | 4 | 4 | 3 |
表格说明:
- 第一行和第一列:初始化为字符串长度,因为将一个空字符串编辑成一个长度为 k 的字符串需要 k 次插入操作
- 绿色单元格:字符匹配时,值等于左上角单元格值,比如 i=3、j=1 时,word1 [2] 与 word2 [0] 都是 'r',所以 dp [3][1] = dp [2][0] = 2
- 黄色单元格:字符不匹配时,值为上方、左侧或左上角单元格最小值 + 1,例如 i=2、j=2 时,word1 [1] 是 'o',word2 [1] 是 'o',匹配,所以 dp [2][2] = dp [1][1] = 1
最终结果为 3,表示将 "horse" 转换为 "ros" 最少需要 3 步操作:
- 将 'h' 替换为 'r'(1 步)
- 删除 'r'(2 步)
- 将 'e' 替换为's'(3 步)
思考时刻
如果编辑距离只允许 "插入" 和 "删除",还能用上面的 DP 吗?你会怎么修改状态转移?
总结与彩蛋
- 子序列问题是 DP 的入门,编辑距离是 DP 的高阶。
- 只要掌握了 "状态定义 + 状态转移 + 初始化 + 遍历顺序",DP 就不再是洪水猛兽。
- 动态规划的世界,套路虽多,但本质不变:拆解问题,复用子问题结果,勇敢画表格!
"DP 的路上,愿你不再迷路,遇到难题,先画表格!"
------ 你的 DP 小助手
这些图表直观展示了 DP 数组的填充过程,帮助你理解每个状态值的来源和状态转移的逻辑。实际解题时,建议自己动手绘制这些表格,这是掌握动态规划思想的有效方法!