给定
N
N
N 个物品,所有物品的体积恰有
K
K
K 种
(
K
≤
N
)
(K \leq N)
(K≤N),第
i
i
i 种物品的体积为
V
i
(
0
<
V
i
<
1
)
V_i (0 < V_i < 1)
Vi(0<Vi<1) 且第
i
i
i 种物品的个数为
N
i
(
1
≤
i
≤
K
)
N_i(1 \leq i \leq K)
Ni(1≤i≤K),其中
N
1
+
N
2
+
⋯
+
N
k
=
N
N_1 + N_2 + \dots + N_k = N
N1+N2+⋯+Nk=N。现有无穷多个体积为 1 的箱子,要将这
N
N
N 个物品装箱。试设计动态规划算法输出一个装箱方案,使得所用的箱子数最少。
给定N个物品的背包问题
一个有趣的反向背包问题,题目描述如上。
普通的0-1背包问题是 背包容量固定,尽可能放入更多的物品;
而这个题目则恰好相反,物品数量固定,尽可能少用背包(箱子)。
这里给出了思路分析和递归代码实现。
动态规划思路
显然可以用动态规划解决这个问题(题目也是如此要求),但是这个题目的动态规划过程要比普通的0-1背包问题复杂很多。
既然要用动态规划,核心问题仍然是找到最优子结构和重叠子问题,然后写出状态转移方程。
首先做如下设定:
- 假设已经将前 i − 1 i - 1 i−1 个物品放入了一些箱子中,这些目前已占用的箱子的集合为 S i S_i Si (并不一定是最优的放置方式),共为 n i n_i ni 个箱子。
- 在此基础上,将从第 i i i 个物品到第 N N N 个物品(也就是后面的 N − i + 1 N - i + 1 N−i+1 个物品)放入箱子中,最后共需占用的箱子数的最小值为 m i m_i mi 。
- 将第 i i i 个物品放入 S i S_i Si 中的第 k k k 个箱子表示为 S i [ k ] ← i S_i[k] \leftarrow i Si[k]←i ,其中 k = − 1 , 0 , 1 , … , n i k = -1, 0, 1, \dots, n_i k=−1,0,1,…,ni( k = − 1 k = -1 k=−1 表示不将 i i i 放入任何已有的箱子,而是将其放入一个新的箱子中)。
基于以上设定,子问题就可以定义为 m i m_i mi ,而 m 0 m_0 m0 就是整个问题的最终答案。
由此,可以写出状态转移方程:
m
i
=
min
{
当
S
i
[
k
]
←
i
时
的
m
i
+
1
∣
k
=
−
1
,
0
,
1
,
…
,
n
i
}
m_i = \min \{ 当 S_i[k] \leftarrow i 时的 m_{i + 1} \ \ | \ \ k = -1, 0, 1, \dots, n_i \}
mi=min{当Si[k]←i时的mi+1 ∣ k=−1,0,1,…,ni}
该状态转移方程的意义是,把当前的物品
i
i
i 放入第
k
k
k 个箱子,然后计算后面
N
−
i
N - i
N−i 个物品放入箱子后所得到的最优解(最小的共需箱子数)
m
i
+
1
m_{i+ 1}
mi+1 。于是,
k
k
k 取某个值时,能够使得
m
i
+
1
m_{i+ 1}
mi+1 最小,而
m
i
m_i
mi 即等于这个最小的
m
i
+
1
m_{i+ 1}
mi+1 值。
Java代码实现
基于上面的子问题定义和状态转移方程,即可写出递归实现的代码。
需要考虑使用什么数据结构来实现目前已占用的箱子的集合为
S
i
S_i
Si ,这里使用数组实现,不仅节省内存空间,使用起来也较为方便。
完整的Java代码如下:
public class Main {
private double[] boxSet; //目前已占用的箱子的集合,用数组实现
private int currentBoxNum = 0; //目前已占用的箱子的数量(实际上是boxSet中最后一个不为空的箱子的下标,需加 1 才表示真正的已占用箱子数量)
public static void main(String[] args) {
double[] items = new double[]{0.6, 0.5, 0.3, 0.2, 0.2, 0.2};
Main main = new Main();
System.out.println("需要的最小箱子数:" + main.minBoxesInNeed(items));
}
/**
* 计算将 items 中的物品全部放入体积为 1 的箱子中所需的最小箱子数量
*
* @param items 所有物品的体积的列表,如 {0.6, 0.5, 0.3, 0.2, 0.2, 0.2}
* @return 所有物品全部放入箱子中所需的最小的箱子数
*/
public int minBoxesInNeed(double[] items) {
boxSet = new double[items.length];
return minBoxesInNeedFromI(items, 0);
}
/**
* 计算在已经占用了 currentBoxNum 个箱子的情况下,将 items 中从第 i 个物品开始,到最后一个物品,全部放入箱子中,共需要的箱子的最小数量
*
* @param items 所有物品的体积的列表,如 {0.6, 0.5, 0.3, 0.2, 0.2, 0.2}
* @param i 第 i 个物品的下标
* @return 在已经占用了 currentBoxNum 个箱子的情况下,把从第 i 个物品开始,到最后一个物品,全部放入箱子中,最后共需要的箱子的最小数量
*/
private int minBoxesInNeedFromI(double[] items, int i) {
int minNum = Integer.MAX_VALUE; //把从 i 开始,后面所有物品放入箱子中,所需要的最小的箱子数量
for (int k = currentBoxNum; k >= -1; k--) { //从大到小遍历,防止遍历 b = -1 时 currentBoxNum 的加一操作导致遍历次数多一轮
boolean putSuccess = putIntoBox(k, items[i]);
if (putSuccess) {
int boxesInNeedAfterI; //把从 i + 1 开始,后面所有物品放入箱子中,需要的箱子数量
if (i == items.length - 1) { //若已经是最后一个物品,则返回目前使用的箱子数,也即是共使用的箱子的总数
boxesInNeedAfterI = currentBoxNum + 1;
deleteFromBox(k, items[i]); //把当前物品从箱子中取出,防止影响后续的递归
return boxesInNeedAfterI;
}
boxesInNeedAfterI = minBoxesInNeedFromI(items, i + 1); //递归
if (minNum > boxesInNeedAfterI) minNum = boxesInNeedAfterI;
deleteFromBox(k, items[i]); //把当前物品从箱子中取出,防止影响后续的递归
}
}
return minNum;
}
/**
* 将一个体积为 volume 的物品放入下标为 boxIndex 的箱子中,若 boxIndex = -1 则放入一个新箱子中
*
* @param boxIndex 当前物品要放入的箱子的下标,若为 -1 则放入一个新的箱子中,并将 currentBoxNum 加一
* @param volume 要放入的物品的体积
* @return 是否成功将物品放入了第 boxIndex 个箱子中
*/
private boolean putIntoBox(int boxIndex, double volume) {
if (boxIndex == -1) {
currentBoxNum++;
boxSet[currentBoxNum] += volume;
return true;
} else {
if (boxSet[boxIndex] + volume <= 1) {
boxSet[boxIndex] = boxSet[boxIndex] + volume;
return true;
} else {
return false;
}
}
}
/**
* 从下标为 boxIndex 的箱子中删除体积为 volume 的物品,若 boxIndex = -1 则删除最后一个箱子中的物品
*
* @param boxIndex 当前物品要删除物品的箱子的下标,若为 -1 则删除最后一个箱子中的物品,并将 currentBoxNum 加一
* @param volume 要删除的物品的体积
*/
private void deleteFromBox(int boxIndex, double volume) {
if (boxIndex == -1) {
boxSet[currentBoxNum] -= volume;
currentBoxNum--;
} else {
boxSet[boxIndex] -= volume;
}
}
}
这里只进行了递归实现,若能改为循环实现可以获得更好的鲁棒性,但是由于目前已占用的箱子的集合为 S i S_i Si 的存在,目前未想出循环实现的方法。