Knapsack背包问题
背包问题(Knapsack problem)是一种组合优化的NP完全问题。问题可以描述为:给定一组物品,每种物品都有自己的重量和价格,在限定的总重量内,我们如何选择,才能使得物品的总价格最高。问题的名称来源于如何选择最合适的物品放置于给定背包中。 也可以将背包问题描述为决定性问题,即在总重量不超过W的前提下,总价值是否能达到V。
0-1背包
最基础的背包问题:有N件物品和一个体积为V的背包, 每种物品均只有一件, 第i件物品的大小/重量是s[i]
,价值是v[i]
. 求将哪些物品装入背包可使这些物品的体积总和不超过背包体积,且价值总和最大.
对于每一个物品,只有两种结果,放入或者不放入背包,那么kn(i, j)
则表示背包容量剩余j
时, 前i
个物品能够达到的最大值:
kn1 = kn(i-1, j-s(i)) + v(i)
表示物品i
放入背包后的总价值, 为前i-1
物品在第i
个物品占用了背包容量s(i)
后的的最优解加上第i
个物品的价值v(i)
.kn2 = kn(i-1, j)
表示物品i
并没有放入背包, 等于前i-1
个物品在相同背包容量的最优价值.
归纳出来的大小子问题间的关系(转移方程)为: kn(i, j) = max(kn1, kn2) = max(kn(i-1, j-s(i)) + v(i), kn(i-1, j))
. 初始状态是对于不同背包剩余容量, 当没有物品可放时, 返回的最大价值一定是0
. 所以背包问题, 就是二维的动态规划问题. 需要确定初始状态, 和哪些信息需要记忆.
可以简单地用一个二维数组记忆所有kn(i, j)
, 但要考虑到当容量非常大, 物品非常多时, 这个二维数组是很大的, 比如当(i, j) = (2000, 2000000)
, 会抛出java.lang.OutOfMemoryError: Java heap space
. 特别是, 当每个物品的价值也比较大时, 二维数组的j
维度其实利用率很低. 所以存在很多优化的空间.
优化的关键点在于减少记忆点. 注意到转移方程中:
kn(i, *)
只需要用到kn(i-1, *)
的值, 但我们又清楚地知道,物品在这里是没有顺序的意义的,所以这里的i
仅仅是表示迭代的步骤, 只是为了遍历所有物品, 至于具体的顺序是不重要的, 所以不需要记录所有i
对应的kn(i, *)
, 仅仅记录最近一次计算值即可. 所以我们只需要至多两个数组用来记录i-1
和i
对应的kn
值.kn(i, j)
要用到kn(i-1, k), k<=j
的值, 具体要用到哪些k
是取决于i
. 所以j
维度的值必须都要记录下来, 以防后续需要用到.- 结合起来发现只需要一个一维数组
kn = new int[size + 1]
即可,i
对应的值可以直接在数组上更新, 不需要额外的数组记录上一次迭代的值. 在实现中, 因为kn(i, j)
要用到kn(i-1, <=j)
的值, 也就是kn[<j]
的值不能先于kn[j]
更新, 所以kn
的计算要从右往左(j = size; j--
). - 每次决定是否加入
i
物品之前, 如果剩余容量j
小于s[i]
, 那么肯定无法放入, 这个判断可以融合进j
的遍历中, 因为j
本身代表了剩余容量.
static int[] values;
static int[] sizes;
public static int knapsack(int size) {
int n = values.length;
int[] vs = new int[size + 1];
for (int i = 0; i < n; i++) { // items
for (int j = size; j >= sizes[i]; j--) {
vs[j] = Math.max(vs[j - sizes[i]] + values[i], vs[j]);
}
}
return vs[size];
}
优化以后空间复杂度由$\theta(NS)$降到$\theta(S)$。但时间复杂度不变.
对于0-1背包问题,如果问题变为求恰好装满时的最大值, 参考这篇博文: 此时只有容量为0
的背包可能被价值为0
的物品(无物品)恰好装满,初始化合法状态kn[0] = 0
, kn[j > 0]
为负数. 反之, 如果要求的是恰好装满时的最小值,初始化为正无穷。要注意的是改变初始化以后最后一个值是恰好装满的最大值,如果不能恰好装满,那肯定是一个负数,而且对于恰好装满的的初始化情况的不要求满的最大值是0-v背包容量的最大值。即是最后一行的MAX。
完全背包
Unbounded Knapsack: 有N
种物品和一个体积为S
的背包,每种物品都有无限件可用。第i
件物品的体积是s[i]
,价值是value[i]
。求解将哪些物品装入背包可使这些物品的体积总和不超过背包体积,且价值总和最大。
0-1背包的问题中每一种物品在背包中的数量只有0
和1
两种, 而完全背包问题每一种物品在背包中的数量是0
个到k = S/s[i]
个. 使用与0-1背包类似的定义, kn(i, j)
表示背包容量剩余j
时, 放入任意个前i
个物品能够达到的最大值, 这样转移方程变为: kn(i, j) = max{kn(i-1, j-k*s(i)) + k*v(i)}, 0 <= k <= S/s[i]
。可以直接在0-1背包的代码中增加一个循环,这样时间复杂度就增加了。对于取多少也可以利用二进制拆分,取的时候取1, 2, 4, ...
。
完全背包的算法优化
注意到完全背包本身也包含0-1背包的情况, 0-1背包是完全背包的特例. 完全背包的kn(i, j)
包含了第i
种物品的数量在0 - S/s[i]
所有可能选择, 并取其最大值:
- 若至少放一个物品
i
进背包, 那么在对物品i
的数量进行0 - S/s[i]
的遍历时, 迭代方程变为kn1 = kn(i, j-s(i)) + v(i)
- 若第
i
个物品不放入背包时, 情况和0-1背包的kn2
一样,kn2 = kn(i-1, j)
所以0-1背包的迭代方程vs[j] = Math.max(vs[j - sizes[i]] + values[i], vs[j]);
可以直接套用在完全背包上.
只是kn
的计算要改为从左往右(j = 0; j <= size; j++
). 因为此时kn1
用的不再是上一次迭代的kn(i-1, j-s(i))
, 而是本次迭代的kn(i, j-s(i))
. 即kn(i, j)
要用到kn(i, <=j)
的值, 所以kn[<j]
的值要先于kn[j]
更新.
同样, 每次决定是否加入i
物品之前, 如果剩余容量j
小于s[i]
, 那么肯定无法放入, 这个判断可以融合进j
的遍历中.
static int[] values;
static int[] sizes;
public static int unboundedKnapsack(int size) {
int n = values.length;
int[] vs = new int[size + 1];
for (int i = 0; i < n; i++) { // items
for (int j = sizes[i]; j <= size; j++) {
vs[j] = Math.max(vs[j - sizes[i]] + values[i], vs[j]);
}
}
return vs[size];
}
优化后的时间复杂度为O(NV)
.
在数据上也可以优化:如果物品a
比b
价值更高, 但体积更小, 那么完全可以不考虑物品b
。对于随机生成的数据,这个方法往往会大大减少搜索空间。
多重背包
有N种物品和一个体积为V的背包。第i
种物品最多有num[i]
件可用,每件体积是sizes[i]
,价值是value[i]
。求解将哪些物品装入背包可使这些物品的体积总和不超过背包体积,且价值总和最大。
多重背包问题可以采取基于0-1背包的算法基础上增加一层循环搜索num[i]
. 但这样的时间复杂度是O(NVC)
.
for (int i = 0; i < n; i++) { // items
for (int k = 1; k <= num[i]; k++) {
for (int j = size; j >= sizes[i]; j--) {
vs[j] = Math.max(vs[j - sizes[i]] + values[i], vs[j]);
}
}
}
多重背包问题其实包含0-1背包和完全背包,可以分类处理。
- 如果满足
value[i]*num[i]>=size
,这个时候就是完全背包问题, 而完全背包要比多重背包的复杂度低,是O(NV)
。 - 如果满足
num[i]=1
就是0-1背包。
其他背包
混合背包: 如果将0-1、完全、多重混合起来,有的物品只可以取一次(01背包),有的物品可以取无限次(完全背包),有的物品可以取的次数有一个上限(多重背包)。需要分类求解,判断是哪一种,然后分别给出循环和循环顺序,分别调用状态转换方程。
其他还有二维费用背包,依赖背包,分组背包…