一、最基礎的動態規划之一
01背包問題是動態規划中最基礎的問題之一,它的解法完美地體現了動態規划的思想和性質。
01背包問題最常見的問題形式是:給定n件物品的體積和價值,將他們盡可能地放入一個體積固定的背包,最大的價值可以是多少。我們可以用費用c和價值v來描述一件物品,再設允許的最大花費為w。只要n稍大,我們就不可能通過搜索來遍查所有組合的可能。運用動態規划的思想,我們把原來的問題拆分為子問題,子問題再進一步拆分直至不可再分(初始值),隨后從初始值開始,盡可能地求取每一個子問題的最優解,最終就能求得原問題的解。由於不同的問題可能有相同的子問題,子問題存在大量重疊,我們需要額外的空間來存儲已經求得的子問題的最優解。這樣,可以大幅度地降低時間復雜度。
有了這樣的思想,我們來看01背包問題可以怎樣拆分成子問題:
要求解的問題是:在n件物品中最大花費為w能得到的最大價值。顯然,對於0 <= i <= n,0 <= j <= w,在前i件物品中最大花費為j能得到的最大價值。
可以使用數組dp[n + 1][w + 1]來存儲所有的子問題,dp[i][j]就代表從前i件物品中選出總花費不超過j時的最大價值。
可知dp[0][j]值一定為零。那么,該怎么遞推求取所有子問題的解呢。顯而易見,要考慮在前i件物品中拿取,首先要考慮前i - 1件物品中拿取的最優情況。
當我們從第i - 1件物品遞推到第i件時,我們就要考慮這件物品是拿,還是不拿,怎樣收益最大。
①:首先,如果j < c[i],那第i件物品是無論如何拿不了的,dp[i][j] = dp[i - 1][j];
②:如果可以拿,那就要考慮拿了之后收益是否更大。拿這件物品需要花費c[i],除去這c[i]的子問題應該是dp[i - 1][j - c[i]],這時,就要比較dp[i - 1][j]和dp[i - 1][j - c[i]] + v[i],得出最優方案。
細節和代碼如下:
int n, w; int c[maxn], v[maxn];//c為費用,v為價值 int dp[maxn][maxw]; int main() { scanf("%d %d", &w, &n);//w為最大費用,n為數量 for(int i = 1;i <= n;i++) { scanf("%d %d", &c[i], &v[i]);//輸入,注意這里的下標是從1開始的 } memset(dp, 0, sizeof(dp));//若不涉及多組輸入,這一步其實可以省略 //如果下標從0開始,下面也需要稍作修改 for(int i = 1;i <= n;i++) { for(int j = 0;j <= w;j++) { if(c[i] > j) dp[i][j] = dp[i - 1][j];//狀態轉移,情況① else dp[i][j] = max(dp[i - 1][j], dp[i - 1][j - c[i]] + v[i]);//情況② } } printf("%d\n", dp[n][w]); }
這樣做,時間復雜度和空間復雜度都是o(nw)
二、空間優化 一維數組實現
在上面的解法中,我們把所有子問題的最優解都用數組保存了下來,實際上,這些最優解並不是一直都有用的。從狀態轉移方程可以看出,對當前的i,只有i - 1時的最優解才是有用的,再之前的已經不會再被使用了。如果能把已經無用的空間節省出來,空間復雜度能夠得到非常大的優化,這在有些問題中是非常必要的。
實際上,只需要一個一維的數組dp[m + 1](省去n那一維)就可以完成任務。外層的循環每一輪開始時,這個數組里存的要么是初始值(i = 0的情況),要么是上一輪遞推得到的值(i - 1時的情況),符合原本狀態轉移方程的要求。
不過,這樣做就需要更加的注意遞推的方向,更新的順序。在更新前,數組里存儲的是上一輪的值(或初始值),更新后,更新了的位置存儲的就是這一輪的值了。在01背包的問題里,我們要從i - 1的情況遞推上來,所以要倒着更新。這一點需要着重理解(反了的話就變成完全背包了)。
代碼如下:
int c[maxn], v[maxn];//c為費用,v為價值 int dp[maxw]; //其余部分略 for(int i = 1;i <= n;i++) { for(int j = w;j >= c[i];j--) //這里要反着更新,否則dp[j - c[i]]會比dp[j]先更新,而更新后它對應的就不是i - 1時的狀態了 { dp[j] = max(dp[j], dp[j - c[i]] + v[i]); } }
三、01背包的常見變形
實踐中很難遇到如此標准的01背包模型,大多數情況下,我們都需要根據具體問題對上面的算法做出一定的修改。
這些問題都是背包問題常見的,解決方法也都相同或者相似:
1.求取的不是價值的最大值而是最小值:把max換成min即可,原理相同。
2.費用不一定都是正數:負數沒法用作數組的下標,可以考慮把所有的費用都先加上一個較大的數再做處理(平移)。
3.費用存在小數:可以把所用費用都先放大一定的倍數,是所有的費用值都為整數(推薦HDU 1864)。
4.要求恰好裝滿,即選取的物品的費用之和恰等於最大花費:
相比基礎的01背包,這里我們需要一個正常情況不可能出現的值來表示狀態非法、不可能實現,這個值可以是-1、-INF之類的,視具體情況而定。
在初始化時,除dp[i][0]的值應為0之外,其他所有值都應為非法值。在狀態轉移時,首先要判斷子問題是否非法。
for(int i = 1;i <= w;i++)//初始化 { dp[i] = -INF; } dp[0] = 0; for(int i = 1;i <= n;i++) { for(int j = w;j >= c[i];j--) { if(dp[i - c[i]] != -INF)//判斷是否合法,這里其實省去了幾種情況,用二維數組實現的話需注意 { dp[j] = max(dp[j], dp[j - c[i]] + v[i]); } } }