動態規划之01背包問題


01背包問題

問題描述

給定 n 件物品,物品的重量為 w[i],物品的價值為 c[i]。現挑選物品放入背包中,假定背包能承受的最大重量為 V,問應該如何選擇裝入背包中的物品,使得裝入背包中物品的總價值最大?

針對這個問題,本人理解了多次,也了看各種題解,嘗試各種辦法總還覺得抽象;或者說,看了多次以后,只是把題解的狀態轉移方程記住了而已,並沒有真正的“掌握”其背后的邏輯。直到我看了這篇文章,在此感謝作者並記錄於此。

 

01背包問題之另一種風格的描述

假設你是一個小偷,背着一個可裝下4磅東西的背包,你可以偷竊的物品如下:

為了讓偷竊的商品價值最高,你該選擇哪些商品?

暴力解法

最簡單的算法是:嘗試各種可能的商品組合,並找出價值最高的組合。

這樣顯然是可行的,但是速度非常慢。在只有3件商品的情況下,你需要計算8個不同的集合;當有4件商品的時候,你需要計算16個不同的集合。每增加一件商品,需要計算的集合數都將翻倍!對於每一件商品,都有選或不選兩種可能,即這種算法的運行時間是O(2ⁿ)。

 

動態規划

解決這樣問題的答案就是使用動態規划!下面來看看動態規划的工作原理。動態規划先解決子問題,再逐步解決大問題。

對於背包問題,你先解決小背包(子背包)問題,再逐步解決原來的問題。

比較有趣的一句話是:每個動態規划都從一個網格開始。 (所以學會網格的推導至關重要,而有些題解之所以寫的不好,就是因為沒有給出網格的推導過程,或者說,沒有說清楚為什么要”這樣“設計網格。本文恰是解決了我這方面長久以來的困惑!)

背包問題的網格如下:

網格的各行表示商品,各列代表不同容量(1~4磅)的背包。所有這些列你都需要,因為它們將幫助你計算子背包的價值。

網格最初是空的。你將填充其中的每個單元格,網格填滿后,就找到了問題的答案!

1. 吉他行

后面會列出計算這個網格中單元格值得公式,但現在我們先來一步一步做。首先來看第一行。

這是吉他行,意味着你將嘗試將吉他裝入背包。在每個單元格,都需要做一個簡單的決定:偷不偷吉他?別忘了,你要找出一個價值最高的商品集合。

第一個單元格表示背包的的容量為1磅。吉他的重量也是1磅,這意味着它能裝入背包!因此這個單元格包含吉他,價值為1500美元。

下面來填充網格。

與這個單元格一樣,每個單元格都將包含當前可裝入背包的所有商品。

來看下一個單元格。這個單元格表示背包容量為2磅,完全能夠裝下吉他!

這行的其他單元格也一樣。別忘了,這是第一行,只有吉他可供你選擇,換而言之,你假裝現在還沒發偷竊其他兩件商品。

此時你很可能心存疑惑:原來的問題說的是4磅的背包,我們為何要考慮容量為1磅、2磅等得背包呢?前面說過,動態規划從子問題着手,逐步解決大問題。這里解決的子問題將幫助你解決大問題。

別忘了,你要做的是讓背包中商品的價值最大。這行表示的是當前的最大價值。它指出,如果你有一個容量4磅的背包,可在其中裝入的商品的最大價值為1500美元。

你知道這不是最終解。隨着算法往下執行,你將逐步修改最大價值。

2. 音響行

我們來填充下一行——音響行。你現在處於第二行,可以偷竊的商品有吉他和音響。

我們先來看第一個單元格,它表示容量為1磅的背包。在此之前,可裝入1磅背包的商品最大價值為1500美元。

該不該偷音響呢?

背包的容量為1磅,顯然不能裝下音響。由於容量為1磅的背包裝不下音響,因此最大價值依然是1500美元。

接下來的兩個單元格的情況與此相同。在這些單元格中,背包的容量分別為2磅和3磅,而以前的最大價值為1500美元。由於這些背包裝不下音響,因此最大的價值保持不變。

背包容量為4磅呢?終於能夠裝下音響了!原來最大價值為1500美元,但如果在背包中裝入音響而不是吉他,價值將為3000美元!因此還是偷音響吧。

你更新了最大價值。如果背包的容量為4磅,就能裝入價值至少3000美元的商品。在這個網格中,你逐步地更新最大價值。

3. 筆記本電腦行

下面以同樣的方式處理筆記本電腦。筆記本電腦重3磅,沒法將其裝入1磅或者2磅的背包,因此前兩個單元格的最大價值仍然是1500美元。

對於容量為3磅的背包,原來的最大價值為1500美元,但現在你可以選擇偷竊價值2000美元的筆記本電腦而不是吉他,這樣新的最大價值將為2000美元。

對於容量為4磅的背包,情況很有趣。這是非常重要的部分。當前的最大價值為3000美元,你可不偷音響,而偷筆記本電腦,但它只值2000美元。

價值沒有原來高,但是等一等,筆記本電腦的重量只有3磅,背包還有1磅的重量沒用!

在1磅的容量中,可裝入的商品的最大價值是多少呢? 你之前計算過!

根據之前計算的最大價值可知,在1磅的容量中可裝入吉他,價值1500美元。因此,你需要做如下的比較:

你可能始終心存疑惑:為何計算小背包可裝入的商品的最大價值呢?但願你現在明白了其中的原因!當出現部分剩余空間時,你可根據這些子問題的答案來確定余下的空間可裝入哪些商品。筆記本電腦和吉他的總價值為3500美元,因此偷它們是更好的選擇。

最終的網格類似於下面這樣。

答案如下:將吉他和筆記本電腦裝入背包時價值更高,為3500美元。

你可能認為,計算最后一個單元格的價值時,我使用了不同的公式。那是因為填充之前的單元格時,我故意避開了一些復雜的因素。其實,計算每個單元格的價值時,使用的公式都相同。這個公式如下。

你可以使用這個公式來計算每個單元格的價值,最終的網格將與前一個網格相同。現在你明白了為何要求解子問題了吧?——因為你可以合並兩個子問題的解來得到更大問題的解。

4. 等等,再增加一件商品將如何變化呢?

假設你發現還有第四件商品可偷——一個iPhone!(或許你會毫不猶豫的拿走,但是請別忘了問題的本身是要拿走價值最大的商品)

此時需要重新執行前面所做的計算嗎?不需要。別忘了,動態規划逐步計算最大價值。到目前為止,計算出的最大價值如下:

這意味着背包容量為4磅時,你最多可偷價值3500美元的商品。但這是以前的情況,下面再添加表示iPhone的行。

我們還是從第一個單元格開始。iPhone可裝入容量為1磅的背包。之前的最大價值為1500美元,但iPhone價值2000美元,因此該偷iPhone而不是吉他。

在下一個單元格中,你可裝入iPhone和吉他。

對於第三個單元格,也沒有比裝入iPhone和吉他更好的選擇了。

對於最后一個單元格,情況比較有趣。當前的最大價值為3500美元,但你可以偷iPhone,這將余下3磅的容量。

3磅容量的最大價值為2000美元!再加上iPhone價值2000美元,總價值為4000美元。新的最大價值誕生了!

最終的網格如下

 


 

相信看到這里,並且親手推導過網格,應該對動態規划的狀態轉移方程背后的邏輯有了更深的理解。現在,再回頭看01背包問題的經典描述,並實現代碼。

問題描述:

給定 3 件物品,物品的重量為 weight[]={1,3,1},對應的價值為 value[]={15,30,20}。現挑選物品放入背包中,假定背包能承受的最大重量 W 為 4,問應該如何選擇裝入背包中的物品,使得裝入背包中物品的總價值最大?

 

dp[i][w] 表示前 i 件物品放入容量為 w 的背包中可獲得的最大價值。為了方便處理,我們約定下標從 1 開始。初始時,網格如下:

根據之前已經引出的狀態轉移方程,我們再來理解一遍,對於編號為 i 的物品:

  • 如果選擇它,那么,當前背包的最大價值等於” i 號物品的價值“ 加上 ”減去 i 號物品占用的空間后剩余的背包空間所能存放的最大價值“,即dp[i][k] = value[i] + dp[i-1][k-weight[i]];

  • 如果不選擇它,那么,當前背包的價值就等於前 i-1 個物品存放在背包中的最大價值,即 dp[i][k] = dp[i-1][k]

dp[i][k] 的結果取兩者的較大值,即:

dp[i][k] = max(value[i] + dp[i-1][k-weight[i]], dp[i-1][k])

 

動態規划

代碼實現如下:

public class BeiBao01 {    
   public int maxValue(int[] weight, int[] value, int W) {
     //這里假定傳入的weight和values數組長度總是一致的
       int n = weight.length;
       if (n == 0) return 0;

       int[][] dp = new int[n + 1][W + 1];
       for (int i = 1; i <= n; i++) {
           for (int k = 1; k <= W; k++) {
              // 存放 i 號物品(前提是放得下這件物品)
              int valueWith_i = (k-weight[i-1] >= 0) ? (value[i-1]+dp[i-1][k-weight[i-1]]) : 0;
              // 不存放 i 號物品
              int valueWithout_i = dp[i - 1][k];
              dp[i][k] = Math.max(valueWith_i, valueWithout_i);
          }
      }

       return dp[n][W];
  }
 
   public static void main(String[] args) {
       BeiBao01 obj = new BeiBao01();
       int[] w = {1, 4, 3};
       int[] v = {15, 30, 20};
       int W = 4;
       System.out.println(obj.maxValue(w, v, W));
  }
}

 

下面實現的版本稍有不同:


   public int maxValue(int[] weight, int[] value, int W) {
       int n = weight.length;
       if (n == 0) return 0;

       int[][] dp = new int[n][W + 1];
       // 先初始化第 0 行,也就是嘗試把 0 號物品放入容量為 k 的背包中
       for (int k = 1; k <= W; k++) {
           if (k >= weight[0]) dp[0][k] = value[0];
           else dp[0][k] = 0; // 這一步其實沒必要寫,因為dp[][]數組默認就是0
      }

       for (int i = 1; i < n; i++) {
           for (int k = 1; k <= W; k++) {
               // 存放 i 號物品(前提是放得下這件物品)
               int valueWith_i = (k-weight[i] >= 0) ? (value[i] + dp[i-1][k-weight[i]]) : 0;
               // 不存放 i 號物品
               int valueWithout_i = dp[i-1][k];
               dp[i][k] = Math.max(valueWith_i, valueWithout_i);
          }
      }

       return dp[n-1][W];
  }

對應的初始化網格如下:

(個人更喜歡第二種實現方式,感覺理解起來更友好)

時間復雜度:O(nW);空間復雜度:O(nW)

 

動態規划+壓縮空間

觀察上面的代碼,會發現,當更新dp[i][..]時,只與dp[i-1][..]有關,也就是說,我們沒有必要使用O(n*W)的空間,而是只使用O(W)的空間即可。下面先給出代碼,再結合圖例進行說明。

    public int maxValue(int[] weight, int[] value, int W) {
       int n = weight.length;
       if (n == 0) return 0;
   // 輔助空間只需要O(W)即可
       int[] dp = new int[W + 1];
       for (int i = 0; i < n; i++) {
         // 注意這里必須從后向前!!!
           for (int k = W; k >= 1; k--) {
               int valueWith_i = (k - weight[i] >= 0) ? (dp[k - weight[i]] + value[i]) : 0;
               int valueWithout_i = dp[k];
               dp[k] = Math.max(valueWith_i, valueWithout_i);
          }
      }
       return dp[W];
  }

這里的狀態轉移方程變成了:dp[k](新值) = max(value[i]+dp[k-weight[i]](舊值), dp[k](舊值))

為什么說這里必須反向遍歷來更新dp[]數組的值呢?原因是索引較小的元素可能會被覆蓋。我們來看例子,假設我們已經遍歷完了第 i=1 個元素(即weight=3, value=30),如下圖所示:

現在要更新第 i=2 個元素(即weight=1, value=20),由於我們只申請了一維空間的數組,因此對dp[]數組的修改會覆蓋上一輪dp[]數組的值,這里用淺色代表上一輪的值,深色代表當前這一輪的值

 

鑒於上面出現的問題,因此必須采用反向遍歷來回避這個問題。仍然假設第 i=1 個元素已經更新完畢,現在更新第 i=2 個元素。示意圖如下:

 

可以看到,反向遍歷就可以避免這個問題了!

事實上,我們還可以進一步簡化上面的代碼,如下:

    public int maxValue(int[] weight, int[] value, int W) {
       int n = weight.length;
       if (n == 0) return 0;

       int[] dp = new int[W + 1];
       for (int i = 0; i < n; i++) {
         //只要確保 k>=weight[i] 即可,而不是 k>=1,從而減少遍歷的次數
           for (int k = W; k >= weight[i]; k--) {
               dp[k] = Math.max(dp[k - weight[i]] + value[i], dp[k]);
          }
      }
       return dp[W];
  }

為什么可以這樣簡化呢?我們重新看一下這段代碼:


for (int k = W; k >= 1; k--) {
int valueWith_i = (k - weight[i] >= 0) ? (dp[k - weight[i]] + value[i]) : 0;
int valueWithout_i = dp[k];
dp[k] = Math.max(valueWith_i, valueWithout_i);
}

如果k>=weight[i] 不成立,則valueWith_i 的值為0,那么顯然有:


dp[k] = Math.max(valueWith_i, valueWithout_i) = max(0, dp[k]) = dp[k]

也就是dp[k]沒有更新過,它的值還是上一輪的值,因此就沒必要執行了,可以提前退出循環!

 

至此,01背包問題就全部講完了。(圖畫的好累~)

更好的閱讀體驗請前往:這里

 


免責聲明!

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



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