动态规划


大纲:

动态五部曲如下
1. 确定 dp 数组( dp table )以及下标的含义
2. 确定递推公式
3. dp 数组如何初始化
4. 确定遍历顺序
5. 举例推导 dp 数组

一、动态规划理论基础 

什么是动态规划?
动态规划,英⽂: Dynamic Programming ,简称 DP ,如果某⼀问题有很多重叠⼦问题,使⽤动态规划是最有效的。
所以动态规划中每⼀个状态⼀定是由上⼀个状态推导出来的, 这⼀点就区分于贪⼼,贪⼼没有状态推导,⽽是从局部直接选最优的
动态规划应该如何debug?
找问题的最好⽅式就是把 dp数组打印出来,看看究竟是不是按照⾃⼰思路推导的!
做动规的题⽬,写代码之前⼀定要把状态转移在 dp 数组的上具体情况模拟⼀遍,⼼中有数,确定最后推出的是想要的结果。 然后再写代码,如果代码没通过就打印 dp 数组,看看是不是和⾃⼰预先推导的哪⾥不⼀样。
如果打印出来和⾃⼰预先模拟推导是⼀样的,那么就是⾃⼰的递归公式、初始化或者遍历顺序有问题 了。 如果和⾃⼰预先模拟推导的不⼀样,那么就是代码实现细节有问题。
我这⾥代码都已经和题解⼀模⼀样了,为什么通过不了呢? 发出这样的问题之前,其实可以⾃⼰先思考这三个问题:
  1. 这道题⽬我举例推导状态转移公式了么?
  2. 我打印dp数组的⽇志了么?
  3. 打印出来了dp数组和我想的⼀样么?

二、基础题目

509. 斐波那契数

70. 爬楼梯
746. 使⽤最⼩花费爬楼梯
62. 不同路径
63. 不同路径 II
343. 整数拆分
96. 不同的⼆叉搜索树

对于⾯试的话,其实掌握01背包,和完全背包,就够⽤了,最多可以再来⼀个多重背包

即⼀个商品如果可以重复多次放⼊是完全背包,⽽只能放⼊⼀次是01背包,写法还是不⼀样的

01 背包

N 件物品和⼀个最多能被重量为 W 的背包。第 i 件物品的重量是 weight[i] ,得到的价值是 value[i]
件物品只能⽤⼀次 ,求解将哪些物品装⼊背包⾥物品价值总和最⼤
这是标准的背包问题,以⾄于很多同学看了这个⾃然就会想到背包,甚⾄都不知道暴⼒的解法应该怎么解了。这样其实是没有从底向上去思考,⽽是习惯性想到了背包,那么暴⼒的解法应该是怎么样的呢?
每⼀件物品其实只有两个状态,取或者不取,所以可以使⽤回溯法搜索出所有的情况,那么时间复杂度就是O(2^n) ,这⾥的 n 表示物品数量。
所以暴⼒的解法是指数级别的时间复杂度。进⽽才需要动态规划的解法来进⾏优化!
在下⾯的讲解中,我举⼀个例⼦:
背包最⼤重量为 4
物品为:
问背包能背的物品最⼤价值是多少?以下讲解和图示中出现的数字都是以这个例⼦为例。
⼆维 dp 数组 01 背包
依然动规五部曲分析⼀波。
1. 确定dp数组以及下标的含义
对于背包问题,有⼀种写法, 是使⽤⼆维数组,即 dp[i][j] 表示从下标为 [0-i] 的物品⾥任意取,放进容量为j 的背包,价值总和最⼤是多少 。 只看这个⼆维数组的定义,⼤家⼀定会有点懵,看下⾯这个图:
2. 确定递推公式
再回顾⼀下 dp[i][j] 的含义:从下标为 [0-i] 的物品⾥任意取,放进容量为 j 的背包,价值总和最⼤是多少。
那么可以有两个⽅向推出来 dp[i][j]
  • dp[i - 1][j]推出,即背包容量为j,⾥⾯不放物品i的最⼤价值,此时dp[i][j]就是dp[i - 1][j]
  • dp[i - 1][j - weight[i]]推出,dp[i - 1][j - weight[i]] 为背包容量为j - weight[i]的时候不放物品i的最⼤价值,那么dp[i - 1][j - weight[i]] + value[i] (物品i的价值),就是背包放物品i得到的最⼤价值
所以递归公式: dp[i][j] = max(dp[i - 1][j], dp[i - 1][j - weight[i]] + value[i]);
3. dp数组如何初始化
关于初始化,⼀定要和 dp 数组的定义吻合,否则到递推公式的时候就会越来越乱
⾸先从 dp[i][j] 的定义出发,如果背包容量 j 0 的话,即 dp[i][0] ,⽆论是选取哪些物品,背包价值总和⼀定为0 。如图:
在看其他情况。
状态转移⽅程 dp[i][j] = max(dp[i - 1][j], dp[i - 1][j - weight[i]] + value[i]); 可以看出 i 是由 i-1 推导出来, 那么i 0 的时候就⼀定要初始化。
dp[0][j] ,即: i 0 ,存放编号 0 的物品的时候,各个容量的背包所能存放的最⼤价值。
// 倒叙遍历
for (int j = bagWeight; j >= weight[0]; j--) {
 dp[0][j] = dp[0][j - weight[0]] + value[0]; // 初始化i为0时候的情况
}
⼤家应该发现,这个初始化为什么是倒叙的遍历的?正序遍历就不⾏么? 正序遍历还真就不⾏,dp[0][j] 表示容量为 j 的背包存放物品 0 时候的最⼤价值,物品 0 的价值就是 15 ,因为题⽬中说了每个物品只有⼀个! 所以 dp[0][j] 如果不是初始值的话,就应该都是物品 0 的价值,也就是15。 但如果⼀旦正序遍历了,那么物品0 就会被重复加⼊多次! 例如代码如下:
// 正序遍历
for (int j = weight[0]; j <= bagWeight; j++) {
 dp[0][j] = dp[0][j - weight[0]] + value[0];
}
例如 dp[0][1] 15 ,到了 dp[0][2] = dp[0][2 - 1] + 15; 也就是 dp[0][2] = 30 了,那么就是物品 0 被重复放⼊了。
所以⼀定要倒叙遍历,保证物品0只被放⼊⼀次!这⼀点对01背包很重要,后⾯在讲解滚动数组的时候,还会⽤到倒叙遍历来保证物品使⽤⼀次!
此时 dp 数组初始化情况如图所示
dp[0][j] dp[i][0] 都已经初始化了,那么其他下标应该初始化多少呢? dp[i][j]在推导的时候⼀定是取价值最⼤的数,如果题⽬给的价值都是正整数那么⾮ 0 下标都初始化为 0 就可以了,因为0 就是最⼩的了,不会影响取最⼤价值的结果。
如果题⽬给的价值有负数,那么⾮ 0 下标就要初始化为负⽆穷了。例如:⼀个物品的价值是 -2 ,但对应的位置依然初始化为0 ,那么取最⼤值的时候,就会取 0 ⽽不是 -2 了,所以要初始化为负⽆穷。
这样才能让dp数组在递归公式的过程中取最⼤的价值,⽽不是被初始值覆盖了
最后初始化代码如下:
// 初始化 dp
vector<vector<int>> dp(weight.size() + 1, vector<int>(bagWeight + 1, 0));
for (int j = bagWeight; j >= weight[0]; j--) {
 dp[0][j] = dp[0][j - weight[0]] + value[0];
}
4. 确定遍历顺序
在如下图中,可以看出,有两个遍历的维度:物品与背包重量
那么问题来了, 先遍历 物品还是先遍历背包重量呢?
其实都可以!! 但是先遍历物品更好理解。 
完整 C++ 测试代码
void test_2_wei_bag_problem1() {
 vector<int> weight = {1, 3, 4};
 vector<int> value = {15, 20, 30};
 int bagWeight = 4;
 // ⼆维数组
 vector<vector<int>> dp(weight.size() + 1, vector<int>(bagWeight + 1, 0));
 // 初始化
 for (int j = bagWeight; j >= weight[0]; j--) {
 dp[0][j] = dp[0][j - weight[0]] + value[0];
 }
 // weight数组的⼤⼩ 就是物品个数
 for(int i = 1; i < weight.size(); i++) { // 遍历物品
 for(int j = 0; j <= bagWeight; j++) { // 遍历背包容量
 if (j < weight[i]) dp[i][j] = dp[i - 1][j];
 else dp[i][j] = max(dp[i - 1][j], dp[i - 1][j - weight[i]] +
value[i]);
 }
 }
 cout << dp[weight.size() - 1][bagWeight] << endl; }
int main() {
 test_2_wei_bag_problem1();
}

01背包理论基础(滚动数组)我觉得更好理解一些

在动态规划中,如果使⽤⼀维dp数组,物品遍历的for循环放在外层,遍历背包的for循环放在内层,且内层for循环倒叙遍历!

⼀维dp数组(滚动数组)

动规五部曲分析如下:
1. 确定dp数组的定义
在⼀维 dp 数组中, dp[j] 表示:容量为 j 的背包,所背的物品价值可以最⼤为 dp[j]
2. ⼀维dp数组的递推公式
dp[j] 为 容量为 j 的背包所背的最⼤价值,那么如何推导 dp[j] 呢?
dp[j] 可以通过 dp[j - weight[j]] 推导出来, dp[j - weight[i]] 表示容量为 j - weight[i] 的背包所背的最⼤价值。dp[j - weight[i]] + value[i] 表示 容量为 j - 物品 i 重量 的背包 加上 物品 i 的价值。(也就是容量为 j 的背包,放⼊物品i 了之后的价值即: dp[j]
此时 dp[j] 有两个选择,⼀个是取⾃⼰ dp[j] ,⼀个是取 dp[j - weight[i]] + value[i] ,指定是取最⼤的,毕竟是求最⼤价值,
所以递归公式为:
dp[j] = max(dp[j], dp[j - weight[i]] + value[i]);
可以看出相对于⼆维 dp 数组的写法,就是把 dp[i][j] i 的维度去掉了。
3. ⼀维dp数组如何初始化
关于初始化,⼀定要和 dp 数组的定义吻合,否则到递推公式的时候就会越来越乱
dp[j] 表示:容量为 j 的背包,所背的物品价值可以最⼤为 dp[j] ,那么 dp[0] 就应该是 0 ,因为背包容量为 0所背的物品的最⼤价值就是0
那么 dp 数组除了下标 0 的位置,初始为 0 ,其他下标应该初始化多少呢?
看⼀下递归公式: dp[j] = max(dp[j], dp[j - weight[i]] + value[i]);
dp 数组在推导的时候⼀定是取价值最⼤的数,如果题⽬给的价值都是正整数那么⾮ 0 下标都初始化为 0 就可以了,如果题⽬给的价值有负数,那么⾮0 下标就要初始化为负⽆穷
这样才能让 dp 数组在递归公式的过程中取的最⼤的价值,⽽不是被初始值覆盖了
那么我假设物品价值都是⼤于 0 的,所以 dp 数组初始化的时候,都初始为 0 就可以了。
4. ⼀维dp数组遍历顺序
代码如下:
for(int i = 0; i < weight.size(); i++) { // 遍历物品
 for(int j = bagWeight; j >= weight[i]; j--) { // 遍历背包容量
 dp[j] = max(dp[j], dp[j - weight[i]] + value[i]);
 }
}
这⾥⼤家发现和⼆维 dp 的写法中,遍历背包的顺序是不⼀样的!
⼆维 dp 遍历的时候,背包容量是从⼩到⼤,⽽⼀维 dp 遍历的时候,背包是从⼤到⼩。
为什么呢?
倒叙遍历是为了保证物品 i 只被放⼊⼀次!
举⼀个例⼦:物品 0 的重量 weight[0] = 1 ,价值 value[0] = 15
如果正序遍历
dp[1] = dp[1 - weight[0]] + value[0] = 15
dp[2] = dp[2 - weight[0]] + value[0] = 30
此时 dp[2] 就已经是 30 了,意味着物品 0 ,被放⼊了两次,所以不能正序遍历。
为什么倒叙遍历,就可以保证物品只放⼊⼀次呢?
倒叙就是先算 dp[2] = dp[2 - weight[0]] + value[0] = 15 dp 数组已经都初始化为 0
dp[1] = dp[1 - weight[0]] + value[0] = 15
所以从后往前循环,每次取得状态不会和之前取得状态重合,这样每种物品就只取⼀次了。
再来看看两个嵌套 for 循环的顺序,代码中是先遍历物品嵌套遍历背包容量,那可不可以先遍历背包容量嵌套遍历物品呢?
不可以!
因为⼀维 dp 的写法,背包容量⼀定是要倒序遍历(原因上⾯已经讲了),如果遍历背包容量放在上⼀层,那么每个dp[j] 就只会放⼊⼀个物品,即:背包⾥只放⼊了⼀个物品。
5. 举例推导dp数组
⼀维 dp ,分别⽤物品 0 ,物品 1 ,物品 2 来遍历背包,最终得到结果如下:
⼀维 dp01 背包完整 C++ 测试代码
可以看出,⼀维dp 01背包,要⽐⼆维简洁的多! 初始化 和 遍历顺序相对简单了。
所以我倾向于使⽤⼀维 dp 数组的写法,⽐较直观简洁,⽽且空间复杂度还降了⼀个数量级!在后⾯背包问题的讲解中,我都直接使⽤⼀维dp 数组来进⾏推导
416. 分割等和⼦集
1049. 最后⼀块⽯头的重量 II
494. ⽬标和
474. ⼀和零
  • 1
    点赞
  • 5
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值