JavaScript 揹包問題詳解

發表於2018-01-17

 

引子

打算好好學一下演算法,先拿揹包問題入手。但是網上許多教程都是C++或java或python,大部分作者都是在校生,雖然演算法很強,但是完全沒有工程意識,全域性變數滿天飛,變數名不明所以。我查了許多資料,花了一個星期才搞懂,最開始的01揹包耗時最多,以前只會列舉(就是普通的for迴圈,暴力地一步步遍歷下去),遞迴與二分,而動態規劃所講的狀態表與狀態遷移方程為我開啟一扇大門。

01揹包問題

篇幅可能有點長,但請耐心看一下,你會覺得物有所值的。本文以後還會擴充套件,因為我還沒有想到完全揹包與多重揹包列印物品編號的方法。如果有高人知道,勞煩在評論區指教一下。

注意,由於社群不支援LaTex數學公式,你們看到${xxxx}$,就自己將它們過濾吧。

1.1 問題描述:

有${n}$件物品和${1}$個容量為W的揹包。每種物品均只有一件,第${i}$件物品的重量為${weights[i]}$,價值為${values[i]}$,求解將哪些物品裝入揹包可使價值總和最大。

對於一種物品,要麼裝入揹包,要麼不裝。所以對於一種物品的裝入狀態只是1或0, 此問題稱為01揹包問題

1.2 問題分析:

資料:物品個數${n=5}$,物品重量${weights=[2,2,6,5,4]}$,物品價值${values=[6,3,5,4,6]}$,揹包總容量${W=10}$。

我們設定一個矩陣${f}$來記錄結果,${f(i, j)}$ 表示可選物品為 ${i…n}$ 揹包容量為 ${j(0

W V I 0 1 2 3 4 5 6 7 8 9 10
2 6 0
2 3 1
6 5 2
5 4 3
4 6 4

我們先看第一行,物品0的體積為2,價值為6,當容量為0時,什麼也放不下,因此第一個格式只能填0,程式表示為${f(0,0) = 0}$或者${f[0][0] = 0}$。 當${j=1}$時,依然放不下${w_0}$,因此依然為0,${f(0, 1) = 0}$。 當${j=2}$時,能放下${w_0}$,於是有 ${f(0, 2)\ = \ v_0=6}$。 當${j=3}$時,也能放下${w_0}$,但我們只有一個物品0,因此它的值依然是6,於是一直到${j=10}$時,它的值都是${v_0}$。

W V I 0 1 2 3 4 5 6 7 8 9 10
2 6 0 0 0 6 6 6 6 6 6 6 6 6
2 3 1
6 5 2
5 4 3
4 6 4

根據第一行,我們得到如下方程

3332384197-5a5a10153b322_articlex

然後我們看第二行,確定確定${f(1,0…10)}$這11個元素的值。當${j=0}$ 時,依然什麼也放不下,值為0,但我們發覺它是上方格式的值一樣的,${f(1,0)=0}$。 當${j=1}$時,依然什麼也放不下,值為0,但我們發覺它是上方格式的值一樣的,${f(1,1)=0}$. 當${j=2}$時,它可以選擇放入物品1或不放。

如果選擇不放物品1,揹包裡面有物品0,最大價值為6。

如果選擇放入物品1,我們要用算出揹包放入物品1後還有多少容量,然後根據容量查出它的價值,再加上物品1的價值,即${f(0,j-w_1)+v_1}$ 。由於我們的目標是儘可能裝最值錢的物品, 因此放與不放, 我們需要通過比較來決定,於是有

3534865510-5a5a1027e09ba_articlex

顯然${v_1=2,v_0=6}$, 因此這裡填${v_0}$。 當${j=3}$時, 情況相同。 當${j=4}$,能同時放下物品0與物品1,我們這個公式的計算結果也合乎我們的預期, 得到${f(1,4)=9}$。 當${j>4}$時, 由於揹包只能放物品0與物品1,那麼它的最大價值也一直停留在${v_0+v_1=9}$

W V I 0 1 2 3 4 5 6 7 8 9 10
2 6 0 0 0 6 6 6 6 6 6 6 6 6
2 3 1 0 0 6 6 9 9 9 9 9 9 9
6 5 2
5 4 3
4 6 4

我們再看第三行,當${j=0}$時,什麼都放不下,${f(2,0)=0}$。當${j=1}$時,依然什麼也放不下,${f(2,1)=0}$,當${j=2}$時,雖然放不下${w_2}$,但我們根據上表得知這個容號時,揹包能裝下的最大價值是6。繼續計算下去,其實與上面推導的公式結果是一致的,說明公式是有效的。當${j=8}$時,揹包可以是放物品0、1,或者放物品1、2,或者放物品0、2。物品0、1的價值,我們在表中就可以看到是9,至於其他兩種情況我們姑且不顧,我們目測就知道是最優值是${6+5=11}$, 恰恰我們的公式也能正確計算出來。當${j=10}$時,剛好三個物品都能裝下,它們的總值為14,即${f(2,10)=14}$

第三行的結果如下:

W V I 0 1 2 3 4 5 6 7 8 9 10
2 6 0 0 0 6 6 6 6 6 6 6 6 6
2 3 1 0 0 6 6 9 9 9 9 9 9 9
6 5 2 0 0 6 6 9 9 9 9 11 11 14
5 4 3
4 6 4

整理一下第1,2行的適用方程:1133709058-5a5a104218250_articlex

 

我們根據此方程,繼續計算下面各列,於是得到

W V I 0 1 2 3 4 5 6 7 8 9 10
2 6 0 0 0 6 6 6 6 6 6 6 6 6
2 3 1 0 0 6 6 9 9 9 9 9 9 9
6 5 2 0 0 6 6 9 9 9 9 11 11 14
5 4 3 0 0 6 6 9 9 9 10 11 13 14
4 6 4 0 0 6 6 9 9 12 12 15 15 15

至此,我們就可以得到解為15.

我們最後根據0-1揹包問題的最優子結構性質,建立計算${f(i,j)}$的遞迴式:

48258241-5a5a10864695b_articlex

1.3 各種優化:

合併迴圈

現在方法裡面有兩個大迴圈,它們可以合併成一個。

然後我們再認真地思考一下,為什麼要孤零零地專門處理第一行呢?f[i][j] = j 是不是能適用於下面這一行f[i][j] = Math.max(f[i-1][j], f[i-1][j-weights[i]] + values[i]) 。Math.max可以輕鬆轉換為三元表示式,結構極其相似。而看一下i-1的邊界問題,有的書與部落格為了解決它,會新增第0行,全部都是0,然後i再往下挪。其實我們也可以新增一個${-1}$行。那麼在我們的方程中就不用區分${i==0}$與${0>0}$的情況,方程與其他教科書的一模一樣了!

2924452237-5a5a10be63824_articlex

W V I 0 1 2 3 4 5 6 7 8 9 10
X X -1 0 0 0 0 0 0 0 0 0 0 0 0
2 6 0 0 0 6 6 6 6 6 6 6 6 6
2 3 1 0 0 6 6 9 9 9 9 9 9 9
6 5 2 0 0 6 6 9 9 9 9 11 11 14
5 4 3 0 0 6 6 9 9 9 10 11 13 14
4 6 4 0 0 6 6 9 9 12 12 15 15 15

負一行的出現可以大大減少了在雙層迴圈的分支判定。是一個很好的技巧。

注意,許多舊的教程與網上文章,通過設定二維陣列的第一行為0來解決i-1的邊界問題(比如下圖)。當然也有一些思維轉不過來的緣故,他們還在堅持數字以1開始,而我們新世代的IT人已經確立從0開始的程式設計思想。

image_1c3lm81p3gd09n5pjk1i4aif92d

選擇物品

上面講解了如何求得最大價值,現在我們看到底選擇了哪些物品,這個在現實中更有意義。許多書與部落格很少提到這一點,就算給出的程式碼也不對,估計是在設計狀態矩陣就出錯了。

仔細觀察矩陣,從${f(n-1,W)}$逆著走向${f(0,0)}$,設i=n-1,j=W,如果${f(i,j)}$==${f(i-1,j-w_i)+v_i}$說明包裡面有第i件物品,因此我們只要當前行不等於上一行的總價值,就能挑出第i件物品,然後j減去該物品的重量,一直找到j = 0就行了。

image_1c3k62d511dtgo7gud815q866m16 image_1c3k617gc6jp10pn1ean1lv81boqp

使用滾動陣列壓縮空間

所謂滾動陣列,目的在於優化空間,因為目前我們是使用一個${i*j}$的二維陣列來儲存每一步的最優解。在求解的過程中,我們可以發現,當前狀態只與前一行的狀態有關,那麼更之前儲存的狀態資訊已經無用了,可以捨棄的,我們只需要儲存當前狀態和前一行狀態,所以只需使用${2*j}$的空間,迴圈滾動使用,就可以達到跟${i*j}$一樣的效果。這是一個非常大的空間優化。

我們還可以用更hack的方法代替currLine, lastLine

注意,這種解法由於丟棄了之前N行的資料,因此很難解出挑選的物品,只能求最大價值。

使用一維陣列壓縮空間

觀察我們的狀態遷移方程:2596012489-5a5a10e56fcd4_articlex

 

weights為每個物品的重量,values為每個物品的價值,W是揹包的容量,i表示要放進第幾個物品,j是揹包現時的容量(假設我們的揹包是魔術般的可放大,從0變到W)。

我們假令i = 02323648372-5a5a10f8c7e3b_articlex

 

f中的-1就變成沒有意義,因為沒有第-1行,而weights[0], values[0]繼續有效,${f(0,j)}$也有意義,因為我們全部放到一個一維陣列中。於是:1909337594-5a5a110c3a31f_articlex

 

這方程後面多加了一個限制條件,要求是從大到小迴圈。為什麼呢?

假設有物體${\cal z}$容量2,價值${v_z}$很大,揹包容量為5,如果j的迴圈順序不是逆序,那麼外層迴圈跑到物體${\cal z}$時, 內迴圈在${j=2}$時 ,${\cal z}$被放入揹包。當${j=4}$時,尋求最大價值,物體z放入揹包,${f(4)=max(f(4),f(2)+v_z) }$, 這裡毫無疑問後者最大。 但此時${f(2)+v_z}$中的${f(2)}$ 已經裝入了一次${\cal z}$,這樣一來${\cal z}$被裝入兩次不符合要求, 如果逆序迴圈j, 這一問題便解決了。

javascript實現:

image_1c3k7lit11fkd1ufumfuovbidr1j

1.4 遞迴法解01揹包

由於這不是動態規則的解法,大家多觀察方程就理解了:

image_1c3kfq8nhddj12m11eh1r68189520

完全揹包問題

2.1 問題描述:

有${n}$件物品和${1}$個容量為W的揹包。每種物品沒有上限,第${i}$件物品的重量為${weights[i]}$,價值為${values[i]}$,求解將哪些物品裝入揹包可使價值總和最大。

2.2 問題分析:

最簡單思路就是把完全揹包拆分成01揹包,就是把01揹包中狀態轉移方程進行擴充套件,也就是說01揹包只考慮放與不放進去兩種情況,而完全揹包要考慮 放0、放1、放2…的情況,4105884950-5a5a1134ed1e1_articlex

 

這個k當然不是無限的,它受揹包的容量與單件物品的重量限制,即${j/weights[i]}$。假設我們只有1種商品,它的重量為20,揹包的容量為60,那麼它就應該放3個,在遍歷時,就0、1、2、3地依次嘗試。

程式需要求解${n*W}$個狀態,每一個狀態需要的時間為${O(W/w_i)}$,總的複雜度為${O(nW*Σ(W/w_i))}$。

我們再回顧01揹包經典解法的核心程式碼

現在多了一個k,就意味著多了一重迴圈

javascript的完整實現:

2.3 O(nW)優化

我們再進行優化,改變一下f思路,讓${f(i,j)}$表示出在前i種物品中選取若干件物品放入容量為j的揹包所得的最大價值。

所以說,對於第i件物品有放或不放兩種情況,而放的情況裡又分為放1件、2件、……${j/w_i}$件

如果不放, 那麼${f(i,j)=f(i-1,j)}$;如果放,那麼當前揹包中應該出現至少一件第i種物品,所以f(i,j)中至少應該出現一件第i種物品,即${f(i,j)=f(i,j-w_i)+v_i}$,為什麼會是${f(i,j-w_i)+v_i}$?

因為我們要把當前物品i放入包內,因為物品i可以無限使用,所以要用${f(i,j-w_i)}$;如果我們用的是${f(i-1,j-w_i)}$,${f(i-1,j-w_i)}$的意思是說,我們只有一件當前物品i,所以我們在放入物品i的時候需要考慮到第i-1個物品的價值${f(i-1,j-w_i)}$;但是現在我們有無限件當前物品i,我們不用再考慮第i-1個物品了,我們所要考慮的是在當前容量下是否再裝入一個物品i,而${(j-w_i)}$的意思是指要確保${f(i,j)}$至少有一件第i件物品,所以要預留c(i)的空間來存放一件第i種物品。總而言之,如果放當前物品i的話,它的狀態就是它自己”i”,而不是上一個”i-1″。

所以說狀態轉移方程為:

348816673-5a5a116e36711_articlex

與01揹包的相比,只是一點點不同,我們也不需要三重迴圈了

984076935-5a5a117be1d54_articlex

javascript的完整實現:

我們可以繼續優化此演算法,可以用一維陣列寫

我們用${f(j)}$表示當前可用體積j的價值,我們可以得到和01揹包一樣的遞推式:

1963278821-5a5a11bb292ff_articlex

多重揹包問題

3.1 問題描述:

有${n}$件物品和${1}$個容量為W的揹包。每種物品最多有numbers[i]件可用,第${i}$件物品的重量為${weights[i]}$,價值為${values[i]}$,求解將哪些物品裝入揹包可使價值總和最大。

3.2 問題分析:

多重揹包就是一個進化版完全揹包。在我們做完全揹包的第一個版本中,就是將它轉換成01揹包,然後限制k的迴圈

直接套用01揹包的一維陣列解法

3.3 使用二進位制優化

其實說白了我們最樸素的多重揹包做法是將有數量限制的相同物品看成多個不同的0-1揹包。這樣的時間複雜度為${O(W*Σn(i))}$, W為空間容量 ,n(i)為每種揹包的數量限制。如果這樣會超時,我們就得考慮更優的拆分方法,由於拆成1太多了,我們考慮拆成二進位制數,對於13的數量,我們拆成1,2,4,6(有個6是為了湊數)。 19 我們拆成1,2,4,8,4 (最後的4也是為了湊和為19)。經過這個樣的拆分我們可以組合出任意的小於等於n(i)的數目(二進位制啊,必然可以)。j極大程度縮減了等效為0-1揹包時候的數量。 大概可以使時間複雜度縮減為${O(W*log(ΣN(i))}$;

參考連結

相關文章