Skip to content

分割等和子集

约 1165 字大约 4 分钟

动态规划背包问题

2025-02-26

416. 分割等和子集

给你一个 只包含正整数非空 数组 nums 。请你判断是否可以将这个数组分割成两个子集,使得两个子集的元素和相等。

示例 1:

输入:nums = [1,5,11,5]
输出:true
解释:数组可以分割成 [1, 5, 5] 和 [11]。

示例 2:

输入:nums = [1,2,3,5]
输出:false
解释:数组不能分割成两个元素和相等的子集。

提示:

  • 1 <= nums.length <= 200
  • 1 <= nums[i] <= 100

动态规划,递推

背包问题。

特殊情况:n<2或总和为奇数或最大值大于target。

dp[i][j] 表示在前 i+1 个元素中是否可以找到和为 j 的子集。任何 dp[i][0] 都为 true,因为和为0的子集就是空集。

class Solution {
    public boolean canPartition(int[] nums) {
        int n = nums.length;
        // 特殊情况
        if (n < 2) {
            return false;
        }
        
        int sum = 0, maxNum = 0;
        // 求最大值和总和
        for (int num : nums) {
            sum += num;
            maxNum = Math.max(maxNum, num);
        }
        // 特殊情况:总和为奇数
        if (sum % 2 != 0) {
            return false;
        }
        // 特殊情况:最大值大于和的一半,说明最大值不行,剩下的值也不行(小于target)。
        int target = sum / 2;
        if (maxNum > target) {
            return false;
        }
        
        // 初始化
        // dp[i][j] 表示在前 i+1 个元素[0...i]中是否可以找到和为 j 的子集。重点。
        boolean[][] dp = new boolean[n][target + 1];
        // 初始化 dp 的第一列,任何 dp[i][0] 都为 true,因为和为0的子集就是空集。重点。
        for (int i = 0; i < n; i++) {
            dp[i][0] = true;
        }
        
        // 初始化第一行中的第nums[0]个元素。dp数组的第一行中的其它值都是false。重点。
        dp[0][nums[0]] = true;
        // 开始递推。先遍历物品再遍历容量。
        for (int i = 1; i < n; i++) {
            int num = nums[i];
            // 不能从num开始遍历,不然j<num时dp[i][j]是默认值,应该是dp[i-1][j]才对。重点。
            // 滚动更新的时候才可以从num开始遍历。
            for (int j = 1; j <= target; j++) {
                if (j >= num) {
                    // 如果当前和 j 大于等于当前元素 num,可以选择当前元素或不选择当前元素
                    dp[i][j] = dp[i - 1][j] || dp[i - 1][j - num];
                } else {
                    // 如果当前和 j 小于当前元素 num,只能不选择当前元素,即和上一轮的值一样。
                    dp[i][j] = dp[i - 1][j];
                }
            }
        }
        
        return dp[n - 1][target];
    }
}
  • 时间复杂度:O(n×target)O(n \times target),其中 nn 是数组的长度, target 是整个数组的元素和的一半。需要计算出所有的状态, 每个状态在进行转移时的时间复杂度为 O(1)O(1)
  • 空间复杂度:O(n×target)O(n \times target),其中 target 是整个数组的元素和的一半。空间复杂度取决于 dp 数组, 在不进行空间优化的情况下,空间复杂度是 O(n×target)O(n \times target),在进行空间优化的情况下,空间复杂度可以降到 O(target)O(target)

动态规划优化空间-数组递推+倒序遍历+滚动更新。

之所以要倒序遍历,是因为dp[j]依赖的是上一轮的dp[j-num],如果从前往后遍历的话,dp[j]依赖的就是本轮的dp[j-num]了

class Solution {
    public boolean canPartition(int[] nums) {
        int n = nums.length;
        // 特殊情况
        if (n < 2) {
            return false;
        }
        
        int sum = 0, maxNum = 0;
        // 求最大值
        for (int num : nums) {
            sum += num;
            maxNum = Math.max(maxNum, num);
        }
        // 特殊情况:总和为奇数
        if (sum % 2 != 0) {
            return false;
        }
        // 特殊情况:最大值大于target
        int target = sum / 2;
        if (maxNum > target) {
            return false;
        }
        
        // 初始化
        boolean[] dp = new boolean[target + 1];
        dp[0] = true;
        // 正序遍历物品,倒序遍历容量。
        for (int i = 0; i < n; i++) {
            int num = nums[i];
            // 之所以要倒序遍历j,是因为dp[j]依赖的是上一轮的dp[j-num],如果从前往后遍历的话,
            // dp[j]依赖的就是本轮的dp[j-num]了。就好像拖地要倒着拖不能正着拖,不然又踩脏了。
            for (int j = target; j >= num; --j) {
                dp[j] |= dp[j - num];
            }
        }
        
        return dp[target];
    }
}
  • 时间复杂度: O(n×O(n \times target )) ,其中 nn 是数组的长度, target 是整个数组的元素和的一半。需要计算出所有的状态,每个状态在进行转移时的时间复杂度为 O(1)O(1)
  • 空间复杂度: OO (target),其中 target 是整个数组的元素和的一半。空间复杂度取决于 dpd p 数组,在不进行空间优化的情况下,空间复杂度是 O(n×O(n \times target ,在进行空间优化的情况下,空间复杂度可以降到 O(O( target ))