1 引言
1.1 背景
在許多算法中都有子程序重複計算的問題。在 Fibi 計算中採用的存儲前面幾個結果數值的方法並不是很通用。這樣, 在很多情況下存儲中間結果全列表的方法就非常有用了。
這種存儲子程序結果列表的算法設計方法就稱爲動態規劃(dynamic programming)。
——《數據結構與算法分析(C++版)(第三版)》
Fibi 指的是書中使用循環實現的斐波那契數列代碼,如下:
long Fibi(int n){
// Fibi(46) is largest value that fits in a long
Assert((n > 0) && (n < 47), "Input out of range");
long past, prev, curr;
past = prev = curr = 1;
for(int i = 3; i <= n; i++){
past = prev; // past holds Fibi(n-2)
prev = curr; // prev holds Fibi(n-1)
curr = past + prev; // curr holds Fibi(n)
}
return curr;
}
2.2 動態規劃問題的性質
任何思想方法都有一定的侷限性,超出了特定條件,它就失去了作用。同樣,動態規劃也並不是萬能的。適用動態規劃的問題必須滿足最優化原理和無後效性。
—— 百度百科詞條“動態規劃”
動態規劃解決的是優化問題,企圖通過將問題劃分成一個個子問題進而求取最優解,所以要求子問題的解是必須是最優的。同時,由於子問題一般帶有重疊性,也就是帶有重複計算的特點,所以我們希望保存中間結果來消除冗餘計算。本質上,動態優化是一種空間換取時間的算法。
使用暴力解法求解優化問題的過程中,我們需要遍歷問題的狀態空間,開銷很大;使用進化算法,如遺傳算法時,種羣的進化帶有隨機性,儘管理論上我們最終會獲得最優解,但是解決諸如陷入局部最優、調參等問題時,我們耗費的精力也是很可觀的。
2 揹包問題
揹包問題(Knapsack problem)是一種組合優化的NP完全問題。問題可以描述爲:給定一組物品,每種物品都有自己的重量和價格,在限定的總重量內,我們如何選擇,才能使得物品的總價格最高。
也可以將揹包問題描述爲決定性問題,即在總重量不超過W的前提下,總價值是否能達到V?它是在1978年由Merkel和Hellman提出的。
——百度百科詞條“揹包問題”
2.1 01揹包
01揹包問題中每種物品只有1個,物品只有兩種狀態,要麼在揹包內,要麼不在揹包內。
先給出狀態轉移方程,之後進行分析:
其中, 表示第 個物體, 表示揹包的容量,表示第 個物體的體積, 表示第 個物體的價值。
表示對於容量爲 的揹包來說,前 件物體放置可以得到的最大價值。(重要)
2.1.1 分析
正如我們在引言中提到動態規劃的概念,在搜索空間中搜索最優解的過程中,我們將問題劃分成一個個子問題,我們會不斷地嘗試、計算、回退,重複計算的開銷很大,所以我們需要保存中間結果,這就是我們使用矩陣 的原因。
那麼怎麼決定每一個子問題最優解 的取值呢?狀態轉移方程是怎麼得到的呢?
舉個例子,求容量爲 10 的揹包的最佳放置方案。
假如我們已經決定了前 件物品的最優方案,得到了 的取值,這個值可能是 100(隨便假設的),接下來我們要考慮第 件物品了,第 件物品的價值是 2(也是隨便假設的)。
若第 個物體在揹包裏, 的取值肯定不小於 ,爲什麼?因爲如果將第 件物品放進入揹包,但取值反而減小了,說明有一些物品爲了給物品 騰位置被拿出來了。這纔會導致取值不增反減,也會使得我們的假設不成立。
所以,我們需要考察放入物品 後的取值能否大於 ,否則就沒有更新的必要了。
我們希望放入物品 後的方案是最優的,不妨這樣考慮:第 個物體我們已經假設在揹包裏了,那麼我們留下的空間只有 ,若能找到這 的空間的最優值,在這個最優值的基礎上加上物品 的價值,不就是我們想要考察的 的最優取值嗎?(當然,是不是最優還要和 比較一番~)
而我們的矩陣正是保存着這樣的最優值,注意到這 的空間只能留給前 個物體,也就是說我們實際上考察的是 的取值能不能大於 。
假如我們運氣好,找到的 爲99,那麼毫無疑問, 可以更新爲 了。
將我們的例子泛化爲一般形式就能得到狀態轉移方程了。
2.1.2 解決方案
假設 n 表示物體個數,v 表示揹包容量。
常見的解決方案有兩種,一種就是使用遞歸,狀態轉移方程實際上也是遞歸的遞推公式,代碼如下:
private static int[] value; // 價值數組
private static int[] volume; // 體積數組
private static int KnapsackCore(int n, int v) {
if (n < 0 || v < volume[n]) return 0;
int temp1 = KnapsackCore(n - 1, v);
int temp2 = KnapsackCore(n - 1, v - volume[n]) + value[n];
return temp1 >= temp2 ? temp1 : temp2;
}
第二種就是使用中間矩陣 自底向上求取最優解。
具體怎樣更新矩陣,下面以體積分別爲 (1,2,3,4,5),價值分別爲 (5,4,3,2,1) 的五個物品爲例,假設揹包的容量爲 10。根據狀態轉移方程有下表:
從左到右,從上到下更新每一行即可,其中的藍色箭頭表示更新數值的計算對象,比如, 。
注意邊界條件,超出邊界的數值直接取0。比如求 時,,很顯然 已經超出範圍了,直接令 ,所以 。迭代結束後,右下角就是最終的答案。
爲什麼使用 列,而不是使用 列呢?
首先 是可以取到0的,我們允許輸入爲0,其次,便於代碼直接使用數組的列標作爲容量進行計算。
爲什麼使用 行,而不使用 行?
很多文章中解決的問題的矩陣大小是 ,其實使用 還是 都可以。使用 行的矩陣就要求我們初始化的時候將第 0 行全部初始化爲 0 ,然後從第 1 行開始更新,而使用 行矩陣就需要注意邊界條件,主要是第 0 行的更新需要做好判斷。
2.1.3 代碼
private static int Knapsack(int v, int[] value, int[] volume) throws Exception{
int n = volume.length;
if (n != value.length) // 注意代碼的魯棒性,面試時崩潰就涼了
throw new Exception("Bad input!");
if (n <= 0 || v <= 0) return 0;
int P[][] = new int[n][v + 1]; // 構建n*(v+1)矩陣
for (int i = 0; i < n; i++) {
for (int j = 0; j < v + 1; j++) {
int temp1 = i > 0 ? P[i - 1][j]:0; // 檢查數組越界
int temp2 = i > 0 && j >= volume[i] ? // 檢查數組越界
P[i - 1][j - volume[i]] : 0;
if(j >= volume[i]) temp2 += value[i]; // j能放下物品i
P[i][j] = temp1 >= temp2 ? temp1 : temp2;
}
}
show(n, v + 1, value, volume, P);
return P[n-1][v];
}
// 格式化輸出
private static void show(int v, int[] value, int[] volume, int[][] P) throws Exception {
int n = volume.length;
if (n != value.length)
throw new Exception("Bad input!");
if (n <= 0 || v <= 0) return 0;
System.out.print("Volume Value ");
for (int i = 0; i < v; i++) {
System.out.printf("%3d", i);
}
System.out.println();
for (int i = 0; i < n; i++) {
System.out.printf("%4d %4d %4d", volume[i], value[i], i);
for (int j = 0; j < v; j++) {
System.out.printf("%3d", P[i][j]);
}
System.out.println();
}
}
// 主函數
public static void main(String[] args) {
int[] va = {5, 4, 3, 2, 1};
int[] vo = {1, 2, 3, 4, 5};
try {
System.out.println("返回值:" + Knapsack(10, va, vo));
} catch (Exception e){
e.printStackTrace();
}
}
結果:
2.1.4 改進
觀察發現,下一行的更新完全依賴於上一行,所以我們實際上可以使用一個 2*(v+1) 的矩陣改進這個算法,矩陣的兩行數值交替更新。代碼如下:
private static int Knapsack(int n, int v, , int[] value, int[] volume) throws Exception {
int n = volume.length;
if (n != value.length)
throw new Exception("Bad input!");
if (n <= 0 || v <= 0) return 0;
int P[][] = new int[2][v + 1];
int flag = 0;
for (int i = 0; i < n; i++) {
for (int j = 0; j < v + 1; j++) {
int temp1 = i > 0 ? P[1 - flag][j] : 0; // i-1換成1-flag
int temp2 = i > 0 && j >= volume[i] ?
P[1 - flag][j - volume[i]] : 0; // i-1換成1-flag
if (j >= volume[i]) temp2 += value[i];
P[flag][j] = temp1 >= temp2 ? temp1 : temp2; // i換成flag
}
flag = 1 - flag;
}
show(2, v + 1, va, vo, P);
return P[1 - flag][v];
}
結果:
2.1.5 進一步改進
問題還可以進一步簡化,繼續觀察發現,每一次更新的元素的列標都在上一行讀取的元素之後,剛好是錯開的,所以使用一個一維數組也能解決,如下圖:
實際上,狀態轉移方程已經變成了:
特別注意我們需要從數組的末端開始更新,這樣才能準確讀取到上一行的值。如果從前往後更新,後半部分元素讀取的計算對象實際上已經被覆蓋。代碼如下:
private static int Knapsack(int v, int[] value, int[] volume) throws Exception {
int n = volume.length;
if (n != value.length)
throw new Exception("Bad input!");
if (n <= 0 || v <= 0) return 0;
int P[] = new int[v + 1]; // v+1 一維數組
for (int i = 0; i < n; i++) {
for (int j = v; j >= 0; j--) { // 從v往前更新
int temp1 = P [j];
int temp2 = j >= volume[i] ? // 注意數組越界,同時判斷j能否放下物品
P[j - volume[i]] + value[i] : 0;
P[j] = temp1 >= temp2 ? temp1 : temp2;
}
}
return P[v];
}
結果:
2.1.6 其他測試用例
value = {6,3,5,4,6};
volume = {2,2,6,5,4};
n = 5, v =10;
結果:
2.2 完全揹包和多重揹包
完全揹包問題中物品的數量是無限的,而多重揹包中的物品數量是有限的。
注意到完全揹包問題的物品數量雖然是無限的,但是由於揹包的容量是有限的,實際上每種物品可以取的數量都是有限的,不多於 ,所以兩者在一定程度上可以相互轉化。假如多重揹包中的每一種物品的數量都超過 ,實際上就是完全揹包問題。
下面將兩個問題統一解決。
2.2.1 分析
我們使用 表示每種物品可以取的最大數量。對於多重揹包問題,這個數量來自用戶的輸入,對於完全揹包問題,需要我們通過 進行初始化。
和 01 揹包問題相比,每一次決定的是不再是一個物體,而是 個物體的狀態。所以,狀態轉移方程改寫爲:
其中,。
和 01 揹包一樣,對方程進行優化,使用一維數組解決,方程改寫爲:
2.2.2 代碼
private static int Knapsack(int v, int[] value, int[] volume, int[] num) throws Exception {
int n = volume.length;
if (n != value.length ||(num!=null && n != num.length))
throw new Exception("Bad input");
if (n <= 0 || v <= 0) return 0;
if (num == null) { // 沒有傳入數量限制,默認爲完全揹包問題
num = new int[n];
for (int i = 0; i < n; i++) {
num[i] = v / volume[i];
}
}
int P[] = new int[v + 1];
for (int i = 0; i < n; i++) {
for (int j = v; j >= 0; j--) {
for (int k = 0; k <= num[i]; k++) {
int temp1 = P[j];
int temp2 = j >= k * volume[i] ?
P[j - k * volume[i]] + k * value[i] : 0;
P[j] = temp1 >= temp2 ? temp1 : temp2;
}
}
}
return P[v];
}
2.2.3 測試
- 完全揹包測試用例:
結果:value = {9,5,3,1}; volume = {7,4,3,2}; num = null; n = 4, v =10;
- 多重揹包測試用例:
結果:value = {20, 10, 6}; volume = {2, 2, 1}; num = {2, 5, 10}; n = 3, v =8;
2.3 揹包問題的其他優化
- 對完全揹包問題輸入用例的優化。假如存在物體a,b,其中 a 的體積大於 b,但是 a 的價值低於 b,那麼可以直接捨去 a。因爲物體是無限的,選擇“性價比”高的物體肯定更優。
- 雖然不能整體上對時間開銷進行優化,但由於我們的循環次數較多,而且每一層循環都是完全循環,可以從這裏入手,進行剪枝操作。
- 規範用戶輸入,減少無用功。比如刪除體積大於揹包容量的物體、檢查 有沒有超出揹包的最大容量等。
3 - 其他形式的揹包問題
假定有一個揹包,揹包中有一定的空間,空間的容積用一個整數 來定義。有 件物品,每一件物品都有一定的體積,第 件物品的體積記爲整數 。揹包問題是,是否存在着 件物品的一個子集,這個子集中的物品的體積之和正好爲 。
可以正式定義揹包問題如下:找出 ,使得
——《數據結構與算法分析(C++版)(第三版)》第16章 算法模式
這個揹包問題和上面的揹包問題有什麼區別嗎?
這個揹包問題實際上已經不是一個優化問題,因爲沒有優化目標。但仍然可以應用動態規劃的思想來處理,我們希望通過劃分子問題、自底向上解決這個問題。
使用 來表示前 個物體能否解決容量爲 的問題。由於本題考察的是存不存在這麼一個子集滿足條件,而不是最大價值,所以我們需要思考一下矩陣存的中間結果是什麼?
3.1 第一種方法
矩陣保存一個boolean,若子問題解決,則該值爲 true,反之爲 false。
比如,有體積分別爲 的 4 個物體,更新矩陣時, 爲 false,因爲只放第一個物體不能達到容量 4,前 1 個物體不能解決問題; 爲 true,前 2 個物體都放進去剛好爲 4; 爲 true,雖然達到容量 4 並不需要放入第 3 個物體,但前 3 個物品能夠解決問題,故應該爲 true;同理, 也爲 true,儘管第 4 個物品同樣沒用。
如下表,注意我們特別指定容積爲 0 的時候恆存在滿足條件的子集——空集。
物品體積 | 序號/容積 | 0 | 1 | 2 | 3 | 4 |
---|---|---|---|---|---|---|
1 | 0 | true | true | false | false | false |
3 | 1 | true | true | false | true | true |
2 | 2 | true | true | true | true | true |
5 | 3 | true | true | true | true | true |
按照我們的思路,似乎從上往下,再從左往右更新比較好。但是矩陣的更新方向其實影響不大,無非是 作爲外層循環還是 作爲外層循環的問題。
和 01 揹包問題一樣,我們可以對這個算法進行優化,使用一維矩陣 解決問題。考慮從一維矩陣的右邊,也就是末端向前更新,對於 ,有:
- 假如 爲真,說明容量爲 的子問題已經被前面 個物體解決了,不需要更新。
- 假如 爲假,說明前面 個物體不能解決子問題,考察使用物體 能否解決問題?若物體 的體積等於 或者 爲真,則 爲真,反之,不需要更新。
代碼如下:
private static boolean Knapsack(int v, int[] volume) {
int n = volume.length;
if (n == 0 || v < 0) return false;
boolean P[] = new boolean[v + 1];
P[0] = true; // 容量0恆有解
for (int i = 0; i < n; i++) {
for (int j = v; j >= 0; j--) {
if (!P[j] && j >= volume[i]) { // 注意數組越界,越界表示不存在子問題
if (j == volume[i] || P[j - volume[i]])
P[j] = true;
}
}
}
return P[v];
}
測試用例:
volume = {9, 2, 7, 4, 6};
v = 10;
結果:
3.2 第二種方法
第一種方法只記錄了結果存不存在,但不能得到結果具體放入了哪些物體,無論是二維數組還是一維數組都不能。
所以,我們希望矩陣除了記錄前 個物體能否解決問題外,還希望矩陣記錄每個物體對於解決這個子問題是否有幫助,以便可以找到子集的具體元素。
所以, 實際上對應了三種狀態:
- 前 個物體沒有解決問題 。
- 前 個物體解決了問題 ,但子集不需要物體 。
- 前 個物體解決了問題 ,子集包括物體 。
據此,我們使用一些可標記三種狀態的數據類型即可,比如字符數組、整型數組等。代碼如下,使用了整型數組,其中 0,-1,1 分別對應三種狀態:
private static void Knapsack(int v, int[] volume) {
int n = volume.length;
if (n == 0) return;
int P[][] = new int[n][v + 1];
// 默認容量0恆有解
for (int i = 0; i < n; i++) {
P[i][0] = -1;
}
for (int i = 0; i < n; i++) {
for (int j = 1; j < v + 1; j++) {
// 先判斷前i-1個物體有沒有解決問題
// 注意,取值爲1或者-1都代表問題被解決
if (i > 0 && (P[i - 1][j] == -1 || P[i - 1][j] == 1)) {
P[i][j] = -1;
}
// 再判斷物體i能否解決問題
if (j == volume[i]) {
P[i][j] = 1;
} else if (i > 0 && j >= volume[i]) {
if (P[i - 1][j - volume[i]] == -1 || P[i - 1][j - volume[i]] == 1) {
P[i][j] = 1;
}
}
}
}
}
測試用例:
volume = {9, 2, 7, 4, 1};
v = 10;
結果:
怎麼獲得子集中的元素?
觀察 有解,且子集包含物體 4。但物體 4 體積只有 1,必然需要依賴子問題 9;物體只能放一次,故指針往上走,觀察 有解,但是物體 3 不在子集內,指針繼續往上走一步,觀察到 有解,且包含物體 2,但體積仍不夠,還要依賴子問題 2,指針繼續走到,物體 1 也被包含到子集中。終於,總體積已經達到了 10,所以子集爲。
有些子問題有兩個 1,代表什麼意思?
這代表子集的形式不止一種。對於容量 10 的問題來說,子集就有 和 。
4 - 參考
- 揹包問題詳解:01揹包、完全揹包、多重揹包
- 揹包問題詳解
- 百度百科詞條“揹包問題”
- 百度百科詞條“動態規劃”
- 《數據結構與算法分析(C++版)(第三版)》
正文結束。
部分代碼多次修改,如有校對錯誤,請留言指出。