分數背包問題可以用貪心算法來求解,而0-1背包問題則需要用動態規划方法求解。
問題描述:
假設我們有n件物品,分別編號為1, 2...n。其中編號為i的物品價值為vi,它的重量為wi。為了簡化問題,假定價值和重量都是整數值。現在,假設我們有一個背包,它能夠承載的重量是W。現在,我們希望往包里裝這些物品,使得包里裝的物品價值最大化,那么我們該如何來選擇裝的東西呢?
問題解答:
我們需要選擇n個元素中的若干個來形成最優解,假定為k個。那么對於這k個元素a1, a2, ...ak來說,它們組成的物品組合必然滿足總重量<=背包重量限制,而且它們的價值必然是最大的。因為它們是我們假定的最優選擇嘛,肯定價值應該是最大的。假定ak是我們按照前面順序放入的最后一個物品。它的重量為wk,它的價值為vk。既然我們前面選擇的這k個元素構成了最優選擇,如果我們把這個ak物品拿走,對應於k-1個物品來說,它們所涵蓋的重量范圍為0-(W-wk)。假定W為背包允許承重的量。假定最終的價值是V,剩下的物品所構成的價值為V-vk。這剩下的k-1個元素是不是構成了一個這種W-wk的最優解呢?
我們可以用反證法來推導。假定拿走ak這個物品后,剩下的這些物品沒有構成W-wk重量范圍的最佳價值選擇。那么我們肯定有另外k-1個元素,他們在W-wk重量范圍內構成的價值更大。如果這樣的話,我們用這k-1個物品再加上第k個,他們構成的最終W重量范圍內的價值就是最優的。這豈不是和我們前面假設的k個元素構成最佳矛盾了嗎?所以我們可以肯定,在這k個元素里拿掉最后那個元素,前面剩下的元素依然構成一個最佳解。
現在我們經過前面的推理已經得到了一個基本的遞推關系,就是一個最優解的子解集也是最優的。可是,我們該怎么來求得這個最優解呢?我們這樣來看。假定我們定義一個函數c[i, w]表示到第i個元素為止,在限制總重量為w的情況下我們所能選擇到的最優解。那么這個最優解要么包含有i這個物品,要么不包含,肯定是這兩種情況中的一種。如果我們選擇了第i個物品,那么實際上這個最優解是c[i - 1, w-wi] + vi。而如果我們沒有選擇第i個物品,這個最優解是c[i-1, w]。這樣,實際上對於到底要不要取第i個物品,我們只要比較這兩種情況,哪個的結果值更大不就是最優的么?
在前面討論的關系里,還有一個情況我們需要考慮的就是,我們這個最優解是基於選擇物品i時總重量還是在w范圍內的,如果超出了呢?我們肯定不能選擇它,這就和c[i-1, w]一樣。
另外,對於初始的情況呢?很明顯c[0, w]里不管w是多少,肯定為0。因為它表示我們一個物品都不選擇的情況。c[i, 0]也一樣,當我們總重量限制為0時,肯定價值為0。
這樣,基於我們前面討論的這3個部分,我們可以得到一個如下的遞推公式:
有了這個關系,我們可以更進一步的來考慮代碼實現了。我們有這么一個遞歸的關系,其中,后面的函數結果其實是依賴於前面的結果的。我們只要按照前面求出來最基礎的最優條件,然后往后面一步步遞推,就可以找到結果了。
我們再來考慮一下具體實現的細節。這一組物品分別有價值和重量,我們可以定義兩個數組int[] v, int[] w。v[i]表示第i個物品的價值,w[i]表示第i個物品的重量。為了表示c[i, w],我們可以使用一個int[i][w]的矩陣。其中i的最大值為物品的數量,而w表示最大的重量限制。按照前面的遞推關系,c[i][0]和c[0][w]都是0。而我們所要求的最終結果是c[n][w]。所以我們實際中創建的矩陣是(n + 1) x (w + 1)的規格。下面是該過程的一個代碼參考實現:
public class DynamicKnapSack { private int[] v; private int[] w; private int[][] c; private int weight; public DynamicKnapSack(int length, int weight, int[] vin, int[] win) { v = new int[length + 1]; w = new int[length + 1]; c = new int[length + 1][weight + 1]; this.weight = weight; for(int i = 0; i < length + 1; i++) { v[i] = vin[i]; w[i] = win[i]; } } public void solve() { for(int i = 1; i < v.length; i++) { for(int k = 1; k <= weight; k++) { if(w[i] <= k) { if(v[i] + c[i - 1][k - w[i]] > c[i - 1][k]) c[i][k] = v[i] + c[i - 1][k - w[i]]; else c[i][k] = c[i - 1][k]; } else c[i][k] = c[i - 1][k]; } } } public void printResult() { for(int i = 0; i < v. length; i++) { for(int j = 0; j <= weight; j++) System.out.print(c[i][j] + " "); System.out.println(); } } public static void main(String[] args) { int[] v = {0, 60, 100, 120}; int[] w = {0, 10, 20, 30}; int weight = 50; DynamicKnapSack knapsack = new DynamicKnapSack(3, weight, v, w); knapsack.solve(); knapsack.printResult(); } }
類似地,leetcode上第416題Partition Equal Subset Sum可以用0-1背包的思想來解決。
問題描述:
Given a non-empty array containing only positive integers, find if the array can be partitioned into two subsets such that the sum of elements in both subsets is equal.
Note:
- Each of the array element will not exceed 100.
- The array size will not exceed 200.
Example 1:
Input: [1, 5, 11, 5] Output: true Explanation: The array can be partitioned as [1, 5, 5] and [11].
Example 2:
Input: [1, 2, 3, 5] Output: false Explanation: The array cannot be partitioned into equal sum subsets.
問題解答:
本題與0-1背包問題類似。用數組dp[i][j]來表示體積不超過j時的前i個元素的最大和。可得出遞推公式為dp[i][j] = Math.max(dp[i - 1][j - nums[i]] + nums[i], dp[i -1][j]), 其中j >= nums[i].當dp[i][sum] = sum,即背包裝滿時,則返回true。
仔細分析發現,在本題情況中,每次循環dp[i][j]只與上一層循環得到的dp[i -1][x]有關,故只需要一維數組,每次更新數組值即可。具體代碼如下。注意第二層循環中j要從大到小遍歷,原因是在更新數組的過程中避免使用到當次更新的數組元素。
public class Solution { public boolean canPartition(int[] nums) { int sum = 0; for (int i = 0; i < nums.length; i++) { sum += nums[i]; } if (sum % 2 != 0) return false; sum /= 2; int[] dp = new int[sum + 1]; for (int i = 0; i < nums.length; i++) { for (int j = sum; j >= nums[i]; j--) { dp[j] = Math.max(dp[j - nums[i]] + nums[i], dp[j]); } } return dp[sum] == sum; } }
問題優化:
本問題只需要知道是否有元素和為sum,因此可以采用boolean數組dp[j]來記錄是否有和為sum的情況存在。可得到遞推公式為dp[j] = dp[j] || dp[j - nums[i]], 其中j >= nums[i].
public class Solution { public boolean canPartition(int[] nums) { int sum = 0; for (int i = 0; i < nums.length; i++) { sum += nums[i]; } if (sum % 2 != 0) return false; sum /= 2; boolean[] dp = new boolean[sum + 1]; dp[0] = true; for (int i = 0; i < nums.length; i++) { for (int j = sum; j >= nums[i]; j--) { dp[j] = dp[j] || dp[j - nums[i]]; if (dp[sum]) return true; } } return false; } }
這種情況下采取boolean運算會比上述解法采用數學運算速度快一些。