LeetCode刷题笔记 - 动态规划
概述
动态规划
DP三问:
状态表示什么?
状态如何转移?
初始状态是什么?
基础DP
状态用一维数组存储,状态转移比较明显,即题目有明显的”从状态A转向状态B“的描述。比如帕楼梯,从台阶\(i\)爬到台阶\(j\)。
背包 DP
基础理论
有
n
个物品和容量为W
的背包,每个物品有重量weight[i]
和价值value[i]
两种属性。要求选取若干物品放入背包,使得总价值最大且不超过背包重量。
0-1背包
在上述题目基础上,每个物品只能选一次。
二维DP
构造(n, W)
二维DP数组dp
。dp[i][j]
表示
在物品
0
到物品i
中选择,容量为j
的背包所能装下的最大价值为dp[i][j]
。
到达dp[i][j]
的方式有两种:
不放物品
i
则最大价值为只能选前
i-1
个物品,容量为j
的背包所能装下的最大价值放物品
i
则最大价值为
value[i]
加上选前i-1
个物品,容量为j-weight[i]
的背包所能装下的最大价值
对应的状态转移方程为
dp[i][j] = max(dp[i-1][j], dp[i-1][j-weight[i]] + value[i]])
对应的核心代码为
// We declare a 2d array of size (n+1, W) for convenience
for (int i = 0; i < n; i++) {
for (int j = W - weight[i]; j < W; j++) {
dp[i+1][j+1] = max(dp[i][j], dp[i][j-weight[i]] + value[i]])
}
}
滚动数组
考虑空间复杂度,我们可以用滚动数组代替二维数组。
对于当前的i
,dp[j]
表示选前i
个物品,容量j
的背包所能装下的最大价值。
对应的核心代码为
// We declare a 1d array of size W
for (int i = 0; i < n; i++) {
for (int j = W-1; j >= weight[i]; j--) {
dp[j] = max(dp[j], dp[j - weight[i]] + value[i]);
}
}
值得注意的是,里面一层循环是从后往前的。这是因为dp[i][j]
是由dp[i-1][j]
和dp[i-1][j-weight[i]]
决定的。从后往前更新一维数组时,在对i
更新到dp[j]
时,dp[j-weight[i]]
还没有被更新,仍然是i-1
时更新的结果,此时的dp[j-weight[i]]
表示“选前i-1
个物品,容量为j-weight[i]
的背包所能装下的最大价值”。
完全背包
完全背包中,物品可被选择无数次。
由上文滚动数组的分析,我们可以引申出完全背包的滚动数组解法:和0-1背包类似,但从前往后更新滚动数组。
这是因为从前往后更新,对i
更新到dp[j]
时,dp[j-weight[i]]
已经是对i
更新后的结果,其含义为“选前i
个物品,容量为j-weight[i]
的背包所能装下的最大价值”。
在上述题目基础上,每个物品可以选取无数次。
416. Partition Equal Subset Sum
思路:目标和tgt_sum
为nums
总和的一半。则可以把问题转换为对于容量为tgt_sum
的背包,让weight[i] = value[i] = nums[i]
。如果最大价值等于tgt_sum
,则表示可以等分集合。
1049. Last Stone Weight II
考虑题目给出的例1,石头相撞的过程如下
We can combine 2 and 4 to get 2, so the array converts to [2,7,1,8,1] then,
we can combine 7 and 8 to get 1, so the array converts to [2,1,1,1] then,
we can combine 2 and 1 to get 1, so the array converts to [1,1,1] then,
we can combine 1 and 1 to get 0, so the array converts to [1], then that's the optimal value.
可以表示成
Given [2, 7, 4, 1, 8, 1]
4 - 2 => [(4-2), 7, 1, 8, 1]
8 - 7 => [(4-2), 1, 1, (8-7)]
(4-2) - 1 = 2 - 1 => [(4-2-1), 1, (8-7)]
(4-2-1) - 1 = 1 - 1 => [(8-7)]
The entire process is the same as saying
4 - 2 - 1 - 1 + 8 - 7 = 1
上述过程可以被解释为“加或减每个数组中的数字,所能得到的最小非负值”。
这一过程也可以被解释为“分割数组为两个子数组,求两个子数组之和的最小差值”。这就回到了416的分割子集的思路。
494. Target Sum
题目可以这样理解:将数组分割成两个子数组,各自的和分别为x
和y
(不妨设x > y
)。求能满足x-y=target
的分割方法的数量。
已知x+y=sum(nums), x-y=target
,则可知y=(sum(nums)-target)/2
,题目可以等价为求出在数组中任选数字、其和为y
的选择方法数量。
dp[j]
表示选前i
个物品,可以填满容量为j
的背包的选择方法的数量。对应的状态转移方程为dp[i][j] = dp[i-1][j] + dp[i-1][j-weight[i]]
。
474. Ones and Zeroes
dp
是大小为(m+1, n+1)
的二维滚动数组。
对于当前的i
,dp[j][k]
表示在前i
个字符串里选择,能满足at most j 0's and n 1's in the subset
的最大子集大小。
状态转移方程为dp[j][k] = max(dp[j][k], dp[j-count_0[i]][k-count_1[i]])
,即选或不选strs[i]
得到的最大子集大小。
518. Coin Change II
比较明显的完全背包。一个注意的点是dp
数组得是uint64_t
,否则在存储中间结果时会integer overflow。
377. Combination Sum IV
很有意思的一个问题。如果求所有可能的排列 / 组合,那么就只能回溯。但这道题求的是排列的数量,就能DP。
另外,用DP可以求完全背包的排列数量和组合数量,其代码正好是相反的。
首先明确一下定义:排列(permutation)是有顺序的,组合(combination)是没有顺序的。(1, 5)
和(5, 1)
是同一个组合,不同的排列。
对于完全背包的组合数量,我们先循环物品,再循环背包。
for (int i = 0; i < nums.size(); i++) {
for (int j = nums[i]; j <= capacity; j++) {
dp[j] = dp[j] + dp[j - nums[i]];
}
}
这一写法会使最后的选择方案里,物品必然按照nums
的顺序排序。假如nums = {1, 2, 3, 4, 5}
,那么就不可能出现5
在1
前的方案。换句话说,对于属于统一组合的不同排列,我们只取按照物品排序和nums
相同的方案(这一方案显然是唯一的)。
对于完全背包的排列数量,我们先循环背包,再循环物品
for (int j = 0; j <= capacity; j++) {
for (int i = 0; i < nums.size() && nums[i] <= j; i++) {
dp[j] = dp[j] + dp[j - nums[i]];
}
}
这一写法则保证了在考虑dp[j]
时,dp[k], k < j
的方案数量时考虑了全部物品的方案数量。
322. Coin Change
At i
, dp[j]
is the fewest number of coins needed to reach amount j
considering coins[0]
to coins[i]
.
279. Perfect Squares
物品的重量是完全平方数,每个物品价值为1,背包总容量是n
,完全背包。
对于当前i
,dp[j]
表示在完全平方数1^2, 2^2, ... i^2
中选择,总和为j
,所需要的最小数量。
例如,对于i = 2
,dp[12] = 3
,对应的选择为4 + 4 + 4
;而对于i = 3
,dp[12] = 2
,对应的选择是4 + 9
。
状态转移方程为
for (int i = 1; i * i < n; i++) {
for (int j = i * i; j < dp.size(); j++) {
dp[j] = min(dp[j], dp[j - i*i] + 1);
}
}
139. Word Break
背包的物品是wordDict
中的单词,考虑到这道题要判断的是单词的组合(而非排列)能否构成字符串,我们需要按照组合背包的方法写代码,即先循环背包容量、后循环物品。
dp[j]
的含义为:wordDict
中的所有单词能否构成字符串s[:j]
。状态转移时需要判断s[:j-cur_word.size()]
能否被构成,以及s[j-cur_word: cur_word]
和cur_word
是否相等。
核心代码为
dp[0] = true;
for (int j = 1; j < dp.size(); j++) {
for (int i = 0; i < wordDict.size(); i++) {
if (dp[j]) {
// s[:j] can be constructed using words in wordDict[:i]
break;
}
const string& cur_word = wordDict[i];
if (j < cur_word.size()) {
continue;
}
const string& cur_substr = s.substr(j - cur_word.size(), cur_word.size());
// State Transition
dp[j] = dp[j - cur_word.size()] && (cur_word == cur_substr);
}
}
打家劫舍
198. House Robber
dp数组含义如下:
dp[i][0]
表示不打劫第i间房时,最大收益dp[i][1]
表示打劫第i间房时,最大收益
状态转移方程为
dp[i][0] = max(dp[i-1][0], dp[i-1][1]);
// Since we can't rob two adjacent houses, we must not rob house i-1 if we rob house i
dp[i][1] = dp[i-1][0] + nums[i-1];
213. House Robber II
在环上打劫,不能打劫相邻住户。可以分类处理问题。由于不能同时打劫第0和n家,我们可以将问题分类为
打劫第0家:只能打劫第0到n-1家。
不打劫第0家:只能打劫第1到n家。
337. House Robber III
在树上打劫,不能打劫在树上直接相连的住户。后序dfs遍历树,每个节点计算两个状态
不打劫该节点,考虑所有子节点和当前节点,最大收益
打劫该节点,考虑所有子节点和当前节点,最大收益
由于是后序遍历,考虑当前节点时,已经计算出左右字节点的两个状态,故可以推出当前状态。
状态转移方程为
vector<int> left_vals = postorder(cur->left);
vector<int> right_vals = postorder(cur->right);
return {
*max_element(left_vals.begin(), left_vals.end()) + *max_element(right_vals.begin(), right_vals.end()),
left_vals[0] + right_vals[0] + cur->val
};
股票
有通解。
子序列
72. Edit Distance
TODO:
647. Palindromic Substrings
这道题不能使用类似于“以i
结尾的子字符串”去做,找不到明显的递推关系。
回文的递推关系:对于q - p > 2
,如果s[p: q]
是回文,那么s[p-1: q+1]
是回文。
由此可得dp[i][j]
的含义为s[i-1: j]
(以s[i-1]
开始,s[j-1]
结束的子字符串)是否为回文,对应的状态转移方程为
// when 0 <= j-i < 2 (1 or 2 char, not applicable for recursion)
dp[i][j] = s[i-1] == s[j-1]
// when j-i > 2, s[i-1: j] depends on s[i: j-1]
dp[i][j] = (s[i-1] == s[j-1]) && dp[i+1][j-1]
值得注意,dp[i][j]
依赖于dp[i+1][j-1]
,所以更新顺序应该为i
从大到小,j
从小到大,且根据区间的定义,i <= j
。
516. Longest Palindromic Subsequence
基本思路和上题一样,dp[i][j]
表示的是区间,递推是从小区间推到大区间。
dp[i][j]
表示s[i-1: j]
的最长回文长度。
值得注意的是,对于s[i-1: j]
的最长回文长度,不同于上题,我们需要看三个子区间:s[i-1: j-1]
,s[i: j]
,如果s[i-1] == s[j+1]
,那还需要考虑s[i-1: j]
。比如说bbbaab
,在计算bbaa
的最长回文长度时,我们需要考虑bba
,baa
。只看中间的ba
是不够的。
由此可得状态转移方程为
dp[i][j] = max({
dp[i+1][j],
dp[i][j-1],
dp[i+1][j-1] + (s[i-1] == s[j-1]) * 2
});
与上题相同,更新顺序应该为i
从大到小,j
从小到大,且根据区间的定义,i <= j
。
区间 DP
树形 DP
树的定义是“无环无向连通图”。在树上DP,可以对树后序遍历,当前节点的状态由子节点状态推出。
968. Binary Tree Cameras
Hard题。虽然做了三个小时,但最后居然真的做出来了…
代码随想录里把这道题划分到了贪心,但我用dp也能做的出来。
等有精力了再研究一下贪心的解法…
我的整体思路是后序遍历整颗树,每个节点的状态由其直接子节点推出。
每个节点有三个状态
dp[node_ptr][0]
: 没有摄像头、未被子节点监视时,最小摄像头数dp[node_ptr][1]
: 没有摄像头,但被子节点监视时,最小摄像头数dp[node_ptr][2]
: 有摄像头时,最小摄像头数量
状态转移的方式如下
// cur has no cam and is not monitored
// => left and right has no cam, and they must be monitored by child
dp[cur][0] = dp[cur->left][1] + dp[cur->right][1];
// cur has no cam but is monitored by child
// = left or right or both have cam, and whichever has no cam must be monitored by child
dp[cur][1] = min({
dp[cur->left][2] + dp[cur->right][1],
dp[cur->left][1] + dp[cur->right][2],
dp[cur->left][2] + dp[cur->right][2]
});
// cur has cam
// min among all possible combination of states of left and right
dp[cur][2] =
*min_element(dp[cur->left].begin(), dp[cur->left].end())
+ *min_element(dp[cur->right].begin(), dp[cur->right].end())
+ 1
然而事情并没有这么简单。有一些情况下,是会存在非法的状态的。考虑以下树中的A节点。dp[A][0]
就是一个非法的状态。如果dp[A][0]
合法,那就意味着A没有摄像头,也没有子节点监视A,由此可知A、B、C都没有摄像头,这会导致B和C无法被任何节点监视到。
[A]
/ \
[B] [C]
为了解决这一问题,我们可以使用一个很大的数字表示非法状态。题干中节点数量范围为\([1,1000]\),摄像头数量最多为1000。所以我们可以用10000
表示非法状态。在初始化叶子节点时,dp[node][1]
就是非法状态,因为叶子节点没有子节点,不可能被子节点监视。因此,有以下代码
if (cur->left == nullptr || cur->right == nullptr) {
auto child = cur->left == nullptr ? cur->right : cur->left;
dp[cur][0] = dp[child][1];
dp[cur][1] = dp[child][2];
dp[cur][2] = *min_element(dp[child].begin(), dp[child].end()) + 1;
} else {
// 上述状态转移的代码
}