JavaScript中揹包問題(面試題)
起因
面試官即排序演算法、斐波那契數列後的第三個問題——揹包問題
在學習的同時,我儘可能用通俗易懂的解釋、程式碼註釋、程式碼分析、問題優化加深這樣的過程來和大家一起分析這個問題。
問題的描述
揹包:揹包容量一定,在一組東西中如何選擇,使得揹包最有價值
本質:是一個組合優化的問題。
問題描述:給一個固定大小,能夠攜重W的揹包,以及一組有價值重量的物品,
請找出一個最佳的方案,使得裝入包中的物品重量不超過W且總價值最大。
問題分析
我們以一個具體的例子進行分析
示例
物品個數 n:5
物品重量 weights:[2,2,6,5,4]
物品價值 values:[6,3,5,4,6]
揹包總容量 W:10
需求:怎麼裝使得總重量w<10,且value價值最大
思路
假設一個函式為: f(i,j) :表示裝入揹包的最大價值
其中的 i:表示裝入的物品 (從第一個到第五個都有可能裝入)
其中的 j:表示揹包目前的重量 (j<w)
邏輯分析:
對於一種物品,要麼裝入揹包,要麼不裝。
所以對於一種物品的裝入狀態只是1或0, 此問題稱為01揹包問題
對第一行分析:
目前我們只有盒子0
當揹包重量j是0時,什麼都裝不下,所以f(0,0)=0
當揹包重量j是1時,什麼都裝不下,所以f(0,1)=0
當揹包重量j等於2或者大於2時,盒子0能裝入,所以f(0,j)=6 (j>=2)
即可得方程式:
第二行
對第二行分析:
目前有盒子0和盒子1
當揹包重量j是0時,什麼都裝不下,所以f(1,0)=0
當揹包重量j是1時,什麼都裝不下,所以f(1,1)=0
當揹包重量j是2時,可以裝盒子0或盒子1,需要比較盒子0和盒子1的價值,所以f(1,2)=6
當揹包重量j是3時,可以裝盒子0或盒子1,需要比較盒子0和盒子1的價值,所以f(1,3)=6
當揹包重量j是4或者>4時,可以裝盒子0和盒子1,所以f(1,j)=9 (j>=4)
方程式為:
這個時候,遞迴就已經有一定的體現了,只要接下來的符合上述邏輯我們就可以實現JavaScript函式了。
我們看第三行:
對第三行分析:
目前有盒子0,盒子1,盒子2
當揹包重量j是0時,什麼都裝不下,所以f(2,0)=0
當揹包重量j是1時,什麼都裝不下,所以f(2,1)=0
當揹包重量j是2時,可以裝盒子0或盒子1,裝不下盒子2,需要比較盒子0和盒子1的價值,所以f(2,2)=6
當揹包重量j是3時,可以裝盒子0或盒子1,裝不下盒子2,需要比較盒子0和盒子1的價值,所以f(2,3)=6
當揹包重量j是4或者5時,可以裝盒子0和盒子1,裝不下盒子2,所以f(2,j)=9 (4<= j <6)
當揹包重量j是6或者7時,可以選擇裝盒子2,也可以選擇裝盒子0和盒子1,比較價值,所以f(2,j)=9 (6<= j <8)
當揹包重量j是8,9時,可以選擇裝下盒子2後在裝下盒子0和盒子1中的一個,所以f(2,j)=11 (8<= j <10)
當揹包重量j是10時,可以同時裝下盒子0、盒子1、盒子2,所以f(2,10)=14
整理方程可得到第1行和第2行的使用方程:
解釋:
依次求出剩餘行
整合我們的方程式為:
函式實現(上述方程式)
function knapsack(weights, values, w){
var n = weights.length -1; // 獲取盒子個數i 從盒子0開始
var f=[[]]; //定義f的矩陣
for(var j=0;j<=w;j++){
// 揹包的重量j從0開始,一直到我們輸入的w
if(j<weights[0]){ // 揹包的重量小於盒子0的重量,價值為0
f[0][j]=0;
}else{
f[0][j]=values[0]; // 否則容量為物品0的價值
}
}
// 上述for迴圈實現了方程式的前兩條
for(var j=0;j<=w;j++){
// 當盒子數不是1個時
for(var i=1;i<=n;i++){
if(!f[i]){ // 建立新的一行
f[i]=[];
}
if(j<weights[i]){ // 等於之前的最優值
f[i][j]=f[i-1][j];
}else{
f[i][j]=Math.max(f[i-1][j],f[i-1][j-weights[i]]+values[i]);
}
}
}
return f[n][w];
}
var a = knapsack([2,2,6,5,4],[6,3,5,4,6],10)
console.log(a)
執行結果
到這裡已經可以解決問題了,如果想深入的去學習,下面還有變種優化以及其他可以實現的思路
優化:
合併迴圈
現在方法裡面有兩個大迴圈,它們可以合併成一個。
function knapsack(weights, values, W){
var n = weights.length; // 盒子個數
var f = new Array(n) // 定義矩陣
for(var i = 0 ; i < n; i++){
f[i] = [] // 共計五行資料
}
for(var i = 0; i < n; i++ ){ // i表示當前盒子
for(var j = 0; j <= W; j++){ // j表示當前揹包的重量
if(i === 0){ //第一行
f[i][j] = j < weights[i] ? 0 : values[i]
// 三元表示式 小於盒子重量 價值0 大於盒子重量 價值第一個盒子重量
}else{
if(j < weights[i]){ //等於之前的最優值
f[i][j] = f[i-1][j]
}else{
f[i][j] = Math.max(f[i-1][j], f[i-1][j-weights[i]] + values[i])
}
}
}
}
return f[n-1][W]
}
這時候我們發現必須要有一個if條件去專門的處理盒子0的狀態(即第一行的資料),f[i][j]=j<weights[i]?0:values[i]
可不可以也能轉換為 f[i][j]=Math.max(f[i-1][j],f[i-1][j-weights[i]]+values[i])
。Math.max可以輕鬆轉換為三元表示式,結構極其相似。而看一下i-1的邊界問題,我們可以在第一行上面再加一行即i可以取-1值。
如圖:
那麼方程式如下:
function knapsack(weights, values, W){
var n = weights.length;
var f = new Array(n)
f[-1] = new Array(W+1).fill(0) // 引入了-1行
for(var i = 0 ; i < n ; i++){ //注意邊界,沒有等號
f[i] = new Array(W).fill(0)
for(var j=0; j<=W; j++){//注意邊界,有等號
if( j < weights[i] ){ //注意邊界, 沒有等號
f[i][j] = f[i-1][j]
}else{
f[i][j] = Math.max(f[i-1][j], f[i-1][j-weights[i]]+values[i]);
}
}
}
return f[n-1][W]
}
負一行的出現可以大大減少了在雙層迴圈的分支判定。是一個很好的技巧。
選擇物品(可能就會問:到底選擇了那些盒子呢)
上面講解了如何求得最大價值,現在我們看到底選擇了哪些盒子,這個在現實中更有意義。
仔細觀察矩陣,從
f
(
n
−
1
,
W
)
{f(n-1,W)}
f(n−1,W)逆著走向
f
(
0
,
0
)
{f(0,0)}
f(0,0),設i=n-1,j=W,
只要我們的方程式中
f
(
i
,
j
)
{f(i,j)}
f(i,j)==
f
(
i
−
1
,
j
−
w
i
)
+
v
i
{f(i-1,j-wi)+vi}
f(i−1,j−wi)+vi成立就說明包裡面有第i件物品,因此我們只要當前行不等於上一行的總價值,就能挑出第i件物品,然後j減去該物品的重量,一直找到j = 0就行了。
function knapsack(weights, values, W){
var n = weights.length;
var f = new Array(n)
f[-1] = new Array(W+1).fill(0)
var selected = []; // 用來儲存選擇的盒子
for(var i = 0 ; i < n ; i++){ //注意邊界,沒有等號
f[i] = [] //建立當前的二維陣列
for(var j=0; j<=W; j++){ //注意邊界,有等號
if( j < weights[i] ){ //注意邊界, 沒有等號
f[i][j] = f[i-1][j]
}else{
f[i][j] = Math.max(f[i-1][j], f[i-1][j-weights[i]]+values[i]);
}
}
}
// 陣列逆遍歷
var j = W, w = 0
for(var i=n-1; i>=0; i--){
if(f[i][j] > f[i-1][j]){
selected.push(i)
console.log("物品",i,"其重量為", weights[i],"其價格為", values[i])
j = j - weights[i];
w += weights[i]
}
}
console.log("揹包最大承重為",W," 現在重量為", w, " 總價值為", f[n-1][W])
// selected.reverse() 陣列的翻轉函式 我們是逆遍歷,陣列翻過來
return [f[n-1][W], selected.reverse() ]
}
var a = knapsack([2,3,4,1],[2,5,3, 2],5)
console.log(a)
var b = knapsack([2,2,6,5,4],[6,3,5,4,6],10)
console.log(b)
結果展示
使用滾動陣列壓縮空間
所謂滾動陣列,目的在於優化空間,因為目前我們是使用一個 i j {ij} ij的二維陣列來儲存每一步的最優解。在求解的過程中,我們可以發現,當前狀態只與前一行的狀態有關,那麼更之前儲存的狀態資訊已經無用了,可以捨棄的,我們只需要儲存當前狀態和前一行狀態,所以只需使用 2 j {2j} 2j的空間,迴圈滾動使用,就可以達到跟 i ∗ j {i*j} i∗j一樣的效果。這是一個非常大的空間優化。
function knapsack(weights, values, W){
var n = weights.length
var lineA = new Array(W+1).fill(0)
var lineB = [], lastLine = 0, currLine
var f = [lineA, lineB]; //case1 在這裡使用es6語法預填第一行
for(var i = 0; i < n; i++){
currLine = lastLine === 0 ? 1 : 0 //決定當前要覆寫滾動陣列的哪一行
for(var j=0; j<=W; j++){
f[currLine][j] = f[lastLine][j] //case2 等於另一行的同一列的值
if( j>= weights[i] ){
var a = f[lastLine][j]
var b = f[lastLine][j-weights[i]] + values[i]
f[currLine][j] = Math.max(a, b);//case3
}
}
lastLine = currLine//交換行
}
return f[currLine][W];
}
var a = knapsack([2,3,4,1],[2,5,3, 2],5)
console.log(a)
var b = knapsack([2,2,6,5,4],[6,3,5,4,6],10)
console.log(b)
注意,這種解法由於丟棄了之前N行的資料,因此很難解出挑選的物品,只能求最大價值。
使用一維陣列壓縮空間
觀察我們的狀態遷移方程:
weights為每個物品的重量,values為每個物品的價值,W是揹包的容量,i表示要放進第幾個物品,j是揹包現時的容量(假設我們的揹包是魔術般的可放大,從0變到W)。
我們假令i = 0
f中的-1就變成沒有意義,因為沒有第-1行,而weights[0], values[0]繼續有效,
f
(
0
,
j
)
{f(0,j)}
f(0,j)也有意義,因為我們全部放到一個一維陣列中。於是:
這方程後面多加了一個限制條件,要求是從大到小迴圈。為什麼呢?
假設有物體z容量2,價值很大,揹包容量為5,如果j的迴圈順序不是逆序,那麼外層迴圈跑到物體時, 內迴圈在 j = 2 {j=2} j=2時 ,z被放入揹包。當 j = 4 {j=4} j=4時,尋求最大價值,物體z放入揹包, f ( 4 ) = m a x ( f ( 4 ) , f ( 2 ) + v z ) {f(4)=max(f(4),f(2)+vz) } f(4)=max(f(4),f(2)+vz), 這裡毫無疑問後者最大。 但此時 f ( 2 ) + v z {f(2)+v_z} f(2)+vz中的 f ( 2 ) {f(2)} f(2) 已經裝入了一次,這樣一來z被裝入兩次不符合要求, 如果逆序迴圈j, 這一問題便解決了。
javascript實現:
function knapsack(weights, values, W){
var n = weights.length;
// 藉助new Array()生成指定陣列長度的假資料的時候,此時陣列是空的
// 使用fill()這個陣列方法,由於沒有傳值,fill()會自動根據陣列長度替換陣列中所有的值為undefined
var f = new Array(W+1).fill(0)
// 值全為0
for(var i = 0; i < n; i++) {
for(var j = W; j >= weights[i]; j--){
f[j] = Math.max(f[j], f[j-weights[i]] +values[i]);
}
console.log(f.concat()) //除錯
}
return f[W];
}
var b = knapsack([2,2,6,5,4],[6,3,5,4,6],10)
console.log(b)
1.4 遞迴法解01揹包
由於這不是動態規則的解法,大家多觀察方程就理解了:
function knapsack(n, W, weights, values, selected) {
if (n == 0 || W == 0) {
//當物品數量為0,或者揹包容量為0時,最優解為0
return 0;
} else {
//從當前所剩物品的最後一個物品開始向前,逐個判斷是否要新增到揹包中
for (var i = n - 1; i >= 0; i--) {
//如果當前要判斷的物品重量大於揹包當前所剩的容量,那麼就不選擇這個物品
//在這種情況的最優解為f(n-1,C)
if (weights[i] > W) {
return knapsack(n - 1, W, weights, values, selected);
} else {
var a = knapsack(n - 1, W, weights, values, selected); //不選擇物品i的情況下的最優解
var b = values[i] + knapsack(n - 1, W - weights[i], weights, values, selected); //選擇物品i的情況下的最優解
//返回選擇物品i和不選擇物品i中最優解大的一個
if (a > b) {
selected[i] = 0; //這種情況下表示物品i未被選取
return a;
} else {
selected[i] = 1; //物品i被選取
return b;
}
}
}
}
}
var selected = [], ws = [2,2,6,5,4], vs = [6,3,5,4,6]
var b = knapsack( 5, 10, ws, vs, selected)
console.log(b) //15
selected.forEach(function(el,i){
if(el){
console.log("選擇了物品"+i+ " 其重量為"+ ws[i]+" 其價值為"+vs[i])
}
})
參考部落格:
https://blog.csdn.net/bangbanggangan/article/details/81087387
相關文章
- 01揹包面試題系列(一)面試題
- 揹包問題
- 揹包問題(01揹包與完全揹包)
- 位元組面試演算法題-0,1揹包問題面試演算法
- 01揹包問題
- 01 揹包問題
- 揹包問題例題總結
- 揹包九講問題
- 經典揹包問題
- 揹包問題大合集
- 部分揹包問題(挖
- 005多重揹包問題||
- 01揹包和完全揹包問題解法模板
- 從【零錢兌換】問題看01揹包和完全揹包問題
- 資料結構和演算法面試題系列—揹包問題總結資料結構演算法面試題
- 華為面試題:購物車問題(01揹包演算法升級)面試題演算法
- 揹包問題解題方法總結
- 【leetcode】揹包問題彙總LeetCode
- 2. 01揹包問題
- leetcode題解(0-1揹包問題)LeetCode
- 揹包問題的一道經典問題
- chapter12-2-揹包問題APT
- 關於各種揹包問題
- 【動態規劃】揹包問題動態規劃
- 01揹包問題的解決
- 二維費用揹包問題
- 深入剖析多重揹包問題(上篇)
- 深入剖析多重揹包問題(下篇)
- 揹包問題----動態規劃動態規劃
- 詳解動態規劃01揹包問題--JavaScript實現動態規劃JavaScript
- 詳解動態規劃01揹包問題–JavaScript實現動態規劃JavaScript
- 51nod 1597 有限揹包計數問題 (揹包 分塊)
- HDU Piggy-Bank(完全揹包問題)
- 整數0-1揹包問題
- 動態規劃 01揹包問題動態規劃
- 【動態規劃】01揹包問題動態規劃
- 動態規劃--01揹包問題動態規劃
- 動態規劃篇——揹包問題動態規劃