如何确定一个题目是否可以用背包问题解决
背包问题的共同特征:给定一个背包容量target,再给定一个物品数组nums,能否按一定方式选取nums中的元素得到target
注意:
1、target和nums可能是数,也可能是字符串
2、target可以是显式(题目已经给出),也可以是非显式(需要从题目信息中挖掘)
3、常见nums选取方式:每个元素只能选一次 / 每个元素可以选多次 / 选元素进行排列组合
常见的背包类型主要有以下几种:
1、0/1背包问题:每个元素最多选取一次
2、完全背包问题:每个元素可以重复选择
3、组合背包问题:背包中的物品要考虑顺序
4、分组背包问题:不止一个背包,需要遍历每个背包
而每个背包问题要求的也是不同的,按照所求问题分类,又可以分为以下几种:
1、最值问题:要求最大值/最小值
2、存在问题:是否存在…………,满足…………
3、组合问题:求所有满足……的排列组合
这个问题是最简单最基础的,懂了这个问题,稍加变通就可以学会剩余背包问题
代码实现(java)
public static void main(String[] args) {
Scanner scanner = new Scanner(System.in);
int bagWeight = scanner.nextInt(); // 背包最大容量
int n = scanner.nextInt(); // 物品数量
// 物品重量数组
int[] weight = new int[n];
for (int i = 0; i < n; i++) {
weight[i] = scanner.nextInt();
}
// 物品价值数组
int[] value = new int[n];
for (int i = 0; i < n; i++) {
value[i] = scanner.nextInt();
}
// 调用方法求解不超出最大容量的前提下,背包最多能背多大价值的物品
System.out.println(bags(bagWeight, weight, value));
}
public static int bags(int bagWeight, int[] weight, int[] value) {
int n = weight.length; // 物品数量
int[][] dp = new int[n][bagWeight+1]; // dp数组,行表示物品,列表示从0到最大容量
// 第一列表示背包容量为0时的情况,第一列应该全为0。
// 由于建dp数组时,java会默认为数组赋0,所以保持第一列为0,更新第二列及以后的即可
// 从上到下从左到右计算dp,右下角即答案
for (int i = 0; i < n; i++) {
for (int j = 1; j <= bagWeight; j++) {
// 第一行表示只能选第一个物品
if (i == 0) {
dp[i][j] = value[i];
}
// 剩余行表示有多个物品可选,需要考虑两种情况
else {
// 情况1:背包容量就算只装一个物品i也装不下
if (j < weight[i]) {
dp[i][j] = dp[i-1][j];
}
// 情况2:背包容量可以装下物品i,需要考虑两种方案,然后取最大
else {
// 方案1:不装物品i
// 方案2:装物品i,最大价值为 物品i的价值 加上 去掉物品i的重量后背包剩余容量的最大价值
dp[i][j] = Math.max(dp[i-1][j], value[i] + dp[i-1][j-weight[i]]);
}
}
}
}
return dp[n-1][bagWeight]; // 答案是数组的右下角
}
得到的dp数组和答案:
dp =
[0, 15, 15, 15, 15]
[0, 15, 15, 20, 35]
[0, 15, 15, 20, 35]
answer = 35
public static int bags(int bagWeight, int[] weight, int[] value) {
int n = weight.length; // 物品数量
int[] dp = new int[bagWeight+1]; // dp数组,表示从0到最大容量可以装的最大价值
// 第一个元素表示背包容量为0时的情况。
// 由于建dp数组时,java会默认为数组赋0,所以保持第一个元素为0,更新第二个元素及以后的即可
// 从左到右计算dp,最后一个元素即答案
for (int i = 0; i < n; i++) {
// 注意!!!在计算转移方程的过程中,我们需要用到上一次循环得到的dp数组,所以内层循环必须倒序,否则转移方程的dp[j-weight[i]]会被覆盖掉,二维数组不存在这个问题
for (int j = bagWeight; j > 0; j--) {
// 当背包容量可以装下物品i时
if (j >= weight[i]) {
// 如果只有一个物品可选
if (i == 0) {
dp[j] = value[i];
}
// 如果有多个物品可选
else {
// 方案1:不装物品i
// 方案2:装物品i,最大价值为 物品i的价值 加上 去掉物品i的重量后背包剩余容量的最大价值
dp[j] = Math.max(dp[j], value[i] + dp[j-weight[i]]);
}
}
}
System.out.println(Arrays.toString(dp));
}
return dp[bagWeight]; // 答案是数组的最后一个元素
}
return dp[bagWeight]; // 答案是数组的最后一个元素
}
得到每一次循环的dp数组和答案:
dp = [0, 15, 15, 15, 15]
dp = [0, 15, 15, 20, 35]
dp = [0, 15, 15, 20, 35]
answer = 35
可以发现思想本质和计算过程是一样的,只是节省了空间而已
分析套路和01最值背包问题基本一样,存在以下区别:
public static void main(String[] args) {
Scanner scanner = new Scanner(System.in);
int n = scanner.nextInt();
int[] stones = new int[n];
for (int i = 0; i < stones.length; i++) {
stones[i] = scanner.nextInt();
}
System.out.println(lastStoneWeightII(stones));
}
// 动规
public static int lastStoneWeightII(int[] stones) {
// 非显式的背包最大容量,需要计算
int sum = 0;
for (int i = 0; i < stones.length; i++) {
sum += stones[i];
}
int maxWeight = sum/2; // 背包可以承受的最大重量
// 把石头分成两堆,计算第一堆石头不超出sum/2的最大重量
// dp表示i个石头时,最大容量为j时,背包最多可以装的重量
int[] dp = new int[maxWeight+1];
for (int i = 0; i < stones.length; i++) {
// 注意!!用一维数组时,内循环必须倒序,否则状态转移方程用到的dp[j-stones[i]]已经被覆盖掉了
for (int j = maxWeight; j > 0; j--) {
// 边界,第一行,只有一个石头
if (i == 0 && j >= stones[i]) {
dp[j] = stones[i];
}
// 有两个及以上石头
else {
if (j >= stones[i]) {
// 两种方案(拿石头i或者不拿石头i)取最大重量
dp[j] = Math.max(dp[j], stones[i] + dp[j-stones[i]]);
}
}
}
}
// 计算两堆石头的差值,即答案
return sum-2*dp[maxWeight];
}
区间DP,其实求的就是一个区间内的最优值.
一般这种题目,在设置状态的时候,都可以设f[i][j]为区间i-j的最优值
而 f[i][j] 的最优值,这有两个小区间合并而来的,为了划分这两个更小的区间,我们则需用用一个循环变量 k 来枚举,而一般的状态转移方程便是:
f[i][j] = max/min (f[i][j], f[i][k]+f[k][j]+something)
我们则需要根据这个题目的实际含义进行变通即可.
而区间dp的大致模板是:
for (int len=2;len<=n;len++)
for (int i=1;i+len-1<=n;i++)
{
int j=i+len-1;
for (int k=i;k<=j;k++)
f[i][j]=max/min(f[i][j],f[i][k]+f[k][j]+something)
}
len枚举区间的长度,i和j分别是区间的起点和终点,k的作用是用来划分区间.
0 | 1 | 2 | 3 | 4 | |
---|---|---|---|---|---|
0 | 0 | 4 | 8 | 10 | 15 |
1 | - | 0 | 7 | 9 | 14 |
2 | - | - | 0 | 6 | 11 |
3 | - | - | - | 0 | 7 |
4 | - | - | - | - | 0 |
初始化dp: 动规二维数组dp,表示子堆沙子的最小合并代价,例如 dp[2][4] 表示子堆沙子 nums=[4,2,5] 的最小合并代价。
对角线为1堆沙子的情况,副对角线为2堆沙子的情况,剩余全为超大的数(因为求的是最小代价,如果求最大代价,剩余应该全为超小的数,方便比较)
0 | 1 | 2 | 3 | 4 | |
---|---|---|---|---|---|
0 | 0 | 4 | max | max | max |
1 | - | 0 | 8 | max | max |
2 | - | - | 0 | 7 | max |
3 | - | - | - | 0 | 6 |
4 | - | - | - | - | 0 |
用状态转移方程填表:
从下到上从左到右填表,dp数组的右上角dp[0][4],表示 nums=[1,3,4,2,5] 的最小合并代价,就是我们要的答案
用 i 和 j 表示子数组的两个边,用 k 表示能将子数组分成两个区间的指针,枚举 k 的所有情况,计算合并代价,取最小,状态转移方程如下:
dp[i][j] = min (dp[i][j], sum[i][j] + dp[i][k] + dp[k+1][j])
0 | 1 | 2 | 3 | 4 | |
---|---|---|---|---|---|
0 | 0 | 4 | 12 | 20 | 34 |
1 | - | 0 | 7 | 15 | 28 |
2 | - | - | 0 | 6 | 17 |
3 | - | - | - | 0 | 7 |
4 | - | - | - | - | 0 |
public static void main(String[] args) {
Scanner scanner = new Scanner(System.in);
int N = scanner.nextInt();
int[] nums = new int[N];
for (int i = 0; i < nums.length; i++) {
nums[i] = scanner.nextInt();
}
System.out.println(stonesCombine(N, nums));
}
static int stonesCombine(int N,int[] nums) {
if (N == 0) {
return -1; // 边界,0堆沙子
}
int[][] dp = new int[N][N]; // 从i到j的子数组的最小代价
int[][] sum = new int[N][N]; // 从i到j的子数组的总代价
// 初始化dp全为最大值,斜对角线全为0,副对角线全为两堆沙子之和
for (int i = 0; i < N; i++) {
for (int j = 0; j < N; j++) {
if (i == j) {
dp[i][j] = 0; // 边界1,只有1堆沙子
}
else if (i+1 == j) {
dp[i][j] = nums[i] + nums[j]; // 边界2,只有2堆沙子
}
else {
dp[i][j] = Integer.MAX_VALUE; // 求最小值,初始化为最大值
}
}
}
// 计算sum
for (int i = 0; i < N; i++) {
for (int j = i+1; j < N; j++) {
if (j == i+1) {
sum[i][j] = nums[i] + nums[j]; // 特殊情况,2堆沙子,1堆沙子总代价为0
}
else {
sum[i][j] = sum[i][j-1] + nums[j];
}
}
}
// 计算dp剩余部分,从下到上,从左到右
for (int i = N-3; i >= 0; i--) {
for (int j = i+2; j < N; j++) {
// 枚举所有指针分割成两个区间的情况,取最小
for (int k = i; k < j; k++) {
dp[i][j] = Math.min(dp[i][j], sum[i][j]+dp[i][k]+dp[k+1][j]);
}
}
}
// dp右上角即答案
return dp[0][N-1];
}
参考网址