揹包問題(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
的揹包中的最大價值,則有:
求解方法
動態規劃有兩種等價的實現方法:
-
帶備忘的自頂向下法。此方法按照自然的遞迴形式編寫過程,但過程中會儲存每個子問題的解(通常儲存在一個陣列或雜湊表中)。 當需要一個子問題的解時,過程首先檢查是否已經儲存過此解。如果是,則直接返回儲存的值,從而節省了時間;否則,按通常方式計算 這個子問題。
-
自底向上法。這種方法一般需要恰當定義子問題“規模”的概念,使得任何子問題的求解都只依賴於“更小的”子問題的求解。因而 我們可以將子問題按規模排序,按由小至大的順序進行求解。當求解某個子問題時,它所依賴的那些更小的子問題都已求解完畢, 結果已經儲存。
帶備忘的自頂向下方法
下面給出一個帶備忘的自頂向下實現:
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
的揹包中時所選擇的第一個物品
現在,讓我們來理一下這個過程:
s[5][10]
表示將前 5 個物品放到容量為 10 的揹包中,選擇了物品{ v: 6, w: 2 }
- 接下來處理子問題
s[4][8]
,選擇了物品{ v: 6, w: 4 }
- 接下來處理子問題
s[3][4]
,選擇了物品{ v: 3, w: 2 }
- 接下來處理子問題
s[2][2]
,沒有選擇任何物品。 - 得到最後所選擇的物品為
{ 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 |
同樣,我們可以反向推匯出最後的選擇:
s[5][10]
為 1,該物體放入袋中- 考察
s[4][6]
,為 0,說明這個物體不放入 - 考察
s[3][6]
,為 0, 不放入 - 考察
s[2][6]
,為 1, 放入 - 考察
s[1][4]
, 為 1, 放入 - 得到最後所選擇的物品為
{ v: 6, w: 2 }
,{ v: 3, w: 2 }
,{ v: 6, w: 4 }
總結
以後碰到動態規劃相關的問題都可以用這個思路來解決了,關鍵在於要構造轉移函式這個模型。 個人感覺自頂向下法更加好理解,但是程式碼略顯囉嗦了。