JavaScript中揹包問題(面試題)

weixin_43909743發表於2020-11-01

起因

面試官即排序演算法、斐波那契數列後的第三個問題——揹包問題
在學習的同時,我儘可能用通俗易懂的解釋、程式碼註釋、程式碼分析、問題優化加深這樣的過程來和大家一起分析這個問題。

問題的描述

揹包:揹包容量一定,在一組東西中如何選擇,使得揹包最有價值
本質:是一個組合優化的問題。
問題描述:給一個固定大小,能夠攜重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)

方程式為:
f(i,j)=\left { f(i-1,j) j<w(i)\bigcap i>0;max\left {f(i-1,j), f(i-1,j-w(i))+v(i) j>=w(i)\right }\right }
在這裡插入圖片描述
這個時候,遞迴就已經有一定的體現了,只要接下來的符合上述邏輯我們就可以實現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(n1,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(i1,jwi)+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} ij一樣的效果。這是一個非常大的空間優化。

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

https://cloud.tencent.com/developer/article/1050285

new Array().fill()的解釋

相關文章