LeetCode刷题笔记 - 动态规划


概述

动态规划

DP三问:

  • 状态表示什么?

  • 状态如何转移?

  • 初始状态是什么?

动态规划可以分为以下几类(参考这篇文章这篇文章)

基础DP

状态用一维数组存储,状态转移比较明显,即题目有明显的”从状态A转向状态B“的描述。比如帕楼梯,从台阶\(i\)爬到台阶\(j\)。

背包 DP

基础理论

n个物品和容量为W的背包,每个物品有重量weight[i]和价值value[i]两种属性。要求选取若干物品放入背包,使得总价值最大且不超过背包重量。

0-1背包

在上述题目基础上,每个物品只能选一次。

二维DP

构造(n, W)二维DP数组dpdp[i][j]表示

在物品0到物品i中选择,容量为j的背包所能装下的最大价值为dp[i][j]

到达dp[i][j]的方式有两种:

  1. 不放物品i

    则最大价值为只能选前i-1个物品,容量为j的背包所能装下的最大价值

  2. 放物品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]])
    }
}

滚动数组

考虑空间复杂度,我们可以用滚动数组代替二维数组。

对于当前的idp[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_sumnums总和的一半。则可以把问题转换为对于容量为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

题目可以这样理解:将数组分割成两个子数组,各自的和分别为xy(不妨设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)的二维滚动数组。

对于当前的idp[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},那么就不可能出现51前的方案。换句话说,对于属于统一组合的不同排列,我们只取按照物品排序和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,完全背包。

对于当前idp[j]表示在完全平方数1^2, 2^2, ... i^2中选择,总和为j,所需要的最小数量

例如,对于i = 2dp[12] = 3,对应的选择为4 + 4 + 4;而对于i = 3dp[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的最长回文长度时,我们需要考虑bbabaa。只看中间的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 {
    // 上述状态转移的代码
}