背包问题题目描述
有 N 件物品和一个容量为 V 的背包,第 i 件物品的体积是c[i]
,价值是w[i]
,忽略物体几何体积,认为只要背包剩余容量大于物品体积就能放进背包,求将哪些物品装入背包可使价值总和最大。
问题解析
非常常见的动态规划问题,对于所有的动态规划问题,核心就是状态转移方程。
第一步都是确定状态。定义状态 dp[i][j] 是表示目前正在枚举第 i 个物品,目前已取的总体积为 j ,最大价值为 dp[i][j] 。
第二步是找状态转移方程。物品只有拿和不拿两种选择
(1):假如不拿这个物品,那么 dp[i][j] 肯定是能从上一个物品,同样体积转移过来的,所以 dp[i][j] = dp[i−1][j]
(2):假如我们取这个物品,那么这个物品上一个就是 i - 1 ,上一个物品的体积为 j − c[i] ,对 dp[i][j] 来说,它就从 *dp[i − 1][j − c [i]]*转移而来,由于拿了这个物品,所以还要加上这个物品的价值 w[i]w[i]
所以最终01背包的状态转移方程如下:
(1)j >= c[i] 时
j>=c[i]时,dp[i][j]=max(dp[i−1][j],dp[i−1][j−c[i]]+w[i])
(1)j < c [i] 时
dp[i][j] = dp[i−1][j]
思路:
for (int i = 1; i <= N; i++){
for (int j = V; j >= 0; j--){
if (j >= w[i]){//如果背包装得下当前的物体
dp[i][j] = max(dp[i - 1][j], dp[i - 1][j - w[i]] + v[i]);
}
else{//如果背包装不下当前物体
dp[i][j] = dp[i-1][j];
}
}
}
Java实现
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);
}
}
时间、空间复杂度
时间复杂度:O(N*V)
空间复杂度:O((N+1)*(V+1))
变化1:空间优化
从1到 i 个物品,每次用到的只有 i - 1 行数据。
如果只定义一个数组 F[v] 来表示当前占用体积 V 下的最大价值。每次我们刚要进行第 i 个物品的遍历时,此时存的就是第 i - 1 物品的遍历结果,我们只需利用这些数据获得第 i 个物品的遍历结果然后覆盖即可。
并且,由于是一行数据,我们每一行只需要遍历 [ c[i] , V ]即可,因为此时数据存的就是 i - 1 个物品的数据。
但是,从遍历来看,我们必须采用逆序遍历,因为我们必须保证,我们在运算过程中,取到的一定是第 i - 1 个物品的数据。
如果正序遍历:假设第 i 个物品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小的下标。
缺点:空间优化的方法只能知道最大价值是多少,而不知道是哪些物品组成的。
思路:
F[0..V] = 0;
for i = 1 to N
for j = V to c[i]
F[j] = max{F[j],F[j-c[i]] + w[i]}
Java实现:
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();
}
}
变化2:是否要求全部装满
如果是不要求全部装满,那么则是上述思路。
如果要求全部装满,不同点在于初始化的时候把第 0 个物品的F[V] =[0,-INF,-INF,…]
原来的下标表示物品用了多少体积,此时我们可以把下标理解成此时背包恰巧有这么多体积。当第0个物品时,只有背包体积为0符合条件“恰好装满”,其余均不符合,为负无穷,这样遍历下去,只要F[V]不为负无穷,那么它代表的值一定是装满该下标所代表体积的情况下的。
Java实现:
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();
}
}