1 今日打卡
01背包二维 46. 携带研究材料(第六期模拟笔试)
01背包一维 46. 携带研究材料(第六期模拟笔试)
分割等和子集 416. 分割等和子集 - 力扣(LeetCode)
2 五部曲
确定dp数组(dp table)以及下标的含义
确定递推公式
dp数组如何初始化
确定遍历顺序
举例推导dp数组
3 01背包二维
3.1 思路
第一步:
dp [i][j] 及其下标 i、j 的含义
dp[i][j] 表示:考虑前i+1个物品(第 0~i 个物品),在背包容量为j时,能装入的物品的最大价值;
下标i:对应物品的索引(0~m-1),表示 "处理到第几个物品";
下标j:对应背包的容量(0~n),表示 "当前背包的剩余容量";
举例:dp[2][5] 表示考虑前 3 个物品(0、1、2 号),背包容量为 5 时的最大价值。
第二步:
确定状态转移方程
0-1 背包的核心规则是 "每个物品只能选或不选",因此对于第i个物品,有两种选择:
情况 1:不选第i个物品
此时背包容量不变,最大价值继承 "考虑前i个物品(0~i-1)、容量j" 的结果 → dp[i][j] = dp[i-1][j];
触发条件:j < weight[i](背包容量不足,装不下第i个物品,只能不选)。
情况 2:选第i个物品
此时背包容量减少weight[i],价值增加value[i],最大价值 = "考虑前i个物品、容量j-weight[i]的价值" + value[i] → dp[i-1][j - weight[i]] + value[i];
触发条件:j >= weight[i](容量足够,可选)。
第三步:
dp 数组如何初始化
初始化需贴合 "无物品 / 无容量" 的边界场景,保证后续计算的基准正确:
容量为 0 时(j=0):无论有多少物品,背包容量为 0 都装不了任何物品,价值为 0 → dp[i][0] = 0(所有行的第 0 列都为 0);
只有第一个物品时(i=0):
若j < weight[0]:装不下第一个物品,价值为 0(数组默认值,无需显式赋值);
若j >= weight[0]:只能装第一个物品,价值为value[0] → dp[0][j] = value[0];
补充:数组默认值为 0,因此j < weight[0]的位置无需额外赋值,只需处理j >= weight[0]的情况。
第四步:
外层循环(物品):从i=1到m-1(第一个物品已初始化),因为dp[i][j]依赖dp[i-1][j](上一行的结果),需按物品顺序遍历;
内层循环(容量):从j=0到n,遍历所有可能的背包容量,因为每个容量的状态都需要基于上一行的结果计算;
核心原则:先遍历物品,再遍历容量(0-1 背包二维 DP 的固定顺序,保证每个物品只被考虑一次)。
二维数组处理的01背包问题实际上先遍历容量再遍历物品也是可以的。
第五步:
输入:m=2(2 个物品),n=5(背包容量 5);
重量:weight = [1, 3],价值:value = [2, 4];
初始化
容量为 0:dp[0][0] = 0,dp[1][0] = 0;
第一个物品(i=0):
j >= 1时,dp[0][j] = 2 → dp[0] = [0,2,2,2,2,2];
计算第二个物品(i=1)
j=0:dp[1][0] = 0;
j=1:j < 3 → dp[1][1] = dp[0][1] = 2;
j=2:j < 3 → dp[1][2] = dp[0][2] = 2;
j=3:j >=3 → max(dp[0][3]=2, dp[0][0]+4=4) → dp[1][3] =4;
j=4:j >=3 → max(dp[0][4]=2, dp[0][1]+4=6) → dp[1][4] =6;
j=5:j >=3 → max(dp[0][5]=2, dp[0][2]+4=6) → dp[1][5] =6;
最终结果
dp[1][5] =6(装第一个 + 第二个物品,总价值 2+4=6),符合最优解。
3.2 实现代码
java
import java.util.*;
import java.lang.*;
public class Main {
public static void main(String[] args) {
Scanner sc = new Scanner(System.in);
// 输入物品数量m和背包总容量n
int m = sc.nextInt(); // m:物品个数
int n = sc.nextInt(); // n:背包最大容量
// 1. 定义dp数组:dp[i][j]表示前i+1个物品、背包容量j时的最大价值
// 行:0~m-1(对应第1~m个物品),列:0~n(对应容量0~n)
int[][] dp = new int[m][n + 1];
// 定义重量数组和价值数组,长度为物品数量m
int[] weight = new int[m]; // 存储每个物品的重量
int[] value = new int[m]; // 存储每个物品的价值
// 输入m个物品的重量
for(int i = 0; i < m; i++) {
weight[i] = sc.nextInt();
}
// 输入m个物品的价值
for(int i = 0; i < m; i++) {
value[i] = sc.nextInt();
}
// 2. 初始化dp数组:容量为0时,所有物品组合的价值都为0
for(int i = 0; i < m; i++) {
dp[i][0] = 0;
}
// 初始化dp数组第一行(只考虑第一个物品)
// 容量>=第一个物品重量时,价值为第一个物品的价值;否则为0(数组默认值)
for(int j = weight[0]; j <= n; j++) {
dp[0][j] = value[0];
}
// 3. 确定遍历顺序:先遍历物品(i从1开始),再遍历容量(j从0到n)
// 遍历第2个到第m个物品(i从1到m-1)
for(int i = 1; i < m; i++) {
// 遍历所有可能的背包容量(0~n)
for(int j = 0; j <= n; j++) {
// 4. 状态转移方程:判断当前容量是否能装下第i个物品
if(j < weight[i]) {
// 容量不足,装不下第i个物品,继承上一行(前i个物品)的结果
dp[i][j] = dp[i-1][j];
} else {
// 容量足够:选"不装第i个物品"或"装第i个物品"的最大值
// 不装:dp[i-1][j];装:dp[i-1][j-weight[i]] + value[i]
dp[i][j] = Math.max(dp[i-1][j], dp[i-1][j - weight[i]] + value[i]);
}
}
}
// 5. 输出结果:前m个物品、背包容量为n时的最大价值
System.out.println(dp[m-1][n]);
sc.close(); // 关闭扫描器,释放资源
}
}
4 01背包一维
4.1 思路
第一步:
dp [j] 及其下标 j 的含义
dp[j] 表示:遍历到当前物品时,背包容量为j的情况下,能装入的物品的最大价值;
下标j:对应背包的容量(0~n),和二维版的j含义一致;
核心区别:二维版用i维度记录 "处理到第几个物品",一维版通过 "遍历物品 + 倒序更新" 隐含这个维度 ------ 每遍历一个物品,dp[j]就从 "前 i 个物品的结果" 更新为 "前 i+1 个物品的结果"。
第二步:
一维版的状态转移方程是二维版的简化,核心逻辑不变(选 / 不选当前物品):
原始二维方程:dp[i][j] = Math.max(dp[i-1][j], dp[i-1][j-weight[i]] + value[i]);
一维简化后:dp[j] = Math.max(dp[j], dp[j-weight[i]] + value[i]);
等号右边的dp[j]:未更新前的值,对应二维版的dp[i-1][j](不选当前物品的结果);
dp[j-weight[i]] + value[i]:对应二维版的dp[i-1][j-weight[i]] + value[i](选当前物品的结果);
触发条件:j >= weight[i](容量足够装当前物品),否则无需更新(dp[j]保留上一轮结果)。
第三步:
dp 数组如何初始化
一维版的初始化极简单,贴合 "无物品时的基准状态":
dp[0] = 0:背包容量为 0 时,无论有没有物品,价值都是 0(数组默认值就是 0,无需显式赋值);
其他dp[j] (j>0):初始值为 0,表示 "还没遍历任何物品时,所有容量的价值都是 0";
对比二维版:一维版无需单独初始化 "第一个物品",遍历第一个物品时会自动更新dp[j]。
第四步:
确定遍历顺序(最关键的难点)
一维版的遍历顺序有严格要求,错了就会变成 "完全背包":
外层循环(物品):从i=0到m-1,依次遍历每个物品(和二维版一致);
内层循环(容量):从j=n(最大容量)倒序遍历到j=weight[i](当前物品的重量);
为什么必须倒序?
答:避免同一个物品被多次选取。倒序时,dp[j-weight[i]]使用的是 "上一轮(未处理当前物品)" 的值;如果正序,dp[j-weight[i]]会被当前物品更新,导致同一个物品被重复装入(变成完全背包)。
举个例子:物品重量 1、价值 2,容量 3;
倒序:j=3→dp[3]=max(0, dp[2]+2)=2;j=2→dp[2]=max(0, dp[1]+2)=2;j=1→dp[1]=max(0, dp[0]+2)=2(每个容量只装一次,符合 0-1);
正序:j=1→dp[1]=2;j=2→dp[2]=max(0, dp[1]+2)=4;j=3→dp[3]=max(0, dp[2]+2)=6(物品被装了多次,变成完全背包)。
第五步:
举例推导 dp 数组(用经典用例)
用和二维版相同的测试用例,手动推导一维版的更新过程,直观理解:
输入:m=2(物品数),n=5(容量);weight=[1,3],value=[2,4];
初始化:dp = [0,0,0,0,0,0](容量 0~5)。
第一步:遍历第一个物品(i=0,weight=1,value=2)
内层倒序遍历 j=5→1:
j=5:dp[5] = max(0, dp[5-1]+2) = max(0,0+2)=2 → dp=[0,0,0,0,0,2];
j=4:dp[4] = max(0, dp[3]+2)=2 → dp=[0,0,0,0,2,2];
j=3:dp[3] = max(0, dp[2]+2)=2 → dp=[0,0,0,2,2,2];
j=2:dp[2] = max(0, dp[1]+2)=2 → dp=[0,0,2,2,2,2];
j=1:dp[1] = max(0, dp[0]+2)=2 → dp=[0,2,2,2,2,2];
遍历完第一个物品,dp和二维版的dp[0][j]完全一致。
第二步:遍历第二个物品(i=1,weight=3,value=4)
内层倒序遍历 j=5→3:
j=5:dp[5] = max(2, dp[5-3]+4)=max(2, dp[2]+4)=max(2,2+4)=6 → dp=[0,2,2,2,2,6];
j=4:dp[4] = max(2, dp[1]+4)=max(2,2+4)=6 → dp=[0,2,2,2,6,6];
j=3:dp[3] = max(2, dp[0]+4)=max(2,0+4)=4 → dp=[0,2,2,4,6,6];
遍历完第二个物品,dp[5]=6(最终结果),和二维版一致。
4.2 实现代码
java
import java.util.*;
import java.lang.*;
public class Main {
public static void main(String[] args) {
Scanner sc = new Scanner(System.in);
// 输入物品数量m和背包总容量n
int m = sc.nextInt(); // m:物品个数
int n = sc.nextInt(); // n:背包最大容量
// 1. 定义一维dp数组:dp[j]表示当前遍历到的物品组合下,容量j的最大价值
// 数组长度n+1,覆盖容量0~n,空间复杂度从O(m*n)优化为O(n)
int[] dp = new int[n + 1];
// 定义重量数组和价值数组,长度为物品数量m
int[] weight = new int[m]; // 存储每个物品的重量
int[] value = new int[m]; // 存储每个物品的价值
// 输入m个物品的重量
for(int i = 0; i < m; i++) {
weight[i] = sc.nextInt();
}
// 输入m个物品的价值
for(int i = 0; i < m; i++) {
value[i] = sc.nextInt();
}
// 2. 遍历顺序:外层遍历物品,内层倒序遍历容量(核心!)
// 外层:依次处理每个物品(0~m-1)
for(int i = 0; i < m; i++) {
// 内层:从最大容量n倒序遍历到当前物品的重量weight[i]
// 倒序原因:避免同一个物品被多次选取(保证dp[j-weight[i]]是上一轮的结果)
for(int j = n; j >= weight[i]; j--) {
// 3. 状态转移方程:
// dp[j](更新前)= 不选当前物品的最大价值(上一轮结果)
// dp[j-weight[i]] + value[i] = 选当前物品的最大价值(容量减少,价值增加)
// 取两者的最大值,更新dp[j]
dp[j] = Math.max(dp[j], dp[j - weight[i]] + value[i]);
}
}
// 4. 输出结果:容量为n时的最大价值(遍历完所有物品后的最终结果)
System.out.println(dp[n]);
sc.close(); // 关闭扫描器,释放资源
}
}
5 分割等和子集
5.1 思路
第一步:
dp [j] 及其下标 j 的含义
dp[j] 表示:遍历到当前数组元素时,能选取若干数组成的、不超过 j 的最大和;
下标j:对应 "虚拟背包" 的容量(范围 0~target);
核心目标:若最终dp[target] == target,说明能选若干数的和恰好等于 target,即数组可分割为两个等和子集。
第二步:
dp[j] = Math.max(dp[j], dp[j - nums[i]] + nums[i]);
等号右边的dp[j]:不选当前元素nums[i]时,能组成的不超过 j 的最大和(上一轮结果);
dp[j - nums[i]] + nums[i]:选当前元素nums[i]时,能组成的和(容量减少 nums [i],和增加 nums [i]);
触发条件:j >= nums[i]("背包容量" 足够装下当前元素);
核心逻辑:对每个元素,选 / 不选它,取能组成的最大和。
第三步:
dp[0] = 0:容量为 0 的背包,能组成的最大和为 0(数组默认值就是 0,无需显式赋值);
其他dp[j] (j>0):初始值为 0,表示 "还没遍历任何元素时,能组成的和为 0";
注意:无需初始化其他值,因为遍历元素时会自动更新。
第四步:
和 0-1 背包一维版完全一致,保证每个元素只选一次:
外层循环:遍历数组元素(i从 0 到 nums.length-1),对应 0-1 背包的 "遍历物品";
内层循环:从target倒序遍历到nums[i],对应 0-1 背包的 "倒序遍历容量";
倒序原因:避免同一个元素被多次选取(保证dp[j-nums[i]]是上一轮的结果)。
第五步:
以经典测试用例 nums = [1,5,11,5] 为例,手动推导:
第一步:计算总和sum=1+5+11+5=22,target=11;
初始化:dp = [0,0,0,0,0,0,0,0,0,0,0,0](索引 0~11);
遍历第一个元素(nums [0]=1)
内层倒序 j=11→1:
j=1:dp[1] = max(0, dp[0]+1)=1;
j=2~11:dp[j] = max(0, dp[j-1]+1)=1;
→ dp = [0,1,1,1,1,1,1,1,1,1,1,1];
dp[11] != 11,继续循环
遍历第二个元素(nums [1]=5)
内层倒序 j=11→5:
j=5:dp[5] = max(1, dp[0]+5)=5;
j=6:dp[6] = max(1, dp[1]+5)=6;
j=7~11:dp[j] = max(1, dp[j-5]+5) → 依次更新为 6、6、6、6、6;
dp[11]=6 ≠ 11,继续循环。
遍历第三个元素 nums [2] = 11
内层循环倒序遍历 j=11 → 11(物品重量是 11,仅 j=11 需要更新)
dp[11] = max(6, dp[0] + 11) = 11
dp[11] == 11,直接返回true
最终结论:数组可分割为[1,5,5]和[11],和均为 11。
5.2 实现代码
java
class Solution {
public boolean canPartition(int[] nums) {
// 边界判断:数组为空或长度为0,直接返回false
if(nums == null || nums.length == 0) return false;
// 第一步:计算数组总和
int sum = 0;
for(int num : nums) {
sum += num;
}
// 总和为奇数:无法分割为两个等和子集(两个整数和为奇数,必一奇一偶)
if(sum % 2 == 1) return false;
// 目标值:总和的一半(转化为0-1背包问题:能否选若干数和为target)
int target = sum / 2;
// 1. 定义一维dp数组:dp[j]表示能选取若干数组成的、不超过j的最大和
// 数组长度target+1,覆盖容量0~target
int[] dp = new int[target + 1];
// 2. 遍历顺序:外层遍历元素(物品),内层倒序遍历容量(核心!)
for(int i = 0; i < nums.length; i++) {
// 内层:从target倒序遍历到nums[i],避免同一元素被多次选取
for(int j = target; j >= nums[i]; j--) {
// 3. 状态转移方程(0-1背包核心):
// dp[j](更新前)= 不选当前元素的最大和(上一轮结果)
// dp[j-nums[i]] + nums[i] = 选当前元素的最大和(容量减少,和增加)
// 取两者最大值,更新dp[j]
dp[j] = Math.max(dp[j], dp[j - nums[i]] + nums[i]);
}
// 提前终止优化:若已找到和为target的子集,直接返回true(无需遍历剩余元素)
if(dp[target] == target) {
return true;
}
}
// 最终判断:能否组成和为target的子集
return dp[target] == target;
}
}