接着按照Labuladong来刷动态规划相关的算法题
1.通过01背包问题来理清动态规划的解题思路
1)对题目进行分析
- 题目:选择价值尽可能多的物品放入背包
- 状态:背包的容量,可选择的物品
- 选择:是否装进背包
- dp数组用于描述其状态,dp[i][w]:表示对于前i个物品,当前背包容量为w,该情况下可以装的最大价值
- 状态转移:第 i 个物品装入背包
dp[i][w]
应该等于dp[i-1][w-wt[i-1]] + val[i-1]
第 i 个物体没有装入背包dp[i][w]
应该等于dp[i-1][w]
2)整体思路
- 1)首先处理特殊情况,例如背包容量为0,或者物体列表为空,这样可以直接返回
- 2)初始化dp table
- 3)对dp表的base case赋值,一般是dp[0][..]和dp[..][0]的情况
- 4)双层的循环内,表示状态转移的关系
- 5)返回dp表最后的值
int dp[N+1][W+1]
dp[0][..] = 0
dp[..][0] = 0
for i in [1..N]:
for w in [1..W]:
dp[i][w] = max(
把物品 i 装进背包,
不把物品 i 装进背包
)
return dp[N][W]
3)该问题中需要特别注意的细节
int knapsack(int W, int N, vector<int>& wt, vector<int>& val) {
// vector 全填入 0,base case 已初始化
vector<vector<int>> dp(N + 1, vector<int>(W + 1, 0)); //为什么大小是n+1,w+1,为什么要加1
for (int i = 1; i <= N; i++) {
for (int w = 1; w <= W; w++) { #循环的起始和末端如何界定的?
if (w - wt[i-1] < 0) {
// 当前背包容量装不下,只能选择不装入背包
dp[i][w] = dp[i - 1][w];
} else {
// 装入或者不装入背包,择优
dp[i][w] = max(dp[i - 1][w - wt[i-1]] + val[i-1],
dp[i - 1][w]);
}
}
}
return dp[N][W]; #最后为什么返回n,w?
}
- 1)初始化时为什么维数要加1?
因为从装0件物品,到装n件(一共有n件物品)一共有n+1个数,所以建立dp表时的维度为长度+1
- 2)双层循环时的起始和结束如何确定?
根据状态转移方程,如果其中有i-1,那么起始需要从1开始而不是0,也可以从另外一个角度理解,在base case中已经给定了dp[0][..], dp[..][0]的值,所以后面的值是根据base case计算的,所以是从1开始,到n结束。
- 3)为什么返回数组每维最后的?
主要看最后得到的结果是什么
2. 背包类题目总结:
1)416.分割等和子集:背包大小为sum/2,看数组里面的数字能否找到和等于sum/2的。dp[i][w] 对于容量为w的背包,前i件物品可以将其装满,则dp[i][w] = true, 否则为false。 状态转移方程仍然是看第i件物品是否要装入背包,注意装入前需要判断能否装的下。
if (j - nums[i - 1] < 0) {
// 背包容量不足,不能装入第 i 个物品
dp[i][j] = dp[i - 1][j];
} else {
// 装入或不装入背包
dp[i][j] = dp[i - 1][j] | dp[i - 1][j-nums[i-1]];
}
2)518.零钱兑换问题2
与背包问题不同的是,这里每种物品有无限多个,但是思路还是一样的,只不过dp表代表的是dp[i][w],前i种硬币凑成w有多少种凑法。然后思考递推关系式,
如果你不把这第i
个物品装入背包,也就是说你不使用coins[i]
这个面值的硬币,那么凑出面额j
的方法数dp[i][j]
应该等于dp[i-1][j]
,继承之前的结果。
如果你把这第i
个物品装入了背包,也就是说你使用coins[i]
这个面值的硬币,那么dp[i][j]
应该等于dp[i][j-coins[i-1]]
。
3)凑零钱问题:dp[n]表示为当前目标金额为n,需要的硬币枚数。是否选择第i枚硬币,选择或者不选择,取两者中较小的。
3.一维的dp表相关问题
1)518.零钱兑换问题2
2)凑零钱问题(见前)
3)斐波那契数列:只与该数的前面两个数有关
4)最长递增子序列:
dp[i] 表示以 nums[i] 这个数结尾的最长递增子序列的长度。从dp[i]变成dp[i+1]找到之前的比nums[i+1]小的数作为结尾的最长子序列,dp[i] = max(dp[j]+1, dp[i]),需要有两层循环嵌套。base case是dp每个值都为1,因为最少也有自身可以作为子序列。最后找到dp的最大值则是最后的结果。
5)信封嵌套:需要长宽都更大才能完成嵌套,首先将数组先按照宽升序,高降序排序,这里相当于找到高的最长递增子序列,思想和上一题一样。
6)最大子序列和:注意是连续的子序列,所以dp[i]代表以nums[i]结尾的最大子序列和,有两种选择是否和前面的接起来,在其中选择最大的dp[i] = max(nums[i], dp[i-1]+nums[i])。 注意这里的dp表的维数为n。
4. 二维dp表(比较复杂需要思考状态转移的题目)
1)最长公共子序列:dp[i][j]表示s1前i个序列和s2前j个序列,最长的公共子序列的长度。需要做的选择是看s1[i]和s[j]是否相等,若相等则公共子序列的长度加一,否则只移动一步(s1或者s2,选择其中子序列最长的)。注意前i个对应在数组里是i-1。
2)最长回文子序列:dp[i][j]是nums从i到j中最长回文子序列的长度,如果nums[i]和nums[j]相等,则回文序列长度加2,如果不相等,则考虑i或者j变化,找到其中最长的回文子序列。dp就是n*n的多维数组,base case是i=j的情况,这种情况只有一个字母所以回文子序列的长度为1。然后是遍历,与之前情况不同,遍历是从大到小反着遍历,不知道为什么要这样遍历,作者说是因为为了保证每次计算dp[i][j]
,左、下、左下三个方向的位置已经被计算出来,只能斜着遍历或者反着遍历:
具体代码如下,注意最后返回的和之前的不一样:
// 反着遍历保证正确的状态转移
for (int i = n - 1; i >= 0; i--) {
for (int j = i + 1; j < n; j++) {
// 状态转移方程
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]);
}
}
// 整个 s 的最长回文子串长度
return dp[0][n - 1];
3)编辑距离
这道题最主要的是如何转换成动态规划问题,作者是从后向前遍历两个字符串,如果字符相等不用做什么操作直接向前移动,如果不一样可以做删除,插入,替换,跳过等操作,从这些操作中找到最少的操作。
插入操作:在 s1[i] 插入一个和 s2[j] 一样的字符,那么 s2[j] 就被匹配了,前移 j,继续跟 i 对比,操作数加一。
删除操作:删除s[i],相当于前移i,继续和s[j]做对比
替换操作:将s[i]替换成s[j],然后i,j向前移动
5.动态规划系列问题——股票买卖问题
每天都有三种「选择」:买入、卖出、无操作,我们用 buy, sell, rest 表示这三种选择。问题的「状态」有三个,第一个是天数,第二个是允许交易的最大次数,第三个是当前的持有状态(即之前说的 rest 的状态,我们不妨用 1 表示持有,0 表示没有持有)。例如dp[3][2][1]
的含义就是:今天是第三天,我现在手上持有着股票,至今最多进行 2 次交易。再比如 dp[2][3][0]
的含义:今天是第二天,我现在手上没有持有股票,至今最多进行 3 次交易。
状态转移框架:
dp[i][k][0] = max(dp[i-1][k][0], dp[i-1][k][1] + prices[i])
max( 选择 rest , 选择 sell )
解释:今天我没有持有股票,有两种可能:
要么是我昨天就没有持有,然后今天选择 rest,所以我今天还是没有持有;
要么是我昨天持有股票,但是今天我 sell 了,所以我今天没有持有股票了。
dp[i][k][1] = max(dp[i-1][k][1], dp[i-1][k-1][0] - prices[i])
max( 选择 rest , 选择 buy )
解释:今天我持有着股票,有两种可能:
要么我昨天就持有着股票,然后今天选择 rest,所以我今天还持有着股票;
要么我昨天本没有持有,但今天我选择 buy,所以今天我就持有股票了。
定义 base case:
dp[-1][k][0] = 0
解释:因为 i 是从 0 开始的,所以 i = -1 意味着还没有开始,这时候的利润当然是 0 。
dp[-1][k][1] = -infinity
解释:还没开始的时候,是不可能持有股票的,用负无穷表示这种不可能。
dp[i][0][0] = 0
解释:因为 k 是从 1 开始的,所以 k = 0 意味着根本不允许交易,这时候利润当然是 0 。
dp[i][0][1] = -infinity
解释:不允许交易的情况下,是不可能持有股票的,用负无穷表示这种不可能。
根据题目改变数组的相应的规则即可。
6.动态规划系列问题——打家劫舍问题
1)House Robber I
不能抢相邻的房子,dp[i]代表前i个房子所抢到的价值,所做的选择是是否抢,dp[i] = max(dp[i-1], dp[i-2]+nums[i])。base case是dp[0], dp[1]。也要考虑房子列表为0或者为1的情况。
2)House Robber II
该情况是房子围成了一个圈,讨巧的方法是,首尾不能同时抢,所以考虑第一个到倒数第二个,第二个到最后一个中价值较高的即可
3)House Robber III
Map<TreeNode, Integer> memo = new HashMap<>();
public int rob(TreeNode root) {
if (root == null) return 0;
// 利用备忘录消除重叠子问题
if (memo.containsKey(root))
return memo.get(root);
// 抢,然后去下下家
int do_it = root.val
+ (root.left == null ?
0 : rob(root.left.left) + rob(root.left.right))
+ (root.right == null ?
0 : rob(root.right.left) + rob(root.right.right));
// 不抢,然后去下家
int not_do = rob(root.left) + rob(root.right);
int res = Math.max(do_it, not_do);
memo.put(root, res);
return res;
}
思路是一样的,是做抢还是不抢的选择,但是具体的操作比较巧妙,如果抢的话,去下下家,不抢的话可以直接去下家。
4)状态压缩:现在是用一维的dp表,但是其实是不需要的,状态转移只和dp[i]
最近的两个状态有关,所以可以进一步优化,将空间复杂度降低到 O(1)。
int robRange(int[] nums, int start, int end) {
int n = nums.length;
int dp_i_1 = 0, dp_i_2 = 0;
int dp_i = 0;
for (int i = end; i >= start; i--) {
dp_i = Math.max(dp_i_1, nums[i] + dp_i_2);
dp_i_2 = dp_i_1;
dp_i_1 = dp_i;
}
return dp_i;
}
5.何时进行状态压缩
如果dp[i][j]
都是通过上一行dp[i-1][..]
转移过来的,之前的数据都不会再使用了,则可以进行状态压缩,将二维dp
数组压缩为一维,节约空间复杂度。