算法沉淀——動態規劃之01背包問題
- 01.【模板】01背包
- 02.分割等和子集
- 03.目標和
- 04.最后一塊石頭的重量 II
01背包問題是一類經典的動態規劃問題,通常描述為:有一個固定容量的背包,以及一組物品,每件物品都有重量和價值,目標是找到在背包容量范圍內,使得背包中的物品總價值最大的組合。
具體來說,問題的輸入包括:
- 一個固定容量的背包(通常表示為一個整數
W
)。 - 一組物品,每個物品有兩個屬性:重量(通常表示為一個整數
weight
)和價值(通常表示為一個整數value
)。 - 求解的目標是找到一種放置物品的方式,使得放入背包的物品的總重量不超過背包容量,并且總價值最大。
這個問題的特點是,對于每件物品,你只能選擇將其放入背包一次(0-1,因此稱為“01背包”),或者不放入背包。不能將物品切割成更小的部分放入背包,要么整個物品放入背包,要么不放入。
動態規劃解法:
-
定義狀態: 通常使用二維數組
dp[i][j]
表示在前i
個物品中,背包容量為j
時的最大總價值。 -
狀態轉移方程: 考慮第
i
個物品,可以選擇放入背包或者不放入。如果選擇放入,那么總價值為dp[i-1][j-weight[i]] + value[i]
,即前i-1
個物品的總價值加上當前物品的價值。如果選擇不放入,那么總價值為dp[i-1][j]
,即前i-1
個物品的總價值。因此,狀態轉移方程為:dp[i][j] = max(dp[i-1][j], dp[i-1][j-weight[i]] + value[i])
其中,
dp[i-1][j]
表示不放入第i
個物品,dp[i-1][j-weight[i]] + value[i]
表示放入第i
個物品。 -
初始條件: 當
i=0
時,表示前0個物品,總價值為0;當j=0
時,表示背包容量為0,總價值也為0。 -
遍歷順序: 外層循環遍歷物品,內層循環遍歷背包容量。
-
返回結果: 最終結果存儲在
dp[N][W]
中,其中N
為物品數量,W
為背包容量。
例子:
假設有如下物品:
Copy code解釋物品1:重量=2,價值=3
物品2:重量=3,價值=4
物品3:重量=4,價值=5
物品4:重量=5,價值=6
背包容量為W=8
,我們要求解在這個條件下的最大總價值。
按照上述動態規劃解法,構建狀態轉移表如下:
luaCopy code解釋 重量/價值 0 1 2 3 4 5 6 7 8----------------------------------------------物品0 0 0 0 0 0 0 0 0 0物品1 0 0 3 3 3 3 3 3 3物品2 0 0 3 4 4 7 7 7 10物品3 0 0 3 4 4 7 8 8 11物品4 0 0 3 4 4 7 8 9 11
因此,最終結果為dp[4][8] = 11
,表示在背包容量為8的情況下,最大總價值為11。這意味著最優解是選擇物品2和物品4放入背包。
01.【模板】01背包
題目鏈接:https://www.nowcoder.com/practice/fd55637d3f24484e96dad9e992d3f62e?tpId=230&tqId=2032484&ru=/exam/oj&qru=/ta/dynamic-programming/question-ranking&sourceUrl=%2Fexam%2Foj%3Fpage%3D1%26tab%3D%25E7%25AE%2597%25E6%25B3%2595%25E7%25AF%2587%26topicId%3D196
你有一個背包,最多能容納的體積是V。
現在有n個物品,第i個物品的體積為vi,價值為wi。
(1)求這個背包至多能裝多大價值的物品?
(2)若背包恰好裝滿,求至多能裝多大價值的物品?
輸入描述:
第一行兩個整數n和V,表示物品個數和背包體積。
接下來n行,每行兩個數vi和wi,表示第i個物品的體積和價值。
1≤n,V;vi,wi≤1000
輸出描述:
輸出有兩行,第一行輸出第一問的答案,第二行輸出第二問的答案,如果無解請輸出0。
示例1
輸入:
3 5
2 10
4 5
1 4
輸出:
14
9
復制
說明:
裝第一個和第三個物品時總價值最大,但是裝第二個和第三個物品可以使得背包恰好裝滿且總價值最大。
示例2
輸入:
3 8
12 6
11 8
6 8
輸出:
8
0
說明:
裝第三個物品時總價值最大但是不滿,裝滿背包無解。 要求O(nV)的時間復雜度,O(V)空間復雜度
思路
第一問:
- 狀態表示:
dp[i][j]
表示從前i
個物品中挑選,總體積不超過j
的情況下,所有的選法中能挑選出的最大價值。
- 狀態轉移方程:
- 對于每個物品,我們有兩種選擇:
- 不選第
i
個物品:此時dp[i][j] = dp[i - 1][j]
。 - 選擇第
i
個物品:此時需要確保總體積不超過j - v[i]
,而且該狀態是合法的,即j >= v[i]
和dp[i - 1][j - v[i]]
存在。狀態轉移方程為dp[i][j] = max(dp[i - 1][j], dp[i - 1][j - v[i]] + w[i])
。
- 不選第
- 對于每個物品,我們有兩種選擇:
- 初始化:
- 多加一行,第一行初始化為
0
,因為不選任何物品總體積為0
時,價值為0
。
- 多加一行,第一行初始化為
- 填表順序:
- 從上往下,每一行從左往右填表。
- 返回值:
- 返回
dp[n][V]
,即最后一行最后一列的值。
- 返回
第二問:
- 狀態表示:
dp[i][j]
表示從前i
個物品中挑選,總體積正好等于j
的情況下,所有的選法中能挑選出的最大價值。
- 狀態轉移方程:
dp[i][j] = max(dp[i - 1][j], dp[i - 1][j - v[i]] + w[i])
。- 在使用
dp[i - 1][j - v[i]]
時,需要判斷j >= v[i]
和dp[i - 1][j - v[i]]
是否為-1
。
- 初始化:
- 多加一行,第一格初始化為
0
,表示正好湊齊體積為0
的背包。 - 第一行后面的格子初始化為
-1
,因為沒有物品,無法滿足體積大于0
的情況。
- 多加一行,第一格初始化為
- 填表順序:
- 從上往下,每一行從左往右填表。
- 返回值:
- 由于最后可能湊不成體積為
V
的情況,需要特判。
- 由于最后可能湊不成體積為
代碼
#include <iostream>
#include <algorithm>
#include <cstring>
using namespace std;const int N=1002;
int n,V,v[N],w[N];
int dp[N][N];int main() {cin>>n>>V;for(int i=1;i<=n;i++) cin>>v[i]>>w[i];for(int i=1;i<=n;i++)for(int j=0;j<=V;j++){dp[i][j]=dp[i-1][j];if(j>=v[i]) dp[i][j]=max(dp[i][j],dp[i-1][j-v[i]]+w[i]);}cout<<dp[n][V]<<endl;memset(dp,0,sizeof dp);for(int j=1;j<=V;j++) dp[0][j]=-1;for(int i=1;i<=n;i++)for(int j=0;j<=V;j++){dp[i][j]=dp[i-1][j];if(j>=v[i]&&dp[i-1][j-v[i]]!=-1)dp[i][j]=max(dp[i][j],dp[i-1][j-v[i]]+w[i]);}cout<<(dp[n][V]==-1?0:dp[n][V])<<endl;
}
優化步驟:
- 滾動數組的應用:
- 在01背包問題中,通過滾動數組可以刪去所有的橫坐標,因為狀態
dp[i][j]
只依賴于上一行的狀態dp[i-1][j]
和dp[i-1][j-v[i]]
,因此只需保留一行狀態。
- 在01背包問題中,通過滾動數組可以刪去所有的橫坐標,因為狀態
- 遍歷順序修改:
- 修改了
j
的遍歷順序,原本的遍歷是從0
到V
,現在改為從V
到0
。這樣做的原因是,如果從0
到V
遍歷,會使用當前行的dp[i-1][j-v[i]]
的值,而我們已經在上一步的滾動數組中刪除了這一行,所以需要改變遍歷順序,從V
到0
。
- 修改了
#include <iostream>
#include <algorithm>
#include <cstring>
using namespace std;const int N=1002;
int n,V,v[N],w[N];
int dp[N];int main() {cin>>n>>V;for(int i=1;i<=n;i++) cin>>v[i]>>w[i];for(int i=1;i<=n;i++)for(int j=V;j>=v[i];j--)dp[j]=max(dp[j],dp[j-v[i]]+w[i]);cout<<dp[V]<<endl;memset(dp,0,sizeof dp);for(int j=1;j<=V;j++) dp[j]=-1;for(int i=1;i<=n;i++)for(int j=V;j>=v[i];j--)if(dp[j-v[i]]!=-1)dp[j]=max(dp[j],dp[j-v[i]]+w[i]);cout<<(dp[V]==-1?0:dp[V])<<endl;
}
02.分割等和子集
題目鏈接:https://leetcode.cn/problems/partition-equal-subset-sum/
給你一個 只包含正整數 的 非空 數組 nums
。請你判斷是否可以將這個數組分割成兩個子集,使得兩個子集的元素和相等。
示例 1:
輸入:nums = [1,5,11,5]
輸出:true
解釋:數組可以分割成 [1, 5, 5] 和 [11] 。
示例 2:
輸入:nums = [1,2,3,5]
輸出:false
解釋:數組不能分割成兩個元素和相等的子集。
提示:
1 <= nums.length <= 200
1 <= nums[i] <= 100
思路
- 狀態表達:
dp[i][j]
表示在前i
個元素中選擇,所有的選法中,能否湊成總和為j
這個數。
- 狀態轉移方程:
- 根據最后一個位置的元素,分兩種情況討論:
- 不選擇
nums[i]
:此時是否能夠湊成總和為j
取決于前i-1
個元素的情況,即dp[i][j] = dp[i-1][j]
。 - 選擇
nums[i]
:如果nums[i]
小于等于j
,則需要看前i-1
個元素中是否能湊成總和為j - nums[i]
,即dp[i][j] = dp[i][j] || dp[i-1][j - nums[i]]
。
- 不選擇
- 根據最后一個位置的元素,分兩種情況討論:
- 初始化:
- 第一行表示不選擇任何元素,要湊成目標和
j
,只有當目標和為0
時才能做到,因此第一行僅需初始化第一個元素dp[0][0] = true
。
- 第一行表示不選擇任何元素,要湊成目標和
- 填表順序:
- 根據狀態轉移方程,從上往下填寫每一行,每一行的順序是無所謂的。
- 返回值:
- 根據狀態表達,返回
dp[n][aim]
的值,其中n
表示數組的大小,aim
表示要湊的目標和。
- 根據狀態表達,返回
- 空間優化:
- 對于 01 背包類型的問題,可以進行空間上的優化,即刪除第一維,并修改第二層循環的遍歷順序。
代碼
class Solution {
public:bool canPartition(vector<int>& nums) {int n=nums.size(),sum=0;for(int x:nums) sum+=x;if(sum%2) return false;int aim=sum/2;vector<vector<bool>> dp(n+1,vector<bool>(aim+1));for(int i=0;i<=n;i++) dp[i][0]=true;for(int i=1;i<=n;i++)for(int j=1;j<=aim;j++){dp[i][j]=dp[i-1][j];if(j>=nums[i-1]) dp[i][j]=dp[i][j]||dp[i-1][j-nums[i-1]];}return dp[n][aim];}
};
空間優化
class Solution {
public:bool canPartition(vector<int>& nums) {int n=nums.size(),sum=0;for(int x:nums) sum+=x;if(sum%2) return false;int aim=sum/2;vector<bool> dp(aim+1);dp[0]=true;for(int i=1;i<=n;i++)for(int j=aim;j>=nums[i-1];j--)dp[j]=dp[j]||dp[j-nums[i-1]];return dp[aim];}
};
03.目標和
題目鏈接:https://leetcode.cn/problems/target-sum/
給你一個非負整數數組 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
思路
- 狀態表示:
dp[i][j]
表示在前i
個數中選,總和正好等于j
,一共有多少種選法。
- 狀態轉移方程:
- 根據最后一個位置的元素,結合題目的要求,有兩種策略:
- 不選
nums[i]
:此時湊成總和j
的總方案數,要看在前i-1
個元素中選,湊成總和為j
的方案數,即dp[i][j] = dp[i-1][j]
。 - 選擇
nums[i]
:如果nums[i]
小于等于j
,則需要看前i-1
個元素中是否能湊成總和為j - nums[i]
,即dp[i][j] += dp[i-1][j - nums[i]]
。
- 不選
- 根據最后一個位置的元素,結合題目的要求,有兩種策略:
- 初始化:
- 需要用到上一行的數據,因此初始化第一行,表示不選擇任何元素湊成目標和
j
。只有當目標和為0
時才能做到,因此第一行僅需初始化第一個元素dp[0][0] = 1
。
- 需要用到上一行的數據,因此初始化第一行,表示不選擇任何元素湊成目標和
- 填表順序:
- 根據狀態轉移方程,從上往下填寫每一行,每一行的順序是無所謂的。
- 返回值:
- 根據狀態表示,返回
dp[n][aim]
的值,其中n
表示數組的大小,aim
表示要湊的目標和。
- 根據狀態表示,返回
代碼
class Solution {
public:int findTargetSumWays(vector<int>& nums, int target) {int sum=0;for(auto x:nums) sum+=x;int aim=(sum+target)/2;if(aim<0||(sum+target)%2) return 0;int n=nums.size();vector<vector<int>> dp(n+1,vector<int>(aim+1));dp[0][0]=1;for(int i = 1; i <= n; i++) for(int j = 0; j <= aim; j++){dp[i][j] = dp[i - 1][j];if(j >= nums[i - 1]) dp[i][j] += dp[i - 1][j - nums[i - 1]];}return dp[n][aim];}
};
04.最后一塊石頭的重量 II
題目鏈接:https://leetcode.cn/problems/last-stone-weight-ii/
有一堆石頭,用整數數組 stones
表示。其中 stones[i]
表示第 i
塊石頭的重量。
每一回合,從中選出任意兩塊石頭,然后將它們一起粉碎。假設石頭的重量分別為 x
和 y
,且 x <= y
。那么粉碎的可能結果如下:
- 如果
x == y
,那么兩塊石頭都會被完全粉碎; - 如果
x != y
,那么重量為x
的石頭將會完全粉碎,而重量為y
的石頭新重量為y-x
。
最后,最多只會剩下一塊 石頭。返回此石頭 最小的可能重量 。如果沒有石頭剩下,就返回 0
。
示例 1:
輸入:stones = [2,7,4,1,8,1]
輸出:1
解釋:
組合 2 和 4,得到 2,所以數組轉化為 [2,7,1,8,1],
組合 7 和 8,得到 1,所以數組轉化為 [2,1,1,1],
組合 2 和 1,得到 1,所以數組轉化為 [1,1,1],
組合 1 和 1,得到 0,所以數組轉化為 [1],這就是最優值。
示例 2:
輸入:stones = [31,26,33,21,40]
輸出:5
提示:
1 <= stones.length <= 30
1 <= stones[i] <= 100
思路
- 狀態表示:
dp[i][j]
表示在前i
個元素中選擇,總和不超過j
的情況下,這些元素的最大和。
- 狀態轉移方程:
- 根據最后一個位置的元素,結合題目的要求,有兩種策略:
- 不選
stones[i]
:此時是否能夠湊成總和為j
,要看在前i-1
個元素中選,能否湊成總和為j
。根據狀態表示,此時dp[i][j] = dp[i-1][j]
。 - 選擇
stones[i]
:這種情況下是有前提條件的,此時的stones[i]
應該是小于等于j
。因為如果這個元素都比要湊成的總和大,選擇它就沒有意義。那么是否能夠湊成總和為j
,要看在前i-1
個元素中選,能否湊成總和為j - stones[i]
。根據狀態表示,此時dp[i][j] = dp[i-1][j-stones[i]] + stones[i]
。
- 不選
- 根據最后一個位置的元素,結合題目的要求,有兩種策略:
- 初始化:
- 由于需要用到上一行的數據,可以先將第一行初始化。
- 第一行表示「沒有石頭」,因此想湊成目標和
j
的最大和都是0
。
- 填表順序:
- 根據狀態轉移方程,從上往下填寫每一行,每一行的順序是無所謂的。
- 返回值:
- 根據狀態表示,找到最接近
sum / 2
的最大和dp[n][sum / 2]
。 - 返回
sum - 2 * dp[n][sum / 2]
,因為我們要的是兩堆石頭的差。
- 根據狀態表示,找到最接近
代碼
class Solution {
public:int lastStoneWeightII(vector<int>& stones) {int sum=0;for(int x:stones) sum+=x;int n=stones.size(),m=sum/2;vector<vector<int>> dp(n+1,vector<int>(m+1));for(int i=1;i<=n;i++)for(int j=0;j<=m;j++){dp[i][j]=dp[i-1][j];if(j>=stones[i-1]) dp[i][j]=max(dp[i][j],dp[i-1][j-stones[i-1]]+stones[i-1]);}return sum-2*dp[n][m];}
};