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.題解
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})\) 。