揹包問題 (Knapsack problem) 是一種組合優化的 NP 完全問題。問題可以描述爲:給定一組物品,每種物品都有自己的重量和價格,在限定的總重量內,我們如何選擇,才能使得物品的總價格最高。問題的名稱來源於如何選擇最合適的物品放置於給定揹包中。相似問題經常出現在商業、組合數學,計算複雜性理論、密碼學和應用數學等領域中。
本文主要介紹最常見的 01 揹包問題,且只介紹動態規化的解法。
1. 基本題目描述與分析
問題描述
現有 件物品和一個容量爲 的揹包。放入第 件物品耗費的空間是 ,得到的價值是 。求解將哪些物品裝入揹包可使價值總和最大。
之所以叫做 “0-1” 揹包是因爲在這裏,每個物件只有一個。如果我們把 視爲解空間中的某一個解,那麼可以這麼來表述:
這個問題的形式化描述如下:
分析
爲了使用動態規化,我們首先定義目標函數 ,它表示前 i 個物品放入容量爲 c 的揹包裏最大的收益價值爲多少。
首先我們只考慮前 i 個物品放入容量爲 c 的揹包裏收益價值可以有多少,它可以分爲兩種情況:
- 把物品 i 放入揹包中,則價值爲
- 不把物品 i 放入揹包中,則價值爲
而 應當取這兩者的較大值。
不過我們上面的考慮有一個疏漏,沒有考慮到物品 i 放不進去的情況,在這種情況下 。
再考慮初始狀況,則有:
前一個表示不把任何物品放入揹包,後一個表示揹包容量爲 0。顯然兩種情況下,最大的價值都只能爲 0。
這樣一來,我們就有了完整的目標函數的定義:
代碼實現
有了以上的分析,我們可以得出基本的算法框架。我們一個一個地考察物品,每次都嘗試着把一個新的物品放入到揹包中,看會發生什麼,直到所有的 N 的物品全部考察完畢爲止。
F[0, 0..C] = 0
for i = 1 to N
for c = Ci, i = 1 to C
if i 可以放入容量爲 c 的揹包
F[i, c] = max {F[i − 1, c], F[i − 1, c − Wi] + Vi}
else
F[i, c] = F[i - 1, c]
end
end
end
具體實現如下:
const int items = 10;
const int capacity = 20;
int weight[items + 1];
int value[items + 1];
bool used[items + 1];
//dp[i][j]: 前 i 件物品放入容量爲 j 的揹包裏產生的最大價值
int dp[items + 1][capacity + 1];
void Knapsack()
{
//放入容量爲 0 的揹包裏,價值爲 0
for (int i = 0; i <= items; ++i)
dp[i][0] = 0;
//沒有東西放入,價值爲 0
for (int c = 0; c <= capacity; ++c)
dp[0][c] = 0;
//DP
int v_i, w_i;
for (int i = 1; i <= items; ++i)
{
for (int c = 1; c <= capacity; ++c)
{
//第 i 件物品的價值和重量 (1 <= i <= items)
v_i = value[i];
w_i = weight[i];
//如果剩餘的容量比 w_i 還小,那物品 i 肯定放不進來
if (c < w_i)
{
dp[i][c] = dp[i - 1][c];
}
//否則,就有兩種選擇: 放入或者不放入
else
{
//方案一: 不把物品 i 放入揹包
//則獲得的價值和把前 i - 1 個物品放入容量爲 c 的揹包中相同
int reward1 = dp[i - 1][c];
//方案二: 把物品 i 放入揹包
int reward2 = dp[i - 1][c - w_i] + v_i;
//取價值更大的一種方案
dp[i][c] = max(reward1, reward2);
}
}
}
}
int main()
{
for (int i = 1; i <= items; ++i)
{
cout << "Item " << i << ": ";
cin >> weight[i] >> value[i];
}
Knapsack();
cout << dp[items][capacity] << endl;
return 0;
}
查看使用了哪些物品
通過 dp
數組可以反向查看哪些物品放入了揹包。思路很簡單,從後往前遍歷 dp
,如果 dp[i][c] > dp[i - 1][c]
,說明物品 i
肯定放入揹包中了。
void UseWhich()
{
//首先從 dp[items][capacity] 查起
int c = capacity;
for (int i = items; i >= 1; --i)
{
//看看是不是用了
if (dp[i][c] > dp[i - 1][c])
{
used[i] = true;
c = c - weight[i];
}
else
used[i] = false;
}
int v = 0;
c = capacity;
for (int i = 1; i <= items; ++i)
{
if (used[i])
{
v += value[i];
c -= weight[i];
cout << "Item " << i << " used! ";
cout << "Value: " << value[i];
cout << ", Weight: " << weight[i];
cout << ", TotalValue: " << v;
cout << ", LeftCapacity: " << c << endl;
}
}
}
2. 優化空間複雜度
以上方法的時間和空間複雜度均爲 ,其中時間複雜度應該已經不能再優化了,但空間複雜度卻可以優化到 。
我們來仔細看看用到的狀態轉移式子:
F[i, c] = max {F[i − 1, c], F[i − 1, c − Wi] + Vi}
F[i, c] = F[i - 1, c]
把算法抽象一下,其實是這樣的:
for i = 1 to N
for c = c1 to cn
F[i, c] = F[i - 1, xxx]
也就是說要填充 F[i, c]
的內容,需要用的兩部分數據:
- 同一行的某個數據
- 上一行正對應的數據
稍加思考我們發現這個迭代式子完全可以改成這樣:
for i = 1 to N
for c = c1 to cn
F[c] <- F[xxx]
爲啥?因爲前面的 i - 1
行數據壓根就用不到啊!既然用不到,那幹嘛不乾脆扔了算了呢?
這樣的思路,叫做“滾動數組”,這在動規中是一個很常用的縮減空間開銷的法子。用了滾動數組後,原本的 dp[items][capacity]
就變成了 dp[capacity]
。在整個外層循環期間,我們可以把 dp[capacity]
形象地想象成一個在 dp[items][capacity]
上不斷往下滾動的窗口,透過窗口,我們看到的就是當前行的 dp[items][capacity]
的數據。由於只保留了當前的數據,以前的數據全部丟棄,所以大大減少了空間消耗。
特別需要注意的是,實現的時候內層循環要從後往前來進行。
這是因爲從 F[i, c] = max {F[i − 1, c], F[i − 1, c − Wi] + Vi}
可以看出,F[i, c]
要用到老數據中考前部分的數據,如果從前往後更新,就會造成信息的丟失。
int dp[MAX_CAPACITY + 1];
void Knapsack()
{
//在空間爲 0 的揹包內放入
dp[0] = 0;
for (int i = 1; i <= items; ++i)
{
for (int c = capacity; c >= 1; --c)
{
//只有在能放入物品的時候才做一下更新
if (c >= weight[i])
{
//不放入
int reward1 = dp[c];
//放入
int reward2 = dp[c - weight[i]] + value[i];
dp[c] = max(reward1, reward2);
}
}
}
}
3. 初始化條件
我們看到的求最優解的揹包問題題目中,事實上有兩種不太相同的問法。
有的題目要求“恰好裝滿揹包”時的最優解,有的題目則並沒有要求必須把揹包裝滿。一種區別這兩種問法的實現方法是在初始化的時候有所不同。
如果是第一種問法,要求恰好裝滿揹包,那麼在初始化時除了 爲 0 ,其
它 均設爲 -∞ ,這樣就可以保證最終得到的 是一種恰好裝滿揹包的最優解。
如果並沒有要求必須把揹包裝滿,而是隻希望價格儘量大,初始化時應該
將 全部設爲 0。
可以這樣理解:初始化的 數組事實上就是在沒有任何物品可以放入揹包時的合法狀態。
如果要求揹包恰好裝滿,那麼此時只有容量爲 0 的揹包可以在什麼也不裝且價值爲 0 的情況下被“恰好裝滿”,其它容量的
揹包均沒有合法的解,屬於未定義的狀態,應該被賦值爲 -∞ 了。
如果揹包並非必須被裝滿,那麼任何容量的揹包都有一個合法解“什麼都不裝”,這個解的
價值爲 0,所以初始時狀態的值也就全部爲 0 了。
參考資料
- 《揹包九講》
- 《算法設計與分析》