题目描述

✅ 805. 数组的均值分割

image-20260928224853812

题意分析

将数组中的每个元素分到两组之一,要求两组都非空,且两组元素的平均值相同,判断是否存在这样的分配。不要求连续,也不要求两组元素个数相同;相同数值的不同位置仍是可以分别选择的元素。

判断的是平均值相等,不是两组总和相等。数组只有一个元素时无法形成两个非空组;零是合法元素。题目最多有三十个元素,可以按数量记录子集和,也可以通过折半枚举降低对数值范围的依赖。

解法:子集和 DP

核心思路

[!blue]

设总元素数为 n,总和为 S。选择一组包含 k 个元素、和为 a,另一组就包含 n - k 个元素、和为 S - a。平均值相等要求 a / k = (S - a) / (n - k),交叉相乘并整理得到 a * n = S * k。因此只需找到一个非空且不含全部元素的子集,使和等于整数目标 S * k / n。

对不同的选择数量,目标和不同,不能只用一张不区分数量的可达和表。定义 dp[k] 为已经处理过的元素中,恰好选择 k 个时能够得到的所有和。初始 dp[0] 只包含零,表示空选择;其他集合为空。

遇到当前元素 num 时,可以不选它,保留原集合中的和;也可以把它接到任意一个旧的 k - 1 元素方案上,将 s + num 加入 dp[k]。集合会自动合并不同选择路径产生的相同和,因为本题只判断是否存在,不需要统计方案数量。

选择数量必须从大到小更新。这样读取 dp[k - 1] 时,它还没有被本轮的 num 扩展,同一个数组位置就不会在一个方案里重复使用。若从小到大,刚加入 num 的和可能立刻被下一数量读取,错误地再次选择同一元素。

全部元素处理完后,枚举 k = 1 到 n - 1。先检查 S * k 能否被 n 整除,不整除说明整数子集不可能达到这个平均值;能够整除时,再检查目标和是否属于 dp[k]。排除零个和全部元素,就同时保证两组都非空。

解题步骤

  1. 计算 n、总和 S,为各个选择数量准备可达和集合,只将零加入 dp[0]。
  2. 遍历每个元素,按数量从 n - 1 倒序到 1 更新。
  3. 对旧 dp[k - 1] 中的每个和 s,把 s + num 加入 dp[k],原有状态保留。
  4. 枚举合法组大小 1 到 n - 1,只有 S * k % n == 0 时才计算对应目标和。
  5. 任意一个目标和可达即返回 true,否则返回 false。

代码实现

class Solution {
    public boolean splitArraySameAverage(int[] nums) {
        int n = nums.length;
        int sum = 0;

        for (int v : nums) {
            sum += v;
        }

        List<Set<Integer>> dp = new ArrayList<>();

        for (int i = 0; i <= n; i++) {
            dp.add(new HashSet<>());
        }

        dp.get(0).add(0);

        for (int num : nums) {
            // 数量倒序,读取的上一数量仍未使用当前元素
            for (int k = n - 1; k >= 1; k--) {
                for (int s : dp.get(k - 1)) {
                    dp.get(k).add(s + num);
                }
            }
        }

        for (int k = 1; k < n; k++) {
            // 目标和必须为整数,先检查整除再计算目标
            if (sum * k % n != 0) {
                continue;
            }

            int target = sum * k / n;

            if (dp.get(k).contains(target)) {
                return true;
            }
        }

        return false;
    }
}
func splitArraySameAverage(nums []int) bool {
    n := len(nums)
    sum := 0
    for _, v := range nums {
        sum += v
    }

    dp := make([]map[int]struct{}, n+1)
    dp[0] = make(map[int]struct{})
    dp[0][0] = struct{}{}

    for _, num := range nums {
        // 数量倒序,读取的上一数量仍未使用当前元素
        for k := n - 1; k >= 1; k-- {
            if dp[k] == nil {
                dp[k] = make(map[int]struct{})
            }
            for s := range dp[k-1] {
                dp[k][s+num] = struct{}{}
            }
        }
    }

    for k := 1; k < n; k++ {
        // 目标和必须为整数,先检查整除再计算目标
        if sum*k%n != 0 {
            continue
        }
        target := sum * k / n
        if _, ok := dp[k][target]; ok {
            return true
        }
    }

    return false
}

复杂度分析

  • 时间复杂度:期望 $O(n^2Q)$,Q 为各个可达和集合的最大大小,每个元素依次扩展各个数量的集合。由于元素非负,Q 不超过 S + 1,这是复杂度依赖总和值大小的伪多项式方法。
  • 空间复杂度:$O(nQ)$,按所选元素个数分别保存可达和集合。

关键点总结

[!green]

  • 交叉相乘把平均值相等改成整数关系,避免浮点比较。
  • 子集和必须和选择数量配套记录,才能确定应查询的目标。
  • 倒序更新数量,保证每个数组位置在一个方案中只使用一次。
  • 只检查非空真子集,另一组作为补集也自然非空。

解法:折半枚举零和子集

核心思路

[!blue]

沿用 a * n = S * k,把每个元素变换成 value = nums[i] * n - S。选择 k 个变换值后的总和就是 a * n - S * k,所以原问题等价于:是否存在一个非空、且不是全集的零和子集。这个变换只使用整数,并且不要求选择位置连续。

最多三十个元素,直接枚举全部子集规模较大;将它们分成左右两半,每半最多十五个元素,各自枚举非空子集即可。子集完全位于某一半且和为零时,可以直接成功,因为另一半仍有元素,所选不可能是全集。

对需要跨越两半的方案,枚举左半子集时,用哈希表保存“子集和到该和所需的最少元素数”。右半某个子集和为 s 时,只需查询左半是否存在和为 -s 的子集,两部分相加就为零。左右元素来源不重叠,因此不需要担心重复使用同一个位置。

全部变换值的和始终为零,所以必须排除把左右两半全选的情况。两边枚举都只含非空子集,再检查总选择数量严格小于 n,就保证得到非空真子集。同一个左半和只保存最少元素数已经足够:它为右半留下最多未选空间;如果最少数量也会凑成全集,其他更大的数量更不可能合法。

每个非空真子集要么只在某一半,要么拆成两个非空部分,上述两类检查覆盖了所有可能。枚举规模只依赖元素个数,与原始总和大小无关;按本题数值上限,变换值及其子集和都能使用整数保存。

解题步骤

  1. 少于两个元素时返回 false,否则计算总和并生成变换值数组。
  2. 从中间分成左右两半,用位掩码枚举左半所有非空子集,计算其和与数量。
  3. 左半和为零时直接成功;否则在哈希表中保留该和对应的最少元素数。
  4. 同样枚举右半非空子集;本身和为零时成功,否则查找左半的相反和。
  5. 找到匹配且两部分数量之和小于 n 时返回 true;全部枚举结束仍未命中则返回 false。

代码实现

class Solution {
    public boolean splitArraySameAverage(int[] nums) {
        int n = nums.length;
        if (n < 2) {
            return false;
        }
        int total = 0;
        for (int num : nums) {
            total += num;
        }
        int[] adjusted = new int[n];
        for (int i = 0; i < n; i++) {
            adjusted[i] = nums[i] * n - total;
        }

        int middle = n / 2;
        Map<Integer, Integer> leftMinCount = new HashMap<>();
        for (int mask = 1; mask < (1 << middle); mask++) {
            int sum = 0;
            int count = 0;
            for (int i = 0; i < middle; i++) {
                if ((mask & (1 << i)) != 0) {
                    sum += adjusted[i];
                    count++;
                }
            }
            if (sum == 0) {
                return true;
            }
            Integer previous = leftMinCount.get(sum);
            if (previous == null || count < previous) {
                leftMinCount.put(sum, count);
            }
        }

        int rightSize = n - middle;
        for (int mask = 1; mask < (1 << rightSize); mask++) {
            int sum = 0;
            int count = 0;
            for (int i = 0; i < rightSize; i++) {
                if ((mask & (1 << i)) != 0) {
                    sum += adjusted[middle + i];
                    count++;
                }
            }
            if (sum == 0) {
                return true;
            }
            Integer leftCount = leftMinCount.get(-sum);
            if (leftCount != null && leftCount + count < n) {
                return true;
            }
        }
        return false;
    }
}
func splitArraySameAverage(nums []int) bool {
    n := len(nums)
    if n < 2 {
        return false
    }
    total := 0
    for _, num := range nums {
        total += num
    }
    adjusted := make([]int, n)
    for i, num := range nums {
        adjusted[i] = num*n - total
    }

    middle := n / 2
    leftMinCount := make(map[int]int)
    for mask := 1; mask < 1<<middle; mask++ {
        sum := 0
        count := 0
        for i := 0; i < middle; i++ {
            if mask&(1<<i) != 0 {
                sum += adjusted[i]
                count++
            }
        }
        if sum == 0 {
            return true
        }
        if previous, exists := leftMinCount[sum]; !exists || count < previous {
            leftMinCount[sum] = count
        }
    }

    rightSize := n - middle
    for mask := 1; mask < 1<<rightSize; mask++ {
        sum := 0
        count := 0
        for i := 0; i < rightSize; i++ {
            if mask&(1<<i) != 0 {
                sum += adjusted[middle+i]
                count++
            }
        }
        if sum == 0 {
            return true
        }
        if leftCount, exists := leftMinCount[-sum]; exists && leftCount+count < n {
            return true
        }
    }
    return false
}

复杂度分析

  • 时间复杂度:期望 $O(n \cdot 2^{\lceil n/2\rceil})$,每半枚举全部子集,每个子集逐位累加至多半个数组,哈希查询按期望常数时间计算。
  • 空间复杂度:$O(n + 2^{\lfloor n/2\rfloor})$,保存变换数组和左半不同子集和对应的最少数量,右半边枚举边查询。

关键点总结

[!green]

  • 将平均值条件变成整数零和条件,随后只需组合两个半边的子集和。
  • 单半边非空零和子集可直接成功,跨半边时检查相反和与总数量。
  • 排除全集是必要约束,因为全部变换值相加总是零。
  • 同和保留最少元素数,足以判断是否还能给补集留下至少一个元素。

易错点总结

[!yellow]

  • 直接寻找总和的一半,额外要求了两组数量相等,不能覆盖原题的全部合法分割。
  • 目标计算前不检查整除,会使用向下取整的和,接受平均值并不相等的方案。
  • 子集 DP 正序更新数量,可能在同一轮重复使用当前元素。
  • 只记可达和而不记数量,无法区分同一个和对应的不同平均值。
  • 折半枚举只发现零和就不检查选取数量,空集或全集都会因为变换而错误命中。
  • 折半合并时覆盖掉同和的较少元素记录,可能只留下会凑成全集的选择,漏掉合法真子集。

相似题目

题目 难度 关联与区别
416. 分割等和子集 中等 同样转成子集选择,但平均值相等还依赖被选元素数量,不能只找总和一半。
2035. 将数组分成两个数组并最小化数组和的差 困难 同样可以按选取数量组织折半枚举的子集和,本题判断平均值条件,原题最小化两组和差。
转载与许可
作者
链接 https://hgnulb.github.io/blog/2024/21622298
许可 本博客所有文章除特别声明外,均采用 CC BY-NC-SA 4.0 许可协议,转载请注明出处!