程式碼隨想錄演算法訓練營第四十一天|01揹包問題, 01揹包問題—— 滾動陣列,分割等和子集

SandaiYoung發表於2024-03-09

01揹包問題,你該瞭解這些!

題目連結:46. 攜帶研究材料(第六期模擬筆試) (kamacoder.com)

思路:第一次遇到揹包問題,好好記住吧。程式碼隨想錄 (programmercarl.com)

#include<bits/stdc++.h>
using namespace std;
int main(){
    int m,n;
    cin>>m>>n;
    vector<int>z(m);
    vector<int>value(m);
    for(int i=0;i<m;i++){
        cin>>z[i];
    }
    for(int i=0;i<m;i++){
        cin>>value[i];
    }
    
    vector<vector<int>> dp(m,vector<int>(n+1,0));
    for(int i=z[0];i<=n;i++){
        dp[0][i]=value[0];
    }
    
    for(int i=1;i<m;i++){
        for(int j=0;j<=n;j++){
            if(j<z[i])dp[i][j]=dp[i-1][j];
            else dp[i][j]=max(dp[i-1][j],dp[i-1][j-z[i]]+value[i]);
            
        }
    }
    cout<<dp[m-1][n];
}

01揹包問題,你該瞭解這些! 滾動陣列

滾動陣列,說白了就是將上一題中的二維陣列壓縮成一維陣列。dp陣列的更新方式變為dp[j] = max(dp[j], dp[j - weight[i]] + value[i]);

但有一點需要注意,這裡我直接引用官網原文:

這裡大家發現和二維dp的寫法中,遍歷揹包的順序是不一樣的!

二維dp遍歷的時候,揹包容量是從小到大,而一維dp遍歷的時候,揹包是從大到小。

為什麼呢?

倒序遍歷是為了保證物品i只被放入一次!。但如果一旦正序遍歷了,那麼物品0就會被重複加入多次!

舉一個例子:物品0的重量weight[0] = 1,價值value[0] = 15

如果正序遍歷

dp[1] = dp[1 - weight[0]] + value[0] = 15

dp[2] = dp[2 - weight[0]] + value[0] = 30

此時dp[2]就已經是30了,意味著物品0,被放入了兩次,所以不能正序遍歷。

為什麼倒序遍歷,就可以保證物品只放入一次呢?

倒序就是先算dp[2]

dp[2] = dp[2 - weight[0]] + value[0] = 15 (dp陣列已經都初始化為0)

dp[1] = dp[1 - weight[0]] + value[0] = 15

所以從後往前迴圈,每次取得狀態不會和之前取得狀態重合,這樣每種物品就只取一次了。

同時,只能先遍歷物品,巢狀倒序遍歷揹包容量,不能反著來。

void test_1_wei_bag_problem() {
    vector<int> weight = {1, 3, 4};
    vector<int> value = {15, 20, 30};
    int bagWeight = 4;

    // 初始化
    vector<int> dp(bagWeight + 1, 0);
    for(int i = 0; i < weight.size(); i++) { // 遍歷物品
        for(int j = bagWeight; j >= weight[i]; j--) { // 遍歷揹包容量
            dp[j] = max(dp[j], dp[j - weight[i]] + value[i]);
        }
    }
    cout << dp[bagWeight] << endl;
}

分割等和子集

題目連結:416. 分割等和子集 - 力扣(LeetCode)

思路:首先來看我最初的思路,先將陣列排序,然後設一個堆疊來記錄我選擇了哪些元素。從小到大遍歷陣列,同時累加壓入堆疊的元素,如果累加值過大於目標值,則將棧頂彈出同時繼續嘗試加入該元素,否則更新累加值並加入堆疊(注意此時可以return的情況)。實際上,這是一種貪心解法,並不能解決這個問題,具體來說,是因為下一個元素並不能決定這個元素是否該留在這個堆疊中,而是該元素之後的所有元素共同決定這個元素能否留在堆疊中(這應該就是貪心和DP的區別所在吧)。下面是錯誤做法的核心部分(與前文略有差異,該做法從大到小遍歷陣列):

        r.push(nums.back());
        int z = nums.size() - 1;
        while (!z) {
            for (int j = z; j >= 0; j--) {
                if (sum - nums[j] > 0) {
                    sum -= nums[j];
                    r.push(nums[j]);
                } else if (sum - nums[j] < 0 && (!r.empty())) {
                    r.pop();
                    j++;
                } else if ((sum - nums[j]) == 0) {
                    return true;
                }
            }
            z--;
        }

該方法不能在走投無路的時回溯到錯誤還沒有發生的時刻,也就不能解決問題。不得不說,這道題讓我對貪心和DP的理解更加深入了。

正確做法如下:

class Solution {
public:
    bool canPartition(vector<int>& nums) {
        int sum = 0;

        // dp[i]中的i表示揹包內總和
        // 題目中說:每個陣列中的元素不會超過 100,陣列的大小不會超過 200
        // 總和不會大於20000,揹包最大隻需要其中一半,所以10001大小就可以了
        vector<int> dp(10001, 0);
        for (int i = 0; i < nums.size(); i++) {
            sum += nums[i];
        }
        // 也可以使用庫函式一步求和
        // int sum = accumulate(nums.begin(), nums.end(), 0);
        if (sum % 2 == 1) return false;
        int target = sum / 2;

        // 開始 01揹包
        for(int i = 0; i < nums.size(); i++) {
            for(int j = target; j >= nums[i]; j--) { // 每一個元素一定是不可重複放入,所以從大到小遍歷
                dp[j] = max(dp[j], dp[j - nums[i]] + nums[i]);
            }
        }
        // 集合中的元素正好可以湊成總和target
        if (dp[target] == target) return true;
        return false;
    }
};

相關文章