經典動態規劃:子集揹包問題
讀完本文,你可以去力扣拿下如下題目:
-----------
上篇文章 經典動態規劃:0-1 揹包問題 詳解了通用的 0-1 揹包問題,今天來看看揹包問題的思想能夠如何運用到其他演算法題目。
而且,不是經常有讀者問,怎麼將二維動態規劃壓縮成一維動態規劃嗎?這就是狀態壓縮,很容易的,本文也會提及這種技巧。
一、問題分析
先看一下題目:
演算法的函式簽名如下:
// 輸入一個集合,返回是否能夠分割成和相等的兩個子集
bool canPartition(vector<int>& nums);
對於這個問題,看起來和揹包沒有任何關係,為什麼說它是揹包問題呢?
首先回憶一下揹包問題大致的描述是什麼:
給你一個可裝載重量為 W
的揹包和 N
個物品,每個物品有重量和價值兩個屬性。其中第 i
個物品的重量為 wt[i]
,價值為 val[i]
,現在讓你用這個揹包裝物品,最多能裝的價值是多少?
那麼對於這個問題,我們可以先對集合求和,得出 sum
,把問題轉化為揹包問題:
給一個可裝載重量為 sum / 2
的揹包和 N
個物品,每個物品的重量為 nums[i]
。現在讓你裝物品,是否存在一種裝法,能夠恰好將揹包裝滿?
你看,這就是揹包問題的模型,甚至比我們之前的經典揹包問題還要簡單一些,下面我們就直接轉換成揹包問題,開始套前文講過的揹包問題框架即可。
PS:我認真寫了 100 多篇原創,手把手刷 200 道力扣題目,全部發布在
二、解法分析
第一步要明確兩點,「狀態」和「選擇」。
這個前文 經典動態規劃:揹包問題 已經詳細解釋過了,狀態就是「揹包的容量」和「可選擇的物品」,選擇就是「裝進揹包」或者「不裝進揹包」。
第二步要明確 dp
陣列的定義。
按照揹包問題的套路,可以給出如下定義:
dp[i][j] = x
表示,對於前 i
個物品,當前揹包的容量為 j
時,若 x
為 true
,則說明可以恰好將揹包裝滿,若 x
為 false
,則說明不能恰好將揹包裝滿。
比如說,如果 dp[4][9] = true
或者說對於本題,含義是對於給定的集合中,若只對前 4 個數字進行選擇,存在一個子集的和可以恰好湊出 9。
根據這個定義,我們想求的最終答案就是 dp[N][sum/2]
,base case 就是 dp[..][0] = true
和 dp[0][..] = false
,因為揹包沒有空間的時候,就相當於裝滿了,而當沒有物品可選擇的時候,肯定沒辦法裝滿揹包。
第三步,根據「選擇」,思考狀態轉移的邏輯。
回想剛才的 dp
陣列含義,可以根據「選擇」對 dp[i][j]
得到以下狀態轉移:
如果不把 nums[i]
算入子集,或者說你不把這第 i
個物品裝入揹包,那麼是否能夠恰好裝滿揹包,取決於上一個狀態 dp[i-1][j]
,繼承之前的結果。
如果把 nums[i]
算入子集,或者說你把這第 i
個物品裝入了揹包,那麼是否能夠恰好裝滿揹包,取決於狀態 dp[i-1][j-nums[i-1]]
。
首先,由於 i
是從 1 開始的,而陣列索引是從 0 開始的,所以第 i
個物品的重量應該是 nums[i-1]
,這一點不要搞混。
dp[i - 1][j-nums[i-1]]
也很好理解:你如果裝了第 i
個物品,就要看揹包的剩餘重量 j - nums[i-1]
限制下是否能夠被恰好裝滿。
換句話說,如果 j - nums[i-1]
的重量可以被恰好裝滿,那麼只要把第 i
個物品裝進去,也可恰好裝滿 j
的重量;否則的話,重量 j
肯定是裝不滿的。
最後一步,把偽碼翻譯成程式碼,處理一些邊界情況。
以下是我的 C++ 程式碼,完全翻譯了之前的思路,並處理了一些邊界情況:
bool canPartition(vector<int>& nums) {
int sum = 0;
for (int num : nums) sum += num;
// 和為奇數時,不可能劃分成兩個和相等的集合
if (sum % 2 != 0) return false;
int n = nums.size();
sum = sum / 2;
vector<vector<bool>>
dp(n + 1, vector<bool>(sum + 1, false));
// base case
for (int i = 0; i <= n; i++)
dp[i][0] = true;
for (int i = 1; i <= n; i++) {
for (int j = 1; j <= sum; j++) {
if (j - nums[i - 1] < 0) {
// 揹包容量不足,不能裝入第 i 個物品
dp[i][j] = dp[i - 1][j];
} else {
// 裝入或不裝入揹包
dp[i][j] = dp[i - 1][j] || dp[i - 1][j-nums[i-1]];
}
}
}
return dp[n][sum];
}
PS:我認真寫了 100 多篇原創,手把手刷 200 道力扣題目,全部發布在 labuladong的演算法小抄,持續更新。建議收藏,按照我的文章順序刷題,掌握各種演算法套路後投再入題海就如魚得水了。
三、進行狀態壓縮
再進一步,是否可以優化這個程式碼呢?注意到 dp[i][j]
都是通過上一行 dp[i-1][..]
轉移過來的,之前的資料都不會再使用了。
所以,我們可以進行狀態壓縮,將二維 dp
陣列壓縮為一維,節約空間複雜度:
bool canPartition(vector<int>& nums) {
int sum = 0, n = nums.size();
for (int num : nums) sum += num;
if (sum % 2 != 0) return false;
sum = sum / 2;
vector<bool> dp(sum + 1, false);
// base case
dp[0] = true;
for (int i = 0; i < n; i++)
for (int j = sum; j >= 0; j--)
if (j - nums[i] >= 0)
dp[j] = dp[j] || dp[j - nums[i]];
return dp[sum];
}
這就是狀態壓縮,其實這段程式碼和之前的解法思路完全相同,只在一行 dp
陣列上操作,i
每進行一輪迭代,dp[j]
其實就相當於 dp[i-1][j]
,所以只需要一維陣列就夠用了。
唯一需要注意的是 j
應該從後往前反向遍歷,因為每個物品(或者說數字)只能用一次,以免之前的結果影響其他的結果。
至此,子集切割的問題就完全解決了,時間複雜度 O(n*sum),空間複雜度 O(sum)。
_____________
我的 線上電子書 有 100 篇原創文章,手把手帶刷 200 道力扣題目,建議收藏!對應的 GitHub 演算法倉庫 已經獲得了 70k star,歡迎標星!