目录
装载问题:有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...