day35 代码随想录算法训练营 动态规划专题3

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;  
    }
}
相关推荐
DeepModel1 小时前
【回归算法】多项式回归详解
算法·回归
百锦再1 小时前
Java中的日期时间API详解:从Date、Calendar到现代时间体系
java·开发语言·spring boot·struts·spring cloud·junit·kafka
Frostnova丶2 小时前
LeetCode 761. 特殊的二进制字符串
算法·leetcode
A懿轩A2 小时前
【Java 基础编程】Java 枚举与注解从零到一:Enum 用法 + 常用注解 + 自定义注解实战
java·开发语言·python
mjhcsp2 小时前
C++ 树形 DP解析
开发语言·c++·动态规划·代理模式
不吃橘子的橘猫2 小时前
《集成电路设计》复习资料3(电路模拟与SPICE)
学习·算法·集成电路·仿真·半导体
m0_531237172 小时前
C语言-函数递归
算法
tuokuac2 小时前
MyBatis-Plus调用getEntity()触发异常
java·mybatis
mjhcsp2 小时前
C++Z 函数超详细解析
c++·算法·z 函数