目錄
裝載問題:有n個集裝箱要裝上 2 艘載重量分別爲c1和c2的輪船,其中集裝箱i的重量爲wi,且∑wi <= c1 + c2。
問是否有一個合理的裝載方案,可將這n個集裝箱裝上這2艘輪船。如果有,找出一種裝載方案。
題目分析:其實就可以理解爲,先裝第一艘船,再裝第二艘船,是否可以將貨物全部裝上,並給出解決方案。
主要待考慮的就是如何去裝第一艘船?這個問題解決了後,剩下的都放入第二艘船即可。
1、與最優裝載問題的對比
首先我們先來看看另一個相似的問題:
最優裝載問題: 有一批集裝箱要裝上一艘載重量爲c的輪船。其中集裝箱i的重量爲Wi。最優裝載問題要求確定在裝載體積不受限制的情況下,將儘可能多的集裝箱裝上輪船。
很明顯,最優裝載問題可以貪心求解。貪心選擇:每次選擇重量最小的集裝箱上船,直到放不下爲止。
但是,裝載問題是不可以像上面一樣貪心的! 假如我們讓第一艘船儘量裝下更多的集裝箱而使用上述的貪心選擇策略,那麼很容易對第一艘船造成空間浪費,從而結果不是最優的。可以看看如下反例:
2、第一艘船的貨物應該如何選擇
既然上面已經說明第一艘船是不能貪心的,會造成空間浪費從而導致結果不是最優的!那麼第一艘船的貨物應該如何選擇呢?
不是應該使得第一艘船裝的貨物數量越多越好,而是應該考慮在載重範圍內,第一艘船裝的貨物重量越大越好。即應該儘可能地裝滿第一艘船,剩餘的貨物全都交給第二艘船。
那麼第一艘船的實現過程其實就是一個 揹包DP | 01揹包問題 :每個貨物只有上船或不上船兩種選擇,在揹包大小爲 c1 的條件下,選擇價值和重量均爲 wi 的物品,使得在容量範圍內儘可能價值最大!
除了動態規劃,其實還有另外一種方法,就是回溯算法。下面詳細講解!
3、選擇樹的回溯算法
先看看暴力枚舉解決:對於 n 件物品,我們將 n 位的二進制數全部列舉出來,每一位對應次號集裝箱是否上船,即包括了所有方式的枚舉。在一一枚舉的同時記錄下最能裝滿船的選擇!(以 cw 記錄在該種選擇下的總重量,bestw 記錄在裝載範圍內最大的cw)
【以 n = 3 爲例】 枚舉的順序可以選擇:
- 字典序:000,001,010,011,100,101,110,111
- 逆子典序:與字典序相反
- 格雷碼序:000,001,011,010,110,111,101,100(減小 cw 的計算量)
暴力枚舉算法的缺點很明顯:遍歷了很多沒有必要的選擇,但是又不好剪枝。
回溯算法可以說是暴力枚舉的合理化實現。首先將我們所有的枚舉方案畫成一棵選擇決策樹(如下圖,樹葉部分就是總的決策),每個決策對應樹的一條邊,樹的節點是選擇的結果。然後回溯算法本質就是深搜這棵樹~
回溯算法的具體過程如下圖,其實就是簡單的 dfs 啦~
- 初始化:cw = bestW = 0
- 調用:backstrack(1)
可以發現,其實代碼中是沒有體現樹這個結構的。但是我們的決策本質就是可以用樹來體現,所以整個代碼的遍歷就是對樹的深搜。回溯算法很神奇吧!下面是一個例子,可以理解一下整個的回溯過程。條件:W[16,15,15], c = 30。
此回溯算法的代碼實現:
int cw; //當前重量
int bestW; //最優重量
int c; //船的最大承載量
int n; //貨物的數量
int w[100]; //對應 n 個集裝箱的重量
/* 儘量裝滿第一艘船的回溯算法
* step:層數 */
void backtrack(int step) {
/* 到達了樹葉 */
if(step > n) {
if(bestW < cw && cw <= c)
bestW = cw;
return;
}
cw += w[step];
backtrack(step + 1);
cw -= w[step];
backtrack(step + 1);
}
4、剪枝操作 —— 回溯算法的優化
上面的回溯算法是沒有經過剪枝操作的,其實整個複雜度和暴力枚舉是沒有區別的。下面我們對回溯算法進行剪枝優化。
剪枝操作1:對於不符合我們最終的約束條件的子樹,跳過遍歷。【約束條件】
剪枝操作2:對於找不到最優解的子樹,跳過遍歷。【限界條件】
(下面的僞代碼同時加入了 x 數組來記錄具體的選擇,在更新最優值的同時維護最優解)
通常,限界條件是需要我們自己去構造的,如本例就引入了 r 來記錄 。
剪枝操作3:提前更新最優值。
不再只在葉子節點上更新最優值,在其他每次節點裏都進行更新,最後到了葉子節點上直接返回即可。這樣可以讓限界條件的剪枝範圍更廣。
有剪枝操作的回溯算法的代碼實現:
coding...