开篇问题:
有n个物品,物品重量为 weight = {7,2,4,6} 背包限重 g =11
求背包可装入最大重量。
整个求解过程分为 n 个阶段,每一个阶段决定一个物品是否放入背包中。每个阶段处理完之后(放了或者没放),背包的总重量会有不同的结果。
用一个 二维数组 boolean[][] states[i][j] 来表示 第i个阶段时,背包总重量为j时,true 和 false表示是否可以这样装入。0<= i < n and 0 <= j <=g
第0阶段(下标为0)进行决策时,重量为7,有两种决策结果,要么放入,要么不放,对应的背包的重量就有两种状态,0或7,都比11 小 对应的 状态表达为 states[0][0] = true, states[0][7] = true
第1阶段 (下标为1)进行决策时,重量为2,基于上一阶段的决策结果,这个物品决策完了以后,背包有四种状态,前两种为保持原有的背包状态,不加入 0(0+0),7(7+0),后两种为加入 9(7+2),2(0+2),都比11小,对应的状态表达为 states[1][0] = true ,states[1][7] = true,states[1][9] = true 和 states[1][2] = true
第2阶段 (下标为2)进行决策时,重量为4,这个物品决策完了以后,背包有八种状态,前四种为保持原有的背包状态,不加入0(0+0+0),7(7+0+0),9(7+2+0),2(0+2+0),后四种为加入背包中,4(0+0+4),11(7+0+4),13(7+2+4),6(0+2+4),其中13比11大,超重了,忽略。所以其对应的状态表达为 states[2][0] = true ,states[2][7] = true,states[2][9] = true ,states[2][2] = true ,states[2][4] = true ,states[2][11] = true,states[2][6] = true
接下来计算,第3阶段,以此类推,有16种情况,其中超重的忽略,其他的记录状态。
最后只需要在最后一个阶段,即第三阶段,遍历找到一个j最大,且states[i][j] = true ,就是背包能装的最大的重量了。
画个图推理如下:
画状态表时,每个阶段填入有个很明显的特征,把上一个阶段的状态先抄过来,然后再把自己的重量在上一个阶段的基础上,加入背包。
即可推导 状态转移方程为
states[i][j] ===>> { states[i-1][j] || states[i-1][j-weight[i] }
代码解释:
public int dpackage(int[] weight, int n, int g) {
boolean[][] states = new boolean[n][g+1];
// 默认值false
// 第一行的数据要特殊处理,可以利用哨兵优化
states[0][0] = true;
if (weight[0] <= g) {
states[0][weight[0]] = true;
}
for (int i = 1; i < n; ++i) {
// 动态规划状态转移
int tmpW = weight[i];
for (int j = 0; j <= g; ++j) {
// 不把第i个物品放入背包
if (states[i - 1][j]) states[i][j] = states[i][j] || states[i-1][j];
//把第i个物品放入背包
if(j + tmpW <= g && states[i-1][j]){
states[i][j+tmpW] = true;
}
}
}
//最后一层,找最大值
for (int i = g; i >= 0; --i) {
if (states[n - 1][i]) return i;
}
return 0;
}
这里借机介绍动态规划的三个特性
动态规划三个特性
- 最优子结构
问题的最优解包含子问题的最优解,后面阶段的状态可以通过前面推导出来。
每个阶段的最优解在解答时,都已经求出来了,后面的阶段决策需要在前面的结果基础上, 再进行决策。
- 无后效性
不关心前面如何推导出来的,现阶段状态一旦确定,就不受之后的决策影响。
- 重复子问题
不同的决策序列,到达某个相同的阶段时,可能会产生重复的状态。
这个应该用递归解答对比最能理解了,会有多次的重复计算,而动态规划记录了每个阶段决策结果,不会重复计算。
进阶题型:
1 判断具体某个总重量能不能存在背包里
举例:leetcode 416 分割数组
target = sum /2 判断target 是否放的进去
和 开头题型 场景 的区别为,最后直接返回最后一个决策阶段, 背包重量为target的状态即可, states[n-1][target]
int len = nums.length;
if (len == 0) {
return false;
}
int sum = 0;
for (int num : nums) {
sum += num;
}
// 特判:如果是奇数,就不符合要求
if ((sum & 1) == 1) {
return false;
}
int g = sum / 2;
// 创建二维状态数组,行:物品索引,列:容量(包括 0)
boolean[][] states = new boolean[len][g + 1];
// 第一行的数据要特殊处理,可以利用哨兵优化
states[0][0] = true;
// 先填表格第 0 行,第 1 个数只能让容积为它自己的背包恰好装满
if (nums[0] <= g) {
states[0][nums[0]] = true;
}
// 再填表格后面几行
for (int i = 1; i < len; i++) {
for (int j = 0; j <= g; j++) {
// 不把第i个物品放入背包
if (states[i - 1][j]) states[i][j] = states[i][j] || states[i-1][j];
//把第i个物品放入背包
if(j + nums[i] <= g && states[i-1][j]){
states[i][j+nums[i]] = true;
}
}
}
//最后一个决策阶段,背包重量为g
return states[len - 1][g];
2 每个物品有价值,求最大价值
题解:第 开头 题型 的基础上加个价值变量,要求最后求到背包的最大价值。
唯一不同的是,states[i][j] 这里二维数组存储的值不再是 boolean 类型,而是当前决策阶段,背包重量为 j 时,对应的最大总价值。
结果为求最大价值时,遍历 n -1 最后一层。
public static int knapsack3(int[] weight, int[] value, int n, int w) {
int[][] states = new int[n][w + 1];
for (int i = 0; i < n; ++i) {
// 初始化states
for (int j = 0; j < w + 1; ++j) {
states[i][j] = -1;
}
}
states[0][0] = 0;
if (weight[0] <= w) {
states[0][weight[0]] = value[0];
}
for (int i = 1; i < n; ++i) {
//动态规划,状态转移
for (int j = 0; j <= w; ++j) {
if (states[i - 1][j] >= 0) {
// 不选择第i个物品
states[i][j] = Math.max(states[i][j], states[i - 1][j]);
// 选择第 i 个物品
if (j + weight[i] <= w) {
states[i][j + weight[i]] = states[i - 1][j] + value[i];
}
}
}
}
// 找出最大值
int maxvalue = -1;
for (int j = 0; j <= w; ++j) {
if (states[n - 1][j] > maxvalue) maxvalue = states[n - 1][j];
}
return maxvalue;
}
3 求某个重量时刻的最大价值
第2种题型的基础上,求某种 重量的 最大价值。 leetcode 494 目标和 与这种情况比较类似,只不过二维数组存的是组合数量,不是价值最大值。感兴趣的同学可以看看。
4 其他变形
在 0-1 背包的基础上,求 组合数,最小使用物品个数等,都是可以在以上的思路上,改变二维数组类型,进行思考的。
5 完全背包问题
物品可重复多次放入背包。
leetcode 322 零钱兑换 leetcode-cn.com/problems/co…
leetcode 518 零钱兑换组合数 leetcode-cn.com/problems/co…
这个状态转移方程 和 0-1 背包不同,它不仅依赖于上一决策阶段的结果,还依赖于 同 一阶段背包重量为的 j - weight[i] 状态。
状态转移式: states[i][j] ===>> states[i-1][j] ,states[i][j-weight[i]]
322 零钱兑换
class Solution {
public int coinChange(int[] coins, int amount) {
int[][] dp = new int[coins.length][amount+1];
Arrays.sort(coins);
for(int k=1;k<=amount;k++){
dp[0][k] = Integer.MAX_VALUE;
//初始化
if(coins[0] <= k && dp[0][k-coins[0]] != Integer.MAX_VALUE){
dp[0][k] = dp[0][k-coins[0]] +1;
}
}
for(int i=1;i<coins.length;i++){
for(int j=1;j<=amount;j++){
int temp = Integer.MAX_VALUE;
if(coins[i] <= j && dp[i][j-coins[i]] != Integer.MAX_VALUE){
temp = dp[i][j-coins[i]] +1;
}
dp[i][j] = Math.min(temp,dp[i-1][j]);
}
}
return dp[coins.length-1][amount] == Integer.MAX_VALUE ? -1 : dp[coins.length - 1][amount];
}
}
总结
背包系列从简到难,一个系列一步一步添加条件,实际leetcode里的动态规划题目很多都是从中衍生来的。如果能掌握这一系列,脑海中会建立相应的逻辑模型。
个人平时解题习惯
一 回溯 -> 状态转移表法
平时遇到比较刁钻的动态规划的题目,会试着先写出递归回溯思路代码,感觉这个是较容易被自己笨笨的脑袋接受的,再导出动态规划的状态转移方程
二 领域建模,抽象思维。
理解题意是个很重要的事情,领域建模,转换成一种能理解或者熟悉的思路。抽离出来,化解题意,可以举一反三,事半功倍。
有的时候问题的答案角度比较难想到。所谓站的角度不同,看到的风景就不同,当然思路也会不一样。但平时可以多看看,多学习学习,拓宽思路,解题时候才好泉思喷涌呀。😊