【動態規劃】01揹包問題

弗蘭克的貓發表於2019-03-14

說明

前面用動態規劃解決了正規表示式的問題,感覺還是不過癮,總覺得對於動態規劃的理解還沒有到位,所以趁熱打鐵,繼續研究幾個動態規劃的經典問題,希望能夠藉此加深對動態規劃的理解。在此之前,還需要說兩個跟動態規劃有關的理論知識。

最優化原理

最優化原理指的最優策略具有這樣的性質:不論過去狀態和決策如何,對前面的決策所形成的狀態而言,餘下的諸決策必須構成最優策略。簡單來說就是一個最優策略的子策略也是必須是最優的,而所有子問題的區域性最優解將導致整個問題的全域性最優。如果一個問題能滿足最優化原理,就稱其具有最優子結構性質

這是判斷問題能否使用動態規劃解決的先決條件,如果一個問題不能滿足最優化原理,那麼這個問題就不適合用動態規劃來求解。

這樣說可能比較模糊,來舉個栗子吧:

【動態規劃】01揹包問題

如上圖,求從A點到E點的最短距離,那麼子問題就是求從A點到E點之間的中間點到E點的最短距離,比如這裡的B點

那麼這個問題裡,怎麼證明最優化原理呢?

我們假設從A點到E點的最短距離為d,其最優策略的子策略假設經過B點,記該策略中B點到E點的距離為d1,A點到B點的距離為d2。我們可以使用反證法,假設存在B點到E點的最短距離d3,並且d3 < d1,那麼 d3 + d2 < d1 + d2 = d,這與d是最短距離相矛盾,所以,d1是B點到E點的最短距離。

為了增加理解,這裡再舉一個反例:

【動態規劃】01揹包問題

圖中有四個點,A、B、C、D,相鄰兩點有兩條連線,代表兩條通道,d1,d2,d3,d4,d5,d6代表的是道路的長度,求A到D的所有通道中,總長度除以4得到的餘數最小的路徑為最優路徑,求一條最優路徑

這裡如果還是按照上面的思路去求解,就會誤入歧途了。按照之前的思路,A的最優取值應該可以由B的最優取值來確定,而B的最優取值為(3+5)mod 4 = 0。所以應該選d2d6這兩條道路,而實際上,全域性最優解是d4+d5+d6或者d1+d5+d3。所以這裡子問題的最優解並不是原問題的最優解,即不滿足最優化原理。所以就不適合使用動態規劃來求解了。

無後效性

無後效性指的是某狀態下決策的收益,只與狀態和決策相關,與到達該狀態的方式無關。某個階段的狀態一旦確定,則此後過程的演變不再受此前各種狀態及決策的影響。換句話說,未來與過去無關,當前狀態是此前歷史狀態的完整總結,此前歷史決策只能通過影響當前的狀態來影響未來的演變。再換句話說,過去做的選擇不會影響現在能做的最優選擇,現在能做的最優選擇只與當前的狀態有關,與經過如何複雜的決策到達該狀態的方式無關。

這也是用來驗證問題是否可以使用動態規劃來解答的重要方法。

我們再回頭看看上面的最短路徑問題,如果在原來的基礎上加上一個限制條件:同一個格子只能通過一次。那麼, 這個題就不符合無後效性了,因為前一個子問題的解會對後面子問題的選擇策略有影響,比如說,如果從A到B選擇了一條如下圖中綠色表示的路線,那麼從B點出發到達E點的路線就只有一條了。也就是說從A點到B點的路徑選擇會影響B點到E點的路徑選擇。

【動態規劃】01揹包問題

理論部分就此打住,接下來我們實戰一下。

01揹包問題

假設你是一名經驗豐富的探險家,揹著揹包來到野外進行日常探險。天氣晴朗而不燥熱,山間的風夾雜著花香,正當你欣賞這世外桃源般的美景時,突然,你發現了一個洞穴,這個洞穴外表看起來其貌不揚,但憑藉著驚為天人的直覺,這個洞穴不簡單。

【動態規劃】01揹包問題

於是,你開始往洞穴內探索,希望能發現一些有意思的東西。終於,皇天不負有心人,你在洞穴的盡頭,發現了一堆不世出的珠寶,憑藉你驚人的閱歷,一眼便看出了它們各自的價值,心想著下下下下下下下下半輩子都有著落了。

【動態規劃】01揹包問題

然而,天有不測風雲,正準備將它們收入囊中,卻不小心觸碰到一個防禦機關,洞穴馬上就要崩塌了。在此危機時刻,你只有一個揹包,你必須儘快做出抉擇,從中選擇最值錢的珠寶塞到你的揹包,讓揹包中珠寶的總價值最大。

【動態規劃】01揹包問題

好了好了,囉裡囉嗦了大半天,我還是來精簡一下問題吧。簡而言之,你只有一個容量有限的揹包,總容量為c,有n個可待選擇的物品,每個物品只有一件,它們都有各自的重量和價值,你需要從中選擇合適的組合來使得你揹包中的物品總價值最大。

問題分析

那還不簡單,不管是什麼,先往揹包裡塞,塞滿趕緊走,狗命要緊,狗命要緊。。。

20190310214935.png

好了好了,開個玩笑,言歸正傳。

簡單起見,我們來將上面的問題具體化,舉一個更具體的栗子:

假設有5個物品,它們的價值(v)和重量(w)如下圖:

【動態規劃】01揹包問題

揹包總容量為10,現在要從中選擇物品裝入揹包中,要求物品的重量不能超過揹包的容量,並且最後放在揹包中物品的總價值最大。

emmm,等等,為什麼叫做0/1揹包呢?為什麼不叫1/2揹包2/3揹包???

仔細想想,這裡每個物品只有一個,對於每個物品而言,只有兩種選擇,盤它或者不盤,盤它記為1,不盤記為0,我們不能將物品進行分割,比如只拿半個是不允許的。這就是這個問題被稱為0/1揹包問題的原因。

所以究竟選還是不選,這是個問題。

【動態規劃】01揹包問題

讓我們先來體驗一下將珠寶裝入揹包的感覺,為了方便起見,用xi代表第i個珠寶的選擇(xi = 1 代表選擇該珠寶,0則代表不選),vi代表第i個珠寶的價值,wi代表第i個珠寶的重量。於是我們就有了這樣的限制條件:

【動態規劃】01揹包問題

我們的初始狀態是揹包容量為10,揹包內物品總價值為0,接下來,我們就要開始做選擇了。對於1號珠寶,當前容量為10,容納它的重量2綽綽有餘,因此有兩種選擇,選它或者不選。我們選擇一個珠寶的時候,揹包的容量會減少,但是裡面的物品總價值會增加。就像下面這樣:

【動態規劃】01揹包問題

這樣就分出了兩種情況,我們繼續進行選擇,如果我們選擇了珠寶1,那麼對於珠寶2,當前剩餘容量為8,大於珠寶2的容量3,因此也有兩種選擇,選或者不選。

【動態規劃】01揹包問題

現在,我們得到了四個可能結果,我們每做出一個選擇,就會將上面的每一種可能分裂成兩種可能,後續的選擇也是如此,最終,我們會得到如下的一張決策圖:

【動態規劃】01揹包問題

這裡被塗上色的方框代表我們的最終待選結果,本來應該有16個待選結果,但有三個結果由於容量不足以容納下最後一個珠寶,所以就沒有繼續進行裂變。

然後,我們從這些結果中,找出價值最大的那個,也就是13,這就是我們的最優選擇,根據這個選擇,依次找到它的所有路徑,便可以知道該選哪幾個珠寶,最終結果是:珠寶4,珠寶2,珠寶1。

分治法

接下來,我們就來分析一下,如何將它擴充套件到一般情況。為了實現這個目的,我們需要將問題進行抽象並建模,然後將其劃分為更小的子問題,找出遞推關係式,這是分治思想中很重要的一步。

  1. 抽象問題,揹包問題抽象為尋找組合(x1,x2,x3...xn,其中xi取0或1,表示第i個物品取或者不取),vi代表第i個物品的價值,wi代表第i個物品的重量,總物品數為n,揹包容量為c。
  2. 建模,問題即求max(x1v1 + x2v2 + x3v3 + ... + xnvn)。
  3. 約束條件,x1w1 + x2w2 + x3w3 + ... + xnwn < c
  4. 定義函式KS(i,j):代表當前揹包剩餘容量為j時,前i個物品最佳組合所對應的價值;

那這裡的遞推關係式是怎樣的呢?對於第i個物品,有兩種可能:

  1. 揹包剩餘容量不足以容納該物品,此時揹包的價值與前i-1個物品的價值是一樣的,KS(i,j) = KS(i-1,j)
  2. 揹包剩餘容量可以裝下該商品,此時需要進行判斷,因為裝了該商品不一定能使最終組合達到最大價值,如果不裝該商品,則價值為:KS(i-1,j),如果裝了該商品,則價值為KS(i-1,j-wi) + vi,從兩者中選擇較大的那個,所以就得出了遞推關係式:

【動態規劃】01揹包問題

對於這個問題的子問題,這裡有必要詳細說明一下。原問題是,將n件物品放入容量為c的揹包,子問題則是,將前i件物品放入容量為j的揹包,所得到的最優價值為KS(i,j),如果只考慮第i件物品放還是不放,那麼就可以轉化為一個只涉及到前i-1個物品的問題。如果不放第i個物品,那麼問題就轉化為“前i-1件物品放入容量為j的揹包中的最優價值組合”,對應的值為KS(i-1,j)。如果放第i個物品,那麼問題就轉化成了“前i-1件物品放入容量為j-wi的揹包中的最優價值組合”,此時對應的值為KS(i-1,j-wi)+vi。

所以,就可以很容易的寫出遞迴解法了:

public class Solution{
    int[] vs = {0,2,4,3,7};
    int[] ws = {0,2,3,5,5};

    @Test
    public void testKnapsack1() {
        int result = ks(4,10);
        System.out.println(result);
    }

    private int ks(int i, int c){
        int result = 0;
        if (i == 0 || c == 0){
            // 初始條件
            result = 0;
        } else if(ws[i] > c){
            // 裝不下該珠寶
            result = ks(i-1, c);
        } else {
            // 可以裝下
            int tmp1 = ks(i-1, c);
            int tmp2 = ks(i-1, c-ws[i]) + vs[i];
            result = Math.max(tmp1, tmp2);
        }
        return result;
    }
}
複製程式碼

這裡為了方便處理,將陣列ws和vs都增加了一個補位數0,防止陣列越界,輸出結果:

13
複製程式碼

這樣,我們就輕鬆加愉快的解決了這個問題。

動態規劃解法

驗證可行性

既然開頭已經說了兩個驗證問題是否可以使用動態規劃求解的方法,那麼為何不試一試呢?

先來看看最優化原理。同樣,我們使用反證法:

假設(x1,x2,…,xn)是01揹包問題的最優解,則有(x2,x3,…,xn)是其子問題的最優解,假設(y2,y3,…,yn)是上述問題的子問題最優解,則有(v2y2+v3y3+…+vnyn)+v1x1 > (v2x2+v3x3+…+vnxn)+v1x1。說明(X1,Y2,Y3,…,Yn)才是該01揹包問題的最優解,這與最開始的假設(X1,X2,…,Xn)是01揹包問題的最優解相矛盾,故01揹包問題滿足最優性原理

【動態規劃】01揹包問題

至於無後效性,其實比較好理解。對於任意一個階段,只要揹包剩餘容量和可選物品是一樣的,那麼我們能做出的現階段的最優選擇必定是一樣的,是不受之前選擇了什麼物品所影響的。即滿足無後效性

自上而下記憶法

就像上一篇裡的解法一樣,自上而下的解法與分治法的區別就是增加了一個陣列用來儲存計算的中間結果來減少重複計算。這裡,我們只需要多定義一個二維陣列。

【動態規劃】01揹包問題

表格中,每一個格子都代表著一個子問題,我們最終的問題是求最右下角的格子的值,也就是i=4,j=10時的值。這裡,我們的初始條件便是i=0或者j=0時對應的ks值為0,這很好理解,如果可選物品為0,或者剩餘容量為0,那麼最大價值自然也是0。程式碼如下:

public class Solution{
    int[] vs = {0,2,4,3,7};
    int[] ws = {0,2,3,5,5};
    Integer[][] results = new Integer[5][11];

    @Test
    public void testKnapsack2() {
        int result = ks2(4,10);
        System.out.println(result);
    }

    private int ks2(int i, int c){
        int result = 0;
        // 如果該結果已經被計算,那麼直接返回
        if (results[i][c] != null) return results[i][c];
        if (i == 0 || c == 0){
            // 初始條件
            result = 0;
        } else if(ws[i] > c){
            // 裝不下該珠寶
            result = ks(i-1, c);
        } else {
            // 可以裝下
            int tmp1 = ks(i-1, c);
            int tmp2 = ks(i-1, c-ws[i]) + vs[i];
            result = Math.max(tmp1, tmp2);
            results[i][c] = result;
        }
        return result;
    }
}
複製程式碼

可以看到,其實只比分治多了三行程式碼。

自下而上填表法

接下來,我們用自下而上的方法來解一下這道題,思路很簡單,就是不斷的填表,回想一下上一篇中的斐波拉契數列的自下而上解法,這裡將使用同樣的方式來解決。還是使用上面的表格,我們開始一行行填表。

【動態規劃】01揹包問題

當i=1時,即只有珠寶1可供選擇,那麼如果容量足夠的話,最大價值自然就是珠寶1的價值了。

【動態規劃】01揹包問題

當i=2時,有兩個物品可供選擇,此時應用上面的遞推關係式進行判斷即可。這裡以i=2,j=3為例進行分析:

【動態規劃】01揹包問題

剩下的格子使用相同的方法進行填充即可:

【動態規劃】01揹包問題

這樣,我們就得到了最後的結果:13。根據結果,我們可以反向找出各個物品的選擇,尋找的方法很簡單,就是從i=4,j=10開始尋找,如果ks(i-1,j)=ks(i,j),說明第i個物品沒有被選中,從ks(i-1,j)繼續尋找。否則,表示第i個物品已被選中,則從ks(i-1,j-wi)開始尋找。

【動態規劃】01揹包問題

轉化成程式碼:

public class Solution{
    int[] vs = {0,2,4,3,7};
    int[] ws = {0,2,3,5,5};
    Integer[][] results = new Integer[5][11];
    
    @Test
    public void testKnapsack3() {
        int result = ks3(4,10);
        System.out.println(result);
    }

    private int ks3(int i, int j){
        // 初始化
        for (int m = 0; m <= i; m++){
            results[m][0] = 0;
        }
        for (int m = 0; m <= j; m++){
            results[0][m] = 0;
        }
        // 開始填表
        for (int m = 1; m <= i; m++){
            for (int n = 1; n <= j; n++){
                if (n < ws[m]){
                    // 裝不進去
                    results[m][n] = results[m-1][n];
                } else {
                    // 容量足夠
                    if (results[m-1][n] > results[m-1][n-ws[m]] + vs[m]){
                        // 不裝該珠寶,最優價值更大
                        results[m][n] = results[m-1][n];
                    } else {
                        results[m][n] = results[m-1][n-ws[m]] + vs[m];
                    }
                }
            }
        }
        return results[i][j];
    }
}
複製程式碼

嗯,完美解決。時間複雜度即填表耗時O(n * c),這裡用了一個二維陣列來儲存子問題的解,所以空間複雜度為O(n * c);

總結

回過頭再看看上面的分析,會發現動態規劃裡最關鍵的問題其實是尋找原問題的子問題,並寫出遞推表示式,只要完成了這一步,程式碼部分都是水到渠成的事情了。

那麼問題來了,怎樣把問題拆分成子問題呢?

emmm,這個問題有點超綱了,說實話,我也沒有掌握到訣竅,還是得具體情況具體分析,但是很多經典的問題都有其經典的套路,其它問題都可以歸結到這些問題上面來,可以看做是它們的變種和延伸,把這些經典的問題吃透的話,自然能舉一反三。比如採藥問題,本質上就是01揹包問題,而硬幣問題,本質上就是我們之後要介紹的完全揹包問題。

個人認為,演算法不在於刷多少個,而在於歸納總結,就跟做數學題一樣,總有一些正規化和套路,不管形式如何變化,其本質是一樣的,萬變不離其宗,說的就是這麼回事。

【動態規劃】01揹包問題

本篇到此就告一段落了,如果覺得有收穫,不要吝嗇你的贊哦,也歡迎關注我的公眾號留言交流。

【動態規劃】01揹包問題

相關文章