动态规划 分割等和子集

416. 分割等和子集

问题描述

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

示例

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

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

算法思路

问题转化

  1. 数学推导
    • 设两个子集和均为 S,则总和 sum = 2SS = sum / 2
    • 问题转化为:是否存在子集,其和等于 sum / 2
  2. 边界条件
    • sum 为奇数 → 不可能分割,返回 false
    • 若最大元素 > S → 不可能分割,返回 false

动态规划(空间优化)

  1. 状态定义
    • dp[j] 表示是否存在和为 j 的子集。
  2. 状态转移
    • 遍历每个数 num,倒序更新 jSnum
      dp[j] = dp[j] || dp[j - num]
  3. 初始化
    • dp[0] = true(空子集和为 0)。

代码实现

class Solution {
    public boolean canPartition(int[] nums) {
        int sum = 0;
        int maxNum = 0;
        for (int num : nums) {
            sum += num;
            if (num > maxNum) maxNum = num;
        }
        
        // 边界条件:总和为奇数
        if (sum % 2 != 0) return false;
        int S = sum / 2;
        // 边界条件:最大元素超过一半
        if (maxNum > S) return false;
        
        boolean[] dp = new boolean[S + 1];
        dp[0] = true; // 初始状态:空子集和为0
        
        // 遍历每个数字
        for (int num : nums) {
            // 倒序遍历背包容量(避免重复计数)
            for (int j = S; j >= num; j--) {
                dp[j] = dp[j] || dp[j - num];
            }
        }
        return dp[S];
    }
}

算法分析

  • 时间复杂度:O(n×S)
    其中 n 为数组长度,S 为总和的一半。
  • 空间复杂度:O(S)
    使用一维 DP 数组。

算法过程

输入nums = [1,5,11,5]

  1. 计算总和sum = 22
  2. 计算目标子集和
    S = 22 / 2 = 11
  3. 初始化 DP 数组
    dp = [true, false, false, ..., false](长度 12)
  4. 遍历数字
    • num=1:更新 j=11→1
      dp[1] = dp[0] || dp[1] = true
      dp[1]=true
    • num=5:更新 j=11→5
      dp[11] = dp[6] || dp[11] = false
      dp[10] = dp[5] || dp[10] = false
      dp[6] = dp[1] || dp[6] = true
      dp[5] = dp[0] || dp[5] = true
      dp[5]=true, dp[6]=true
    • 后续数字继续更新,最终 dp[11]=true
  5. 结果true

测试用例

public static void main(String[] args) {
    Solution solution = new Solution();
    
    // 测试用例1: 标准示例(可分割)
    int[] nums1 = {1,5,11,5};
    System.out.println("Test 1: " + solution.canPartition(nums1)); // true
    
    // 测试用例2: 不可分割(总和为奇数)
    int[] nums2 = {1,2,3,5};
    System.out.println("Test 2: " + solution.canPartition(nums2)); // false
    
    // 测试用例3: 单个元素
    int[] nums3 = {5};
    System.out.println("Test 3: " + solution.canPartition(nums3)); // false(无法分割)
    
    // 测试用例4: 全相同元素(可分割)
    int[] nums4 = {3,3,3,3};
    System.out.println("Test 4: " + solution.canPartition(nums4)); // true
    
    // 测试用例5: 最大元素超过一半
    int[] nums5 = {10,1,1};
    System.out.println("Test 5: " + solution.canPartition(nums5)); // false
    
}

关键点

  1. 问题转化
    • 将分割问题转化为子集和问题(背包问题)。
  2. 边界剪枝
    • 总和为奇数直接返回 false
    • 最大元素超过总和一半直接返回 false
  3. 动态规划核心
    • 状态转移:dp[j] = dp[j] || dp[j - num]
    • 倒序遍历避免重复计数。
  4. 初始化
    • dp[0] = true(空子集和为 0)。

常见问题

  1. 为什么需要倒序遍历 j
    防止同一数字被重复使用(每个数字只能选一次)。
  2. 如何处理最大元素等于 S
    此时直接成立(单独选择该元素),但代码会自动处理:当 j = num 时,dp[j] = dp[0] = true
  3. 为什么需要 maxNum > S 的检查?
    优化剪枝:若存在元素大于目标值 S,则不可能构造出和为 S 的子集。

698. 划分为k个相等的子集

问题描述

给定一个整数数组 nums 和一个正整数 k,判断是否能将该数组划分为 k 个非空子集,使得每个子集内所有元素的和都相等。

示例

输入:nums = [4, 3, 2, 3, 5, 2, 1], k = 4
输出:true
解释:数组可划分为 [5], [1,4], [2,3], [2,3](和均为5)

算法思路

深度优先搜索(DFS)+ 回溯 + 剪枝优化

  1. 预处理

    • 计算总和,若不能被 k 整除则直接返回 false
    • 排序并反转数组(从大到小),优先处理大数可加速剪枝
    • 初始化 k 个桶记录当前和
  2. DFS 核心

    • 递归终止:所有数字分配完成
    • 桶选择策略
      • 跳过和超过目标值的桶
      • 跳过与前一个桶和相同的桶(避免重复)
      • 空桶放置失败后不再尝试其他空桶(关键剪枝)
  3. 剪枝优化

    • 大数优先:减少无效搜索路径
    • 空桶剪枝:若当前数字放入空桶失败,则无需尝试其他空桶
    • 重复桶剪枝:跳过和相同的相邻桶

代码实现

import java.util.Arrays;

class Solution {
    public boolean canPartitionKSubsets(int[] nums, int k) {
        // 计算总和
        int total = Arrays.stream(nums).sum();
        
        // 基础检查:总和必须能被k整除
        if (total % k != 0) return false;
        int target = total / k;
        
        // 排序并反转(大数优先)
        Arrays.sort(nums);
        reverse(nums);
        
        // 剪枝:最大数超过目标值
        if (nums[0] > target) return false;
        
        // 初始化桶
        int[] buckets = new int[k];
        return dfs(nums, 0, buckets, target);
    }
    
    /**
     * DFS 回溯搜索
     * 
     * @param nums     输入数组
     * @param index    当前处理的数字索引
     * @param buckets  桶数组(记录每个桶当前和)
     * @param target   每个桶的目标和
     * @return         是否能成功划分
     */
    private boolean dfs(int[] nums, int index, int[] buckets, int target) {
        // 所有数字已分配
        if (index == nums.length) {
            return true;
        }
        
        // 尝试将当前数字放入每个桶
        for (int i = 0; i < buckets.length; i++) {
            // 剪枝:跳过超过目标值的桶
            if (buckets[i] + nums[index] > target) continue;
            
            // 剪枝:跳过和相同的相邻桶(避免重复计算)
            if (i > 0 && buckets[i] == buckets[i - 1]) continue;
            
            // 放入当前桶
            buckets[i] += nums[index];
            
            // 递归处理下一个数字
            if (dfs(nums, index + 1, buckets, target)) {
                return true;
            }
            
            // 回溯:取出数字
            buckets[i] -= nums[index];
            
            // 关键剪枝:空桶放置失败后不再尝试其他空桶
            if (buckets[i] == 0) break;
        }
        
        return false;
    }
    
    /** 反转数组(大数在前) */
    private void reverse(int[] nums) {
        int left = 0, right = nums.length - 1;
        while (left < right) {
            int temp = nums[left];
            nums[left] = nums[right];
            nums[right] = temp;
            left++;
            right--;
        }
    }
}

注释

  1. 预处理阶段

    • total % k != 0:快速失败
    • Arrays.sort(nums) + reverse():大数优先策略
    • nums[0] > target:最大数超过目标值直接失败
  2. DFS 核心逻辑

    • 循环遍历桶:尝试将当前数字放入每个有效桶
    • 剪枝条件
      • buckets[i] + nums[index] > target:跳过超限桶
      • i>0 && buckets[i]==buckets[i-1]:跳过相同和桶
    • 递归探索dfs(index+1)
    • 回溯恢复buckets[i] -= nums[index]
    • 空桶剪枝buckets[i]==0 时终止尝试
  3. 辅助方法

    • reverse():实现数组反转

算法分析

  • 时间复杂度:最坏 O(kⁿ),但剪枝大幅提升效率
  • 空间复杂度:O(n) 递归栈 + O(k) 桶数组
  • 关键优化
    1. 大数优先减少无效路径
    2. 空桶剪枝避免重复尝试
    3. 相同和桶跳过

算法过程

输入:nums = [4,3,2,3,5,2,1], k=4, target=5

  1. 排序反转[5,4,3,3,2,2,1]
  2. 分配过程
    • 5 → 桶1:[5]
    • 4 → 桶2:[4]
    • 3 → 桶3:[3]
    • 3 → 桶4:[3]
    • 2 → 桶2:[4+2](需回溯)
      • 回溯:2 → 桶3:[3+2]
    • 2 → 桶4:[3+2]
    • 1 → 桶2:[4+1=5]

测试用例

public static void main(String[] args) {
    Solution solution = new Solution();
    
    // 测试用例1:标准示例
    int[] nums1 = {4,3,2,3,5,2,1};
    System.out.println("Test1: " + solution.canPartitionKSubsets(nums1, 4)); // true
    
    // 测试用例2:无法划分
    int[] nums2 = {1,2,3,4};
    System.out.println("Test2: " + solution.canPartitionKSubsets(nums2, 3)); // false
    
    // 测试用例3:含重复元素
    int[] nums3 = {2,2,2,2,3,3,3,3};
    System.out.println("Test3: " + solution.canPartitionKSubsets(nums3, 4)); // true
    
    // 测试用例4:大数超限
    int[] nums4 = {5,5,5,5};
    System.out.println("Test4: " + solution.canPartitionKSubsets(nums4, 2)); // false
    
    // 测试用例5:总和不足
    int[] nums5 = {1,1,1,1};
    System.out.println("Test5: " + solution.canPartitionKSubsets(nums5, 5)); // false
}

关键点

  1. 总和检查total % k != 0 快速失败
  2. 大数优先
    • 排序反转使大数在前
    • 优先处理约束性强的数字
  3. 剪枝策略
    • 桶超限跳过
    • 相同和桶跳过
    • 空桶失败终止循环
  4. 回溯机制
    • 递归前更新桶状态
    • 失败后恢复状态
  5. 边界处理
    • 单桶超限直接失败
    • 空数组和单元素处理

为什么空桶剪枝有效
若当前数字放入空桶失败,说明该数字无法单独成组或与其他数字组合成有效子集。由于桶是等价的,尝试其他空桶必然同样失败。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值