416. 分割等和子集
问题描述
给定一个只包含正整数的非空数组 nums
,判断是否可以将这个数组分割成两个子集,使得两个子集的元素和相等。
示例:
输入: nums = [1,5,11,5]
输出: true
解释: 数组可以分割成 [1,5,5] 和 [11],元素和均为 11。
输入: nums = [1,2,3,5]
输出: false
解释: 数组不能分割成两个和相等的子集。
算法思路
问题转化:
- 数学推导:
- 设两个子集和均为
S
,则总和sum = 2S
→S = sum / 2
。 - 问题转化为:是否存在子集,其和等于
sum / 2
。
- 设两个子集和均为
- 边界条件:
- 若
sum
为奇数 → 不可能分割,返回false
。 - 若最大元素 >
S
→ 不可能分割,返回false
。
- 若
动态规划(空间优化):
- 状态定义:
dp[j]
表示是否存在和为j
的子集。
- 状态转移:
- 遍历每个数
num
,倒序更新j
从S
到num
:
dp[j] = dp[j] || dp[j - num]
- 遍历每个数
- 初始化:
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]
- 计算总和:
sum = 22
- 计算目标子集和:
S = 22 / 2 = 11
- 初始化 DP 数组:
dp = [true, false, false, ..., false]
(长度 12) - 遍历数字:
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
- 结果:
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
}
关键点
- 问题转化:
- 将分割问题转化为子集和问题(背包问题)。
- 边界剪枝:
- 总和为奇数直接返回
false
。 - 最大元素超过总和一半直接返回
false
。
- 总和为奇数直接返回
- 动态规划核心:
- 状态转移:
dp[j] = dp[j] || dp[j - num]
。 - 倒序遍历避免重复计数。
- 状态转移:
- 初始化:
dp[0] = true
(空子集和为 0)。
常见问题
- 为什么需要倒序遍历
j
?
防止同一数字被重复使用(每个数字只能选一次)。 - 如何处理最大元素等于
S
?
此时直接成立(单独选择该元素),但代码会自动处理:当j = num
时,dp[j] = dp[0] = true
。 - 为什么需要
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)+ 回溯 + 剪枝优化:
-
预处理:
- 计算总和,若不能被
k
整除则直接返回false
- 排序并反转数组(从大到小),优先处理大数可加速剪枝
- 初始化
k
个桶记录当前和
- 计算总和,若不能被
-
DFS 核心:
- 递归终止:所有数字分配完成
- 桶选择策略:
- 跳过和超过目标值的桶
- 跳过与前一个桶和相同的桶(避免重复)
- 空桶放置失败后不再尝试其他空桶(关键剪枝)
-
剪枝优化:
- 大数优先:减少无效搜索路径
- 空桶剪枝:若当前数字放入空桶失败,则无需尝试其他空桶
- 重复桶剪枝:跳过和相同的相邻桶
代码实现
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--;
}
}
}
注释
-
预处理阶段:
total % k != 0
:快速失败Arrays.sort(nums)
+reverse()
:大数优先策略nums[0] > target
:最大数超过目标值直接失败
-
DFS 核心逻辑:
- 循环遍历桶:尝试将当前数字放入每个有效桶
- 剪枝条件:
buckets[i] + nums[index] > target
:跳过超限桶i>0 && buckets[i]==buckets[i-1]
:跳过相同和桶
- 递归探索:
dfs(index+1)
- 回溯恢复:
buckets[i] -= nums[index]
- 空桶剪枝:
buckets[i]==0
时终止尝试
-
辅助方法:
reverse()
:实现数组反转
算法分析
- 时间复杂度:最坏 O(kⁿ),但剪枝大幅提升效率
- 空间复杂度:O(n) 递归栈 + O(k) 桶数组
- 关键优化:
- 大数优先减少无效路径
- 空桶剪枝避免重复尝试
- 相同和桶跳过
算法过程
输入:nums = [4,3,2,3,5,2,1]
, k=4
, target=5
- 排序反转:
[5,4,3,3,2,2,1]
- 分配过程:
- 5 → 桶1:
[5]
- 4 → 桶2:
[4]
- 3 → 桶3:
[3]
- 3 → 桶4:
[3]
- 2 → 桶2:
[4+2]
(需回溯)- 回溯:2 → 桶3:
[3+2]
- 回溯:2 → 桶3:
- 2 → 桶4:
[3+2]
- 1 → 桶2:
[4+1=5]
- 5 → 桶1:
测试用例
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
}
关键点
- 总和检查:
total % k != 0
快速失败 - 大数优先:
- 排序反转使大数在前
- 优先处理约束性强的数字
- 剪枝策略:
- 桶超限跳过
- 相同和桶跳过
- 空桶失败终止循环
- 回溯机制:
- 递归前更新桶状态
- 失败后恢复状态
- 边界处理:
- 单桶超限直接失败
- 空数组和单元素处理
为什么空桶剪枝有效?
若当前数字放入空桶失败,说明该数字无法单独成组或与其他数字组合成有效子集。由于桶是等价的,尝试其他空桶必然同样失败。