1. 问题背景与理解
今天遇到一道有趣的算法题:LeetCode 805题"数组的均值分割"。题目要求我们判断一个整数数组是否能被分割成两个非空子集,使得这两个子集的平均值相等。乍看之下这像是个简单的数学问题,但实际编码时却有不少值得深究的地方。
先来看题目给出的函数签名:
java复制public boolean splitArraySameAverage(int[] nums)
举个例子,对于输入数组[1,2,3,4,5,6,7,8],我们可以将其分割为[1,4,5,8]和[2,3,6,7],两个子集的平均值都是4.5,因此应该返回true。
2. 数学原理分析
2.1 关键数学转化
这个问题本质上是要找到两个子集A和B,满足:
- A ∪ B = 原始数组
- A ∩ B = ∅
- sum(A)/|A| = sum(B)/|B|
经过数学推导,我们可以将这个条件转化为:
sum(A)/|A| = (totalSum - sum(A))/(n - |A|)
进一步简化可以得到:
sum(A) * (n - k) = (totalSum - sum(A)) * k
=> sum(A) * n = totalSum * k
=> sum(A) = totalSum * k / n
其中k是子集A的大小,n是数组总长度。这意味着我们需要找到一个子集A,其元素和正好等于(totalSum * k)/n,且k的取值范围是1到n-1。
2.2 可行性条件
从这个等式我们可以得出几个重要结论:
- (totalSum * k)必须能被n整除,否则这样的子集不可能存在
- 我们需要检查k从1到n/2的所有可能值(因为k和n-k是对称的)
3. 算法设计与实现
3.1 动态规划解法
这个问题可以转化为一个变种的子集和问题。我们需要检查是否存在某个k,使得数组中有k个元素的和等于(totalSum * k)/n。
具体实现步骤:
- 计算数组总和totalSum和长度n
- 检查是否存在k∈[1, n/2],使得(totalSum * k) % n == 0
- 对于每个满足条件的k,检查是否存在大小为k的子集,其和为target = (totalSum * k)/n
java复制public boolean splitArraySameAverage(int[] nums) {
int n = nums.length;
int totalSum = Arrays.stream(nums).sum();
// 提前检查是否存在可能的k
boolean possible = false;
for (int k = 1; k <= n/2; ++k) {
if ((totalSum * k) % n == 0) {
possible = true;
break;
}
}
if (!possible) return false;
// 动态规划:dp[i][j]表示使用前i个元素能否组成和为j的子集
Set<Integer>[] dp = new Set[n+1];
for (int i = 0; i <= n; i++) {
dp[i] = new HashSet<>();
}
dp[0].add(0);
for (int num : nums) {
for (int i = n; i >= 1; i--) {
for (int s : dp[i-1]) {
int newSum = s + num;
dp[i].add(newSum);
}
}
}
for (int k = 1; k <= n/2; ++k) {
if ((totalSum * k) % n == 0) {
int target = (totalSum * k) / n;
if (dp[k].contains(target)) {
return true;
}
}
}
return false;
}
3.2 优化思路
上述解法在最坏情况下时间复杂度是O(n^2 * sum),对于较大的n和sum值可能会超时。我们可以进行以下优化:
- 提前终止:一旦找到满足条件的k就可以立即返回
- 剪枝:如果剩余的数的和加上当前和小于target,可以提前终止
- 位运算优化:使用位掩码来表示可能的和
优化后的版本:
java复制public boolean splitArraySameAverage(int[] nums) {
int n = nums.length, m = n / 2;
int totalSum = Arrays.stream(nums).sum();
boolean isPossible = false;
for (int k = 1; k <= m; ++k) {
if (totalSum * k % n == 0) {
isPossible = true;
break;
}
}
if (!isPossible) return false;
List<Set<Integer>> dp = new ArrayList<>(m+1);
for (int i = 0; i <= m; i++) {
dp.add(new HashSet<>());
}
dp.get(0).add(0);
for (int num : nums) {
for (int i = m; i >= 1; i--) {
for (int s : dp.get(i-1)) {
int newSum = s + num;
if (newSum * n == totalSum * i) {
return true;
}
dp.get(i).add(newSum);
}
}
}
return false;
}
4. 复杂度分析
4.1 时间复杂度
原始动态规划解法的时间复杂度是O(n^2 * S),其中S是数组元素的和。这是因为我们需要考虑所有可能的子集大小k(最多n/2种),对于每个k,我们需要计算所有可能的和。
优化后的版本在最坏情况下仍然是O(n^2 * S),但通过提前终止和剪枝,实际运行时间会大大减少。
4.2 空间复杂度
空间复杂度主要来自DP数组,为O(n * S),因为我们需要存储不同子集大小对应的所有可能和。
5. 边界条件与测试用例
5.1 典型测试用例
java复制// 示例1:可以分割
[1,2,3,4,5,6,7,8] → true
// 分割为[1,4,5,8]和[2,3,6,7],平均值都是4.5
// 示例2:无法分割
[3,1] → false
// 示例3:所有元素相同
[5,5,5,5] → true
// 可以分割为任意两个子集
// 示例4:空数组或单元素数组
[] → false
[1] → false
5.2 边界情况处理
- 数组长度为0或1时直接返回false
- 所有元素相同的情况需要特殊处理
- 大数情况下的整数溢出问题
- 浮点数精度问题(应避免使用浮点数比较)
6. 实际编码中的注意事项
-
整数除法问题:在计算(targetSum * k) / n时,确保先进行乘法再除法,避免整数除法带来的精度损失
-
提前终止条件:一旦找到满足条件的子集就立即返回,可以节省大量计算时间
-
剪枝优化:在递归或回溯实现中,可以记录已经计算过的状态,避免重复计算
-
位运算技巧:对于较小的n(≤30),可以用位掩码来表示子集和
-
动态规划的空间优化:可以使用滚动数组来减少空间复杂度
7. 算法扩展与变种
这个问题有几个有趣的变种:
-
找出所有可能的分割方式:不仅仅是判断是否存在,而是找出所有满足条件的分割
-
多子集分割:将数组分割成k个子集,要求所有子集的平均值相等
-
带权平均值分割:每个元素有不同的权重,考虑加权平均值的分割
-
近似平均值分割:允许平均值有微小差异的分割问题
8. 个人实现心得
在实际编码中,我发现以下几点特别重要:
-
数学转化是关键:最初我尝试直接比较两个子集的平均值,但浮点数精度问题导致了很多麻烦。后来发现将条件转化为整数等式后问题简化了很多。
-
动态规划的初始化:dp[0][0] = true这个初始条件很容易忽略,但它是整个DP过程的基础。
-
提前检查可行性:在开始复杂计算前,先检查是否存在可能的k值,可以避免不必要的计算。
-
测试用例的设计:边界条件的测试用例能帮助发现很多潜在问题,特别是全相同元素和极小/极大值的情况。
这个题目很好地展示了如何将数学洞察与算法设计相结合,通过问题转化将看似复杂的问题简化为已知的算法模式。在实际面试中,清晰地解释这种转化过程往往比直接给出代码更重要。