1、问题描述
0-1背包问题:
给定N件物品和一个容量为V的背包。放入第i件物品耗费的空间为C[i] ,得到的价值是 W[i] 。
问:哪些物品装入背包可使价值总和最大?最大是多少?
2、基本思路
2.1 基本思路
这是最基础的背包问题,特点是:每种物品仅有一件,可以选择放或不放。
分析:
面对每个物品,我们只有选择拿与不拿两种选择,不能够选择装入物品的一部分,也不能装入同一物品多次。
解决方法:
声明一个二维数组F[N + 1 , V + 1] ,F[i ][ v] 表示 前i件物品恰放入一个容量恰为v的背包可以获得的最大价值。通过分析可得出F[i , v]的计算方法,
1) 当 v < W[i] 时, 说明背包容量不足以放下第i件物品,只能选择不拿,此时:
F[i ][ v] = F[i -1][ v]
2) 当 v >= W[i] 时,这是背包容量可以放下第i件物品,可以选择拿还是不拿,判断标准:拿这件物品是否能获取更大的价值。
- 如果拿,则 F[i ][ v] = F[ i - 1][ v - C[i] ] +W[i];
- 如果不拿,则 F[i ][ v] = F[i -1 ][ v]。
究竟拿不拿,取决于哪种情况使价值最大,由此可得到状态转移方程:
//如果容量为j的背包放得下第i个物体
if(j >= weight[i]) {
//两种选择:放与不放第i个物体,策略:哪个使价值最大就选用哪种策略
F[i][j] = max(F[i - 1][j - weight[i]] + value[i], F[i - 1][j]);
}else {
//放不下,只能选择不放第i个物体
F[i][j] = F[i - 1][j];
}
我们最终要求的就是: F[ N ] [ V ],表示 N件物品恰放入一个容量恰为V的背包可以获得的最大价值。
举例分析:假设有5个物体,背包容量为10,物体的体积分别是2 2 6 5 4,物体的收益分别是6 3 5 4 6。假设要求F[1][3],则需要求解的是:
F[1][0] = F[0][0] = 0 ; F[1][1] = F[0][1] = 0 ; F[1][2] = max { F[1][2-2] + 6, F[0][2] } = 6;
F[1][3] = max {F[1][3-2] + 6 , F[0][3]} = 6;
因此,我们求解F[N][V]的时候,我们可以逆序枚举,因为在计算F[i,j]前,它的F[i - 1][j - weight[i]] 和 F[i - 1][j] 一定已经计算出来了。
2.2 复杂度分析
逆序枚举的时间复杂度为O(N*V),且由于使用了二维数组 F[N + 1 , V + 1] ,其空间复杂度为O((N+1)*(V+1))。
其时间复杂度已经不能再优化了,但是空间复杂度可以优化到O(V),具体看2.4节。
2.3 如何确定哪些物品构成最大价值?
那么,如何确定哪几样物品能够获得最大价值呢?
另起一个数组isAdd[N + 1],isAdd[i] = false表示不拿第i个物体,否则表示拿了。
F[i][j],它是最优值,那么如果:
- F[i][j] = F[i - 1][j],说明有没有第i个物品都一样,第i个物品没有放,则isAdd[i] = false;
- 否则,说明放了第i个物体,则isAdd[i] = true。
具体实现代码可以看3.2节。
2.4 空间复杂度优化(降维)
空间复杂度可以优化为O(V),只适用于求最大价值,但是如果要求解哪些物体构成最大价值,则不可以降维。
二维的伪代码如下:
考虑3.1节基本思路的具体实现,每一次都有一个主循环i = 1 .. N,每次算出来二维数组F[ i ][0 .. V]的所有值。那么,如果只用一维数组F[0 .. V],能不能保证 第i次循环结束后F[v]中表示的就是我们定义的状态F[i][v]呢?
降成一维的伪代码:
为什么能够二维降一维?
看上面的伪代码,其实对于外层循环的每一个i值,其实是不需要记录的。在进行第i次循环时,所有的F[0 ... V]都还未更新时,F[v]此时记录的表示前i-1个物品在背包空间为 v 时的最大价值,这样就相当于还记录着F[i-1,v]和F[i-1,v-Ci]。
那么为何内层循环要递减遍历?
第i次循环时,刚开始F[0 ... V]还未更新时,F[v]此时记录的表示前i-1个物品在背包空间为 v 时的最大价值,那么如何保证F[v]在进行更新时,每一次用到的最优值都是未更新前的? 进行逆序遍历,就可以保证更新正确。
举例说明:假设一个物品A价值100,体积为2,正在进行第i次外层循环,假设第i-1次的F[v]全为0,按照[0 ... V]的顺序遍历,那么v = 2时,F[2] = max(F[2], F[0] + 1000) = 1000;当j = 4时,F[4] = max(F[4], F[2] + 1000) = 2000,此时max用到的F[2]是已经更新过的,所以正序遍历会造成错误的结果。
那么为什么逆序遍历就不会出错了呢?
按照上面的例子,按照[V ... 0]的顺序进行遍历,先求F[4] = max(F[4], F[2] + 1000) = 1000,此时max用到的F[2]就是第i-1次循环的结果。
不适用的情况:
虽然降维有效降低了空间复杂度,但是如果题目要求算出哪些物体组成最大价值的话,还是得用二维数组。具体可看2.3节。
java具体实现代码在3.3小节。
2.5 扩展——改成“恰好装满背包”
求最优解的背包问题中,有两种不太相同的问法:
- 有的题目要求“恰好装满背包”时的最优解
- 有的题目则没有要求必须把背包装满
一种实现方法的区别仅在初始化的时候有所不同:
- 对于第一种问法,要求恰好装满背包,那么在初始化时除了F[0]为0,其余F[1 .. V]均设为-INF,这样就可以确保最终得到的F[V]是一种恰好装满背包的最优解。
- 如果没有要求必须把背包装满,只是希望价格尽量大,初始化时应该将F[0 .. V]全部设为0。
如何理解?
- 初始化的F数组事实上就是在没有任何物体放进背包的合法状态。如果要求背包恰好装满,那么此时只有容量为0的背包可以在什么也不装且价值为0的情况下被“恰好装满”,其它容量的背包均没有合法解,属于未定义的状态,应该赋值为-INF。
- 如果背包并非必须被装满,那么任何容量的背包都有一个合法解“什么都不装”,这个解的价值为0,所以初始时状态的值全部为0。
为什么取负无穷大为不合法的状态?
因为状态选择方程中,F[v]是通过max函数取值较大的那个,所以将不合法的状态设为负无穷,则这种状态怎么样都不会被选到。
一个常数优化
===================================================================
待添加...
===================================================================
3、Java实现
3.1 二维数组实现
package pack9jiang;
import java.util.Scanner;
/**
* 使用二维数组非递归的方法求解0/1背包问题
*/
public class ZeroOnePack {
// N表示物体的个数,V表示背包的载重
int N,V;
//用于存储每个物体的重量,下标从1开始
private int[] weight;
//存储每个物体的收益,下标从1开始
private int[] value;
//二维数组,用来保存每种状态下的最大收益
private int[][] F;
/**
* 使用非递归方式,求解F[0 .. N][0 .. V],即for循环从下至上求解
*/
public void ZeroOnePackNonRecursive() {
//对二维数组F进行初始化
for(int j = 0; j <= V; j++) {
F[0][j] = 0;
}
//注意边界问题,i是从1开始的,j是从0开始的
//因为F[i - 1][j]中i要减1
for(int i = 1; i <= N; i++) {
for(int j = 0; j <= V; j++) {
//如果容量为j的背包放得下第i个物体
if(j >= weight[i]) {
F[i][j] = Math.max(F[i - 1][j - weight[i]] + value[i], F[i - 1][j]);
}else {
//放不下,只能选择不放第i个物体
F[i][j] = F[i - 1][j];
}
}
}
//打印所有结果,我们要求的是F[N][V]
for(int i = 0; i <= N; i++) {
for(int j = 0; j <= V; j++) {
System.out.print(F[i][j] + " ");
}
System.out.println();
}
}
/**
* 求解F[n][m]这个最优值具体选取哪几样物品能获得最大价值,但只会输出一种情况
* @param n 表示前n个物体,n <= N
* @param v 表示背包的容量,v <= V
*/
public void printResult(int n, int v) {
boolean[] isAdd = new boolean[n + 1];
for(int i = n; i >= 1; i--) {
if(F[i][v] == F[i-1][v])
isAdd[i] = false;
else {
isAdd[i] = true;
v -= weight[i];
}
}
for(int i = 1; i <= n; i++) {
System.out.print(isAdd[i] + " ");
}
System.out.println();
}
/**
* 输入格式:
5 10
2 2 6 5 4
6 3 5 4 6
* result:15
* 第一行是物体个数、背包总空间;
* 第二行是每个物体的空间;
* 第三行是每个物体的收益。
*/
public void init() {
Scanner sc = new Scanner(System.in);
N = sc.nextInt();
V = sc.nextInt();
//下标从1开始,表示第1个物品
weight = new int[N + 1];
value = new int[N + 1];
F= new int[N + 1][V + 1];//注意是 N + 1,因为需要一个初始状态F[0][0],表示前0个物品放进空间为0的背包的最大收益
for(int i = 1; i <= N; i++) {
weight[i] = sc.nextInt();
}
for(int i = 1; i <= N; i++) {
value[i] = sc.nextInt();
}
}
public static void main(String[] args) {
ZeroOnePack zop = new ZeroOnePack();
zop.init();
zop.ZeroOnePackNonRecursive();
zop.printResult(zop.N,zop.V);
}
}
3.2 确定哪些物品构成最大值
/**
* 求解F[n][m]这个最优值具体选取哪几样物品能获得最大价值,但只会输出一种情况
* @param n 表示前n个物体,n <= N
* @param v 表示背包的容量,v <= V
*/
public void printResult(int n, int v) {
boolean[] isAdd = new boolean[n + 1];
for(int i = n; i >= 1; i--) {
if(F[i][v] == F[i-1][v])
isAdd[i] = false;
else {
isAdd[i] = true;
v -= weight[i];
}
}
for(int i = 1; i <= n; i++) {
System.out.print(isAdd[i] + " ");
}
System.out.println();
}
3.3 降维
package pack9jiang;
import java.util.Scanner;
/**
* 0/1背包的降维,将空间复杂度降为O(V)
*/
public class ZeroOnePackExtend1 {
// N表示物体的个数,V表示背包的载重
int N,V;
//用于存储每个物体的重量,下标从1开始
private int[] weight;
//存储每个物体的收益,下标从1开始
private int[] value;
//降成一维数组,用来保存每种状态下的最大收益
private int[] F;
/**
* 使用非递归方式
*/
public void ZeroOnePackNonRecursive() {
//对一维数组F进行初始化
for(int i = 0; i <= V; i++) {
F[i] = 0;
}
//注意边界问题,i是从1开始的
for(int i = 1; i <= N; i++) {
for(int j = V; j >= 0; j--) {
//降序遍历
if(j >= weight[i]) {
F[j] = Math.max(F[j - weight[i]] + value[i], F[j]);
}else {
//可以省略
F[j]= F[j];
}
}
}
//打印所有结果,我们要求的是F[V]
for(int i = 0; i <= V; i++) {
System.out.print(F[i] + " ");
}
System.out.println();
}
/**
* 输入格式:
5 10
2 2 6 5 4
6 3 5 4 6
* result:15
* 第一行是物体个数、背包总空间;
* 第二行是每个物体的空间;
* 第三行是每个物体的收益。
*/
public void init() {
Scanner sc = new Scanner(System.in);
N = sc.nextInt();
V = sc.nextInt();
//下标从1开始,表示第1个物品
weight = new int[N + 1];
value = new int[N + 1];
F= new int[V + 1];//注意是 V + 1
for(int i = 1; i <= N; i++) {
weight[i] = sc.nextInt();
}
for(int i = 1; i <= N; i++) {
value[i] = sc.nextInt();
}
}
public static void main(String[] args) {
ZeroOnePackExtend1 zope1 = new ZeroOnePackExtend1();
zope1.init();
zope1.ZeroOnePackNonRecursive();
}
}
3.4 扩展——改成“恰好装满背包”
package pack9jiang;
import java.util.Scanner;
/**
* 0/1背包问题变种:求恰好装满背包的最优解
*/
public class ZeroOnePackExtend2 {
// N表示物体的个数,V表示背包的载重
int N,V;
//用于存储每个物体的重量,下标从1开始
private int[] weight;
//存储每个物体的收益,下标从1开始
private int[] value;
//降成一维数组,用来保存每种状态下的最大收益
private int[] F;
int INF = 9999;
/**
* 使用非递归方式
*/
public void ZeroOnePackNonRecursive() {
//仅在初始化有区别
F[0] = 0;
for(int i = 1; i <= V; i++) {
F[i] = -INF;
}
//注意边界问题,i是从1开始的
for(int i = 1; i <= N; i++) {
for(int j = V; j >= 0; j--) {
//降序遍历
if(j >= weight[i]) {
F[j] = Math.max(F[j - weight[i]] + value[i], F[j]);
}else {
//可以省略
F[j]= F[j];
}
}
}
//打印所有结果,我们要求的是F[V]
for(int i = 0; i <= V; i++) {
System.out.print(F[i] + " ");
}
System.out.println();
}
/**
* 输入格式:
5 10
2 2 6 5 4
6 3 5 4 6
* result:14
* 第一行是物体个数、背包总空间;
* 第二行是每个物体的空间;
* 第三行是每个物体的收益。
*/
public void init() {
Scanner sc = new Scanner(System.in);
N = sc.nextInt();
V = sc.nextInt();
//下标从1开始,表示第1个物品
weight = new int[N + 1];
value = new int[N + 1];
F= new int[V + 1];//注意是 V + 1
for(int i = 1; i <= N; i++) {
weight[i] = sc.nextInt();
}
for(int i = 1; i <= N; i++) {
value[i] = sc.nextInt();
}
}
public static void main(String[] args) {
ZeroOnePackExtend2 zope2 = new ZeroOnePackExtend2();
zope2.init();
zope2.ZeroOnePackNonRecursive();
}
}