题目描述

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

image-20260928222331331

题意分析

把数组中的每个数恰好使用一次,分成 k 个非空子集;子集不要求在原数组中连续,只要求元素和相等。所有数都是正数,所以每组目标和只能是 target = sum / k。

总和不能被 k 整除,或最大元素已经超过 target,都一定无解。但通过这两项检查不代表一定能分组,仍要搜索各个数的归属。数组长度不超过 16,可以用回溯枚举组合,再通过重复状态与对称性剪枝。

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

核心思路

[!blue]

把每个子集看成一个容量为 target 的桶,先装满当前桶,再开始下一个。buckets 是包括当前桶在内尚未完成的桶数,current 是当前桶的和,start 是当前桶下一次允许选择的最大下标;used[i] 表示该元素已经进入已完成的桶或当前桶。

先升序排序,再从末尾往前选数,大数更容易让桶超额,能较早暴露不可行分支。加入 nums[i] 后,递归只继续考虑 i - 1 及以前的下标。同一组元素总能按下标递减的顺序选出,因此这样不会漏掉组合,只会去掉同一组合的不同选择顺序。新开桶时必须重新从数组末尾开始,才能使用上一桶跳过的大数。

搜索中始终保证已完成的桶恰好等于 target,当前桶不超过 target,且每个元素最多使用一次。由于元素全为正数,current + nums[i] > target 后无法靠加入其他元素降低和,可以直接跳过。递归失败时撤销 used[i],恢复到选择它之前的状态,再试下一个候选。

previous 只记录当前循环中已经尝试失败的值。如果随后遇到相同值,交换这两个等值元素的身份不会改变任何桶的和;而先前下标较大的元素被选中时,后面的等值元素仍可继续使用,所以需要多个相同值的组合也已经覆盖。这里跳过的是同一层的等价选择,不能在不同递归层之间共享 previous。

当前桶为空时,第一个候选一定是最大的未使用元素。任何合法划分中,这个数总属于某个剩余桶;这些桶没有编号上的区别,可以把包含它的桶改名为当前桶。因此只需枚举“当前桶包含这个数”的所有组合;这一整条分支失败后,就可以直接判定当前状态无解。桶里已有元素时,这些元素已经固定了当前桶的组成,不能再通过交换桶名强制加入某个候选,所以该剪枝只能用于 current == 0。

当前桶装满后,将 buckets 减一并重置 current、start。只剩一个桶时,已完成桶用掉了 (k - 1) * target,所有未使用元素的和必然等于 target,直接把它们放入最后一桶即可。因为 target > 0,每个填满的桶及最后一桶都必定非空。

解题步骤

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

k == 1 时,全部元素本来就构成唯一一组,搜索入口会直接成功。previous 使用 -1 作为初值,依赖题目保证元素全为正数;正数条件同时支撑超额剪枝和非空性判断。

代码实现

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(nk^n)$,其中 $n$ 包含每个状态扫描候选元素的成本;此外排序需要 $O(n \log n)$。剪枝能减少实际搜索量,但不能把最坏情况变成多项式时间。
  • 空间复杂度:$O(n)$,used 保存每个元素的占用情况,递归深度包括至多 $n$ 次选数和 $k-1$ 次换桶,而 $k \le n$。

关键点总结

[!green]

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

易错点总结

[!yellow]

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

相似题目

题目 难度 关联与区别
473. 火柴拼正方形 中等 火柴拼正方形是k=4的等和分组特例,同样需要把每个元素只分配到一组。
补充题 175. 等和划分的最大组数 中等 同属等和划分。固定组数后的可行性判定可复用;补充题再按总和的因子枚举组数,求最大可行值。
416. 分割等和子集 中等 同属等和划分。两组时只需找和为总和一半的子集;推广到 k 组后还要追踪其余分组的使用情况。
转载与许可
作者
链接 https://hgnulb.github.io/blog/2026/87907170
许可 本博客所有文章除特别声明外,均采用 CC BY-NC-SA 4.0 许可协议,转载请注明出处!