例子:
現有三個物品, 重量分別為 3, 4, 6, 價值分別為 20, 60, 70. 有一個載荷為 8 的包, 它應該裝入哪些物品才能使其價值最大呢?
答案是第 1, 2 個物品, 它們的總重量是 7, 總價值為 80, 此時背包的價值達到最大.
分析:
設 OPT(n, w) 表示: 有 n 個物品, 一個載荷為 w 的背包的場景下的最優解. 這里的最優解指的是這種裝填方案使得背包的價值最大.
假設我們現在有 n - 1 個物品, 它在各種載荷的背包下的最優解我們都已經知曉. 即 OPT(n - 1, 0), OPT(n - 1, 1), OPT(n - 1, 2), ..., OPT(n - 1, w) 都是已知的.
現在我們再加一個物品, 設這個物品的重量為 wn, 價值為 vn. 對于載荷為 w 的背包來說, 它有兩種選擇: 放入這個物品或者不放入這個物品.
如果不放入這個新增的物品, 此時背包的內容物沒有變化, 其價值仍是 OPT(n - 1, w);
如果放入這個物品, 此時背包的內容物發生了變化. 除去這個物品的重量 wn, 背包還剩余 w - wn 的載荷, 這部分剩余載荷的最高價值可以到多少呢? 很顯然, 這是另外 n - 1 個物品在載荷為 w - wn 的背包下的最優解, 即 OPT(n - 1, w - wn). 此時背包的總價值最高可達 OPT(n - 1, w - wn) + vn.
顯然, 這兩種情況下, 誰的價值大, 誰就是最優解. 于是我們得到下面的遞推公式:
OPT(n, w) = max{OPT(n - 1, w), OPT(n - 1, w - wn) + vn}
當然, 0 個物品的情況下, 任意背包的最優解都是 0, 即 OPT(0, w) = 0
.
基于以上遞推公式和初始條件, 我們就可以求解任意多個物品在任意載荷的背包下的最優解.
代碼實現:
若有 n 個物品, 背包的載荷為 w, 以下實現的時間復雜度為 O(nw).
public class Test {
public static void main(String[] args) {
int capacity = 30;
int[] weightArr = {4, 2, 8, 3, 1, 9, 11, 7, 8, 13};
int[] valueArr = {28, 50, 62, 10, 20, 88, 101, 43, 97, 155};
Knapsack result = optimize(capacity, weightArr, valueArr);
System.out.println(
String.format("背包中物品的索引為: %s, 總價值: %s",
Arrays.toString(result.getItems()), result.getValue()));
}
public static Knapsack optimize(int capacity, int[] weightArr, int[] valueArr) {
Knapsack[] temp0 = new Knapsack[capacity + 1];
Arrays.fill(temp0, Knapsack.EMPTY);
for (int i = 0; i < weightArr.length; i++) {
int itemWeight = weightArr[i];
int itemValue = valueArr[i];
Knapsack[] temp1 = new Knapsack[capacity + 1];
for (int j = 0; j < temp0.length; j++) {
int value0 = temp0[j].getValue();
int value1 = 0;
if (itemWeight <= j) {
value1 = temp0[j - itemWeight].getValue() + itemValue;
}
if (value0 < value1) {
temp1[j] = temp0[j - itemWeight].addItem(i, itemValue);
} else {
temp1[j] = temp0[j];
}
}
temp0 = temp1;
}
return temp0[capacity];
}
public static class Knapsack {
public static final Knapsack EMPTY = new Knapsack(new int[0], 0);
private final int[] items;
private final int value;
private Knapsack(int[] items, int value) {
this.items = items;
this.value = value;
}
public Knapsack addItem(int itemIdx, int itemValue) {
int[] newItems = Arrays.copyOf(items, items.length + 1);
newItems[newItems.length - 1] = itemIdx;
int newValue = value + itemValue;
return new Knapsack(newItems, newValue);
}
public int[] getItems() {
return items;
}
public int getValue() {
return value;
}
}
}
進階 1:
若背包的載荷和物品的重量都是浮點數呢? 我們第一反應是把浮點數轉換成整數, 再用以上的算法來解決. 這樣是可以的, 但是效率呢? 請考慮如果一個物品的重量為 23.0985, 我們要乘以 10000 才能將其轉換為整數. 根據 O(nw), 相應的時間復雜度也得增加 10000 倍. 當然, 空間復雜度也是如此.
還有另一種情況, 就是背包載荷和物品的重量很大, 比如重量為 230985, 嗯, 其實就回到了上面那種情況. 此時如果用第一種算法, 時間復雜度和空間復雜度會不受控的.
這里有一個思路: 對于 n 個物品, 我們是否有必要把它在所有載荷下的最優解都計算一遍? 即我們是否有必要計算全部的 OPT(n, 0), OPT(n, 1), OPT(n, 2), ..., OPT(n, 230985), ..., OPT(n, w)?
當然是沒必要的啦, 實際上我們只需要計算那些有可能產生變化的點, 即關鍵點的 OPT 即可. 那么哪些點是關鍵點呢? 這個留給讀者自己思考吧, 下面附上這種思路的實現.
實測這種算法在物品重量緊湊且規模不大時, 效率不如第一種算法. 但是一旦上面提到的情況出現時, 其效率可大幅領先第一種算法.
public class Test {
public static void main(String[] args) {
double capacity = 36.66;
double[] weightArr = {1.22, 6.3, 3.33, 5.25, 7.1, 2.12, 8.06, 7.32, 6.66, 5.42};
double[] valueArr = {4.9, 5.5, 8.93, 8.14, 5.37, 4.22, 8.8, 10.31, 7.36, 6.21};
Knapsack result = optimize(capacity, weightArr, valueArr);
System.out.println(
String.format("背包中物品的索引為: %s, 總價值: %s",
Arrays.toString(result.getItems()), result.getValue()));
}
public static Knapsack optimize(double capacity, double[] weightArr, double[] valueArr) {
TreeMap<Double, Knapsack> temp0 = new TreeMap<>();
temp0.put(0D, Knapsack.EMPTY);
for (int i = 0; i < weightArr.length; i++) {
double itemWeight = weightArr[i];
double itemValue = valueArr[i];
TreeMap<Double, Knapsack> temp1 = new TreeMap<>();
temp1.put(0D, Knapsack.EMPTY);
for (Map.Entry<Double, Knapsack> entry0 : temp0.entrySet()) {
Double weight0 = entry0.getKey();
Knapsack knapsack0 = entry0.getValue();
double value0 = knapsack0.getValue();
Knapsack knapsack1 = temp1.floorEntry(weight0).getValue();
double value1 = knapsack1.getValue();
if (value1 < value0) {
temp1.put(weight0, knapsack0);
temp1.tailMap(weight0, false).entrySet()
.removeIf(_entry -> _entry.getValue().getValue() <= value0);
}
if (weight0 + itemWeight <= capacity) {
temp1.put(weight0 + itemWeight, knapsack0.addItem(i, itemValue));
}
}
temp0 = temp1;
}
return temp0.floorEntry(capacity).getValue();
}
public static class Knapsack {
public static final Knapsack EMPTY = new Knapsack(new int[0], 0D);
private final int[] items;
private final double value;
private Knapsack(int[] items, double value) {
this.items = items;
this.value = value;
}
public Knapsack addItem(int itemIdx, double itemValue) {
int[] newItems = Arrays.copyOf(items, items.length + 1);
newItems[newItems.length - 1] = itemIdx;
double newValue = value + itemValue;
return new Knapsack(newItems, newValue);
}
public int[] getItems() {
return items;
}
public double getValue() {
return value;
}
}
}
進階 2:
若物品之間存在關聯, 例如如果取了物品 A, 就必須同時取物品 B. 或者取了物品 C, 就不能再取物品 D 等等. 這種情況下, 背包算法怎么實現? 其實這里的基本思路和基礎版的背包問題是一致的, 只是在決定是否放入第 n 個物品時, 要把與之關聯的物品考慮進來, 其遞推公式的基本形式是不變的. 具體算法實現請有興趣和耐心的讀者自己嘗試吧.
進階 3:
若物品可以重復獲取, 背包算法怎么實現? 將基礎背包算法稍微改動一下就可以得到這種情況的解. 有興趣的讀者自己思考一下吧.
進階 4:
若某些物品可以重復獲取, 另一些物品不可以呢? 若已經解決了進階 3 的算法, 聯立基礎版背包算法即可.