第九章 動態規劃part03
正式開始背包問題,背包問題還是挺難的,雖然大家可能看了很多背包問題模板代碼,感覺挺簡單,但基本理解的都不夠深入。
如果是直接從來沒聽過背包問題,可以先看文字講解慢慢了解 這是干什么的。
如果做過背包類問題,可以先看視頻,很多內容,是自己平時沒有考慮到位的。
背包問題,力扣上沒有原題,大家先了解理論,今天就安排一道具體題目。
掌握01背包,和完全背包,就夠用了,最多可以再來一個多重背包。
詳細布置
01背包問題 二維
https://programmercarl.com/%E8%83%8C%E5%8C%85%E7%90%86%E8%AE%BA%E5%9F%BA%E7%A1%8001%E8%83%8C%E5%8C%85-1.html
視頻講解:https://www.bilibili.com/video/BV1cg411g7Y6
01背包問題 一維
https://programmercarl.com/%E8%83%8C%E5%8C%85%E7%90%86%E8%AE%BA%E5%9F%BA%E7%A1%8001%E8%83%8C%E5%8C%85-2.html
視頻講解:https://www.bilibili.com/video/BV1BU4y177kY
01背包總結
416. 分割等和子集
本題是 01背包的應用類題目
https://programmercarl.com/0416.%E5%88%86%E5%89%B2%E7%AD%89%E5%92%8C%E5%AD%90%E9%9B%86.html
視頻講解:https://www.bilibili.com/video/BV1rt4y1N7jE
01背包
題目鏈接
https://kamacoder.com/problempage.php?pid=1046
解題思路
什么是01背包?
背包問題的理論基礎重中之重是01背包,一定要理解透!
有n件物品和一個最多能背重量為w 的背包。第i件物品的重量是weight[i],得到的價值是value[i] 。每件物品只能用一次,求解將哪些物品裝入背包里物品價值總和最大。
自底向上思考,暴力解法如何做?
每一件物品只有倆個狀態,取或不取,所以可以使用回溯法搜索出所有的情況,那么時間復雜度就是O(2^n),n表示物品數量
所以暴力的解法是指數級別的時間復雜度。進而才需要動態規劃的解法來進行優化!
public class BagProblem {/*** 有n件物品和一個最多能背重量為w 的背包。第i件物品的重量是weight[i],得到的價值是value[i] 。* 每件物品只能用一次,求解將哪些物品裝入背包里物品價值總和最大。**/int maxValue=0;List<Integer> bagLog =new ArrayList<>();int curWeight=0;int curValue=0;public void backtracking(int bagSize,int [] weight,int [] value,int startIndex){if(curWeight>bagSize){return;}//遍歷物品for(int i=startIndex;i<weight.length;i++){if(curWeight+weight[i]>bagSize){break;}bagLog.add(i);curValue+=value[i];curWeight+=weight[i];System.out.println("當前背包的物品:"+ bagLog.toString() +"價值:"+curValue);maxValue=Math.max(maxValue,curValue);backtracking(bagSize,weight,value,i+1);if(bagLog.size()==weight.length){return;}curValue-=value[i];curWeight-=weight[i];bagLog.remove(bagLog.size()-1);}}public int bagProblemWithBacktracking(int bagSize,int [] weight,int [] value){backtracking(bagSize,weight,value,0);return maxValue;}public static void main(String[] args) {int[] weight = {1,3,4};int[] value = {15,20,30};int bagSize = 10;BagProblem bagProblem = new BagProblem();int _maxValue = bagProblem.bagProblemWithBacktracking(bagSize, weight, value);System.out.printf(""+_maxValue);}}
二維dp數組01背包
:::tips
動規五步曲分析
1.確定dp數組以及下標的含義
對于背包問題,有一種寫法, 是使用二維數組,即dp[i][j] 表示從下標為[0-i]的物品里任意取,放進容量為j的背包,價值總和最大是多少。
二維數組如圖所示,要時刻記著dp數組的含義,i代表什么 j代表什么
2.確定遞推公式
再回顧一下dp[i][j]的含義:從下標為[0-i]的物品里任意取,放進容量為j的背包,價值總和最大是多少。
那么可以有兩個方向推出來dp[i][j]
2.1 不放物品i : 由dp[i - 1][j]推出 此時dp[i][j]=dp[i-1][j]
(其實就是當物品i的重量大于背包j的重量時,物品i無法放進背包中,所以背包內的價值依然和前面相同)
2.2 放物品i : 由dp[i - 1][j - weight[i]]推出,dp[i - 1][j - weight[i]] 為背包容量為j - weight[i]的時候不放物品i的最大價值,value[i]是物品i的價值,那么dp[i - 1][j - weight[i]] + value[i] ,就是背包放物品i得到的最大價值
所以遞推公式是: dp[i][j]=Math.max(dp [i-1] [j], dp [i-1] [j - weight[i] ]+value[i])
進一步理解疑惑,這里為什么取最大,為什么j-weight[i]?
因為這里放物品i 不是說你的背包是無限大的,直接就放進去,比如:你背包重量是5,當前背包有
i1 重量是1 i2 重量是2 i3重量是1 ,此時來到背包重量5,物品i4的重量是4,你要想能放物品i4,n你就得j-weight[i4] =1 , 那么現在背包有物品i1 + 物品i4 ,這時候就要比較 i1,i4的價值大還是i1,i2,i3的價值大,是不確定的,所以要取他倆的最大值。
j - weight[i] 的意思是要確保能放進去 weight[i] 才能加入背包,也就是背包容量為j - weight[i]的價值是多少 再加上 value[i] ,當然你能不能讓數組越界 j - weight[i] <0 根本就放不下這個物品,還是要取dp [i-1] [j]
3.dp數組如何初始化
關于初始化,一定要和dp數組的定義吻合,否則到遞推公式的時候就會越來越亂。
3.1首先從dp[i][j]的定義出發,如果背包容量j為0的話,即dp[i][0],無論是選取哪些物品,背包價值總和一定為0
3.2在看其他情況,狀態轉移方程 dp[i][j] = max(dp[i - 1][j], dp[i - 1][j - weight[i]] + value[i]); 可以看出i 是由 i-1 推導出來,那么i為0的時候就一定要初始化
** j < weight[0]的時候,dp[0][j] 應該是 0,因為背包容量比編號0的物品重量還小**
當j >= weight[0]時,dp[0][j] 應該是value[0],因為背包容量放足夠放編號0物品
所以初始化如圖
4.確定遍歷順序
如上圖所示有倆個遍歷維度,物品與背包重量
遍歷物品更好理解,因為邏輯上是取物品放到背包里面嘛,但在二維里都可以。
為什么都可以的呢?
要理解遞歸的本質和遞推的方向。
dp[i][j] = max(dp[i - 1][j], dp[i - 1][j - weight[i]] + value[i]); 遞歸公式中可以看出dp[i][j]是靠dp[i-1][j]和dp[i - 1][j - weight[i]]推導出來的。
dp[i-1][j]和dp[i - 1][j - weight[i]] 都在dp[i][j]的左上角方向(包括正上方向)
無非就豎或橫著填充二維數組,雖然兩個for循環遍歷的次序不同,但是dp[i][j]所需要的數據就是左上角,根本不影響dp[i][j]公式的取值 推導!
5.舉例推導dp數組
最終結果就是dp[2][4]。
總結:
做動態規劃的題目,最好的過程就是自己在紙上舉一個例子把對應的dp數組的數值推導一下,然后在動手寫代碼!
:::
public class BagProblem {public int bagProblem(int bagSize,int[] weight,int[] value){//1.確定dp數組以及下標的含義//dp[i][j] 任取0-i物品放進重量為j的背包的最大價值//2.確定遞推公式//dp[i][j]=Math.max(dp[i-1][j],dp[i-1][j-weight[i]]+value[i])//3.如何初始化dp數組int dp[][]=new int[weight.length][bagSize+1];//weight.length 物品大小//3.1 背包重量是0的時候放不下物品 初始化價值為0, java int數組默認是0//3.2 遞推公式由i-1推出,要初始化dp[0,j]的值for (int j = 0; j <= bagSize ; j++) {if(j>=weight[0]){dp[0][j]=value[0];}}//4.確定遍歷順序for(int i=1;i<weight.length;i++){//遍歷物品for(int j=1;j<=bagSize;j++){//遍歷背包重量//處理 j-weight[i] 小于0的情況放不下物品iif(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]);}}}//5.打印dp數組for(int i=0;i<weight.length;i++){for(int j=0;j<=bagSize;j++){System.out.print(dp[i][j] + "\t");}System.out.println();}//最后一個位置就是答案return dp[weight.length-1][bagSize];}public static void main(String[] args) {int[] weight = {1,3,4};int[] value = {15,20,30};int bagSize = 4;int maxValue = new BagProblem().bagProblem(bagSize, weight, value);System.out.println("最大值:"+maxValue);}
}
滾動數組(一維dp數組)01背包
:::tips
滾動數組就是把二維dp降為一維dp
對于背包問題其實狀態都是可以壓縮的。
在使用二維數組的時候,遞推公式:dp[i][j] = max(dp[i - 1][j], dp[i - 1][j - weight[i]] + value[i]);
其實可以發現如果把dp[i - 1]那一層拷貝到dp[i]上,表達式完全可以是:dp[i][j] = max(dp[i][j], dp[i][j - weight[i]] + value[i]);
與其把dp[i - 1]這一層拷貝到dp[i]上,不如只用一個一維數組了,只用dp[j](一維數組,也可以理解是一個滾動數組)。
這就是滾動數組的由來,需要滿足的條件是上一層可以重復利用,直接拷貝到當前層。
動規五步曲
1.確定dp數組的定義以及下標的含義
dp[j] 表示容量為j的背包,所背的物品價值最大為dp[j]
2.一維數組的遞推公式
二維遞推公式
dp[i][j] = max(dp[i - 1][j], dp[i - 1][j - weight[i]] + value[i]);
一維dp數組,其實就是上一層dp[i-1] 這一層拷貝到dp[i]
dp[j]=max(dp[j],dp[j-weight[i]] + value[i])
此時dp[j]有兩個選擇:
一個是取自己dp[j] 相當于 二維dp數組中的dp[i-1][j],即不放物品i,
一個是取dp[j - weight[i]] + value[i]
,即放物品i,
指定是取最大的,畢竟是求最大價值。
3.一維dp數組的初始化
dp[j]表示:容量為j的背包,所背的物品價值可以最大為dp[j],那么dp[0]就應該是0,因為背包容量為0所背的物品的最大價值就是0。
那么dp數組除了下標0的位置,初始為0,其他下標應該初始化多少呢?
看一下遞歸公式:dp[j] = max(dp[j], dp[j - weight[i]] + value[i]);
dp數組在推導的時候一定是取價值最大的數,如果題目給的價值都是正整數那么非0下標都初始化為0就可以了。
這樣才能讓dp數組在遞歸公式的過程中取的最大的價值,而不是被初始值覆蓋了。
那么我假設物品價值都是大于0的,所以dp數組初始化的時候,都初始為0就可以了。
4.一維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
所以從后往前循環,每次取得狀態不會和之前取得狀態重合,這樣每種物品就只取一次了。
二維不用倒序是因為dp[i][j]都是通過上一層dp[i-1][j]而來
為什么不可以先遍歷背包在遍歷物品?
因為背包容量是倒序遍歷,如果這樣背包里就會只放一個物品,舉個例子試下就知道了
例如:遍歷背包容量第一次取容量是4, 取物品0放入, 取物品1 dp[j-weight[1]] 它是空值 +value[i],此時背包就只有物品i ,因為是從后向前,前面不可能有值,最終背包容量是4的,只有一個價值最大的物品是不對的。
5.舉例推導dp數組
:::
public class BagProblem {public int bagProblem(int bigSize,int[] weight,int[] value){//1.確定dp數組以及下標的含義//dp[j] 容量為j的背包,所背物品的最大價值為dp[j]//2.確定遞推公式//dp[j]=Math.max(dp[j],dp[j-weight[i]]+value[i])//3.初始化int [] dp=new int[bigSize+1];Arrays.fill(dp,0);//4.確定遍歷順序 先遍歷物品在遍歷背包,背包要倒序for(int i=0;i<weight.length;i++){//遍歷物品//for(int j=bigSize;j>=weight[i];j--){// dp[j]=Math.max(dp[j],dp[j-weight[i]]+value[i]);//}//方便打印全部dp數組,實際寫用上面注釋的遍歷方式for(int j=bigSize;j>=0;j--){//遍歷背包重量if(j<weight[i]){//確保要當前背包可以裝的下物品idp[j]=dp[j];//這里不用寫,裝不下物品i了,因為是滾動數組,維持原來的物品,上面的循環省去了額外遍歷還是有好處的}else {dp[j]=Math.max(dp[j],dp[j-weight[i]]+value[i]);}System.out.print(dp[j]+"\t");}System.out.println();}//5.舉例推導dp數組return dp[bigSize];}public static void main(String[] args) {int[] weight = {1,3,4};int[] value = {15,20,30};int bagSize = 4;int maxValue = new BagProblem().bagProblem(bagSize, weight, value);System.out.println("最大值:"+maxValue);}}
這里打印出的背包遍歷順序是4-0 ,下圖打印的第一個位置就是dp數組的4號位置,最后一個位置是dp數組的0號位置,因為打印先處理的背包4號位置->3…0。
解釋的意思是不要產生誤會,看打印然后取dp[0],實際取dp[4] 就是dp[bigSize]。
for(int j=bigSize;j>=weight[i];j–) 這個循環明顯執行次數更少
正常想的模擬圖應該這么畫,實際對應dp數組打印的翻轉,你應該懂的!
416. 分割等和子集
題目鏈接
https://leetcode.cn/problems/partition-equal-subset-sum/description/
解題思路
要明確本題中我們要使用的是01背包,因為元素我們只能用一次。
回歸主題:首先,本題要求集合里能否出現總和為 sum / 2 的子集。
那么來一一對應一下本題,看看背包問題如何來解決。只有確定了如下四點,才能把01背包問題套到本題上來。
背包的體積為sum / 2
背包要放入的商品(集合里的元素)重量為 元素的數值,價值也為元素的數值
背包如果正好裝滿,說明找到了總和為 sum / 2 的子集。
背包中每一個元素是不可重復放入。
code
class Solution {// 只有確定了如下四點,才能把01背包問題套到本題上來。// 背包的體積為sum / 2// 背包要放入的商品(集合里的元素)重量為 元素的數值,價值也為元素的數值// 背包如果正好裝滿,說明找到了總和為 sum / 2 的子集。// 背包中每一個元素是不可重復放入。public boolean canPartition(int[] nums) {int sum=Arrays.stream(nums).sum();if(sum%2==1){return false;}//1.確定dp數組以及下標的含義//01背包中,dp[j] 表示: 容量為j的背包,所背的物品價值最大可以為dp[j]。//j 是sum/2 物品就是子集//2.確定遞推公式//01背包的遞推公式為:dp[j] = max(dp[j], dp[j - weight[i]] + value[i]);//本題,相當于背包里放入數值,那么物品i的重量是nums[i],其價值也是nums[i]//dp[j]=Math.max(dp[j],dp[j=nums[i]+nums[i]]);//3.dp數組如何初始化int bagSize=sum/2;int[] dp=new int[bagSize+1];Arrays.fill(dp,0);//4.確定遍歷順序for(int i=0;i<nums.length;i++){for(int j=bagSize;j>=nums[i];j--){dp[j]=Math.max(dp[j],dp[j-nums[i]]+nums[i]);}}//dp[bagSize] 計算出背包的最大數值,必定是裝滿 //題目要求倆個子集相等,sum/2代表,倆個子集的值//那么sum/2=bagSize = dp[bagSize] 就說明存在這倆個子集return dp[bagSize]==bagSize;}
}