力扣-805. 数组的均值分割

1.题目

题目地址(805. 数组的均值分割 - 力扣(LeetCode))

https://leetcode.cn/problems/split-array-with-same-average/

题目描述

给定你一个整数数组 nums

我们要将 nums 数组中的每个元素移动到 A 数组 或者 B 数组中,使得 A 数组和 B 数组不为空,并且 average(A) == average(B) 。

如果可以完成则返回true , 否则返回 false  。

注意:对于数组 arr ,  average(arr) 是 arr 的所有元素的和除以 arr 长度。

 

示例 1:

输入: nums = [1,2,3,4,5,6,7,8]
输出: true
解释: 我们可以将数组分割为 [1,4,5,8] 和 [2,3,6,7], 他们的平均值都是4.5。

示例 2:

输入: nums = [3,1]
输出: false

 

提示:

  • 1 <= nums.length <= 30
  • 0 <= nums[i] <= 104

2.题解

这其实也是一个0/1背包问题,为了保存可能是小数的平均值,所以同时保存了总和sum和数量j两个索引,但本质上每个数只能选择一次

2.1 动态规划

思路

这里我们很容易知道
n * average = x * average(a) + (n-x) * average(b);
如果有average(a) == average(b), 那么相当于: n * average = x * average(a) + (n-x) * average(a) = n * average(a);
即 average = average(a) = average(b);
而我们很容易知道averge = sum / n;

那么问题就转换为找到一个子集数组平均值等于该数组平均值即可!
这里最开始我们想到使用dp数组:
dp[average] = true / false; 表示是否存在值为average的情况组合
但是这里average可能存在小数,我们并不能作为索引!
所以思考使用二维dp数组,dp[sumx][nx] = true / false; 表示由nx个元素构成的和为sumx的数组是否存在,可以直接由sumx / nx 获得该数组average
但注意在判断的时候,不要使用 sumx / nx == sum / n 作为判断条件,除法毕竟可能存在精度确实,我们换做乘法是最合适的,也就是 sumx * n == sum * nx

1.初始代码

  • 语言支持:C++

C++ Code:


class Solution {
public:
    bool splitArraySameAverage(vector<int>& nums) {
        int n = nums.size();
        int sum = accumulate(nums.begin(), nums.end(), 0);
        vector<vector<bool>> dp(sum+1, vector<bool>(n + 1));
        dp[0][0] = true;
        for(int i = 0; i < n; i++){
            int num = nums[i];
            for(int j = sum; j >= num; j--){
                for(int k = n - 1; k >= 1; k--){
                    dp[j][k] = dp[j][k] | dp[j-num][k-1];
                    if(dp[j][k] && (k * sum) == (j * n)){
                        return true;
                    }
                }
            }
        }
        return false;
    }
};

复杂度分析

在最坏情况下,sum 是数组所有元素之和,记作 S,那么时间复杂度可以表示为:
\(\begin{aligned}\bullet&\text{时间复杂度:}O(n^2\cdot S)\\\bullet&\text{空间复杂度:}O(S\cdot n)\end{aligned}\)

2.思路优化

这里很明显我们的方法会出现超时问题,尤其当数都很大的时候,我们必须进行优化剪枝。
1.首先我们发现,无论如何整个数组都会被分为两个数组,我们只需要找到那个元素较少的数组能否平均值与整个数组平均值相等即可,如果该数组存在,较多元素的那个数组也一定存在!
即我们不需要遍历到n,只需要遍历到 n / 2即可(表示较小数组)

2.其次我们思考一下(k * sum) == (j * n)
如果成立,必然有 k * sum % n == 0, 这样才能得出一个整数和 j,
所以我们首先思考能否这样写:

for(int j = n - 1; j >= 1; j--){
     if(k * sum % n != 0) continue;
     for(int k = sum; k >= num; k--){
        dp[j][k] = dp[j][k] | dp[j-1][k-num];
        if(dp[j][k] && (j * sum) == (k * n)){
            return true;
        }
    }
}

答案是错误的,为何呢?我们将不可能出现结果的情况跳过不是正确的思路吗?
这个是因为我们在进行判断是否存在的同时,也在更新dp数组,即使这种情况不存在正确答案,但是dp数组依旧需要更新
这样写就会直接跳过dp数组的更新,导致最终结果的错误。

所以针对于此,我们的剪枝操作修改为将这个逻辑单独提取出来, 单独进行一次遍历判断,是否存在没有一种情况能满足k * sum % n == 0的,那么结果必然不存在,return false;

bool isPossible = false;
for(int i = 1; i <= m; i++){
    if((i * sum % n) == 0){
        isPossible = true;
        break;
    }
}
if(!isPossible) return false;

3.经过测试,结果还是超时了!我们还能如何简化呢?
我们注意到 dp[j][k] = dp[j][k] | dp[j-1][k-num];
进行更新时,我们只会更新 dp[j-1] 中可能出现的k-num, 我们却从sum -> num 整个遍历了一遍
我们能不能提前知道dp[j-1]存在哪些和的情况呢,而不需要从头到尾依次遍历呢?
答案是使用set集合即可!
我们前面数组里面套数组,相当于里层数组只能顺序遍历,但是set集合无需,可以直接找到所有存在的值!

代码2

class Solution {
public:
    bool splitArraySameAverage(vector<int>& nums) {
        int n = nums.size();
        int m = n / 2;
        int sum = accumulate(nums.begin(), nums.end(), 0);

        bool isPossible = false;
        for(int i = 1; i <= m; i++){
            if((i * sum % n) == 0){
                isPossible = true;
                break;
            }
        }
        if(!isPossible) return false;

        vector<unordered_set<int>> dp(m + 1);
        dp[0].insert(0);
        for(int i = 0; i < n; i++){
            int num = nums[i];
            for(int j = m; j >= 1; j--){
                for(int prev: dp[j - 1]){
                    int curr = prev + num;
                    if((j * sum) == (curr * n)){
                        return true;
                    }
                    dp[j].emplace(curr);
                }
            }
        }
        return false;
    }
};

复杂度分析

  • 时间复杂度:\(O(n^{2}\times sum(nums))\)),
    其中\(n\)表示数组的长度\(sum(nums)\)表示数组nums的和。
    我们需要求出给定长度下所有可能的子集元素之和,数组的长度为\(n\),每种长度下子集的和最多有\(sum(nums)\)个,
    因此总的时间复杂度为\(O(n^2\times sum(nums))\)

  • 空间复杂度:\(O(n\times sum(nums))\))。
    一共有\(n\)种长度的子集,每种长度的子集和最多有\(sum(nums)\)个,因此需要的空间为\(O(n\times\)\(sum(nums)).\)

2.2 折半查找

思路

1.这里面我们为了处理average = sum / n 可能出现的平均值,做了一些处理操作,具体可以看代码,总之就是将每个数组元素nums[i] * n - sum (*n是为了方便求平均值没有小数,-sum是因为新数组平均值是sum,减去后新数组平均值就是0了,方便参考)

2.如果直接从n个元素中选择元素,使得子集数组平均值等于average,那么一共有\(2^n\)种方法,但题目中的 n 可以达到 30,此时 $230=1,073,741,8242 = 1,073,741,8242 $组合的数据非常大;
因此这里将整个数组分为左右两等份,左边选一点,右边选一点,两者共同构成一个我们的自己数组A(期望average_A == 0)
这里可以直接在左半数组或者右伴数组中直接找到数组A(tol = 0, 即为平均值), 也可以由两个数组选择的子集数组共同构成(左半子集数组和tol,右半子集数组和-tol,总和和平均数均为0)
且只要这个数组A存在,根据上面的推导,另一个数组B一定也是存在average_B = 0的,达到了查找的目的。

3.\(\begin{aligned}&\text{需要注意的是,我们不能同时选择 }A_0\text{ 和 }B_0\text{ 中的所有元素,这样数组 B 就为空了。}\end{aligned}\)
// 在右半数组找到 / 右半数组不全选找到 / 右半数组全选但左半数组不全选找到
if(tol == 0 || (tol != rsum && left.count(-tol)) || (tol == rsum && tol != -lsum && left.count(-tol) )) return true;

4.防止一手整个数组只有一个元素,if(n == 1) return false;

代码

class Solution {
public:
    bool splitArraySameAverage(vector<int>& nums) {
        int n = nums.size(), m = n / 2;
        int sum = accumulate(nums.begin(), nums.end(), 0);
        if(n == 1) return false;
        // 为防止浮点数的存在,这里更新数组元素为 n * nums[i] (nums[i]直接/n求平均数会有浮点数)
        // average_new = n * nums[1..i] / n = sum;
        // 为方便我们后续判断是否存在均值分割数组(average == average_new)
        // 我们这里再将所有元素减去一个平均值,故判断条件变为 average_new = sum - sum = 0
        for(int i = 0; i < n; i++){
            nums[i] = n * nums[i] - sum;
        }

        // 这里将整个数组分为左右两等份,左边选一点,右边选一点,两者一个我们的自己数组A(期望average_A == 0)
        unordered_set<int> left;
        // 使用二进制表示选择哪些数,外层循环用于选择,内层循环用于判断,模拟从中挑选数的操作(共2^m中挑选方式)
        // 这里 i 从 1 -> 1<< m, 表明从第一个数到第m个数的二进制表示位
        int lsum = accumulate(nums.begin(), nums.begin() + m, 0);
        for(int i = 1; i < (1 << m); i++){
            int tol = 0;
            for(int j = 0; j < m; j++){
                // 1 << j & i 表示这一位j是否在我的选择方式 i 中
                if(i & (1 << j)) tol += nums[j];
                // 如果此式成立,我们直接判断有 average == average_new, 成立 
            }
            if(tol == 0) return true;
            // 保存这个当前和
            left.emplace(tol);
        }

        // 使用二进制模拟选择右半数组
        int rsum = accumulate(nums.begin() + m, nums.end(), 0);
        for(int i = 1; i < (1 << (n - m)); i++){
            int tol = 0;
            for(int j = m; j < n; j++){
                // 1 << j & i 表示这一位j是否在我的选择方式 i 中
                if(i & (1 << (j - m))) tol += nums[j];
                // 如果此式成立,我们直接判断有 average == average_new, 成立
            }
            // 在右半数组找到 / 右半数组不全选找到 / 右半数组全选但左半数组不全选找到 
            if(tol == 0 || (tol != rsum && left.count(-tol)) || (tol == rsum && tol != -lsum && left.count(-tol) )) return true;
        }
        return false;
    }
};

复杂度分析

  • 时间复杂度:\(O(n\times2^{\frac n2})\),
    其中\(n\)表示数组的长度。我们需要求出每个子集的元素之和,数组的长度为\(n\),一共有 2\(\times2^{\frac n2}\)个子集,
    求每个子集的元素之和需要的时间为\(O(n)\) ,因此总的时间复杂度为\(O(n\times2^{\frac n2})\)

  • 空间复杂度\(:O(2^{\frac n2})\)
    一共有 \(2^{\frac n2}\) 个子集的元素之和需要保存,因此需要的空间为\(O(2^{\frac n2})\)

posted @ 2024-06-10 01:37  DawnTraveler  阅读(32)  评论(0编辑  收藏  举报