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點的最短距離。

爲了增加理解,這裏再舉一個反例:

圖中有四個點,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揹包問題

題目 :
N件物品和一個容量爲V的揹包。第i件物品的費用是c[i],價值是w[i]。求解將哪些物 品裝入揹包可使價值總和最大。

 

一、驗證動態規劃求解的可行性

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

假設(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揹包問題滿足最優性原理

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

 

二、解題思路

這是最基礎的揹包問題,特點是:每種物品僅有一件,可以選擇放或不放。

用子問題定義狀態:即 f[i][j] 表示前i 件物品恰放入一個容量爲j 的揹包可以獲得的最大價值。

則其狀態轉移方程便是:

這個方程表示的意思:

“ 將前 i 件物品放入容量爲 v 的揹包種 ” 這個子問題,若只考慮第 i 件物品的策略(放或者不放),那麼就可以轉化爲一個只牽扯前 i-1 件物品的問題。

如果不放第 i 件物品,那麼問題就轉化爲 “ 前 i-1 件物品放入容器爲 v 的揹包中 ” ,價值爲 f[i-1][v] ;

如果放第 i 件物品,那麼問題就轉化爲 “ 前 i-1 件物品放入剩下的容量爲 v-c[i] 的揹包中 ”,的揹包中此時能獲得的最大價值就是 f[i-1][v-c[i]] 價值再加上通過放入第 i 件物品獲得的價值 w[i]。

 

三、過程分析

簡單起見,我們來將上面的問題具體化:假設有5個物品,它們的價值(v)和重量(w)如下圖:

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

 

詳細過程: 

a) 把揹包問題抽象化(X1,X2,…,Xn,其中 Xi 取0或1,表示第 i 個物品選或不選),Vi表示第 i 個物品的價值,Wi表示第 i 個物品的體積(重量);

b) 建立模型,即求max(V1X1+V2X2+…+VnXn);

c) 約束條件,W1X1+W2X2+…+WnXn<capacity;

d) 定義 f(i,j):當前揹包容量 j,前 i 個物品最佳組合對應的價值;

e) 尋找遞推關係式,面對當前商品有兩種可能性:

    第一,包的容量比該商品體積小,裝不下,此時的價值與前i-1個的價值是一樣的,即 f(i,j) = f(i-1,j);

    第二,還有足夠的容量可以裝該商品,但裝了也不一定達到當前最優價值,所以在裝與不裝之間選擇最優的一個,即f(i,j)=max{ f(i-1,j),f(i-1,j-w(i))+v(i) }

       其中f(i-1,j)表示不裝,f(i-1,j-w(i))+v(i) 表示裝了第i個商品,揹包容量減少w(i)但價值增加了v(i);

    由此可以得出遞推關係式:

    1) j<w(i)      f (i,j)=f (i-1,j)

    2) j>=w(i)     f (i,j)=max{ f (i-1,j),f (i-1,j-w(i))+v(i) 

  g) 填表,首先初始化邊界條件,f (0,j)=f (i,0)=0;

 

  h) 然後一行一行的填表,

    1) 如,i=1,j=1,w(1)=2,v(1)=3,有j<w(1),故 f (1,1)=f (1-1,1)=0;

    2) 又如i=1,j=2,w(1)=2,v(1)=3,有j=w(1),故 f (1,2)=max{ f (1-1,2),f (1-1,2-w(1))+v(1) }=max{0,0+3}=3;

    3) 如此下去,填到最後一個,i=4,j=8,w(4)=5,v(4)=6,有j>w(4),故 f (4,8)=max{ f (4-1,8),f (4-1,8-w(4))+v(4) }=max{9,4+6}=10;所以填完表如下圖:

 

 

四、僞代碼+代碼

​for (int i = 1; i <= N; i++)
    for (int j = 1; j <= V; j++)
        f[i][j] = max(f[i-1][j], f[i][j-w[i]] + v[i]);

 

#include <iostream>
#include <algorithm>
using namespace std;
#define N 1001
#define W 1002

int dp[N][W];
int w[N];
int v[N];

int main() {
    int n,ww; cin >> n >> ww;
    for(int i=1;i<=n;i++) {
        cin >> w[i] >> v[i];
    }
    for(int i=1;i<=n;i++) {
        for(int j=1;j<=ww;j++) {
            if(j < w[i]) {
                dp[i][j] = dp[i-1][j];
            }
            else {
                dp[i][j] = max( dp[i-1][j], dp[i-1][ j - w[i] ] + v[i] );
            }
        }
    }
    cout << dp[n][ww] << endl;
    return 0;
}

 

 

五、優化空間複雜度

 
以上方法的時間和空間複雜度均爲Θ(V N),其中時間複雜度應該已經不能 再優化了,但空間複雜度卻可以優化到Θ(N)1
 
 

每一次 f (i)(j)改變的值只與 f (i-1)(x) {x:1...j}有關,f (i-1)(x)是前一次i循環保存下來的值;

因此,可以將 f 縮減成一維數組,從而達到優化空間的目的,狀態轉移方程轉換爲 B(j)= max{B(j), B(j-w(i))+v(i)}

並且,狀態轉移方程,每一次推導 f(i)(j)是通過 f(i-1)(j-w(i))來推導的,所以一維數組中j的掃描順序應該從大到小(capacity到0),否者前一次循環保存下來的值將會被修改,從而造成錯誤。

 

 

如果j不逆序而採用正序j=0...capacity,則在計算 f(i)(j) 時,變成了通過 f(i)(j-w(i))來推導, 而不是由 f(i-1)(j-w(i)) 來推導的。

所以該一維數組後面的值需要前面的值進行運算再改動,如果正序便利,則前面的值將有可能被修改掉從而造成後面數據的錯誤;相反如果逆序遍歷,先修改後面的數據再修改前面的數據,此種情況就不會出錯了。

 

優化後代碼如下:

#include <iostream>
#include <algorithm>
using namespace std;
#define N 1001
#define W 1002

int dp[W];
int w[N];
int v[N];

int main() {
    int n,ww; cin >> n >> ww;
    for(int i=1;i<=n;i++) {
        cin >> w[i] >> v[i];
    }
    for(int i=1;i<=n;i++) {
        for(int j=ww;j>=w[i];j--) {
            if(j < w[i]) {
                dp[j] = dp[j];
            }
            else {
                dp[j] = max( dp[j], dp[ j - w[i] ] + v[i] );
            }
        }
    }
    cout << dp[ww] << endl;
    return 0;
}

 

 

六、初始化的細節問題

在求最優解的揹包問題題目中,有兩種不太相同的問法。

1. 有的題目要求  “ 恰好裝滿揹包 ” 時的最優解。

2. 有的題目則並沒有要求把揹包裝滿。

這兩種問法的區別是在初始化的時候有所不同。

如果是第一種問法,要求恰好裝滿揹包,那麼在初始化時除了 f[0]  爲 0 ,其他  f[1...V]  均設爲  −∞  ,這樣就可以保證最終得到的  是一種恰好裝滿揹包的最優解。

如果並沒有要求必須把揹包裝滿,而是隻希望價格儘量大,初始化時應該將  f[0...V] 全部設爲 0 。 

因爲:初始化的 f 數組事實上就是沒有任何物品可以放入揹包時的合法狀態。如果要求揹包恰好裝滿,那麼只有容量爲 0 的揹包可能被價值爲 0 的 nothing  恰好裝滿,其他容量的揹包均沒有合法的解,屬於未定義的狀態,他們的值就都應該是−∞了。如果揹包並非必須被裝滿,那麼任何容量的揹包都有一個合法解 “ 什麼都不裝 ” ,這個解的價值爲0,所以初始狀態的值也就全部爲 0 了。

這個小技巧完全可以推廣到其它類型的揹包問題。

 

 

七、一個常數優化
 

前面的代碼中有:

可以將這個循環的下限進行改進。

由於只需要最後f[j] f[j]f[j]的值,倒推前一個物品,其實只要知道 f[j−w[n]]  即可。以此類推,對以第j 個揹包,其實只需要知道到  f [ j-sum{w[j...n]} ] 即可,即代碼可以改成

for (int i = 1; i <= n; i++) {
    int bound = max(V - sum{w[i]...w[n]}, w[i]);
    for (int j = V; j >= bound, j--)
        f[j] = max(f[j], f[j - w[i]] + v[i]);
}


對於求sum 可以用前綴和,這對於V 比較大時是有用的。
 

 

 

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章