背包理论基础
背包问题分类
01背包
有n件物品和一个最多能背重量为w 的背包。第i件物品的重量是weight[i],得到的价值是value[i] 。每件物品只能用一次,求解将哪些物品装入背包里物品价值总和最大。
二维dp数组01背包
依然动规五部曲分析一波。
- 确定dp数组以及下标的含义 dp[i][j] 表示从下标为[0-i]的物品里任意取,放进容量为j的背包,价值总和最大是多少。
- 递推公式 dp[i][j] = max(dp[i - 1][j], dp[i - 1][j - weight[i]] + value[i]);
- dp初始化 第0列全0 (容量为0) 第一列根据当前容量与首个物品重量的关系进行初始化
- 确定遍历顺序 先物品再背包 / 先背包再物品均可
- 举例推导dp数组
递推公式确定:
- 不放物品i:由dp[i - 1][j]推出,即背包容量为j,里面不放物品i的最大价值,此时dp[i][j]就是dp[i - 1][j]。(其实就是当物品i的重量大于背包j的重量时,物品i无法放进背包中,所以背包内的价值依然和前面相同。)
- 放物品i:由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得到的最大价值
46. 携带研究材料
题目描述
小明是一位科学家,他需要参加一场重要的国际科学大会,以展示自己的最新研究成果。他需要带一些研究材料,但是他的行李箱空间有限。这些研究材料包括实验设备、文献资料和实验样本等等,它们各自占据不同的空间,并且具有不同的价值。
小明的行李空间为 N,问小明应该如何抉择,才能携带最大价值的研究材料,每种研究材料只能选择一次,并且只有选与不选两种选择,不能进行切割。
输入描述
第一行包含两个正整数,第一个整数 M 代表研究材料的种类,第二个正整数 N,代表小明的行李空间。
第二行包含 M 个正整数,代表每种研究材料的所占空间。
第三行包含 M 个正整数,代表每种研究材料的价值。
输出描述
输出一个整数,代表小明能够携带的研究材料的最大价值。
输入示例
6 1
2 2 3 1 5 2
2 3 1 5 4 3
输出示例
5
思路
二维dp数组,标准01背包问题, 按照上述五步曲分析
代码
import java.util.Scanner;
public class Main {
public static void main(String[] args) {
// 读取 N
Scanner scanner = new Scanner(System.in);
int M = scanner.nextInt();
int N = scanner.nextInt();
int[] weights = new int[M];
int[] values = new int[M];
for (int i = 0; i < M; i++) {
weights[i] = scanner.nextInt();
}
for (int j = 0; j < M; j++) {
values[j] = scanner.nextInt();
}
// 创建一个动态规划数组dp,初始值为0
int[][] dp = new int[M][N+1];
for(int i = 0; i <= N; i++){
if(i >= weights[0]) dp[0][i] = values[0];
}
for (int i = 1; i < M; ++i) {
for (int j = 1; j <= N; j++) {
if(j < weights[i]) dp[i][j] = dp[i-1][j];
else dp[i][j] = Math.max(dp[i-1][j], dp[i-1][j - weights[i]] + values[i]);
}
}
// 输出dp[M-1][N],即在给定 M 材料中任选, N 行李空间可以携带的研究材料最大价值
System.out.println(dp[M-1][N]);
}
}
01背包理论基础 滚动数组
一维dp数组(滚动数组)
①对于背包问题,状态都是可以压缩的。
可以把dp[i - 1]那一层拷贝到dp[i]上,只用一个一维数组dp[j](滚动数组)。
这就是滚动数组的由来,需要满足的条件是上一层可以重复利用,直接拷贝到当前层。
②动态规划五步曲
1,确定dp数组及下标含义:在一维数组中 dp[ j ] 表示 容量为 j 的背包所能取得的最大价值
2,确定递推公式: dp[j] = max(dp[ j ], dp[ j - w[ i ]] + value[ i ])相对于二维 dp 数组的写法,是把 dp[i][j] 中 i 的维度去掉了;
3.dp初始化: 对于价值都大于0的情况, dp初始化为0
关于初始化,一定要和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就可以了。
这样才能让dp数组在递归公式的过程中取的最大的价值,而不是被初始值覆盖了。
那么我假设物品价值都是大于0的,所以dp数组初始化的时候,都初始为0就可以了。
4. 确定遍历顺序:
先遍历物品, 再遍历背包
同时, 状态值的更新只与它上边和左上方元素有关. 将空间投影到一行后, 状态转移(填表)的时候,从右边到左边更新状态值;保证物品i只被放入一次!
5.具体推导dp数组: 依然使用上述例子
代码
import java.util.Scanner;
public class Main {
public static void main(String[] args) {
// 读取 N
Scanner scanner = new Scanner(System.in);
int M = scanner.nextInt();
int N = scanner.nextInt();
int[] weights = new int[M];
int[] values = new int[M];
for (int i = 0; i < M; i++) {
weights[i]= scanner.nextInt();
}
for (int j = 0; j < M; j++) {
values[j] = scanner.nextInt();
}
// 创建一个动态规划数组dp,初始值为0
int [] dp = new int [N+1];
for(int i = 0; i < M; i++){
for(int j = N; j >= weights[i]; j--){
dp[j] = Math.max(dp[j], dp[j - weights[i]] + values[i]);
}
}
// 输出dp[N],即在给定 N 行李空间可以携带的研究材料最大价值
System.out.println(dp[N]);
}
}
698.划分为k个相等的子集
给定一个整数数组 nums
和一个正整数 k
,找出是否有可能把这个数组分成 k
个非空子集,其总和都相等。
示例 1:
输入: nums = [4, 3, 2, 3, 5, 2, 1], k = 4 输出: True 说明: 有可能将其分成 4 个子集(5),(1,4),(2,3),(2,3)等于总和。
示例 2:
输入: nums = [1,2,3,4], k = 3 输出: false
思路
回溯算法, 回溯三步曲: 确定递归函数参数及返回值 , 确定终止条件 , 确定搜索的遍历过程; 这里再提出
「回溯算法框架」
解决回溯问题需要思考的 3 个问题:
- 路径:已经做出的选择
- 选择列表:当前可以做的选择
- 结束条件:到达决策树底层,无法再做选择的条件
我们先结合下面的决策树,根据「排列」问题来详细分析一下如何理解「路径」和「选择列表」
- 当我们处于
第 0 层
的时候,其实可以做的选择有 3 种,即:选择 1 or 2 or 3 - 假定我们在
第 0 层
的时候选择了 1,那么当我们处于第 1 层
的时候,可以做的选择有 2 种,即:2 or 3 - 假定我们在
第 1 层
的时候选择了 2,那么当我们处于第 2 层
的时候,可以做的选择有 1 种,即:3 - 当我们到达
第 3 层
的时候,我们面前的选择依次为:1,2,3。这正好构成了一个完整的「路径」,也正是我们需要的结果
经过上面的分析,我们可以很明显的知道「结束条件」,即:所有数都被选择
// 结束条件:已经处理完所有数
if (track.size() == nums.length) {
// 处理逻辑
}
对问题进行一层抽象:有 n 个球,k 个桶,如何分配球放入桶中使得每个桶中球的总和均为target
。如下图所示:
为了可以更好的理解「回溯」的思想,我们这里提供两种不同的视角进行分析对比
视角一:我们站在球的视角,每个球均需要做出三种选择,即:选择放入 1 号桶、2 号桶、3 号桶。所有球一共需要做 𝑘𝑛 次选择 (分析时间复杂度会用到)
这里提一个点:由于回溯就是一种暴力求解的思想,所以对于每个球的三种选择,只有执行了该选择才知道该选择是否为最优解。说白了就是依次执行这三种选择,如果递归到下面后发现该选择为非最优解,然后开始回溯,执行其他选择,直到把所有选择都遍历完
视角二:我们站在桶的视角,每个桶均需要做出六次选择,即:是否选择 1 号球放入、是否选择 2 号球放入、...、是否选择 6 号球放入。对于一个桶最多需要做 2𝑛 次选择,所有的桶一共需要做 (2𝑛)𝑘 次选择
视角一:球视角
如下图所示,「球」选择「桶」
下面给出「球视角」下的决策树
首先解释一下这棵决策树,第 i 层
为第 i 个球
做选择,可做的选择:选择 1 or 2 or 3 号桶
,直到第 n 个球
做完选择后结束
由于,每个桶可以放下不止一个球,所以不存在某一个球选择了 1 号桶,另一个球就不能放入 1 号桶。判断是否可以放下的条件为:放入该球后,桶是否溢出?
同样的,根据本文给出的框架,详细分析一下如何理解「路径」和「选择列表」
- 当我们处于
第 1 层
的时候,即值为「1」的球开始做选择,可以做的选择有 3 种,即:选择放入 1 or 2 or 3 号桶 - 假定我们在
第 1 层
的时候选择了放入 1 号桶,那么当我们处于第 2 层
的时候,即值为「2」的球开始做选择,可以做的选择有 3 种,即:选择放入 1 or 2 or 3 号桶 - 假定我们在
第 2 层
的时候选择了放入 1 号桶,那么当我们处于第 3 层
的时候,即值为「2」的球开始做选择,可以做的选择有 3 种,即:选择放入 1 or 2 or 3 号桶 - 假定我们在
第 3 层
的时候选择了放入 1 号桶,那么当我们处于第 4 层
的时候,即值为「4」的球开始做选择,可以做的选择有 2 种,即:选择放入 2 or 3 号桶 (原因:1 号桶放入了 1 2 2,已经满了) - 假定我们在
第 4 层
的时候选择了放入 2 号桶,那么当我们处于第 5 层
的时候,即值为「3」的球开始做选择,可以做的选择有 1 种,即:选择放入 3 号桶 (原因:2 号桶放入了 4,容纳不下 3 了) - 假定我们在
第 5 层
的时候选择了放入 3 号桶,那么当我们处于第 6 层
的时候,即值为「3」的球开始做选择,可以做的选择有 0 种 (原因:3 号桶放入了 3,容纳不下 3 了) - 此时我们已经到达了最后一层!!我们来梳理一下选择的路径,即:「1 号桶:1 2 2」「 2 号桶:4」「3 号桶:3」。显然这条路径是不符合要求的,所以就开始回溯,回溯到
第 5 层
,改变第 5 层
的选择,以此类推,直到得出「最优解」
经过上面的分析,我们可以很明显的知道「结束条件」,即:所有球都做了选择后结束
// 结束条件:已经处理完所有球
if (startIndex == nums.length) {
// 处理逻辑
}
然后就是套用回溯三步曲, 利用好剪枝
代码
import java.util.Arrays;
//leetcode submit region begin(Prohibit modification and deletion)
class Solution {
int sum = 0;
public boolean canPartitionKSubsets(int[] nums, int k) {
for(int i=0; i < nums.length; i++){
sum += nums[i];
}
if(sum % k != 0) return false;
int target = sum / k;
int [] bucket = new int [k];
//排序优化, 降序排列 使得剪枝更易命中
Arrays.sort(nums);
int left = 0, right = nums.length-1;
while(left <= right){
int temp = nums[right];
nums[right] = nums[left];
nums[left] = temp;
left++;
right--;
}
return backtracking(nums, 0, bucket, k, target);
}
public boolean backtracking(int [] nums, int startIndex, int [] bucket, int k, int target){
if(startIndex == nums.length){
// for(int i = 0; i < k; i++){
// if(bucket[i] != target) return false;
// }
//当 startIndex == num.length 时,所有球已经按要求装入所有桶,所以肯定是一个满足要求的解
return true;
}
for(int i = 0; i < k; i++){
//剪枝 当前桶放入球后超过 target 的值,选择下一个桶
if(bucket[i] + nums[startIndex] > target) continue;
// 树层去重 当当前桶与上一个桶内元素和相等, 则跳过
// 因为如果元素和相等,那么 nums[index] 选择上一个桶和选择当前桶可以得到的结果是一致的
if(i > 0 && bucket[i] == bucket[i-1]) continue;
bucket[i] += nums[startIndex];
if(backtracking(nums, startIndex + 1, bucket, k, target)) return true;
bucket[i] -= nums[startIndex];
}
return false;
}
}
//leetcode submit region end(Prohibit modification and deletion)
473.火柴拼正方形
你将得到一个整数数组 matchsticks
,其中 matchsticks[i]
是第 i
个火柴棒的长度。你要用 所有的火柴棍 拼成一个正方形。你 不能折断 任何一根火柴棒,但你可以把它们连在一起,而且每根火柴棒必须 使用一次 。
如果你能使这个正方形,则返回 true
,否则返回 false
。
示例 1:
输入: matchsticks = [1,1,2,2,2] 输出: true 解释: 能拼成一个边长为2的正方形,每边两根火柴。
示例 2:
输入: matchsticks = [3,3,3,3,4] 输出: false 解释: 不能用所有火柴拼成一个正方形。
思路
回溯算法, 本题相当于划分 4 个相等的子集
代码
class Solution {
public boolean makesquare(int[] matchsticks) {
int sum = 0;
for (int match : matchsticks) {
sum += match;
}
if (sum % 4 != 0) return false;
int len = sum / 4;
int[] bucket = new int[4];
Arrays.sort(matchsticks);
int left = 0, right = matchsticks.length - 1;
while(left < right){
int temp = matchsticks[right];
matchsticks[right] = matchsticks[left];
matchsticks[left] = temp;
left++;
right--;
}
return backtracking(matchsticks, 0, len, bucket);
}
public boolean backtracking(int[] matchsticks, int startIndex, int len, int[] bucket) {
if (startIndex == matchsticks.length) {
return true;
}
for (int i = 0; i < 4; i++) {
if(bucket[i] + matchsticks[startIndex] > len) continue;
if(i > 0 && bucket[i] == bucket[i-1]) continue;
bucket[i] += matchsticks[startIndex];
if(backtracking(matchsticks, startIndex + 1, len, bucket)) return true;
bucket[i] -= matchsticks[startIndex];
}
return false;
}
}
416. 分割等和子集
给你一个 只包含正整数 的 非空 数组 nums
。请你判断是否可以将这个数组分割成两个子集,使得两个子集的元素和相等。
示例 1:
输入:nums = [1,5,11,5] 输出:true 解释:数组可以分割成 [1, 5, 5] 和 [11] 。
示例 2:
输入:nums = [1,2,3,5] 输出:false 解释:数组不能分割成两个元素和相等的子集。
这道题目初步看,和上面两题几乎是一样的,大家可以用回溯法,解决如下两题
- 698.划分为k个相等的子集
- 473.火柴拼正方形
这道题目是要找是否可以将这个数组分割成两个子集,使得两个子集的元素和相等。
那么只要找到集合里能够出现 sum / 2 的子集总和,就算是可以分割成两个相同元素和子集了。
本题是可以用回溯暴力搜索出所有答案的,但最后超时了,也不想再优化了,放弃回溯,直接上01背包吧。
问题转化:
背包问题是指 有N件物品和一个最多能背重量为W 的背包。第i件物品的重量是weight[i],得到的价值是value[i] 。每件物品只能用一次,求解将哪些物品装入背包里物品价值总和最大。
背包问题有多种背包方式,常见的有:01背包、完全背包、多重背包、分组背包和混合背包等等。
要注意题目描述中物品是不是可以重复放入。
即一个商品如果可以重复多次放入是完全背包,而只能放入一次是01背包,写法还是不一样的。
要明确本题中我们要使用的是01背包,因为元素我们只能用一次。
回归主题:首先,本题要求集合里能否出现总和为 sum / 2 的子集。那么来一一对应一下本题,看看背包问题如何来解决。
-
背包容量为 sum / 2
-
背包所能放入的物品 (集合中的元素) 重量nums[ i ] 价值也是nums[ i ]
- 背包如果正好装满,说明找到了总和为 sum / 2 的子集。
- 背包中每一个元素是不可重复放入。
以上分析完,我们就可以套用01背包,来解决这个问题了。
思路
动态规划五步曲
确定dp数组以及下标含义: dp[ i ] 容量为 i 的背包所能容纳物品的最大价值 套到本题,dp[j]表示 背包总容量(所能装的总重量)是j,放进物品后,背的最大重量为dp[j]。
确定递推公式: dp{ j ] = max(dp[ j ] , dp[ j - nums[ i ] + nums[ i ])
初始化:
从dp[j]的定义来看,首先dp[0]一定是0。
如果题目给的价值都是正整数那么非0下标都初始化为0就可以了,如果题目给的价值有负数,那么非0下标就要初始化为负无穷。
这样才能让dp数组在递推的过程中取得最大的价值,而不是被初始值覆盖了。
确定递归顺序:01背包问题 使用一维dp数组,物品遍历的for循环放在外层,遍历背包的for循环放在内层,且内层for循环倒序遍历!
举例推导:
代码
class Solution {
public boolean canPartition(int[] nums) {
int sum = Arrays.stream(nums).sum();
if(sum % 2 != 0) return false;
sum = sum/2;
int [] dp = new int [sum + 1];
for(int i = 0; i < nums.length; i++){
for(int j = sum; j >= nums[i]; j--){
dp[j] = Math.max(dp[j], dp[j - nums[i]] + nums[i]);
}
//剪枝一下,每一次完成內層的for-loop,立即檢查是否dp[target] == target,
//优化时间复杂度(26ms -> 19ms)
if(dp[sum] == sum) return true;
}
return dp[sum] == sum;
}
}