0-1 背包问题的动态规划实现(Java)

发表于2019-10-10,长度2985, 338个单词, 8分钟读完
Flag Counter

01背包是最经典的动态规划问题。假设有一堆化石,因为是化石所以价值和重量无关。你有一个背包,承重有限。从化石中挑选总重量不超过背包能力、总价值又最大的问题就是01背包问题。常见算法有贪心算法和动态规划算法(DP算法)。本文基于我github上的一个老项目:https://github.com/davelet/dp-just-code-no-word

01的叫法是因为不能把化石打碎,要么整个拿要么不拿

问题初始化

假设背包称重最大9个单位。你面前有4个化石,重量分别是2、3、4、5,价值分别是3,4,5,6。问题的描述位于文件DpSourceData.java中:

    static final int[] volume = {0, 2, 3, 4, 5};// 4个物品的占用
    static final int[] values = {0, 3, 4, 5, 6};// 4个物品的价值
    static final int maxVolume = 9; // 最大体积 可以用其他值尝试 比如8

数组使用0前缀是因为一方面物品序号就是数组序号,另一方面dp的二维数组也是有围栏的。

二维数组实现

动态规划最常用的解放是通过二维数组记录,也就是一个表格。这个表的宽度是背包容量+1,高度是物品数量+1。

因为动态规划的过程是基于子问题的,所以一行一行的走(走到当前行就看一眼上一行)。 表格有围栏,所以用0围起来:

int length = DpSourceData.volume.length;
int width = DpSourceData.maxVolume + 1;
Integer[][] table = new Integer[length][width];
for (int i = 0; i < length; i++) {
        for (int j = 0; j < width; j++) {
                if (i == 0) { // 第一行全是0
                        table[i][j] = 0;
                } else if (j == 0) {  // 第一列也是0
                        table[i][j] = 0;
                } else { // 从第二行第二列开始
                        ...
                }
        }
}

围栏的目的是当一个东西都不选也是一个子问题。

从第二行第二列开始动态规划。如果当前容量可以容纳物品i(即下面代码中的j >= DpSourceData.volume[i]),就判断加入当前物品后的价值是否提升了。 如果提升了就加入,否则价值不变。 加入的方法是看一下留置足够空间的背包的最大价值,再加上当前物品的价值(即table[i - 1][j - DpSourceData.volume[i]] + DpSourceData.values[i])。

Integer pre = table[i - 1][j];
int nxt = pre; // 放不下就去之前的
if (j >= DpSourceData.volume[i]) {
        nxt = table[i - 1][j - DpSourceData.volume[i]] + DpSourceData.values[i];
}
table[i][j] = max(pre, nxt);

这样整个算法就结束了,最优解就是表格右下角的值。

从算法过程也能看出使用数组的好处:不会更新数据,还能随机读取。

最优解的构成

这个最优解是哪些化石组成的呢?

程序打印出了表格:

0   0   0   0   0   0   0   0   0   0   
0   0   3   3   3   3   3   3   3   3   
0   0   3   4   4   7   7   7   7   7   
0   0   3   4   5   7   8   9   9   12   
0   0   3   4   5   7   8   9   10   12 

可以看到第二行(第一个物品)中从第三列开始取了当前物品,也就是背包容量足够时可以拿当前物品。

所以既然只有一个物品,并且被选取了,那么后面都写3不就行了。这样可以,但是从算法复杂性角度没好处所以没用。实际应用中使用是应该的。

当有两个物品时(也就是第三行),因为第二个物品的价值更大。给第二个物品留置空间就要把第一个扔掉,所以查询的值是上一行的第一列(也就是围栏,可以发现围栏的作用了)。到了第六列的时候(也就是背包容量达到7了),发现两个都可以放下了(这时候又可以优化了:总共就两个还都被拿走了,那当前行后面的都可以是7了)。

后面的过程都一样,一直到最后一行,你可以发现倒数第二行就已经达到最大值12了,说明最后一个物品没有被选取,而倒数第二个物品(也就是第三个)。 也就是说增加物品价值没变化就是没被选取。剔除了被选取物品后,继续判断更新背包的子问题,也就是下面代码中的right -= DpSourceData.volume[i]:

int right = width - 1;
for (int i = length - 1; i > 0; i--) { // 逐行回退
        if (!table[i][right].equals(table[i - 1][right])) { // 相等就继续回退,否则输出当前元素
                System.out.println(DpSourceData.values[i]);
                right -= DpSourceData.volume[i]; // 回退空间到之前
        }
}

这样就逆序输出了被选取的物品价值。简单修改输出就能输出物品下标。

一维数组实现

如果只需要求得最大价值而不用记录物品组成,可以使用一维数组:

int[] table = new int[width];
for (int i = 0; i < length; i++) {// 一次一次的刷
        for (int j = width-1; j >= 0; j--) {
                if (j >= DpSourceData.volume[i]){  // 每一次看能替换就替换
                        table[j] = max(table[j], table[j - DpSourceData.volume[i]] + DpSourceData.values[i]);
                }
        }
}

了解的二维动态规划的过程,一维的也很简单,就不解释了。

总结

01背包问题的解法挺多的。除了穷举法,其他性能都差不多。当然贪心算法难以找到最优解(性能最好),因为它优先选取价值最大的。以上面为例,价值最大的恰好没选取。

Written on October 10, 2019
分类: dev, 标签: java
如果你喜欢,请赞赏! davelet