0-1背包问题
目录
问题描述
有 N N N 件物品和一个容量为 C C C 的背包。放入第 i i i 件物品耗费的容量是 W i W_i Wi,得到的价值是 V i V_i Vi。求解将哪些物品装入背包可使价值总和最大。
这个是最基础的背包问题,特点是:每种物品仅有一件,可以选择放或不放。
递归算法
定义函数 F ( i , C ) F(i,C) F(i,C) 表示前 i i i 件物品放入容量为 C C C 的背包可以获得的最大价值。于是
F ( i , C ) = m a x { F ( i − 1 , C ) , F ( i − 1 , C − W i ) + V i } F(i,C) = max\{F(i-1,C) , F(i-1,C-W_i)+V_i\} F(i,C)=max{
F(i−1,C),F(i−1,C−Wi)+Vi}
对于将前 i i i 件物品放入容量为 C C C 的背包中,获得的最大价值的问题。可以只考虑第 i i i 件物品放与不放的策略:
- 不放第 i i i 件,那么问题就转化为“将前 i − 1 i-1 i−1件物品放入容量为 C C C 的背包中,获得的最大价值”,这时候价值为 F ( i − 1 , C ) F(i-1,C) F(i−1,C) 。
- 放第 i i i 件,那么问题就转化为“将前 i − 1 i-1 i−1 件物品放入容量为 C − W i C-W_i C−Wi 的背包中,获得的最大价值”,这时候获得的最大价值就是 F ( i − 1 , C − W i ) + V i F(i-1,C-W_i)+V_i F(i−1,C−Wi)+Vi
于是伪代码为:GitHub代码:递归(recursion)
//重量数组
double[] W;
//价值数组
double[] V;
//递归函数,有时候W和V可能需要传参使用,而不使用成员变量,也就是F(double[] W,double[] V,int i ,double C)
public double F(int i, double C) {
//边界条件
if (i < 0 || C < 0) return 0;
// 不选第i个的价值
double value = F(i - 1, C);
//只有背包容量大于或等于第i个物品的重量时,才能考虑是否装第i个
if (C >= W[i]) {
//选第i个时的价值F(i - 1, C - W[i]) + V[i];
//看选与不选第i个哪个获得的价值最大。然后赋值给value,然后返回。
value = Math.max(value, F(i - 1, C - W[i]) + V[i]);
}
return value;
}
带备忘录的自顶向下法(top-down with memoization)
GitHub代码:top_down_with_memoization
对于递归算法,为了减少重复计算的子问题,于是定义memory数组,用于记录已经计算了的子问题
//重量数组
int[] W;
//价值数组
int[] V;
//用于存储已经计算的子问题。首先将其全部赋值为-1,表示还未计算。
//其中memory[i][j]表示递归函数F(i,j)返回的值
int[][] memory = new int[N][C];
//或者在最开始的时候定义一个非常大的数组,比如new int[1024][1024]
//递归函数
F(i,C){
//边界条件
if (i < 0 || C < 0) return 0;
//如果有数据,那么就返回。
if (memory[i][C] != -1) {
return memory[i][C];
}
// 不选第i个的价值
int value = F(i - 1, C);
//只有背包容量大于或等于第i个物品的重量时,才能考虑是否装第i个
if (C >= W[i]) {
//选第i个时的价值F(i - 1, C - W[i]) + V[i];
//看选与不选第i个哪个获得的价值最大。然后赋值给value,然后返回。
value = Math.max(value, F(i - 1, C - W[i]) + V[i]);
}
//到了这里说明未被记录
memory[i][C] = value;
return value;
}
自底向上法(bottom-up method)
定义子问题: d p [ i ] [ j ] dp[i][j] dp[i][j] 表示前 i i i 件物品放入背包容量为 j j j 的最大价值。于是:
- 当 i = 0 i=0 i=0 时,表示没有物品放入背包为 j j j 中的最大价值,为0,即 d p [ 0 ] [ j ] = 0 dp[0][j] = 0 dp[0][j]=0
- 当 j = 0 j=0 j=0 时,表示前i件物品放入背包容量为0的最大价值,也是0,即 d p [ i ] [ 0 ] = 0 dp[i][0] = 0 dp[i][0]=0
- 当 i ≠ 0 i \ne 0 i=0 并且 j ≠ 0 j \ne 0 j=0 时, d p [ i ] [ j ] = m a x { d p [ i − 1 ] [ j ] , d p [ i − 1 ] [ j − W i ] + V i } dp[i][j] = max\{dp[i-1][j],dp[i-1][j-W_i]+V_i\} dp[i][j]=max{ dp[i−1][j],dp[i−1][j−Wi]+Vi}
由于我们要计算的是 d p [ N ] [ C ] dp[N][C] dp[N][C],于是我们需要定义的数组大小为 [ N + 1 ] [ C + 1 ] [N+1][C+1] [N+1][C+1]
//其中W表示重量数组,V表示价值数组,N表示物品个数,C表示背包容量
//说明,对于W与V而言,下标是从0开始的,其中第i个物品的重量和价值为W[i-1]和V[i-1]
public int knapsack(int[] W, int[] V, int N, int C) {
//定义dp数组
int[][] dp = new int[N + 1][C + 1];
//初始化,对于当重量为0和物品个数为0时,价值为0
for (int i = 0; i < N + 1; i++) {
dp[i][0] = 0;
}
for (int j = 0; j < C + 1; j++) {
dp[0][j] = 0;
}
//根据状态转移方程编写迭代
for (int i = 1; i < N + 1; i++) {
for (int j = 1; j < C + 1; j++) {
//放与不放,哪个价值更高,就将其存储到dp中
//不放第i个物品的价值value
int value = dp[i - 1][j];
//如果要放入,那么第i个物品的重量需要比背包容量小
if (W[i - 1] <= j) {
//表示第i个物品放得下
//放入第i个物品的价值为value2
// value2 = dp[i-1][j-W[i-1]]+V[i-1];
// value = Math.max(value,value2)
//将上面的写在一起
value = Math.max(value, dp[i - 1][j - W[i - 1]] + V[i - 1]);
}
//将计算出来的最优dp[i][j]进行存储
dp[i][j] = value;
}
}
//看需要返回什么,如果只是最大值,那就直接返回dp[N][C],如果返回数组也行。
return dp[N][C];
}
回溯算法(backtracking)
简单概述
回溯法按深度优先策略搜索问题的解空间树。首先从根节点出发搜索解空间树,当算法搜索至解空间树的某一节点时,先利用剪枝函数判断该节点是否可行(即能得到问题的解)。如果不可行,则跳过对该节点为根的子树的搜索,逐层向其祖先节点回溯;否则,进入该子树,继续按深度优先策略搜索。
回溯法的基本行为是搜索,搜索过程使用剪枝函数来为了避免无效的搜索。剪枝函数包括两类:
- 使用约束函数,剪去不满足约束条件的路径;
- 使用限界函数,剪去不能得到最优解的路径。
对于回溯法,
- 问题解的形式:其问题的解一般可以表示为1个n元组 ( x [ 1 ] , x [ 2 ] , . . . , x [ n ] ) (x_{[1]},x_{[2]},...,x_{[n]}) (x[1],x[2],...,x[n]) 的形式。
- 显式约束:对分量 x [ i ] x_{[i]} x[i] 的取值范围的限定
- 解空间:对问题的一个实例,解向量满足显式约束的所有n元组构成该实例的一个解空间
- 隐式约束:为满足问题的解对不同的分量之间施加的约束。
以0-1背包问题为例:
问题解的形式:对于有 n n n 种可选大的物品的0-1背包问题,其解为长度 n n n 的向量 ( x [ 1 ] , x [ 2 ] , . . . , x [ n ] ) (x_{[1]},x_{[2]},...,x_{[n]}) (x[1],x[2],...,x[n])
显式约束: x [ i ] ∈ { 0 , 1 } x_{[i]}\in \left\{ 0,1 \right\} x[i]∈{ 0,1} ,其中 x [ i ] = 1 x_{[i]}=1 x[i]=1 表示选取第 i i i 个物品, x [ i ] = 0 x_{[i]} = 0 x[i]=0 表示不选第 i i i 个物品。解向量中每一个变量所有可能的0-1赋值,构成了该问题的解空间。比如,对于 n = 4 n=4 n=4 时,其解空间为 ( 0 , 0 , 0 , 0 ) (0,0,0,0) (0,0,0,0), ( 0 , 0 , 0 , 1 ) (0,0,0,1) (0,0,0,1), ( 0 , 0 , 1 , 0 ) (0,0,1,0) (0,0,1,0), ( 0 , 0 , 1 , 1 ) (0,0,1,1) (0,0,1,1), ( 0 , 1 , 0 , 0 ) (0,1,0,0) (0,1,0,0), ( 0 , 1 , 0 , 1 ) (0,1,0,1) (0,1,0,1), ( 0 , 1 , 1 , 0 ) (0,1,1,0) (0,1,1,0), ( 0 , 1 , 1 , 1 ) (0,1,1,1) (0,1,1,1), ( 1 , 0 , 0 , 0 ) (1,0,0,0) (1,0,0,0), ( 1 , 0 , 0 , 1 ) (1,0,0,1) (1,0,0,1), ( 1 , 0 , 1 , 0 ) (1,0,1,0) (1,0,1,0), ( 1 , 0 , 1 , 1 ) (1,0,1,1) (1,0,1,1), ( 1 , 1 , 0 , 0 ) (1,1,0,0) (1,1,0,0), ( 1 , 1 , 0 , 1 ) (1,1,0,1) (1,1,0,1), ( 1 , 1 , 1 , 0 ) (1,1,1,0) (1,1,1,0), ( 1 , 1 , 1 , 1 ) (1,1,1,1) (1,1,1,1)
对于物品{ A A A, B B B, C C C, D D D},其中 A A A 表示选取物品 A A A , A ˉ \bar{A} Aˉ 表示不选取物品 A A A 。那么对于选取物品 A A A, B B B, C C C, D D D 的问题的解向量就是 ( 1 , 1 , 1 , 1 ) (1,1,1,1) (1,1,1,1)
解空间:对问题的一个实例,解向量满足显式约束的所有n元组构成该实例的一个解空间
隐式约束:装入背包的物品总重量不超过背包的容量,即: Σ n i = 1 x [ i ] × w [ i ] ≤ C \underset{i=1}{\overset{n}{\varSigma}}x_{[i]} \times w_{[i]} \le C i=1Σnx[i]×w[i]≤C
回溯法的实现
假设问题的解用向量 x = ( x [ 1 ] , x [ 2 ] , . . . , x [ n ] ) x=(x_{[1]},x_{[2]},...,x_{[n]}) x=(x[1],x[2],...,x[n]) 表示,其中 x [ i ] x_{[i]} x[i]属于 X i X_i Xi ,从空向量开始,首先选择 X 1 X_1 X1 的最小值作为 x [ 1 ] x_{[1]} x[1] 的值,如果合法,部分解为 ( x [ 1 ] ) (x_{[1]}) (x[1]) ,继续在 X 2 X_2 X2 中选择最小的值赋值给 x [ 2 ] x_{[2]} x[2] ,否则把 X 1 X_1 X1 的下一个元素赋值给 x [ 1 ] x_{[1]} x[1] 。
一般地,假设算法已得到部分解 ( x [ 1 ] , x [ 2 ] , . . . , x [ j ] ) (x_{[1]},x_{[2]},...,x_{[j]}) (x[1],x[2],...,x[j]) ,考虑向量 v = ( x [ 1 ] , x [ 2 ] , . . . , x [ j ] , x [ j + 1 ] ) v=(x_{[1]},x_{[2]},...,x_{[j]},x_{[j+1]}) v=(x[1],x[2],...,x[j],x[j+1])
- 若 v v v 为问题的可行解,算法记录它作为1个解。如果只需要1个解时,算法终止,否则继续寻找其他解。
- 若 v v v 为部分解,算法在 X j + 2 X_{j+2} Xj+2 中选择最小值赋值给 x [ j + 2 ] x_{[j+2]} x[j+2] ,继续步骤1
- 若 v v v 既不是部分解,也不是最终解,
- 若 X j + 1 X_{j+1} Xj+1 还有其它元素可选择,赋值下一个元素给 x [ j + 1 ] x_{[j+1]} x[j+1]
- 如果 X j + 1 X_{j+1} Xj+1 没有其他元素可选择,算法回溯上一层,即把 X j X_j Xj 的下一个元素赋值给 x [ j ] x_{[j]} x[j] 。若 X j X_{j} Xj 也没有其他元素可选,则算法回溯再上一层,即把 X j − 1 X_{j-1} Xj−1 的下一个元素赋值给 x j − 1 x_{j-1} xj−1 ,依此类推。
递归实现
x x x :表示解向量,一般是x[]数组形式
n n n :表示解向量的长度
f ( n , t ) f(n,t) f(n,t) 和 g ( n , t ) g(n,t) g(n,t) :表示当前扩展结点处未被搜索过的子树的起始编号与终止编号
h ( i ) h(i) h(i) :表示当前扩展结点处 x [ t ] x_{[t]} x[t] 的第 i i i 个可选值
c o n s t r a i n t ( t ) constraint(t) constraint(t) :当前扩展结点处的约束条件
b o u n d ( t ) bound(t) bound(t) :当前扩展结点的限界条件
void backtrackRec(int t) {
if (t > n) {
//表示搜索至叶结点,也就是获得了一个可行解,输出x向量或者记录x向量
output(x);
} else {
//对x[t]每一个可能的取值进行搜索,对于0-1背包,其取值可为0或1
for (int i = f(n, t); i <= g(n, t); i++) {
x[t] = h(i);
//(x[1],x[2],...,x[t])满足约束,限界条件
if (constraint(t) && bound(t)) {
backtrackRec(t + 1);//前进
}
}
}
}
在看上面代码的时候可以在脑中想象一颗解空间树,然后对于每一个函数的调用,就类似于一个人站在了某一个结点处,选择该怎么走(而选择的选项就是下一个解向量能取的值),有点类似于递归思想。
迭代实现
s o l u t i o n ( t ) solution(t) solution(t):判断在当前扩展结点处是否已得到问题的可行解。如果返回true,表示扩展结点处 x [ 1 : t ] x[1:t] x[1:t] 是问题的可行解;如果返回false表示在当前扩展结点处 x [ 1 : t ] x[1:t] x[1:t] 只是问题的部分解,还需要向纵深方向继续搜索。
f ( n , t ) f(n,t) f(n,t) 和 g ( n , t ) g(n,t) g(n,t) :表示当前扩展结点处未被搜索过的子树的起始编号与终止编号
h ( i ) h(i) h(i) :表示当前扩展结点处 x [ t ] x_{[t]} x[t] 的第 i i i 个可选值
c o n s t r a i n t ( t ) constraint(t) constraint(t) :当前扩展结点处的约束条件,如果返回true,在当前扩展结点处 x [ 1 : t ] x[1:t] x[1:t] 取值满足问题的约束条件,否则,不满足约束条件,可剪去相应的子树。
b o u n d ( t ) bound(t) bound(t) :当前扩展结点的限界条件,如果返回true时,在当前扩展结点处 x [ 1 : t ] x[1:t] x[1:t] 取值未使目标函数越界,还需对其相应的子树进一步搜索。否则,当前扩展结点处 x [ 1 : t ] x[1:t] x[1:t] 取值使目标函数越界,可剪去相应的子树。
void backtrackIter() {
int t = 1;
while (t > 0) {
//X_t还有其他元素
if (f(n, t) <= g(n, t)) {
for (int i = f(n, t); i <= g(n, t); i++) {
x[t] = h(i);
//(x[1],x[2],...,x[t])满足约束及限界条件
if (constraint(t) && bound(t)) {
if (solution(t)) {
//求得一个解,输出x,或者保存x
output(x);
} else {
t++; //前进
}
} else {
t--; //回溯
}
}
}
}
}
子集树与排列树
-
当所给的问题是从 n n n 个元素的集合 S S S 中找出 S S S 满足某种性质的子集时,相应的解空间树称为子集树。一般有 2 n 2^n 2n 个叶结点。其结点总数为 2 n + 1 − 1 2^{n+1}-1 2n+1−1 。遍历子集树的算法需要 Ω ( 2 n ) \varOmega(2^n) Ω(2n) 计算时间。
-
当所给的问题是确定 n n n 个元素满足某种性质的排列时,相应的解空间树称为排列树。排列树一般有 n ! n! n! 个叶结点。遍历排列树需要 Ω ( n ! ) \varOmega(n!) Ω(n!) 计算时间。 其算法框架可描述如下:
void backtrack(int t) { if (t > n) { output(x) } else { for (int i = t; i <= n; i++) { swap(x[t], x[i]); if (constraint(t) && bound(t)) { backtrack(t + 1); } swap(x[t], x[i]); } } }
回溯法的效率分析
算法的效率很大程度上依赖于以下因素:
- 产生 x [ k ] x_{[k]} x[k] 的时间
- 满足显约束的 x [ k ] x_{[k]} x[k] 值的个数
- 计算约束函数 c o n s t r a i n t constraint constraint 的时间
- 计算上界函数 b o u n d bound bound 的时间
- 满足约束函数和上界函数约束的所有 x [ k ] x_{[k]} x[k] 的个数
一般而言,对于解空间结点数为 2 n 2^n 2n 或者 n ! n! n! ,在最坏情况下,回溯法的时间复杂度一般为 O ( p ( n ) ∗ 2 n ) O(p(n)*2^n) O(p(n)∗2n) 或 O ( q ( n ) ∗ n ! ) O(q(n)*n!) O(q(n)∗n!) ,其中 p ( n ) p(n) p(n) 和 q ( n ) q(n) q(n) 均为 n n n 的多项式。
0-1背包问题回溯法代码
//对于放入背包的物品,该类记录单个物品的信息
public class BLBag implements Comparable<BLBag> {
//物品名字
private String name;
//物品重量
private int weight;
//物品价值
private int value;
//单位重量价值,设置为int类型有问题,比如5/3和4/3int以后就会出现相等。
private double unitValue;
//重写Comparable接口的方法,用于比较该类与传入的BLbag的单位价值的大小
//方便后面Array.sort调用的时候使用
@Override
public int compareTo(BLBag snapsack) {
double value = snapsack.unitValue;
if (unitValue > value)
return 1;
if (unitValue < value)
return -1;
return 0;
}
//下面构造方法与get,set方法
public BLBag(String name, int weight, int value) {
this.weight = weight;
this.value = value;
this.name = name;
//由于value和weight都是int类型,在算除法之前需要转成double
this.unitValue = (weight == 0) ? 0 : (double) value / (double) weight;
}
public String getname() {
return name;
}
public void setname(int weight) {
this.name = name;
}
public int getWeight() {
return weight;
}
public void setWeight(int weight) {
this.weight = weight;
}
public int getValue() {
return value;
}
public void setValue(int value) {
this.value = value;
}
public double getUnitValue() {
return unitValue;
}
}
那么使用回溯法解决背包问题如下:
import java.util.Arrays;
import java.util.Collections;
public class Knapsack{
// 待选择物品数量
private int n;
// 待选择的物品
private BLBag[] bags;
// 背包的总承重
private int totalWeight;
// 背包的当前承重
private int currWeight;
// 放入物品后背包的最优价值
private int bestValue;
// 放入物品和背包的当前价值
private int currValue;
//构造方法,将物品按照一定规则排序,并且初始化背包容量
public Knapsack(BLBag[] bags, int totalWeight) {
this.bags = bags;
this.totalWeight = totalWeight;
this.n = bags.length;
// 物品依据单位重量价值从大到小进行排序
Arrays.sort(bags, Collections.reverseOrder());
}
//回溯算法,从子集树第i个开始。。。
public int backtrack(int i){
//到达子集树的叶结点,相当于此时没有物品可以放入背包,当前价值为最优价值
if (i >= n) {
bestValue = currValue;
return bestValue;
}
// 首要条件:放入当前物品,判断物品放入背包后是否小于背包的总承重
if (currWeight + bags[i].getWeight() <= totalWeight) {
// 将物品放入背包中的状态
currWeight += bags[i].getWeight();
currValue += bags[i].getValue();
// 选择下一个物品进行判断
bestValue = backtrack(i + 1);
// 将物品从背包中取出的状态
currWeight -= bags[i].getWeight();
currValue -= bags[i].getValue();
}
// 次要条件:不放入当前物品,放入下一个物品可能会产生更优的价值,则对下一个物品进行判断
// 当前价值+剩余价值<=最优价值,不需考虑右子树情况,由于最优价值的结果是由小往上逐层返回,
// 为了防止错误的将单位重量价值大的物品错误的剔除,需要将物品按照单位重量价值从大到小进行排序
if (currValue + getSurplusValue(i + 1) > bestValue) {
// 选择下一个物品进行判断
bestValue = backtrack(i + 1);
}
return bestValue;
}
/**
两种方法获取上界:
1.是直接获取未被选择的所有的价值,作为上界
2.根据当前剩余重量,根据贪心思想获取能够获得的最大价值,作为上界
**/
// // 方法1:获得物品的剩余总价值surplusValue
// public int getSurplusValue(int i) {
// int surplusValue = 0;
// for (int j = i; j < n; j++)
// surplusValue += bags[i].getValue();
// return surplusValue;
// }
//方法二:剩余容量能够获得的最大价值surplusValue。
public int getSurplusValue(int i) {
int residualWeight = totalWeight - currWeight;//计算剩余容量 = 总承重 - 当前承重
int surplusValue = 0;
//由于物品数组bags已经按照单位重量价值从大到小排好序了。
//所以按照贪心策略,如果第i个能够装进背包,那就将其价值累加。
while (i < n && bags[i].getValue() <= residualWeight) {
//考虑了装第i个的,那么剩余容量相应减少,而获得的最大价值相应增加
residualWeight -= bags[i].getWeight();
surplusValue += bags[i].getValue();
i++;
}
//到这里可能还存在剩余背包未装满,但是剩余容量却小于当前的第i个的重量。也按照贪心的思想分块加
if (i < n) {
surplusValue += bags[i].getValue() * residualWeight / bags[i].getWeight();
}
return surplusValue;
}
}
对于上面的代码,可以写个测试用例:
public void test1(){
int w = 194;// 背包的容量
int n = 7;// 物品的个数
BLBag[] bags = new BLBag[n];
int[] weight = {
72, 33, 37, 94, 39, 99, 5};
int[] value = {
9, 95, 6, 4, 88, 42, 37};
String pid;
for (int i = 0; i < n; i++) {
pid = "p" + i;
bags[i] = new BLBag(pid, weight[i], value[i]);
}
Knapsack knapsack = new Knapsack(bags, w);
System.out.println("最优解为:" + knapsack.backtrack(0));
}
分支限界法(branch-and-bound)
GitHub代码:分支限界法(branch_and_bound)
分支限界法类似于回溯法,都是在问题的解空间树上搜索问题解的算法,但是又不同于回溯法:
- 回溯法的求解目标是找出解空间树中满足约束条件的所有解。一般使用深度优先的方式搜索解空间树。
- 分支限界法则是找出满足约束条件的一个解。或者说满足约束条件的解中的一个最优解。一般使用广度优先搜索的方式进行搜索。
对于广度优先搜索,可以借用队列来实现广度优先搜索。
没写完,感觉要表述出来很麻烦。,但是可以参考我的代码,该文章的所有的代码都在: 0-1背包问题求解总结
参考
a.k.a. dd_engi
的tianyicui/pack: 背包问题九讲 (github.com)
算法设计与分析(第四版)