将n个项目的权重和值,放入一个容量为W的背包中,得到背包中最大的总价值。换句话说,给定两个整数数组val[0..n - 1]和wt [0 . .n-1],分别表示与n个项目相关的值和权重。同样,给定一个表示背包容量的整数W,找出val[]的最大值子集,使该子集的权值之和小于或等于W。
动态规划
一个简单的解决方案是考虑项目的所有子集并计算所有子集的总权重和值。考虑唯一子集的总重量小于w .从所有这些子集,选择最大价值的子集。
要考虑所有项目的子集,每个项目可以有两种情况:
- 项目包含在最优子集中
- 不属于最优集。
因此,从n个项目中可以得到的最大值是以下两个值中的最大值。
- n-1项和W权值的最大值(不含第n项)。
- 第n项的值加上n-1项的最大值和W减去第n项(包括第n项)的权重。
如果第n项的权值大于W,则不能包含第n项,情况1是唯一的可能。
下面是递归实现,它简单地遵循上面提到的递归结构。
/* 一个简单的递归实现0-1背包问题 */
#include
//返回两个整数最大值的实用函数
int max(int a, int b) { return (a > b)? a : b; }
// 返回可放入容量为W的背包中的最大值
int knapSack(int W, int wt[], int val[], int n)
{
// 基本情况
if (n == 0 || W == 0)
return 0;
// 如果第n项的重量大于背包容量W,则
//这一项不能包含在最优解中
if (wt[n-1] > W)
return knapSack(W, wt, val, n-1);
//最多返回两种情况: (1)包括第n项 (2)不包括在内
else return max( val[n-1] + knapSack(W-wt[n-1], wt, val, n-1),
knapSack(W, wt, val, n-1)
);
}
//驱动程序测试上述功能
int main()
{
int val[] = {60, 100, 120};
int wt[] = {10, 20, 30};
int W = 50;
int n = sizeof(val)/sizeof(val[0]);
printf("%d", knapSack(W, wt, val, n));
return 0;
}
输出:220
应该注意的是,上面的函数一次又一次地计算相同的子问题。看下面的递归树,K(1,1)被求了两次。这个简单递归解的时间复杂度为指数(2^n)。
在下面的递归树中,K()表示knapSack()。这两个下面递归树中的参数是n和W。递归树用于以下示例输入。
递归树背包容量2单位和3项1单位重量。
由于子问题是重新求值的,所以这个问题具有重叠子问题的性质。0-1背包问题具有动态规划问题的两个性质。与其他典型的动态规划(DP)问题一样,通过自底向上构造一个临时数组K[][],可以避免相同子问题的重计算。下面是基于动态规划的实现
int knapSack(int W, int wt[], int val[], int n)
{
int i, w;
int K[n+1][W+1];
// 以自底向上的方式构建表K[][]
for (i = 0; i <= n; i++)
{
for (w = 0; w <= W; w++)
{
if (i==0 || w==0)
K[i][w] = 0;
else if (wt[i-1] <= w)
K[i][w] = max(val[i-1] + K[i-1][w-wt[i-1]], K[i-1][w]);
else
K[i][w] = K[i-1][w];
}
}
return K[n][W];
}
(主函数与上面的一致)
时间复杂度:O(nW)其中n为物品数量,W为背包容量。
回溯法
这个问题的解空间由 2^n 种不同的方法组成,它们为x分配0或1个值。因此,解空间与子集和问题的解空间相同。
需要使用边界函数来帮助杀死一些活动节点,而不需要实际展开它们。通过使用可通过扩展给定的活动节点及其任何后代获得的最佳可行解的值的上界来获得该问题的良好边界函数。如果这个上界不高于目前确定的最佳解的值,则可能会杀死活动节点。
这里我们使用固定的元组大小公式。如果在节点Z处已经确定了xi的值,1≤i≤k,则对k+1≤i≤n放宽xi = 0或1到0≤xi≤1的要求,用贪心法求解放宽问题,得到Z的上界。
程序 Bound(p、w、k、M)确定了在状态空间树的k+1级展开任何节点Z所能得到的最佳解的上界。
目标重量和收益是W(i)和P(i), 假定P (i) / W (i)≥P (i + 1) / W (i + 1), 1≤ i ≤ n。
procedure BOUND(p,w,k,M)
// p:当前利润总额
// w:当前的总重量
// k:最后删除项的索引
// M:背包大小
//结果是新的利润
global n , P(1:n) , W(1:n)
integer k, i l real b,c,p,w, M
b := p ; c := w
for i := k+1 to n do
c := c + W(i)
if c < M then b := b + P(j)
else return (b + (1 - (c - M)/W(i))*P(i))
endif
repeat
return (b)
end BOUND
备注:由此可知,节点Z的可行左子节点(x(k) = 1)的界与节点Z相同。因此,当回溯算法移动到节点的左子节点时,不需要使用边界函数。由于回溯算法会尝试在给定的左子节点和右子节点之间进行移动,所以只有在一系列成功的左子节点移动(i,e,移动到可行的左子节点)之后才需要使用边界函数。
伪代码:
procedure Knapsack(M,n,W,P, fw,fp,X)
// M:背包的大小
// n:权重和利润的数量
// W(1:n):权重
// P(1:n):对应利润;P (i) / W(i) ≥ P (i + 1) / W (i + 1)
// fw:背包的最终重量
// fp:最终的最大利润
// X(1:n),不是0就是1;如果W(k)不在背包里,则X(k) = 0,否则X(k) = 1
integer n,k, Y(1:n), i , X(1:n) ;
real M, W(1:n), P(1:n), fw, fp, cw, cp ;
cw := cp := 0 ; k := 1 ; fp := -1
// cw =权重,cp =利润
loop
while k <= n and cw + W(k) <= M do
// 把k放在背包里
cw := cw + W(k) ; cp := cp + P(k) ; Y(k) := 1 ; k := k+1
repeat
if k > n then fp := cp; fw := cw ; k := n ; X := Y
// 更新解决方案
else Y(k) := 0
// 超过M,所以对象k不合适
endif
while BOUND(cp,cw,k,M) ≤ fp do
// 在上面设置fp之后,BOUND = fp
while k <> 0 and Y(k) <> 1 do
k := k -1 // 找出背包里的最后一个权值
repeat
if k = 0 then return endif // 算法结束
Y(k) := 0 ; cw := cw - W(k) ; cp := cp - P(k) // 删除第k项
repeat
k := k+1
repeat
end knapsack
分支限界法解决0-1背包问题
动态规划通过最优子结构,将问题转换为子问题的求解。转换的过程中,涉及到某个具体的商品是否选择的问题。
回溯法
根据数学表达式,搜索解向量(x1, x2, ..., xn)的整个解空间
搜索的时候利用贪心性质(按照单位重量价值递减排序,估算可能的最高上界)、以及已经计算出的可行解作为界限进行剪枝。
但是回溯法,原则上要穷尽所有可能,只不过是对有些分支提前返回了。
分支限界法
剪枝方法同回溯法是一样的:利用贪心性质(按照单位重量价值递减排序,估算可能的最高上界)、以及已经计算出的可行解作为界限进行剪枝。
唯一的不同是,分支限界法利用的是优先队列,并且,当针对一个结点进行扩展时,会将所有儿子结点进行展开,计算出所有儿子结点所能达到的最高上界。因此,当一个优先队列中首结点是一个可行解,则结束。
因此,可以看出回溯法与分支限界法的本质不同是在于搜索解空间的遍历方式不同。
回溯法是深度优先,要穷尽解空间的所有可能,找到最优解。
分支限界法是广度优先,本质上也是穷尽了解空间的所有可能,找到最优解。