動態規划---01背包問題詳解
鳴謝:本次的學習是跟着Carl的筆記來的,原創作者為Carl,可以在b站或者公眾號關注Carl,搜索代碼隨想錄。
一、01背包問題理論基礎
1、問題
有N件物品和一個最多能背重量為W的背包(也就是說背包的容量是W),第i件物品的重量是weight[i],其價值是value[i],每件物品只能背一次,求解將哪些物品放到背包里面物品價值的總和最大。
2、二維dp數組下的01背包
①確定dp數組以及下標的含義
dp[i][j]表示:
當背包容量為j,現有編號為0~i的物品可以拿,此時所能背的價值最大為多少。
②確定遞推公式
如何推出dp[i][j]呢?
首先再次明確一下dp[i][j]的含義:
當背包容量為j,現有編號為0~i的物品可以拿,此時所能背的價值最大為多少。
那么就可以分情況來討論,當前背包容量為j,當前物品的編號為i,那么我們要不要把第i件物品放到背包中。
- 當前物品編號i不放入背包中,那么dp[i][j] = dp[i-1][j]
- 當前物品編號i放入背包中,那么dp[i][j] = dp[i-1][j-weight[i]]+value[i]
- 注意此時的dp[i][j],意思是當前這個編號的物品放進來了,我們還需要加上value[i]。
綜上,我們只需要在這兩種情況中,選擇最優的,也就是價值最大的dp[i][j]即可。
所以:
dp[i][j] = Math.max( dp[i-1][j] , dp[i-1][j-weight[i]]+value[i] )
③dp數組初始化
大體的思路是根據遞推公式來的,從遞推公式可以看出,我們需要的是dp[i][j]左上方的數據,所以我們整體循環的方向就是從左到右。從上到下。
我的思路還是通過分情況來討論:
可能存在兩種情況:
- 當前背包還可以容納物品,也就是說背包的重量j不為0,但是當前可以拿的物品只有物品編號為0扥物品,也就是說i=0,j≠0的情況。
- 當前有不同種的物品可以拿,但是背包的重量j為0,也就是說當前背包無法容納任何的物品,dp[i][j] = 0,這就是i≠0,j=0的情況。
此時已經初始化完了第一行和第一列,那么中間的數該如何初始化呢?
實際編寫代碼的時候,觀察我們的遞推公式,我們用的是最大值,所以用-1或者0都可以。如果是比較小的情況,我們就設置為一個大的數。
④確定遍歷順序
我們 有兩個維度來描述當前背包和物品的狀態,i和j。
那么,是先遍歷物品還是先遍歷背包的重量呢?
其實兩種方法都是可以的。
-
先遍歷物品,然后遍歷背包重量。
-
//weight數組的大小 就是物品的個數 for(int i=1;i<weight.length;i++){//遍歷物品 for(int j=0;i<=bagweight;j++){//遍歷背包容量 if(j<weight[i])//如果當前的這個背包容量,比當前這個物品的重量小,那么就自動放棄了。 dp[i][j] = dp[i-1][j]; else dp[i][j] = Math.max( dp[i-1][j] , dp[i-1][j-weight[i]]+value[i]); } }
-
-
先遍歷背包,再遍歷物品。
-
for(int j=0;j<=bagweight;j++){//遍歷背包的容量 for(int i=1;i<weight.length;i++){//遍歷物品 if(j<weight[i]) dp[i][j] = dp[i-1][j]; else dp[i][j] = Math.max( dp[i-1][j] , dp[i-1][j-weight[i]]+value[i]); } }
-
⑤舉例推導dp數組
實際在處理的過程當中,我們需要開辟的多大的數組。根據實際情況來決定。
3、一維數組下的01背包
①確定dp數組以及下標的含義
設dp[j]表示,容量為j的背包,所背的物品價值最大為dp[j]。
②確定遞推公式
是否把當前的這個物品放入,分兩種情況,拿還是不拿。
dp[j] = Math.max(dp[j], dp[j - weight[i]] + value[i])
③如何初始化
那么我假設物品價值都是⼤於0的,所以dp數組初始化的時候,都初始為0就可以了。
④一維dp數組的遍歷順序
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]);
}
}
⑤舉例推導
二、LeetCode-416.分割等和子集
1、題干
2、動規思路
①確定dp數組以及下標的含義
在01背包中,dp[j]表示:
設dp[j]表示,容量為j的背包,所背的物品價值最大為dp[j]。
套到本題,dp[i]表示 背包總容量是i,最⼤可以湊成i的⼦集總和為dp[i]
②確定遞推公式
③初始化
④遍歷順序
⑤舉例推導
dp[i]的數值⼀定是⼩於等於i的。
如果dp[i] == i 說明,集合中的⼦集總和正好可以湊成總和i,理解這⼀點很重要
3、一維dp數組代碼
class Solution {
public boolean canPartition(int[] nums) {
int sum = 0;
for(int i=0;i<nums.length;i++)
sum += nums[i];
if (sum % 2==1) return false;//不能平分倆數組
sum /= 2;//背包容量
int[] dp = new int[sum+1];//多一位,因為存在背包容量為0的情況
for(int i=0;i<nums.length;i++){
for(int j=sum;j>=nums[i];j--){//每一個元素不可重復放入
dp[j] = Math.max(dp[j],dp[j-nums[i]]+nums[i]);
}
}
//集合中的元素正好可以湊成總和sum
if (dp[sum] == sum) return true;
return false;
}
}
我的理解
Carl的思路是,先把數組中所有元素總和加起來,然后除以二,這就是每個子集加起來的和,如果不能整除2的話,一定是錯誤的,從數學的角度上就不能划分成兩個相等的子集。
如果從數學的角度上可以划分成兩個相等的子集,那么轉換為01背包問題,給的nums數組中的每一個元素,就相當於是物品,它的重量也就相當於是它的價值值都是nums[i]。我們背包的容量就是sum/2,並且背包最終能夠背的最大價值,肯定不會大於sum/2。
我們要做的任務就是,有sum/2這么大容量的一個背包,有一組物品,存不存在放入其中的幾個物品,物品重量使得恰好等於背包容量。01背包最終的結果,是求得了可以放下的最大值,我們最終拿這個最大值和sum/2比較即可。
4、二維dp數組代碼
dp[i][j]指的是當前背包容量為j,可以選擇的物品為i,所能背的最大價值。
class Solution {
public boolean canPartition(int[] nums) {
int sum = 0;
for(int i=0;i<nums.length;i++)
sum += nums[i];
if (sum % 2==1) return false;//不能平分倆數組
int target = sum/2;//背包的最大容量和最大價值都為target
int[][] dp = new int[nums.length+1][target+1];
for (int i=0;i<nums.length;i++)
dp[i][0] = 0;
for (int j=0;j<target+1;j++)
dp[0][j] = 0;
for (int i=1;i<nums.length+1;i++){
for (int j=1;j<=target;j++){
if (nums[i-1] > j) dp[i][j] = dp[i-1][j];//容量放不下當前這個物品的重量
else
dp[i][j] = Math.max( dp[i-1][j] , dp[i-1][j-nums[i-1]]+nums[i-1]);
}
}
if (dp[nums.length][target] == target) return true;
return false;
}
}
最終的數組
測試數據為nums[1,5,11,5]
對比一維的dp數組結果
5、一維dp和二維dp再思考
可以看出一維dp和二維dp的最終狀態是一樣的,只是一維dp節省了空間復雜度。其次就是一維dp數組,需要倒序的去迭代更新,因為我們取得是Math.max(a,b),如果我們遍歷順序從前到后,那么后面的值就會被前面的所覆蓋。最后,開辟dp數組的大小也很有講究,開多大,怎么初始化,最終的結果是數組的哪個下標所對應的值?心中要明確好dp[i][j]的含義。
三、LeetCode-1049.最后一塊石頭的重量II
1、題干
2、動規思路
本題和上面的分割等和子集很像,如何才能使得石頭碰撞后重量最小呢?也就是說最好把石頭重量分成相近的兩堆,相撞之后剩下的石頭最小。
本題中物品的重量為store[i],物品的價值也為store[i]。
對應於01背包中的物品重量weight[i]和價值value[i]。
①確定dp數組以及下標的含義
dp[j]表示容量(這⾥說容量更形象,其實就是重量)為j的背包,最多可以背dp[j]這么重的⽯頭。
②確定遞推公式
01背包的遞推公式為:dp[j] = Math.max(dp[j], dp[j - weight[i]] + value[i]);
本題則是:dp[j] = Math.max(dp[j], dp[j - stones[i]] + stones[i]);
③dp數組初始化
dp[j]中的j表示容量,那么j最大為多少呢?答案是所有石頭重量的總和
然而我們需要的是target:也就是最大重量(總和)的一半。
④遍歷順序
從左到右,:如果使⽤⼀維dp數組,物 品遍歷的for循環放在外層,遍歷背包的for循環放在內層,且內層for循環倒敘遍歷。
⑤舉例推導
3、一維dp數組代碼
class Solution {
public int lastStoneWeightII(int[] stones) {
int sum = 0;
for(int i=0;i<stones.length;i++)
sum += stones[i];
int target = sum/2;//背包容量,盡可能能達到這么大。
int[] dp = new int[target+1];//多一位,因為存在背包容量為0的情況
for(int i=0;i<stones.length;i++){
for(int j=target;j>=stones[i];j--){//每一個元素不可重復放入,背包容量夠才能放入
dp[j] = Math.max(dp[j],dp[j-stones[i]]+stones[i]);
}
}
return sum - dp[target] - dp[target];
}
}
思考:
- 從整體的角度考慮:首先通過計算總和的一半,這樣兩堆石頭撞擊會有最小的剩余。
- 01背包動態規划的特點:有容量為 j 的背包,怎么裝物品,最后的價值最大。
- 如何靠到01背包問題:現在我們的最大容量為target,那么我們怎么裝物品,使得總價值(也就是總重量)達到最大。
- 答案:在最后一個dp[j]我們得到了,容量為 j 的背包,所能裝下的最大價值為dp[j],也就是我們石頭堆的最大子總重量。
- 然后通過總重量,減去2*dp[target]也就是說,最多會有這么多的石頭發生碰撞,損失,用總重量減去即可算的剩余的最小重量。
4、二維dp數組代碼
dp[i][j]代表指的是當前背包容量為j,可以選擇的石頭為i,所能背的最大重量。
class Solution {
public int lastStoneWeightII(int[] stones) {
int sum = 0;
for(int i=0;i<stones.length;i++)
sum += stones[i];
int target = sum/2;//背包容量,盡可能能達到這么大。
int[][] dp = new int[stones.length+1][target+1];//多一位,因為存在背包容量為0的情況
for(int i=1;i<stones.length+1;i++){
for(int j=1;j<target+1;j++){
if (stones[i-1] > j)
dp[i][j] = dp[i-1][j];//容量放不下當前這個物品的重量
else
dp[i][j] = Math.max( dp[i-1][j] , dp[i-1][j-stones[i-1]]+stones[i-1]);
}
}
return sum - 2*dp[stones.length][target];
}
}
四、LeetCode-494.目標和
1、題干
2、動規思路
要想有target,那么分析target從哪里來。
target是最終的結果,target = 左面數字的組合 - 右面數字的組合。即target = left - right
target = left - right
left + right = sum
所以target = left - (sum-left) = 2*left -sum
所以left = (sum + target) / 2(這里要求sum + target)是非負偶數
此時我們就找到了左面組合的值應該為多少。
此時的問題就是在集合中找出和為left的組合共有多少種
①確定dp數組以及下標的含義
dp[i][j]表示在數組nums的前i個數中選取元素,使得這些元素之和等於背包容量j的方案,有dp[i][j]個。
②確定遞推公式
分析dp[i][j]的來源
1、如果j<nums[i],則不能選nums[i]
此時有dp[i][j] = dp[i-1][j]
2、如果j>=nums[i],則可以選nums[i],也可以不選。
如果選了nums[i],方案數是dp[i-1][j-nums[i]]
如果不選nums[i],方案數是dp[i-1][j]
所以總的方案數就是:dp[i][j] = dp[i-1][j] + dp[i-1][j-nums[i]]
③初始化
如果i=0,那么說明從前0個數中選取元素,元素和肯定為0
如果j=0,那么dp[i][j]=1
如果j>=1,那么dp[i][j]=0,即沒有方案可以滿足j
④遍歷順序
從左到右遍歷
⑤最終答案
最終的答案就是dp[nums.length][left]
3、二維dp數組代碼
class Solution {
public int findTargetSumWays(int[] nums, int target) {
int sum = 0;
for(int i=0;i<nums.length;i++)
sum += nums[i];
//前提條件
if((sum + target)%2!=0 || (sum + target)<0 )
return 0;
int left = (sum + target)/2;
int n = nums.length;
int[][] dp = new int[n+1][left+1];
//初始化
dp[0][0] = 1;
for(int i=1;i<=n;i++){
int num = nums[i-1];
for(int j=0;j<=left;j++){
dp[i][j] = dp[i-1][j];
if(j >= num)
dp[i][j] += dp[i-1][j-num];
}
}
return dp[n][left];
}
}
五、LeetCode-474.一和零
1、題干
2、動規思路
①確定dp數組下標及其含義
dp[i][j]:最多有i個0和j個1的strs的最大子集的大小為dp[i][j]
②確定遞推公式
dp[i][j] 可以由前⼀個strs⾥的字符串推導出來,strs⾥的前一個字符串有zeroNum個0,oneNum個1。
dp[i][j] 就可以是 dp[i - zeroNum][j - oneNum] + 1。(相當於再把當前這個字符串加到子集中去)
然后我們在遍歷的過程中,取dp[i][j]的最⼤值。
所以遞推公式:dp[i][j] = max(dp[i][j], dp[i - zeroNum][j - oneNum] + 1);
此時⼤家可以回想⼀下01背包的遞推公式:dp[j] = max(dp[j], dp[j - weight[i]] + value[i]);
對⽐⼀下就會發現,字符串的zeroNum和oneNum相當於物品的重量(weight[i]),字符串本身的個數相當於物品的價值(value[i])。
這就是⼀個典型的01背包! 只不過物品的重量有了兩個維度⽽已。
③初始化
01背包的dp數組初始化為0就可以,因為物品價值不會是負數,初始為0,保證遞推的時候不被覆蓋。
④確定遍歷順序
外層for循環遍歷物品
內層for循環遍歷背包容量,並且是從后向前遍歷
因為我們這里用的相當於是二維的滾動數組,如果從前向后遍歷的話,就會發生覆蓋與重復。
//通過indexOf()尋找字符串中含有多少個某個字符
public int countString(String str,String s){
int count = 0,len = str.length();
while(str.indexOf(s) != -1) {
str = str.substring(str.indexOf(s) + 1,str.length());
count++;
}
return count;
}
//遍歷順序
for(String str : strs){//外層遍歷物品
int oneNum = 0,zeroNum = 0;
oneNum = countString(str,"1");
zeroNum = countString(str,"0");
for(int i=m;i>=zeroNum;i--){//遍歷背包容量從后向前
for(int j=n;j>=onNum;j--){
dp[i][j] = Math.max(dp[i][j],dp[i-zeroNum][j-oneNum]+1);
}
}
}
⑤距離推導dp數組
以輸⼊:["10","0001","111001","1","0"],m = 3,n = 3為例
3、代碼
class Solution {
public int findMaxForm(String[] strs, int m, int n) {
int[][] dp = new int[m+1][n+1];
//遍歷順序
for(String str : strs){//外層遍歷物品
int oneNum = 0,zeroNum = 0;
oneNum = countString(str,"1");
zeroNum = countString(str,"0");
for(int i=m;i>=zeroNum;i--){//遍歷背包容量從后向前
for(int j=n;j>=oneNum;j--){
dp[i][j] = Math.max(dp[i][j],dp[i-zeroNum][j-oneNum]+1);
}
}
}
return dp[m][n];
}
//通過indexOf()尋找字符串中含有多少個某個字符
public int countString(String str,String s){
int count = 0,len = str.length();
while(str.indexOf(s) != -1) {
str = str.substring(str.indexOf(s) + 1,str.length());
count++;
}
return count;
}
}
4、再次理解滾動數組(一維dp)和二維dp數組
- 二維dp數組實際上就是外層循環控制物品,內層循環控制背包,順序按照具體的題目從前到后或者從后到前。
- 關鍵的點是可以物品在外層,也可以背包在外層。
- 一維dp(滾動數組)就是將物品通過題目給的物品數組來表示了。不需要我們通過特定的dp數組中的屬性來表示。