上一章讲了动态对话的一些 Easy
和 Medium
难度的题,如果会做这些题目,远远谈不上掌握了动态规划,最多只能算“略懂皮毛”。
这里我们挑几个更复杂的动规题目,全部是 Hard
难度的题,并且全部都是双序列问题,能把这些题目做到 bug free 才算是 基本掌握了动态规划。
第一题
题目地址:https://leetcode.com/problems/edit-distance/
大意就是对一个单词进行最少的操作变成另一个单词,求最少的操作次数。
先分析题目,为什么这一题会用DP来解。首先看到 求最少操作次数,这种十有八九就是DP,然后分析题目,发现其实很容易把这个问题分解为若干个小问题来解,并且每一步都依赖上一步的结果。
还是针对前面说的两个难点:
1, 如何定义 dp[i][j]
dp[i][j] 我们可以有两种最常见的定义:
- word1 的 0~i 个字符变成 word2 的 0~j 个字符需要的最少操作次数
- word1 的前 i 个 字符变成 word2 的前 j 个字符需要的最少操作次数
这里我们会选择第二种定义。因为其实初始值不是有一个字符,而是一个字符都没有,如果用第一种定义,初始值0其实就代表了第一个字符,那么我们就不好处理一个字符都没有的情况。因此我们选择第二种定义。
2,如何求解 dp[i][j]
显然 dp[i][[j]
依赖于前面的结果,并且和当前 word1[i]
和 word2[j]
是否相等有关,我们来分情况讨论
word1[i-1] !== word2[j-1]
,这种情况下,我们有三种选择可以做:在word1上删除第i个,在word2上删除第j个,或者把word1[i]替换成word2[j],这三种情况都需要额外的一步操作,所以我们只需要比较这三种情况所依赖的前提条件的大小即可,即求 Math.min(dp[i-1][j], dp[i][j-1], dp[i-1][j-1])word1[i-1] === word2[j-1]
,这种情况会简单一点,因为最后一个已经相等了,所以不需要额外的操作,所以其实就和 dp[i-1][j-1] 是一样的。
然后是第三个难点,如何设置初始值,没有特别强调这个不是因为它不重要,而是想清楚了前两个问题之后,自然就知道怎么设置初始值了。其实这里就是对 i===0
和 j===0
的情况设置初始值。
完整的代码如下:
var minDistance = function(word1, word2) {
var dp = [];
for(var i=0;i<=word1.length;i++) {
var row = [];
for(var j=0;j<=word2.length;j++) {
if(i===0) row.push(j);
else if(j===0) row.push(i);
else row.push(0);
}
dp.push(row);
}
for(i=1;i<=word1.length;i++) {
for(j=1;j<=word2.length;j++) {
if(word2[j-1] === word1[i-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[word1.length][word2.length];
};
可能有些人对 相等的情况下 dp[i][j] = dp[i-1][j-1];
不太放心,感觉可能这个不是最优解,那么没关系,这里也可以把各种情况都比较一下取一个最小值。事实上会发现 dp[i-1][j-1]
永远是最小值,其实原因就是对两个相同的字符不做任何操作总是最优方案。
第二题
题目地址:https://leetcode.com/problems/distinct-subsequences/
很经典的一个子序列问题,其实仔细想和上一题挺像的。通过上面一题可以看出,对这种两个序列的问题,基本上 dp[i][j]
的定义都是 前 i,j 个元素
而不是 第 i, j 个元素
,因为我们的起始点总是没有元素的情况,而 第 0,0个元素
其实两个序列各有一个元素。
所以这一题我们依然沿用这个定义。
那么怎么求解 dp[i][j]呢,显然依然要分情况讨论:
T[i-1] === S[j-1]
这种情况下,分两种情况,取s[j] 和 不取 S[j],为什么可以不取 S[j] ? 因为 可能j前面还有字母和 T[i]相等的。两种情况分别是dp[i-1][j-1]
和dp[i][j-1]
。T[i-1] !== S[j-1]
只有一种可能了,就是不取 S[j],也就是 dp[i][j-1]。
代码如下:
var numDistinct = function(s, t) {
var dp = [];
for(var i=0;i<=t.length;i++) {
var row = [];
for(var j=0;j<=s.length;j++) {
if(i===0) row.push(1);
else row.push(0);
}
dp.push(row);
}
for(i=1;i<=t.length;i++) {
for(j=i;j<=s.length;j++) {
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[t.length][s.length];
};
第三题
这个题目猛一看会比上面的两个难一些,因为他有三个字符串。我们需要用 dp[i][j][k] 表示s1的前i个字符和s2的前j个字符组成s3的前k个字符的吗,这样时间复杂度直接上升到 O(n^3) 显然是不行的。
仔细看因为其实 k===i+j,所以其实我们只需要这样定义就行了:
dp[i][j] 表示s1的前i个字符和s2的前j个字符组成s3的前i+j个字符
那么这个定义好了之后,我们怎么求解 dp[i][j]。其实因为是按顺序交叉,所以 s3的最后一个字符要么来自 s1 要么来自 s2,分情况讨论
s1[i-1] === s3[i+j-1] && s2[j-1] === s3[i+j-1]
,双方最后一个字符都相等,s3的最后一个字符即可以从s1取也可以从s2取,那么只要有一种情况满足就行了就行了 dp[i][j] = dp[i-1][j] || dp[i][j-1];s1[i-1] === s3[i+j-1]
,只能从s1取, dp[i][j] = dp[i-1][j];s2[i-1] === s3[i+j-1]
,只能从s2取, dp[i][j] = dp[i][j-1];- dp[i][j] = false;
另外这一题特别要注意初始化的问题,dp[0][j] 和 dp[i][0] 的初始化都要注意。
代码如下:
var isInterleave = function(s1, s2, s3) {
if(s3.length !== (s1.length + s2.length)) return false;
if(s3.length === 0) return true;
var dp = [];
for(var i=0;i<=s1.length;i++) {
var row = [];
for(var j=0;j<=s2.length;j++) {
if(i===0) row.push(s2.slice(0,j) === s3.slice(0,i+j));
else if(j===0) row.push(s1.slice(0,i) === s3.slice(0,i+j));
else row.push(false);
}
dp.push(row);
}
for(i=1;i<=s1.length;i++) {
for(j=1;j<=s2.length;j++) {
if(s1[i-1] === s3[i+j-1] && s2[j-1] === s3[i+j-1]) {
dp[i][j] = dp[i-1][j] || dp[i][j-1];
} else if(s1[i-1] === s3[i+j-1]) {
dp[i][j] = dp[i-1][j];
} else if(s2[j-1] === s3[i+j-1]) {
dp[i][j] = dp[i][j-1];
} else {
dp[i][j] = false;
}
}
}
return dp[s1.length][s2.length];
};
除了双序列都是 hard 难度的之外,下一章我们再讲一下其他几种 hard 难度的动规题。