目录

题目描述

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

题意分析

题目要的是一个判定:能不能把数组里的每一个数恰好用一次,分成 k 组,使每组的和都一样。注意是「划分」不是「选出」,没有数可以被剩下,也没有数可以被用两次。

约束里最关键的信号是数组长度只有 16 量级,而 k 也很小。这个规模摆明了不指望多项式算法,而是允许指数级搜索,前提是剪枝要够狠。另一个信号是数字全为正整数,这保证了「往一组里加数,和只会变大不会变小」,超过目标就可以立刻放弃这条分支。

从和出发能马上得到两个必要条件:总和必须能被 k 整除,否则每组的和不是整数,直接无解;每组的目标和就是总和除以 k。另外任何一个数都不能超过目标和,否则它自己一个人就撑爆了所属的那一组。

边界要留意:最大值恰好等于目标和(合法,它独占一组)、数组里有大量重复值(会制造大量等价的搜索分支)、k 等于 1(整个数组就是唯一一组)、以及 k 等于数组长度(每个数各成一组)。

解法:逐桶回溯 + 对称剪枝

核心思路

若能划分成功,每个子集的目标和只能是 sum / k。因此先检查总和能否整除 k,并排除最大元素超过目标和的情况。数组长度最多 16,适合回溯,但必须减少等价分支。

本文逐个填桶:当前桶的和为 current,选取尚未使用且不会超过 target 的数;填满后再开下一个桶。递归不变量是:已完成的桶都恰好等于 targetused 标记了已完成桶和当前桶中的元素。当只剩一个桶时,剩余元素的总和必然等于 target,可以直接成功。

三个剪枝决定了效率:

  1. 降序选择:先放大数,让“不可能装下”的矛盾尽早暴露。
  2. 同层去重:同一递归层中,相同数值产生的后续状态完全相同;一个失败后无需再试相同值。
  3. 空桶对称剪枝:空桶尝试最大的未使用数仍失败时,直接返回。因为未开始的桶彼此没有区别,而这个最大数必然属于某个桶;可以把那个桶重命名为当前桶,所以换另一个数作为开头只是重复排列桶编号。

解题步骤

  1. 计算总和;不能被 k 整除时直接返回 false
  2. 排序数组,从末尾开始按降序选数;最大值超过 target 时无解。
  3. 回溯参数记录剩余桶数、当前桶可选下标上界和当前桶和。
  4. 当前桶达到 target 时,重置下标上界与桶和,开始填下一个桶。
  5. 枚举未使用且能放入当前桶的数,标记后递归,失败则撤销。
  6. previous 跳过同层已经失败的相同值;若失败发生在空桶的第一次选择,立即剪掉所有等价空桶分支。
  7. 剩一个桶时直接返回 true

[4,3,2,3,5,2,1]k = 4,目标和为 5。降序搜索会依次形成 {5}{4,1}{3,2},最后剩余 {3,2} 自动成立。

代码实现

import java.util.Arrays;

class Solution {
    public boolean canPartitionKSubsets(int[] nums, int k) {
        int sum = 0;
        for (int num : nums) {
            sum += num;
        }
        if (sum % k != 0) {
            return false;
        }

        int target = sum / k;
        Arrays.sort(nums);
        if (nums[nums.length - 1] > target) {
            return false;
        }
        return fill(nums, new boolean[nums.length], k, nums.length - 1, 0, target);
    }

    private boolean fill(int[] nums, boolean[] used, int buckets,
                         int start, int current, int target) {
        if (buckets == 1) {
            return true;
        }
        if (current == target) {
            return fill(nums, used, buckets - 1, nums.length - 1, 0, target);
        }

        int previous = -1;
        for (int i = start; i >= 0; i--) {
            if (used[i] || nums[i] == previous || current + nums[i] > target) {
                continue;
            }

            used[i] = true;
            if (fill(nums, used, buckets, i - 1, current + nums[i], target)) {
                return true;
            }
            used[i] = false;
            previous = nums[i];

            if (current == 0) {
                return false;
            }
        }
        return false;
    }
}
import "sort"

func canPartitionKSubsets(nums []int, k int) bool {
	sum := 0
	for _, num := range nums {
		sum += num
	}
	if sum%k != 0 {
		return false
	}

	target := sum / k
	sort.Ints(nums)
	if nums[len(nums)-1] > target {
		return false
	}
	return fillBuckets(nums, make([]bool, len(nums)), k, len(nums)-1, 0, target)
}

func fillBuckets(nums []int, used []bool, buckets, start, current, target int) bool {
	if buckets == 1 {
		return true
	}
	if current == target {
		return fillBuckets(nums, used, buckets-1, len(nums)-1, 0, target)
	}

	previous := -1
	for i := start; i >= 0; i-- {
		if used[i] || nums[i] == previous || current+nums[i] > target {
			continue
		}

		used[i] = true
		if fillBuckets(nums, used, buckets, i-1, current+nums[i], target) {
			return true
		}
		used[i] = false
		previous = nums[i]

		if current == 0 {
			return false
		}
	}
	return false
}

复杂度分析

  • 时间复杂度:最坏可达 $O(k^n)$,因为每个数都可能被尝试分配到多个桶;排序、同层去重和空桶剪枝会显著缩小实际搜索树,但不改变指数级上界。
  • 空间复杂度:$O(n)$,used 数组与递归栈都至多为数组长度。

关键点总结

  • 先用总和整除和最大值检查排除必定无解的输入。
  • 降序选择不是为了正确性,而是为了更早触发超额剪枝。
  • previous 去掉同一层的等值分支,不能跨递归层共享。
  • 空桶剪枝来自桶编号的对称性:强制最大的未使用数进入当前空桶不会漏解。
  • buckets == 1 能直接成功,依赖“前面每个桶都严格填满”的不变量。

易错点总结

  • 不先判断 sum % k,整数除法会产生错误目标和。
  • 新开一个桶时必须把 start 重置到数组末尾,否则会漏掉此前下标较大的未使用元素。
  • 递归失败后要同时撤销 used[i] 并记录 previous,否则状态污染或去重失效。
  • 空桶剪枝只能在 current == 0 时使用;桶中已有元素时直接返回会误剪合法组合。
  • 不排序仍可能正确,但大数太晚参与会让大量分支直到深层才失败,容易超时。
  • 排序会原地修改 nums;题目允许这样做,若调用方要求保留输入则需先复制。

相似题目

题目 难度 考察点
416. 分割等和子集 中等 k=2 的特例,01 背包
473. 火柴拼正方形 中等 k 固定为 4 的同型题
39. 组合总和 中等 凑定值的组合枚举
90. 子集 II 中等 同层等值去重
47. 全排列 II 中等 重复元素的排列剪枝
51. N 皇后 困难 回溯配合可行性剪枝