Leetcode 分割等和子集

发布于:2024-10-15 ⋅ 阅读:(11) ⋅ 点赞:(0)

在这里插入图片描述
这段代码的目的是解决 LeetCode 416 问题:分割等和子集,即判断一个只包含正整数的数组,是否能够将其分割成两个子集,使得这两个子集的元素和相等。

算法思想(动态规划 - 背包问题)

该问题本质上是一个经典的动态规划问题,可以转化为“0-1 背包问题”,具体解释如下:

1. 总和判断:
  • 首先,我们需要计算数组中所有元素的总和 sum
  • 如果这个总和 sum奇数,那我们无法将其分为两个和相等的子集,因为奇数无法均匀分成两个整数。因此,直接返回 false
  • 如果 sum偶数,则问题转化为:我们能否找到一个子集,使得该子集的和等于 sum / 2。如果可以找到这样一个子集,那么剩余的元素自然也会构成另一个子集,它们的和也为 sum / 2
2. 动态规划:
  • 动态规划的核心思想是:我们用一个布尔数组 dp 来表示从数组中是否可以选出若干元素使得这些元素的和为 j。其中 dp[j] 表示数组中的某些子集是否可以构成和为 j 的子集。
  • 初始化:dp[0] = true,表示我们可以通过不选择任何元素来构成和为 0 的子集。
  • 遍历数组中的每个元素 num,从后向前更新 dp 数组。对于每一个 num,我们要判断是否能通过之前的选择和这个 num 构成和为 j 的子集。
3. 代码执行过程:
  • 首先计算数组元素的总和。
  • 如果总和是奇数,直接返回 false
  • 然后创建一个目标和 target = sum / 2,这也是我们希望找到的子集的和。
  • 初始化一个长度为 target + 1 的布尔数组 dp,用于记录是否可以通过某些元素构成特定的子集和。
  • 对数组中的每个元素 num,从后向前遍历 dp 数组,更新 dp[j],表示是否可以用之前的元素和当前元素 num 构成和为 j 的子集。
  • 最后,dp[target] 的值即为我们想要的结果,如果 dp[target]true,则表示可以找到这样的子集,返回 true;否则返回 false

动态规划的状态转移方程:

  • dp[j] = dp[j] || dp[j - num],即:
    • 如果在没有当前数字 num 的情况下可以构成和为 j 的子集,那么 dp[j]true
    • 或者,如果在没有当前数字 num 的情况下可以构成和为 j - num 的子集,那么加上当前数字 num 后也可以构成和为 j 的子集。

时间复杂度:

  • 时间复杂度为 O(n * target),其中 n 是数组中元素的个数,target 是总和的一半(即 sum / 2)。

总结:

这个问题使用动态规划解决,类似于“背包问题”的思路。通过判断是否可以找到和为 sum / 2 的子集,我们就可以判断数组是否能够被分割成两个和相等的子集。

java solution

class Solution {
    public boolean canPartition(int[] nums) {
        //只有nums所有元素和为偶数时,才有可能分割等和子集
        int sum = 0;
        for(int num : nums) {
            sum = sum + num;
        }

        if(sum % 2 != 0) {
            return false;
        }
        
        //如果所有元素和为偶数, 那么我们只要可以找到凑成和为 sum / 2 的子集,就可以返回 true
        int target = sum / 2;

        //创建boolean类型的dp数组, dp[i] 表示子集元素是否可以凑成和为 i
        // 通过 dp[target] 来判断是否凑成功, 所以创建  target + 1 个存储单元
        boolean[] dp = new boolean[target + 1];  //创建的boolean数组初始值默认为false
        dp[0] = true; //我们可以不选择任何元素来凑成 0

        //遍历每个数组元素,从后往前更新 dp
        for(int num : nums) {
            for(int j = target; j >= num; j--) {
                dp[j] = dp[j] || dp[j - num];
            }
        }

        return dp[target];
    }
}

如果 dp[j] 原本是 true,表示已经能够找到和为 j 的子集,保留 true
或者,如果 dp[j - num]true,表示可以通过之前的元素组合成和为 j - num 的子集,那么加上当前的 num,就可以组合成和为 j 的子集,所以 dp[j] 也应更新为 true

这部分代码片段,为什么是从后往前更新dp数组?

    // 遍历每个数字
    for (int num : nums) {
        // 从后向前更新 dp 数组
        for (int j = target; j >= num; j--) {
            dp[j] = dp[j] || dp[j - num];
        }
    }

这个代码片段中从 后往前 更新 dp 数组是为了防止在更新过程中产生错误的结果。原因与避免重复使用同一个元素有关,这是动态规划中处理“0-1 背包问题”时的常见技巧。

问题背景:

在这个问题中,每个元素只能使用一次。这与完全背包问题不同,后者允许一个元素多次使用。为了确保我们在更新 dp 数组时,使用的每个数字 num 只被处理一次,我们需要从后往前更新 dp 数组。

详细解释:

假设我们从前往后更新 dp 数组(即 for (int j = num; j <= target; j++))会导致的问题:

  • 例如,如果我们有一个数字 num = 5,当前目标和 target = 11
  • 当我们从前往后更新时,如果首先更新了 dp[5],假设原本 dp[5] = false,在这次更新中我们设置了 dp[5] = true
  • 但是,当我们接着更新 dp[10] 时,dp[10] 的更新依赖于 dp[5]。由于我们刚刚把 dp[5] 更新为了 true,此时 dp[10] 也会被更新为 true这样导致的问题是我们实际上用了两次 num = 5 来实现目标和,因为 dp[10] 是由新更新的 dp[5] 推导而来的,而这个 dp[5] 实际上是在同一轮循环中刚刚被更新过的。

因此,从前往后更新会导致我们错误地“多次使用”同一个元素。

为什么从后往前更新可以避免这个问题?

通过从后往前更新,可以确保每个元素 num 在每轮循环中只被使用一次。因为当我们更新 dp[j] 时,dp[j - num] 是基于 上一次循环的结果,而不是当前循环已经更新过的结果。具体原因如下:

  • 当我们从 targetnum 方向遍历时,dp[j] 只依赖于 dp[j - num],而 dp[j - num] 在当前循环之前还没有被更新过,因此不会重复使用同一个元素。

举例说明:

假设我们有数组 nums = [1, 5],目标和为 6,dp 数组的初始状态如下:

dp = [true, false, false, false, false, false, false]

其中 dp[0]true 表示可以通过不选任何元素实现和为 0

  • 首先处理 num = 1,从后往前更新 dp 数组:
    • 更新 dp[6],不变,因为 dp[5] 还是 false
    • 更新 dp[5],不变,因为 dp[4] 还是 false
    • 更新 dp[1] = dp[1] || dp[0] = true。更新后:
dp = [true, true, false, false, false, false, false]
  • 接下来处理 num = 5,从后往前更新 dp 数组:
    • 更新 dp[6] = dp[6] || dp[1] = true,因为 dp[1] 已经是 true(由之前的 num = 1 得到)。
    • 更新 dp[5] = dp[5] || dp[0] = true,因为 dp[0]true

最终得到:

dp = [true, true, false, false, false, true, true]

这说明我们可以找到两个子集,分别为 {1, 5},它们的和都是 6。

总结:

从后往前更新 dp 数组的目的是防止在同一次循环中重复使用同一个元素。这样确保了每个元素在一轮循环中只被考虑一次,从而正确解决问题。

为什么外层循环是遍历nums数组,而不是遍历j?

这段代码的外层循环遍历 nums 数组而不是直接遍历 j,是因为我们需要逐个处理数组中的每个数字,并根据这些数字来逐步更新 dp 数组。它遵循的算法思想是“0-1 背包问题”,每个数字只能使用一次,而不是可以无限制地使用。

具体原因和解释:

1. 动态规划的过程依赖于逐个数字的处理:
  • 这个问题的核心是寻找是否存在一个子集,其和等于目标值 target。为了实现这一点,我们必须逐个遍历数组中的每一个元素,检查当前元素 num 是否能与之前选择的数字组合成新的子集,从而更新是否能够构成特定的和。
  • 外层循环遍历 nums 数组,表示我们依次将数组中的每个元素加入到子集的选择中,然后根据这些元素更新 dp 数组,记录我们是否能够构成特定的子集和。
2. 动态规划状态的更新依赖于每个数字:
  • 对于每个数字 num,我们需要判断当前这个 num 能否帮助我们构成新的子集和,即从和为 j - num 的子集中,加上 num,能否构成和为 j 的子集。这是通过内层循环来完成的。
  • 外层循环遍历数组中的每个数字,每次拿到一个新的 num,我们就尝试更新所有可能的子集和(从 targetnum)。因此,外层循环必须遍历 nums 数组,以确保每个元素都被考虑一次。
3. 逐步构造子集和:
  • 动态规划的过程是一步步地构造所有可能的子集和,并用 dp[j] 来记录是否能构成和为 j 的子集。因此,必须逐个遍历每个 num,以确保每个数字都被正确处理,并且能基于前面的状态更新新的状态。
4. 确保每个数字只被使用一次:
  • 如果外层循环遍历 j,而不是遍历 nums 数组,我们就无法确保每个数字 num 只被使用一次。这是因为,如果直接遍历 j,那么在每一轮更新 dp 数组时,我们可能会使用同一个 num 多次。
  • 通过外层循环遍历 nums,我们保证每次只处理一个数字 num,并在内层循环中根据这个 num 更新 dp 数组。这符合“0-1 背包问题”的要求:每个元素最多只能使用一次。

举个例子:

假设我们有数组 nums = [1, 5],目标和为 6,dp 数组初始为:

dp = [true, false, false, false, false, false, false]

此时:

  • 外层循环遍历 nums,首先处理 num = 1,它会从 target 开始逐步更新 dp 数组,确保它和之前的组合是否可以构成新的子集。
  • 接下来处理 num = 5,同样从 target 开始往前更新。

通过这种方式,我们保证每个 num 只被处理一次,避免重复使用同一个元素。

总结:

  • 外层循环遍历 nums 数组,是为了逐一处理数组中的每个数字,确保每个数字 num 都能正确参与到子集和的构造过程中。
  • 通过外层循环遍历 nums 数组,可以有效地控制每个数字 num 只使用一次,避免重复选择。这符合“0-1 背包问题”的要求,即每个元素只能使用一次。