動態規劃-01揹包問題

狼爺發表於2021-02-14

揹包問題(Knapsack problem)是一種組合優化的NP完全問題。問題可以描述為: 給定一組物品,每種物品都有自己的重量和價格,在限定的總重量內,我們如何選擇,才能使得物品的總價格最高。

問題

假設山洞裡共有a,b,c,d,e這5件寶物(不是5種寶物),它們的重量分別是2,2,6,5,4, 它們的價值分別是6,3,5,4,6,現在給你個承重為 10 的揹包, 怎麼裝揹包,可以才能帶走最多的財富。

動態規劃

轉化方程

動態規劃一個關鍵的步驟是得到狀態轉化方程,物體的價值用 v(k) 表示, 重量用 w(k) 表示,f[i, j] 表示將前 i 個物體放入到容量為 j 的揹包中的最大價值,則有:

動態規劃-01揹包問題

求解方法

動態規劃有兩種等價的實現方法:

  1. 帶備忘的自頂向下法。此方法按照自然的遞迴形式編寫過程,但過程中會儲存每個子問題的解(通常儲存在一個陣列或雜湊表中)。 當需要一個子問題的解時,過程首先檢查是否已經儲存過此解。如果是,則直接返回儲存的值,從而節省了時間;否則,按通常方式計算 這個子問題。

  2. 自底向上法。這種方法一般需要恰當定義子問題“規模”的概念,使得任何子問題的求解都只依賴於“更小的”子問題的求解。因而 我們可以將子問題按規模排序,按由小至大的順序進行求解。當求解某個子問題時,它所依賴的那些更小的子問題都已求解完畢, 結果已經儲存。

帶備忘的自頂向下方法

下面給出一個帶備忘的自頂向下實現:

var v = [6,3,5,4,6]
var w = [2,2,6,5,4]
var c = 10

function bag (v, w, c) {
  function _bag (v, w, c, f, s) {
    // 子問題的規模
    var n = v.length
    // 子問題已經被求解
    if (f[n][c] > 0) {
      return f[n][c]
    }
    // 從剩下的物品中選擇一件
    for (var i = 0; i < n; i++) {
      var newW = w.slice()
      newW.splice(i, 1)
      var newV = v.slice()
      newV.splice(i, 1)
      // 選出來的物品重量大於揹包剩餘容量,則該子問題的解為0
      if (w[i] > c) {
        return 0
      }
      // 否則遞迴求解,得到子問題的最大的解及當前選擇的物品
      var maxValue = v[i] + _bag(newV, newW, c - w[i], f, s)
      if (f[n][c] < maxValue) {
        f[n][c] = maxValue
        s[n][c] = {v: v[i], w: w[i]}
      }
    }
    // 返回子問題的最大解
    return f[n][c]
  }

  var n = v.length
  // 記錄最大的價值
  var f = []
  // 記錄每一步所做的選擇
  var s = []
  for (var i = 0; i <= n; i++) {
    f[i] = []
    s[i] = []
    for (var j = 0; j <= c; j++) {
      f[i][j] = 0
      s[i][j] = null
    }
  }
  _bag(v, w, c, f, s)

  // 列印兩個二維陣列
  console.log(f)
  console.log(s)

  // 從s中得到所選擇的物品
  var selected = []
  var i = n
  var j = c
  var sum = 0
  do {
    var thing = s[i][j]
    if (thing) {
      selected.push(thing)
      j -= thing.w
      i--
    }
  } while (thing)

  return {
    maxV: f[n][c],
    selected: selected
  }
}
複製程式碼

說明

程式中 f 最後如下所示,其中第一行可以忽略,這麼做只是為了讓陣列索引從 1 開始,跟上面的公式保持一致:

0 1 2 3 4 5 6 7 8 9 10
null null null null null null null null null null null
null null null null null null null null null null null
0 0 0 null null null null null null null null
0 0 0 0 0 null 6 null null null null
null null null null 6 6 6 null 9 null null
null null null null null null null null null null 15

其中,f[5][10] 就是最後所求的最大價值,即 15。 從上表還可以知道求解過程中遞迴求解了哪些問題,即上表中值不為 null 的那些。 而如果需要知道最後所選擇的物品,還需要藉助 s :

0 1 2 3 4 5 6 7 8 9 10
null null null null null null null null null null null
null null null null null null null null null null null
null null null null null null null null null null null
null null { v: 3, w: 2 } { v: 3, w: 2 } { v: 3, w: 2 } null { v: 6, w: 4 } null null null null
null null null null { v: 6, w: 4 } { v: 6, w: 4 } { v: 6, w: 2 } null { v: 3, w: 2 } null null
null null null null null null null null null null { v: 6, w: 2 }

其中,s[i][j] 表示將前 i 個物體放入到容量為 j 的揹包中時所選擇的第一個物品

現在,讓我們來理一下這個過程:

  1. s[5][10] 表示將前 5 個物品放到容量為 10 的揹包中,選擇了物品 { v: 6, w: 2 }
  2. 接下來處理子問題 s[4][8] ,選擇了物品 { v: 6, w: 4 }
  3. 接下來處理子問題 s[3][4] ,選擇了物品 { v: 3, w: 2 }
  4. 接下來處理子問題 s[2][2] ,沒有選擇任何物品。
  5. 得到最後所選擇的物品為 { v: 6, w: 2 }, { v: 6, w: 4 }, { v: 3, w: 2 }

自底向上法

下面是自底向上法的實現:

function bag2 (v, w, c) {
  var f = []
  var s = []
  var n = v.length

  for (var i = 0; i <= n; i++) {
    f[i] = []
    s[i] = []
    for (var j = 0; j <= c; j++) {
      f[i][j] = 0
      s[i][j] = 0
    }
  }

  // 遍歷物品
  for (var i = 1; i <= n; i++) {
    var index = i - 1
    // 遍歷容量
    for (var j = 0; j <= c; j++) {
      // 當前物品放入的情況
      if (w[index] <= j && v[index] + f[i - 1][j - w[index]] > f[i - 1][j]) {
        f[i][j] = v[index] + f[i - 1][j - w[index]]
        s[i][j] = 1
      }
      // 當前物品不放入的情況
      else {
        f[i][j] = f[i - 1][j]
      }
    }
  }

  return{
    f: f,
    s: s
  }
}
複製程式碼

說明

首先,注意到這個事實:物品放入的順序不會影響我們最後的結果。這裡按照題目中的順序依次考察 每個物品在每個容量的情況下是否放入。

仍然用 f 來記錄最大值,用 s 來記錄選擇。

不過這裡的 s[i][j] 只需標記當前物品是否放入即可, 所以 s[i][j] 取值為 0 或 1。

f 如下所示:

v w 0 1 2 3 4 5 6 7 8 9 10
- - 0 0 0 0 0 0 0 0 0 0 0
6 2 0 0 6 6 6 6 6 6 6 6 6
3 2 0 0 6 6 9 9 9 9 9 9 9
5 6 0 0 6 6 9 9 9 9 11 11 14
4 5 0 0 6 6 9 9 9 10 11 13 14
6 4 0 0 6 6 9 9 12 12 15 15 15

s 如下所示:

v w 0 1 2 3 4 5 6 7 8 9 10
- - 0 0 0 0 0 0 0 0 0 0 0
6 2 0 0 1 1 1 1 1 1 1 1 1
3 2 0 0 0 0 1 1 1 1 1 1 1
5 6 0 0 0 0 0 0 0 0 1 1 1
4 5 0 0 0 0 0 0 0 1 0 1 0
6 4 0 0 0 0 0 0 1 1 1 1 1

同樣,我們可以反向推匯出最後的選擇:

  1. s[5][10] 為 1,該物體放入袋中
  2. 考察 s[4][6],為 0,說明這個物體不放入
  3. 考察 s[3][6],為 0, 不放入
  4. 考察 s[2][6],為 1, 放入
  5. 考察 s[1][4], 為 1, 放入
  6. 得到最後所選擇的物品為 { v: 6, w: 2 }, { v: 3, w: 2 }, { v: 6, w: 4 }

總結

以後碰到動態規劃相關的問題都可以用這個思路來解決了,關鍵在於要構造轉移函式這個模型。 個人感覺自頂向下法更加好理解,但是程式碼略顯囉嗦了。

相關文章