1. 程式人生 > 實用技巧 >經典動態規劃:子集揹包問題

經典動態規劃:子集揹包問題

讀完本文,你可以去力扣拿下如下題目:

416.分割等和子集

-----------

上篇文章 經典動態規劃: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 道力扣題目,全部發布在

labuladong的演算法小抄,持續更新。建議收藏,按照我的文章順序刷題,掌握各種演算法套路後投再入題海就如魚得水了。

二、解法分析

第一步要明確兩點,「狀態」和「選擇」

這個前文 經典動態規劃:揹包問題 已經詳細解釋過了,狀態就是「揹包的容量」和「可選擇的物品」,選擇就是「裝進揹包」或者「不裝進揹包」。

第二步要明確 dp 陣列的定義

按照揹包問題的套路,可以給出如下定義:

dp[i][j] = x 表示,對於前 i 個物品,當前揹包的容量為 j 時,若 xtrue,則說明可以恰好將揹包裝滿,若 xfalse,則說明不能恰好將揹包裝滿。

比如說,如果 dp[4][9] = true

,其含義為:對於容量為 9 的揹包,若只是用前 4 個物品,可以有一種方法把揹包恰好裝滿。

或者說對於本題,含義是對於給定的集合中,若只對前 4 個數字進行選擇,存在一個子集的和可以恰好湊出 9。

根據這個定義,我們想求的最終答案就是 dp[N][sum/2],base case 就是 dp[..][0] = truedp[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,歡迎標星!