動態規划---01背包問題詳解


動態規划---01背包問題詳解

鳴謝:本次的學習是跟着Carl的筆記來的,原創作者為Carl,可以在b站或者公眾號關注Carl,搜索代碼隨想錄。

image-20211025141353701

一、01背包問題理論基礎

1、問題

​ 有N件物品和一個最多能背重量為W的背包(也就是說背包的容量是W),第i件物品的重量是weight[i],其價值是value[i],每件物品只能背一次,求解將哪些物品放到背包里面物品價值的總和最大。

image-20211021100648309

2、二維dp數組下的01背包

①確定dp數組以及下標的含義

dp[i][j]表示:
	當背包容量為j,現有編號為0~i的物品可以拿,此時所能背的價值最大為多少。

image-20211021100752403

②確定遞推公式

如何推出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的情況。

image-20211021102328070

此時已經初始化完了第一行和第一列,那么中間的數該如何初始化呢?

實際編寫代碼的時候,觀察我們的遞推公式,我們用的是最大值,所以用-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數組

image-20211021103337381

實際在處理的過程當中,我們需要開辟的多大的數組。根據實際情況來決定。

3、一維數組下的01背包

image-20211021104834314

①確定dp數組以及下標的含義

設dp[j]表示,容量為j的背包,所背的物品價值最大為dp[j]。

②確定遞推公式

是否把當前的這個物品放入,分兩種情況,拿還是不拿。

dp[j] = Math.max(dp[j], dp[j - weight[i]] + value[i])

③如何初始化

image-20211021105320971

那么我假設物品價值都是⼤於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]);
 	}
}

image-20211021105702558

image-20211021105722590

⑤舉例推導

image-20211021105858956

二、LeetCode-416.分割等和子集

1、題干

image-20211021110304080

2、動規思路

image-20211021110852475

image-20211021110916937

①確定dp數組以及下標的含義

在01背包中,dp[j]表示:

設dp[j]表示,容量為j的背包,所背的物品價值最大為dp[j]。

套到本題,dp[i]表示 背包總容量是i,最⼤可以湊成i的⼦集總和為dp[i]

②確定遞推公式

image-20211021111342676

③初始化

image-20211021111416901

④遍歷順序

image-20211021111546977

⑤舉例推導

dp[i]的數值⼀定是⼩於等於i的。
如果dp[i] == i 說明,集合中的⼦集總和正好可以湊成總和i,理解這⼀點很重要

image-20211021111645718

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]

image-20211021163843816

對比一維的dp數組結果

image-20211021164028543

5、一維dp和二維dp再思考

​ 可以看出一維dp和二維dp的最終狀態是一樣的,只是一維dp節省了空間復雜度。其次就是一維dp數組,需要倒序的去迭代更新,因為我們取得是Math.max(a,b),如果我們遍歷順序從前到后,那么后面的值就會被前面的所覆蓋。最后,開辟dp數組的大小也很有講究,開多大,怎么初始化,最終的結果是數組的哪個下標所對應的值?心中要明確好dp[i][j]的含義。

三、LeetCode-1049.最后一塊石頭的重量II

1、題干

image-20211024150246166

image-20211024150254578

image-20211024150302280

image-20211024151421281

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循環倒敘遍歷。

⑤舉例推導

image-20211024151658023

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、題干

image-20211024161028218

image-20211024161304847

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、題干

image-20211025140945138

image-20211025141035439

2、動規思路

image-20211025141854702

①確定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為例

image-20211025153557031

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數組中的屬性來表示。


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM