動態規劃-揹包01問題推理與實踐

Neko_Code發表於2024-11-10

動態規劃-揹包01問題推理與實踐

揹包01問題描述:

有storage大小的揹包和weights.size()數量的物品,每個物品i對應的物品大小為sizes[i],價值為values[i],在不超過storage大小的情況下,如何裝載物品使揹包中的values和最大.


物品大小: vector<int> sizes;

物品價值: vector<int> values;

揹包容量: <int> storage;


理解(狀態轉移公式的推理):

構建二維陣列dp[i][j],定義式為 std::vector<std::vector<int>> dp(sizes.size(),std::vector<int>(storage + 1,0));預設值為0;

dp[i][j] 的意義是在前 i 個物品中選擇任意少於等於 i 個物品,其總大小不超過 j 的最大價值和.

則同理 dp[i - 1][j] 為在前 i - 1 物品中選擇任意少於等於 i - 1 個物品,其總大小不超過 j - 1的最大價值和,此即為子狀態.

假設求取dp[i][j],則考慮兩種情況:

①第i個物品取的可能

②第i個物品不取的可能

針對上述情況進行分類討論:

①已知dp[i-1][j],此時揹包已滿,則應騰出某物品以裝在第 i 物品,則裝載前必須知道dp[i - 1][j - sizes[i]]的值,即揹包扣除第i個物品的大小後的最大價值裝載方式[1],則可推匯出dp[i][j] = dp[i - 1][j - sizes[i]] + values[i];

②已知dp[i - 1][j],此時第 i 個物品的價值過低或大小過大,不適合替換揹包中的物品,則推理出dp[i][j] = dp[i - 1][j];

因為無法知道上述兩種情況何時會發生,應該取兩者的較大值:

dp[i][j] = std::max(dp[i - 1][j - sizes[i]] + values[i],dp[i - 1][j]);

此即為揹包01問題的狀態轉移公式.

程式碼實現dp陣列如下[2]:

//初始化第一個物品放入揹包的子狀態,即dp[0][j],此處的j代表的是>=第一個物品的大小的位置處,都填充values[0],注意子陣列的額存放索引為增長的storage.
for(int j = sizes[0]; j <= storage; j++) {
    dp[0][j] = values[0];
}

//構建dp陣列
for(int i = 1; i < sizes.size(); i++) {
    for(int j = 0; j <= storage; j++) {
        //該裝載的物品過大,即使只裝它一個也裝不下,直接返回不裝載的情況即可
        if(j < sizes[i]) dp[i][j] = dp[i - 1][j];
        else dp[i][j] = std::max(
        			dp[i - 1][j - sizes[i]] + values[i],
            		dp[i - 1][j]
            );
    }
}

完整程式碼(對應2. 01揹包問題 - AcWing題庫):

#include <iostream>
#include <vector>
#include <algorithm>

int main(int argc,char** argv) {
    std::vector<int> sizes;
    std::vector<int> values;
    int storage;
    int size;

    std::cin >> size >> storage;
    for(int i = 0; i < size; i++) {
        int t_size;
        int t_value;
        std::cin >> t_size >> t_value;
        sizes.push_back(t_size);
        values.push_back(t_value);
    }

    std::vector<std::vector<int>> dp(sizes.size(),std::vector<int>(storage + 1,0));

    for(int j = sizes[0]; j <= storage; j++) {
        dp[0][j] = values[0];
    }

    for(int i = 1; i < sizes.size(); i++) {
        for(int j = 0; j <= storage; j++) {
            if(j < sizes[i]) dp[i][j] = dp[i - 1][j];
            else dp[i][j] = std::max(
                        dp[i - 1][j - sizes[i]] + values[i],
                        dp[i - 1][j]
                );
        }
    }

    //Debug檢視部分
    // for(int i = 0; i < size; i++) {
    //     for(int j = 0; j <= storage; j++) {
    //         std::cout << "[" << i << "]" << "[" << j << "]" << " = " << dp[i][j] << std::endl;
    //     }
    // }

    std::cout << dp[size - 1][storage];

    return 0;
}

一維陣列簡化理解:

構建dp[j]一維陣列,定義式為 std::vector<std::vector<int>> dp(storage + 1,0);

dp[j]陣列意義為揹包容量為j時,揹包的最大價值為dp[j];

一維陣列的遍歷程式碼:

for(int i = 0; i < sizes.size(); i++) {
    for(int j = storage; j >= sizes[i]; j--) {
        dp[j] = std::max(
        	dp[j - sizes[i]] + values[i],
            dp[j]
        );
    }
}

這裡複用dp[j]的推理是,外層迴圈可以理解為依次遍歷當次放入的物品 i 與放入該物品時容量為 j 的最大價值計算,即dp[j]為複用上一層迴圈 i = i - 1, j = j 時的最大價值,所變化的是考慮了新加入的 i 物品;內層迴圈反向遍歷的理由因此可以推理得出,若正向進行遍歷時,外層的i物品會被內層多次放入揹包中(多次被遍歷到),而反向遍歷則不會重複遍歷到,即舉例以下情況:

// i:(in) 1 2 3...sizes.size() - 1 當 i = 2時
// j:(in) size[i] size[i] + 1 size[i] + 2...storage 當 j = x - 1(實際值在此例中不重要,合理即可)時
dp[j] = dp[j - sizes[i]] + values[i] == dp[x - 1] = dp[x - 1 - sizes[2]] + values[2];
//當 i = 2時
//當 j = x時
dp[j] = dp[j - sizes[i]] + values[i] == dp[x] = dp[x - sizes[2]] + values[2]; 
//觀察可得: 在j:容量增長的情況下重複考慮了 i = 2 的情況,即重複加入了揹包
//此時我們反向考慮
//i:(in) 與上文相同, 當i = 2時
//j:(in) storage... size[i] 當 j = x + 1(同上)時
dp[j] == dp[x + 1] = dp[x + 1 - size[2]] + values[2];
//當 i = 2時
//當 j = x時
dp[j] == dp[x] = dp[x - sizes[2]] + values[2];
//觀察可得: 在j:容量遞減的情況下,dp[x + 1] 依賴於上一次外層迴圈的dp[x + 1 - sizes[2]]值,而與此次迴圈的dp[x]值無關,故不會出現重複加入的情況

一維陣列的初始化方式:

一維陣列dp[j]不需要像二維陣列dp[i][j]那樣繁瑣的初始化

僅僅只需要設定整體預設值(一般為0)即可,可參考定義式[3]

完整程式碼(對應416. 分割等和子集(LeetCode)):

class Solution {
    public:
    bool canPartition(std::vector<int>& nums) {
        int sum{0};
        for(int elem : nums) {
            sum += elem;
        }
        
        if(sum % 2 != 0) return false;
        
        int target = sum / 2;
        
        std::vector<int> dp(target + 1,0);
        
        for(int i = 0; i < nums.size(); i++) {
            for(int j = target; j >= nums[i]; j--) {
                dp[j] = std::max(
                	dp[j],
                    dp[j - nums[i]] + nums[i]
                );
            }
        }
        
        if(dp[target] == target) return true;
        else return false;
        
    }
}

例題理解(對應494. 目標和(LeetCode)):

給你一個非負整數陣列 nums 和一個整數 target

向陣列中的每個整數前新增 '+''-' ,然後串聯起所有整數,可以構造一個 表示式

  • 例如,nums = [2, 1] ,可以在 2 之前新增 '+' ,在 1 之前新增 '-' ,然後串聯起來得到表示式 "+2-1"

返回可以透過上述方法構造的、運算結果等於 target 的不同 表示式 的數目。

示例 1:

輸入:nums = [1,1,1,1,1], target = 3
輸出:5
解釋:一共有 5 種方法讓最終目標和為 3 。
-1 + 1 + 1 + 1 + 1 = 3
+1 - 1 + 1 + 1 + 1 = 3
+1 + 1 - 1 + 1 + 1 = 3
+1 + 1 + 1 - 1 + 1 = 3
+1 + 1 + 1 + 1 - 1 = 3

示例 2:

輸入:nums = [1], target = 1
輸出:1

提示:

  • 1 <= nums.length <= 20
  • 0 <= nums[i] <= 1000
  • 0 <= sum(nums[i]) <= 1000
  • -1000 <= target <= 1000

解題思路:

將nums陣列的選擇抽取劃分為題幹對應的兩種狀態,"+" 和 "-";我們稱 "+" 的成員整合為left陣列,"-" 的成員整合為right陣列;

顯然可見:

① int sum{0}; for: sum += nums.elems; sum = sum(left) + sum(right); sum => const	//對nums陣列成員求和,其值為定值,且可轉換為left和 right的成員和
② sum(left) - sum(right) = target; target => const	//滿足題目要求的left和right,其相減值為定值

①和②可推理出

target + sum = sum(left) * 2 => sum(left) = (target + sum) / 2; //依此可得,sum(left)也為定值

推匯出,當我們滿足sum(left)為 (target + sum) / 2時,便可滿足題幹要求,抽象為當我們裝滿容量為sum(left)的揹包時,dp[left]所儲存的值即為解

確定揹包定義式: std::vector<int> dp (storage + 1,0); storage = (sum + target) / 2;

確定揹包狀態轉移公式: dp[j] = dp[j] + dp[j - nums[i]] + nums[i] == dp[j] += dp[j - nums[i]] + nums[i];解釋為在揹包容量為j時,當前dp[j]的值為裝入選擇該i物品加入left陣列和不加入left陣列後的滿足次數;

該題目與許多其他揹包問題的思維大致相同,都是將題目問題轉換為可理解的揹包01問題.

完整程式碼:

class Solution {
public:
    int findTargetSumWays(std::vector<int>& nums, int target) {
        int sum{0};
        for(int elem : nums) {
            sum += elem;
        }

        if((target + sum) % 2 != 0 || abs(target) > sum) return 0;

        int storage = (sum + target) / 2;



        std::vector<int> dp (storage + 1,0);
        dp[0] = 1;

        for(int i = 0; i < nums.size(); i++) {
            for(int j = storage; j >= nums[i]; j--) {
                dp[j] += dp[j - nums[i]];
            }
        }

        return dp[storage];
    }
};

文章參考於: 程式碼隨想錄,CSDN,CNBlog等各優秀編輯者,開發者的優質文章作品.

                                                                                                --2024.11.10 Neko 總結

  1. 這裡是在推理在storage為 j - sizes[i] 的情況下的最大價值(dp[i - 1][j - sizes[i]),從而求得裝載第i個物品後總sizes <= storage的最大價值,因為dp[i - 1][ j -sizes[i]]為子狀態,其已經定義且為已知結果,故能推匯出選擇第i個物品時的最大價值 ↩︎

  2. 這裡的dp[i][j] 初始化程式碼為 (sizes.size(),std::vector<int>(storage + 1,0)); 此處為相容語言陣列特性,將對其自然數字記錄方式;第i個物品的大小在sizes[i - 1]處存放,而重量則符合自然數字規律(0-storage) ↩︎

  3. C++的此定義式本身也初始化了所有元素(為0),語法請自行查略 ↩︎

相關文章