【算法】背包问题

246 阅读3分钟

1. 简介

  背包问题的大致介绍如下:有一个总容量为 V 的背包,给定一些物品,它们的体积存放在数组 v 中,它们的价值存放在数组 w 中,从这些物品中挑出一部分放入背包中,使得背包的总价值最大,问如何挑选。

2. 0/1背包问题

2.1 解决思路

  0/1背包问题的关键点在于每个物品的数量只有一个,则对于某件物品,它要么放入背包,要么不放入背包。我们可以创建一个二维数组f来记录结果,并根据问题列出以下式子:

f[i][j] = Max{f[i-1][j], f[i-1][j-v[i]]+w[i]}

注:f[i][j]表示当挑选范围为前i个物品,且背包总容量只有j时,背包的最大价值。能理解这点很重要。

  当背包总容量为 j 时,对于某物品i来说:

  • 假如该物品 i 不放入背包,则此时价值为 f[i-1][j]
  • 假如该物品 i 放入背包,则此时价值为 f[i-1][j-v[i]]+w[i]
  • 然后取这两个值的最大值,保存在 f[i][j] 中

2.2 代码实现

/**
* 0/1背包问题
* @param v      各个物品的体积
* @param w      各个物品的价值
* @param volume 背包的总容量
* @return
*/
public int zeroOneKnapsack(int[] v, int[] w, int volume) {
    if (v.length != w.length) {
        throw new IllegalArgumentException("体积数组的长度与价值数组的长度不一致!");
    } else {
        // 为了操作方便,在数组v和数组w的第一个位置插入一个无效值
        int[] newV = new int[v.length + 1];
        int[] newW = new int[w.length + 1];
        for (int i = 0; i < newV.length; i++) {
            if (i != 0) {
                newV[i] = v[i - 1];
                newW[i] = w[i - 1];
            } else {
                newV[i] = -1;
                newW[i] = -1;
            }
        }
        v = newV;
        w = newW;

        // 真正开始计算
        int[][] f = new int[v.length][volume + 1];
        for (int i = 1; i < v.length; i++) {
            for (int j = 1; j <= volume; j++) {
                if (j >= v[i]) {
                    f[i][j] = Math.max(f[i - 1][j], f[i - 1][j - v[i]] + w[i]);
                } else {
                    f[i][j] = f[i - 1][j];
                }
            }
        }

        return f[v.length - 1][volume];
    }
}

2.3 优化结果集空间

  仔细观察式子f[i][j] = Max{f[i-1][j], f[i-1][j-v[i]]+w[i]}并结合代码可以发现,对于外循环i来说,其实每次都是与上一轮循环(第i-1轮)的结果进行比较,而不关心更早的轮次,于是我们可以把结果集优化成一维的。代码如下:

/**
* 0/1背包问题,对记录结果的数组空间进行优化
* @param v      各个物品的体积
* @param w      各个物品的价值
* @param volume 背包的总容量
* @return
*/
public int zeroOneKnapsack2(int[] v, int[] w, int volume) {
    if (v.length != w.length) {
        throw new IllegalArgumentException("体积数组的长度与价值数组的长度不一致!");
    } else {
        int[] f = new int[volume + 1];

        for (int i = 0; i < v.length; i++) {
            // j必须是倒序遍历,保证等式右边的f[j]和f[j - v[i]]都是上一轮计算后的结果
            // 假如j是正序,则对于外循环来说,其实是与本轮次进行比较,不符合原来的逻辑“与第i-1轮比较”
            for (int j = volume; j >= v[i]; j--) {
                if (i == 0) {
                    f[j] = w[i];
                } else {
                    f[j] = Math.max(f[j], f[j - v[i]] + w[i]);
                }
            }
        }
        
        return f[volume];
    }
}

3. 完全背包问题

  完全背包问题与0/1背包问题相似,区别仅仅在于在本问题中,每个物品可选的数量是k个,k∈[0, +∞)。正常情况下,再对k进行遍历就能解决了,但是此时复杂度达到了O(n^3)。耗时较高,可以优化成O(n^2),伪代码如下:

for (int i = 0; i < v.length; i++)
	for (int j = v[i]; j <= V; j++)
		f[j] = Max{f[j], f[j-v[i]]+w[i]};

  神奇的地方在于,当对 j 进行正序遍历时,其实就已经对 k 进行了遍历讨论,若不能理解的强烈建议以手写的形式跟进一下整个计算过程,我自己就是这样理解的。最终代码如下:

/**
 * 完全背包问题
 * @param v      各个物品的体积
 * @param w      各个物品的价值
 * @param volume 背包的总容量
 * @return
 */
public int completeKnapsack(int[] v, int[] w, int volume) {
    if (v.length != w.length) {
        throw new IllegalArgumentException("体积数组的长度与价值数组的长度不一致!");
    } else {
        int[] f = new int[volume + 1];

        for (int i = 0; i < v.length; i++) {
            for (int j = v[i]; j <= volume; j++) {
                f[j] = Math.max(f[j], f[j - v[i]] + w[i]);
                System.out.print(f[j] + "\t");
            }
            System.out.println();
        }
        return f[volume];
    }
}

4. 相关链接