代码随想录刷题day43| 最后一块石头的重量II&目标和&一和零

本文详细解释了动态规划在解决最后一块石头的重量II和目标和问题中的应用,涉及dp数组的确定、递推公式的推导、初始化、遍历顺序以及关键代码段的解析。作者通过实例说明了状态转移的逻辑和算法优化技巧。
摘要由CSDN通过智能技术生成


day43学习内容

day43主要内容

  • 最后一块石头的重量II
  • 目标和
  • 一和零

声明
本文思路和文字,引用自《代码随想录》

一、 最后一块石头的重量II

1049.原题链接

1.1、动态规划五部曲

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

- 这个问题可以转化为一个类似背包问题的形式:尝试将石头分成两堆,使得两堆的总重量尽可能接近。最后,问题变成了找出两堆石头重量差的最小值。
- 因此dp[j]表示当背包的容量为 j 时,可以选择的石头的最大总重量。

1.1.2、确定递推公式

定义状态

定义 dp[j] 作为当背包容量为 j 时,可以选择的石头的最大总重量。这里的“背包容量”是一个抽象的概念,用于表示石头总重量的一个上限。

状态转移方程的推导

动态规划解题的关键是找到状态之间如何转移的规则。在这个问题中,对于每块石头 stones[i] 和每个可能的背包容量 j,有两种选择:

  1. 不选择当前石头:不把 stones[i] 加入背包,那么背包的最大可承载重量不会变,即 dp[j] 保持不变。

  2. 选择当前石头:将 stones[i] 加入背包,前提是 j >= stones[i](背包的容量要大于或等于石头的重量)。在这种情况下,背包的最大可承载重量变为 dp[j - stones[i]] + stones[i],也就是在减去这块石头重量的容量下的最大可承载重量加上这块石头的重量。

所以,对于每个 j都需要在这两种选择中找到最大值来更新 dp[j],即:

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

1.1.3、 dp数组如何初始化

int[] dp = new int[target + 1];

动态规划数组 dp 用来存储每个可能的重量下,石头的最大总重量。数组的大小是目标值加1,因为我们要考虑从0到目标值的所有可能性。

1.1.4、确定遍历顺序

需要逆序遍历,为什么呢?

for (int j = target; j >= stones[i]; j--) {
    // ... 更新 dp[j] ...
}

为什么需要逆序

在这个问题中,我们需要决定是否将当前石头 stones[i] 加入背包。对于每块石头,我们有两种选择:加入或不加入。如果我们采用正序遍历,即从小到大遍历 j,那么在更新 dp[j] 时可能会遇到一个问题:我们在计算 dp[j] 的时候可能会用到已经被当前石头更新过的 dp[j - stones[i]] 值,这会导致一块石头被重复计算。

逆序遍历的作用

逆序遍历是为了确保当我们考虑是否把当前石头 stones[i] 加入背包时,用于更新 dp[j] 的较小的 j 值(即 dp[j - stones[i]])尚未被当前石头影响。这样可以保证每块石头只被计算一次。

具体例子

假设 stones[i] 为 3,target 为 5。我们用 dp 数组来记录最大可承载重量。

  • 如果是正序遍历(从小到大):

    • j = 3 时,我们更新 dp[3]
    • 然后 j = 6 时,我们在计算 dp[6] 时会使用新的 dp[3],即 dp[6] = dp[6 - 3] + 3 = dp[3] + 3。这实际上意味着我们加入了两次 stones[i],这是不正确的。
  • 如果是逆序遍历(从大到小):

    • j = 6 时,我们在计算 dp[6] 时使用的是原始的 dp[3],因为 dp[3] 还没有更新。
    • 然后 j = 3 时,我们更新 dp[3]

1.1.5、计算并返回最终结果

1.2、代码

class Solution {
    public int lastStoneWeightII(int[] stones) {
        int sum = 0;
        for (int i : stones) {
            sum += i;
        }
        int target = sum >> 1;
        // 初始化dp数组
        int[] dp = new int[target + 1];
        for (int i = 0; i < stones.length; i++) {
            // 采用倒序
            for (int j = target; j >= stones[i]; j--) {
                // 检查将其放入背包(即其中一堆)是否会导致这堆的总重量更接近目标值。
                // 更新 dp[j] 
                dp[j] = Math.max(dp[j], dp[j - stones[i]] + stones[i]);
            }
        }
        // 最后剩下的石头的最小可能重量。
        return sum - 2 * dp[target];
    }
}

1.2.1、sum - 2 * dp[target];为什么是2乘以

sum - 2 * dp[target] 这部分代码的作用是计算两堆石头重量差的最小值。为了理解为什么是 2 * dp[target],我们需要深入了解这个问题和动态规划解法的核心思想。

  1. 目标的转换
    初始问题是将石头分成两堆,使得两堆石头的重量差最小。假设两堆石头的重量分别为 AB(其中 A >= B),我们想要最小化 A - B

  2. 总重量和目标值
    我们知道总重量 sum = A + B。动态规划的目标是找出使 B 最大的情况,但 B 不能超过 sum / 2(因为我们把问题转换成了背包问题,背包的容量是 sum / 2)。我们用 dp[target] 表示当背包容量为 target(即 sum / 2)时,能装入背包的最大重量,这实际上就是 B 的最大可能值。

  3. 计算重量差
    现在,AB 的重量是 sum - BB。因此,重量差 A - B 实际上是 (sum - B) - B

  4. 推导公式

    • 重量差 = A - B = (sum - B) - B
    • 代入 B = dp[target],得到:
    • 重量差 = sum - dp[target] - dp[target]
    • 整理得到 sum - 2 * dp[target]

因此,sum - 2 * dp[target] 就是计算两堆石头(一堆石头的重量最大不超过总重量的一半)的重量差。这也是为什么要乘以 2 的原因,它表示从总重量中减去两倍的 dp[target],即一堆石头的最大可能重量,来得到两堆石头的最小重量差。


二、目标和

494.原题链接

2.1、动态规划五部曲

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

-  dp[j]表示的是:在当前考虑的nums数组的元素中,能够组成和为j的不同子集的数量。

2.1.2、确定递推公式

直接给结论

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

2.1.3、 dp数组如何初始化

int[] dp = new int[size + 1];
dp[0] = 1; // 不选取任何元素时,存在一种方式使得子集的和为0。这是动态规划的基础情况。

2.1.4、确定遍历顺序

需要逆序遍历。

2.2、代码

class Solution {
    public int findTargetSumWays(int[] nums, int target) {
        int sum = 0;
        for (int i = 0; i < nums.length; i++) {
            sum += nums[i];
        }
        // 如果 target 是负数且其绝对值大于 sum,那么不可能通过加减操作得到 target,因此返回 0。
        if (target < 0 && sum < -target) {
            return 0;
        }
        // 和的奇数偶数检查。
        // 如果 target + sum 的和不是偶数,则不可能将 nums 分成两部分,使得两部分之差为 target。
        if ((target + sum) % 2 != 0) {
            return 0;
        }
        // 确定新目标size
        int size = (target + sum) / 2;
        if (size < 0) {
            size = -size;
        }
        // 初始化动态规划数组
        int[] dp = new int[size + 1];
        dp[0] = 1;
        for (int i = 0; i < nums.length; i++) {
            for (int j = size; j >= nums[i]; j--) {
                dp[j] += dp[j - nums[i]];
            }
        }
        return dp[size];
    }
}

2.2.1、如何理解int size = (target + sum) / 2;

这一步是将原问题转换为一个子集和问题。我们需要找到一个子集,其和为 (target + sum) / 2。这是因为如果 S1 是这个子集,S2 是 nums 中剩余元素的集合,则 S1 - S2 = target,同时 S1 + S2 = sum。解这两个方程,我们得到 S1 = (target + sum) / 2。

2.2.2、如何理解 size = -size;

在给定的代码中,size = (target + sum) / 2; 这一行计算的是把原问题转换成了一个子集和问题的新目标size。原问题是通过给数组nums中的每个元素添加正号或负号,使得它们的总和等于给定的target。通过数学变换,这个问题可以转换为找到nums的一个子集,使得该子集中元素的和为size。这里的size是将原来的target值和数组nums的所有元素之和sum相加后除以2得到的。

  1. 为什么需要计算(target + sum) / 2

    这个转换基于这样一个事实:如果你可以找到一个子集S的和等于(target + sum) / 2,那么数组中剩余的元素和就是sum - S。由于原问题要求我们将元素分成和为target的两部分,这等价于找到一个子集S使得S - (sum - S) = target,解这个方程你会得到S = (target + sum) / 2

  2. size = -size;的作用:
    就是用来处理负数的情况
    由于目标size必须是非负数(因为你不能有负数个元素的和),size = -size;这行代码的目的是确保当我们通过(target + sum) / 2得到一个负数时,我们将它转换成正数。这是基于我们想要的是一个目标和的绝对值,而不是实际的负值。因为实际上,寻找一个子集使其和为负-x和寻找一个子集使其和为正x在数学上是等价的,都是在说“我们需要这个子集和其他所有未选中的元素之间有x的差值”。

2.2.3、状态转移返程是怎么推导出来的

转化问题

首先,这个问题可以转化为子集求和问题:从 nums 数组中找到元素,使它们的总和等于一个特定的值。这个特定的值是 (target + sum) / 2,其中 sum 是数组中所有元素的和。这种转化基于以下思考:

  • 假设选定的子集为 S1,剩下的子集为 S2。那么 S1 - S2 = target
  • 同时我们知道 S1 + S2 = sum
  • 解这两个方程,我们可以得到 S1 = (target + sum) / 2

状态定义

在动态规划中,我们定义 dp[j] 为从数组中选取若干元素,使得这些元素的和恰好为 j 的方法数量。

状态转移方程推导

  1. 初始状态dp[0] = 1,表示总和为 0 的方法只有一种,即不选择任何元素。

  2. 状态转移:对于每个元素 nums[i] 和每个目标和 j(从 size 递减到 nums[i]),我们考虑是否选择当前元素 nums[i]

    • 如果不选择 nums[i],则 dp[j] 的值保持不变,即之前计算的结果。
    • 如果选择 nums[i],则新的方法数需要加上和为 j - nums[i] 的方法数,即 dp[j - nums[i]]

因此,状态转移方程为:

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

这表示达到和为 j 的方法数是在不包括当前元素的情况下达到和为 j 的方法数(dp[j])加上包括当前元素后达到和为 j - nums[i] 的方法数。

通过这种方式,我们可以确保每个状态 dp[j] 都是在包含或不包含当前元素的两种情况下的方法数的总和。

三、一和零

474.原题链接

3.1、动态规划五部曲

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

- dp[i][j]:最多有i个0和j个1的strs的最大子集的大小为dp[i][j]。

3.1.2、确定递推公式

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

对于每一个i,j对,你有两个选择:不包含当前字符串(保持dp[i][j]不变)或者包含当前字符串(则dp[i][j]应该等于在去除了当前字符串的zeroNum和oneNum后的dp[i - zeroNum][j - oneNum]的值加一)。选择这两个选项中较大的一个来更新dp[i][j]

3.1.3、 dp数组如何初始化

初始化一个(m + 1) * (n + 1)的DP数组,因为’0’的数量可以从0到m,'1’的数量可以从0到n。

 int[][] dp = new int[m + 1][n + 1];

3.1.4、确定遍历顺序

逆序遍历

3.1.5、计算并返回最终结果

return dp[m][n];

3.2、代码

class Solution {
    public int findMaxForm(String[] strs, int m, int n) {
        // dp[i][j]表示m个0和n个1时的最大子集
        // 初始化一个(m + 1) * (n + 1)的DP数组,因为'0'的数量可以从0到m,'1'的数量可以从0到n。
        int[][] dp = new int[m + 1][n + 1];
        int oneNum, zeroNum;
        // 计算出strs包含的'0'的数量(zeroNum)和'1'的数量(oneNum)。
        for (String str : strs) {
            oneNum = 0;
            zeroNum = 0;
            for (char ch : str.toCharArray()) {
                if (ch == '0') {
                    zeroNum++;
                } else {
                    oneNum++;
                }
            }
            // 从m和n开始倒序遍历到当前字符串所需的zeroNum和oneNum
            for (int i = m; i >= zeroNum; i--) {
                for (int j = n; j >= oneNum; j--) {
                    dp[i][j] = Math.max(dp[i][j], dp[i - zeroNum][j - oneNum] + 1);
                }
            }
        }
        return dp[m][n];
    }
}

总结

1.感想

  • 第三题,已经晕掉了。。

2.思维导图

本文思路引用自代码随想录,感谢代码随想录作者。

  • 21
    点赞
  • 24
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值