動態規劃-揹包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 總結
這裡是在推理在storage為 j - sizes[i] 的情況下的最大價值(dp[i - 1][j - sizes[i]),從而求得裝載第i個物品後總sizes <= storage的最大價值,因為dp[i - 1][ j -sizes[i]]為子狀態,其已經定義且為已知結果,故能推匯出選擇第i個物品時的最大價值 ↩︎
這裡的dp[i][j] 初始化程式碼為 (sizes.size(),std::vector<int>(storage + 1,0)); 此處為相容語言陣列特性,將對其自然數字記錄方式;第i個物品的大小在sizes[i - 1]處存放,而重量則符合自然數字規律(0-storage) ↩︎
C++的此定義式本身也初始化了所有元素(為0),語法請自行查略 ↩︎