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

1 今日打卡

最后一块石头的重量Ⅱ 1049. 最后一块石头的重量 II - 力扣(LeetCode)

目标和 494. 目标和 - 力扣(LeetCode)

一和零 474. 一和零 - 力扣(LeetCode)

2 动态规划五部曲

确定dp数组(dp table)以及下标的含义

确定递推公式

dp数组如何初始化

确定遍历顺序

举例推导dp数组

3 最后一块石头的重量Ⅱ

3.1 思路

第一步:

dp [j] 及其下标 j 的含义

dp[j] 表示:遍历到当前石头时,能选取若干石头组成的、不超过 j 的最大重量;

下标j:对应 "虚拟背包" 的容量(范围 0~target);

核心作用:dp[target]是 "不超过总重量一半的最大堆重量",另一堆重量 = sum-dp [target],两堆差 = sum-2×dp [target](即最后剩余的最小石头重量)。

第二步:

dp[j] = Math.max(dp[j], dp[j - stones[i]] + stones[i]);

等号右边的dp[j]:不选当前石头stones[i]时,能组成的不超过 j 的最大重量(上一轮结果);

dp[j - stones[i]] + stones[i]:选当前石头stones[i]时,能组成的重量(背包容量减少 stones [i],重量增加 stones [i]);

触发条件:j >= stones[i]("背包容量" 足够装下当前石头);

核心逻辑:对每个石头,选 / 不选它,取能组成的最大重量。

第三步:

dp[0] = 0:容量为 0 的背包,能装的最大重量为 0(数组默认值就是 0,无需显式赋值);

其他dp[j] (j>0):初始值为 0,表示 "还没遍历任何石头时,能装的重量为 0";

注意:无需额外初始化,遍历石头时会自动更新。

第四步:

和 0-1 背包一维版完全一致,保证每个石头只选一次:

外层循环:遍历石头数组(i从 0 到 stones.length-1),对应 0-1 背包的 "遍历物品";

内层循环:从target倒序遍历到stones[i],对应 0-1 背包的 "倒序遍历容量";

倒序原因:避免同一个石头被多次选取(保证dp[j-stones[i]]是上一轮的结果)。

第五步:

以经典测试用例 stones = [2,7,4,1,8,1] 为例,手动推导:

第一步:计算总和sum=2+7+4+1+8+1=23,target=23/2=11(整数除法,取 11);

初始化:dp = [0,0,0,0,0,0,0,0,0,0,0,0](索引 0~11)。

遍历石头 2(stones [0]=2)

内层倒序 j=11→2:

j=2:dp[2] = max(0, dp[0]+2)=2;

j=3~11:dp[j] = max(0, dp[j-2]+2) → 依次更新为 2、2、2、2、2、2、2、2、2;

→ dp = [0,0,2,2,2,2,2,2,2,2,2,2]。

遍历石头 7(stones [1]=7)

内层倒序 j=11→7:

j=7:dp[7] = max(2, dp[0]+7)=7;

j=8:dp[8] = max(2, dp[1]+7)=7;

j=9:dp[9] = max(2, dp[2]+7)=9;

j=10:dp[10] = max(2, dp[3]+7)=9;

j=11:dp[11] = max(2, dp[4]+7)=9;

→ dp = [0,0,2,2,2,2,2,7,7,9,9,9]。

遍历剩余石头(4、1、8、1)

最终遍历完所有石头后,dp[11] = 11(不超过 11 的最大重量);→ 最小剩余重量 = 23 - 2×11 = 1(和实际碰撞结果一致)。

3.2 实现代码

java 复制代码
class Solution {
    public int lastStoneWeightII(int[] stones) {
        // 第一步:计算所有石头的总重量
        int sum = 0;
        for(int num : stones) {
            sum += num;
        }
        
        // 目标值:总重量的一半(向下取整),作为0-1背包的容量
        // 核心:尽可能让一堆石头的重量接近这个target,两堆差最小
        int target = sum / 2;
        
        // 1. 定义一维dp数组:dp[j]表示能选取若干石头组成的、不超过j的最大重量
        // 数组长度target+1,覆盖容量0~target,空间复杂度O(target)
        int[] dp = new int[target + 1];
        
        // 2. 遍历顺序:外层遍历石头(物品),内层倒序遍历容量(核心!)
        for(int i = 0; i < stones.length; i++) {
            // 内层:从target倒序遍历到stones[i],避免同一石头被多次选取
            for(int j = target; j >= stones[i]; j--) {
                // 3. 状态转移方程(0-1背包核心):
                // dp[j](更新前)= 不选当前石头的最大重量(上一轮结果)
                // dp[j-stones[i]] + stones[i] = 选当前石头的最大重量(容量减少,重量增加)
                // 取两者最大值,更新dp[j]
                dp[j] = Math.max(dp[j], dp[j - stones[i]] + stones[i]);
            }
        }
        
        // 4. 计算最后剩余的最小石头重量:
        // sum - dp[target]:另一堆石头的重量
        // 两堆重量差 = (sum - dp[target]) - dp[target] = sum - 2*dp[target]
        return sum - 2 * dp[target];
    }
}

4 目标和

4.1 思路

第一步:

dp [j] 及其下标 j 的含义

dp[j] 表示:从数组中选若干数,使其和恰好等于j的组合方式数量;

下标j:对应 "目标和"(范围 0~left);

核心初始化:dp[0] = 1(和为 0 的组合方式只有 1 种:不选任何数);

举例:dp[5] = 3 表示 "选若干数和为 5 的方式有 3 种"。

第二步:

dp[j] += dp[j - nums[i]];

等号右边的dp[j]:不选当前数nums[i]时,和为j的组合数(上一轮结果);

dp[j - nums[i]]:选当前数nums[i]时,和为j的组合数(因为选了nums[i],剩下的数需要凑j-nums[i]);

触发条件:j >= nums[i](当前数的大小不超过目标和j);

核心逻辑:对每个数,"选它" 的组合数 + "不选它" 的组合数 = 新的组合数。

第三步:

dp[0] = 1:必须显式初始化,这是组合数的 "基准"(和为 0 的组合方式只有 1 种);

其他dp[j] (j>0):初始值为 0,表示 "初始状态下,没有任何组合能凑出 j";

关键:如果dp[0] = 0,后续所有组合数都会是 0,计算完全错误。

第四步:

和 0-1 背包一维版一致,保证每个数只选一次:

外层循环:遍历数组元素(i从 0 到 nums.length-1),对应 "遍历物品";

内层循环:从left倒序遍历到nums[i],对应 "倒序遍历容量";

倒序原因:避免同一个数被多次选取(保证dp[j-nums[i]]是上一轮的结果,即未选当前数时的组合数)。

第五步:

以经典测试用例 nums = [1,1,1,1,1],target = 3 为例:

第一步:计算sum=5,left=(5+3)/2=4;

初始化:dp = [1,0,0,0,0](索引 0~4);

遍历第一个 1(nums [0]=1)

内层倒序 j=4→1:

j=1:dp[1] += dp[0] → dp[1] = 0+1=1;

j=2~4:dp[j] += dp[j-1] → 仍为0;

→ dp = [1,1,0,0,0];

遍历第二个 1(nums [1]=1)

内层倒序 j=4→1:

j=1:dp[1] += dp[0] → 1+1=2;

j=2:dp[2] += dp[1] → 0+1=1;

j=3~4:仍为 0;

→ dp = [1,2,1,0,0];

遍历第三个 1(nums [2]=1)

内层倒序 j=4→1:

j=1:dp[1] += dp[0] → 2+1=3;

j=2:dp[2] += dp[1] → 1+2=3;

j=3:dp[3] += dp[2] → 0+1=1;

j=4:仍为 0;

→ dp = [1,3,3,1,0];

遍历第四个 1(nums [3]=1)

内层倒序 j=4→1:

j=1:dp[1] += dp[0] → 3+1=4;

j=2:dp[2] += dp[1] → 3+3=6;

j=3:dp[3] += dp[2] → 1+3=4;

j=4:dp[4] += dp[3] → 0+1=1;

→ dp = [1,4,6,4,1];

遍历第五个 1(nums [4]=1)

内层倒序 j=4→1:

j=1:dp[1] += dp[0] →4+1=5;

j=2:dp[2] += dp[1] →6+4=10;

j=3:dp[3] += dp[2] →4+6=10;

j=4:dp[4] += dp[3] →1+4=5;

→ dp = [1,5,10,10,5];

最终dp[4] = 5,即有 5 种方式凑出和为 4 的数(对应原问题:5 种方式凑出 target=3),和实际结果一致。

4.2 实现代码

java 复制代码
class Solution {
    public int findTargetSumWays(int[] nums, int target) {
        // 第一步:计算数组总和
        int sum = 0;
        for(int num : nums) {
            sum += num;
        }
        
        // 边界剪枝1:目标和的绝对值超过总和,不可能凑出,直接返回0
        if(Math.abs(target) > sum) return 0;
        
        // 边界剪枝2:left=(sum+target)/2 必须是整数,否则无解方程
        if((target + sum) % 2 == 1) return 0;
        
        // 转化后的目标和:选若干数的和为left,对应原问题的"+"数的和
        int left = (sum + target) / 2;
        
        // 1. 定义一维dp数组:dp[j]表示选若干数和为j的组合方式数量
        // 数组长度left+1,覆盖0~left的所有目标和
        int[] dp = new int[left + 1];
        
        // 2. 初始化dp数组:dp[0]=1(和为0的组合方式只有1种:不选任何数)
        dp[0] = 1;
        
        // 3. 遍历顺序:外层遍历数组元素(物品),内层倒序遍历目标和(容量)
        for(int i = 0; i < nums.length; i++) {
            // 内层:从left倒序遍历到nums[i],避免同一数被多次选取
            for(int j = left; j >= nums[i]; j--) {
                // 4. 状态转移方程:
                // dp[j](更新前)= 不选当前数时,和为j的组合数
                // dp[j-nums[i]] = 选当前数时,和为j的组合数(需凑j-nums[i])
                // 累加两者,得到新的组合数
                dp[j] += dp[j - nums[i]];
            }
        }
        
        // 5. 返回结果:和为left的组合方式数量(对应原问题的答案)
        return dp[left];
    }
}

5 一和零

5.1 思路

步骤1:

确定 dp 数组含义

dp[i][j]:i 个 0、j 个 1 能选的最大字符串数量,初始化全为 0(数组默认值)------ 因为 "不用任何 0 和 1 时,能选的字符串数为 0"。

步骤2:

dp[i][j] = Math.max(dp[i][j], dp[i - zeroNum][j - oneNum] + 1);

把这个方程翻译成 "人话":

左边dp[i][j]:更新后的值(选 / 不选当前字符串后的最优解);

右边dp[i][j]:更新前的值 → 表示 "不选当前字符串时,i 个 0、j 个 1 能选的最大数量";

dp[i - zeroNum][j - oneNum] + 1 → 表示 "选当前字符串时的数量":

i - zeroNum:用了 zeroNum 个 0 后,剩下的 0 的数量;

j - oneNum:用了 oneNum 个 1 后,剩下的 1 的数量;

+1:选了当前字符串,数量加 1;

整个方程的意思:选当前字符串 和 不选当前字符串,挑数量多的那个(因为目标是 "最多字符串数",所以用 max)。

步骤3:

dp[i][j] 初始化为 0:

dp[0][0] = 0:0 个 0、0 个 1,选 0 个字符串;

其他dp[i][j] = 0:初始状态下,还没选任何字符串,数量都是 0;

无需额外初始化,数组默认值就是 0。

步骤4:

遍历顺序(拆成两步)

第一步:遍历每个字符串(外层循环) → 对应一维背包的 "遍历每个物品";

第二步:倒序遍历二维容量(内层双层循环):

先遍历 0 的容量 i:从 m(最大 0 数量)倒序到 zeroNum(当前字符串需要的 0 数量)→ 不够 zeroNum 个 0 的话,装不下这个字符串,不用更;

再遍历 1 的容量 j:从 n(最大 1 数量)倒序到 oneNum(当前字符串需要的 1 数量)→ 不够 oneNum 个 1 的话,装不下这个字符串,不用更;

核心:先物品,后倒序容量(和一维 0-1 背包完全一致,只是多了一个维度)。

步骤5:

测试用例:

strs = ["0", "1", "01"](3 个字符串);

m=1(最多 1 个 0),n=1(最多 1 个 1);

目标:求 dp [1][1](用 1 个 0、1 个 1 能选的最多字符串数)。

步骤 1:初始化 dp 数组(2 行 2 列,索引 0~1)

plaintext

dp = [

0, 0\], // i=0(0个0):j=0→0,j=1→0 \[0, 0\] // i=1(1个0):j=0→0,j=1→0

步骤 2:遍历第一个字符串 "0"(zeroNum=1,oneNum=0)

内层倒序遍历:

i 从 1→1(因为 zeroNum=1);

j 从 1→0(因为 oneNum=0);

逐个计算:

i=1, j=0:dp[1][0] = max(0, dp[1-1][0-0]+1) = max(0, dp[0][0]+1)=1;

i=1, j=1:dp[1][1] = max(0, dp[0][1]+1)=max(0,0+1)=1;

更新后 dp 数组:

plaintext

dp = [

0, 0\], \[1, 1\] // i=1,j=0→1;i=1,j=1→1

步骤 3:遍历第二个字符串 "1"(zeroNum=0,oneNum=1)

内层倒序遍历:

i 从 1→0(zeroNum=0);

j 从 1→1(oneNum=1);

逐个计算:

i=0, j=1:dp[0][1] = max(0, dp[0-0][1-1]+1)=max(0,0+1)=1;

i=1, j=1:dp[1][1] = max(1, dp[1-0][1-1]+1)=max(1, dp[1][0]+1)=max(1,1+1)=2;

更新后 dp 数组:

plaintext

dp = [

0, 1\], // i=0,j=1→1 \[1, 2\] // i=1,j=1→2

步骤 4:遍历第三个字符串 "01"(zeroNum=1,oneNum=1)

内层倒序遍历:

i 从 1→1(zeroNum=1);

j 从 1→1(oneNum=1);

计算 i=1,j=1:dp[1][1] = max(2, dp[1-1][1-1]+1)=max(2,0+1)=2(无变化);

最终结果

dp [1][1] = 2 → 用 1 个 0、1 个 1,最多能选 2 个字符串(比如选 "0" 和 "1"),符合预期。

5.2 实现代码

java 复制代码
class Solution {
    public int findMaxForm(String[] strs, int m, int n) {
        // 1. 定义二维dp数组:dp[i][j]表示用i个0、j个1时,能选的最大字符串数量
        // 数组维度(m+1)×(n+1),覆盖0~m个0、0~n个1的所有情况
        int[][] dp = new int[m + 1][n + 1];
        
        int oneNum, zeroNum; // 临时变量:存储当前字符串的1的数量、0的数量
        // 2. 外层遍历:逐个处理每个字符串(对应0-1背包的"遍历物品")
        for (String str : strs) {
            // 初始化当前字符串的0、1数量为0
            oneNum = 0;
            zeroNum = 0;
            // 统计当前字符串的0和1的数量(计算"物品的二维费用")
            for (char ch : str.toCharArray()) {
                if (ch == '0') {
                    zeroNum++;
                } else {
                    oneNum++;
                }
            }
            
            // 3. 内层倒序遍历:先遍历0的容量,再遍历1的容量(核心!)
            // 倒序原因:避免同一个字符串被多次选取(保证dp[i-zeroNum][j-oneNum]是上一轮结果)
            // i从m倒序到zeroNum:只有0的数量≥zeroNum,才装得下当前字符串
            for (int i = m; i >= zeroNum; i--) {
                // j从n倒序到oneNum:只有1的数量≥oneNum,才装得下当前字符串
                for (int j = n; j >= oneNum; j--) {
                    // 4. 状态转移方程:
                    // dp[i][j](更新前)= 不选当前字符串的最大数量
                    // dp[i-zeroNum][j-oneNum]+1 = 选当前字符串的数量(剩余容量+数量+1)
                    // 取两者最大值,更新dp[i][j]
                    dp[i][j] = Math.max(dp[i][j], dp[i - zeroNum][j - oneNum] + 1);
                }
            }
        }
        // 5. 返回结果:用m个0、n个1能选的最大字符串数量
        return dp[m][n];
    }
}
相关推荐
Mr YiRan1 小时前
C++二义性,多态,纯虚函数和模版函数
java·jvm·c++
ab1515171 小时前
2.24完成129、134、135
数据结构·算法
2301_816997881 小时前
虚拟DOM与Diff算法
前端·vue.js·算法
闻缺陷则喜何志丹1 小时前
P8153 「PMOI-5」送分题/Yet Another Easy Strings Merging|普及+
c++·数学·算法·洛谷
tankeven2 小时前
HJ102 字符统计
c++·算法
升讯威在线客服系统2 小时前
从 GC 抖动到稳定低延迟:在升讯威客服系统中实践 Span 与 Memory 的高性能优化
java·javascript·python·算法·性能优化·php·swift
We་ct2 小时前
LeetCode 199. 二叉树的右视图:层序遍历解题详解
前端·算法·leetcode·typescript·广度优先
孤独的追光者2 小时前
MATLAB导出滤波器参数至ADSP中使用
算法·matlab
weixin_449310842 小时前
使用轻易云平台实现数据ETL转换与写入金蝶云星辰V2
java·数据仓库·etl