有N件物品和一个容量为V的背包。(每种物品均只有一件)第i件物品的费用是c[i],价值是w[i]。求解将哪些物品装入背包可使价值总和最大。
特点是:每种物品仅有一件,可以选择放或不放。
然后,我分别用了动态规划法,分支限界法和回溯法来解决这个问题。
//=======================================
以下是测时间的模板:
LARGE_INTEGER BegainTime ;
LARGE_INTEGER EndTime ;
LARGE_INTEGER Frequency ;
QueryPerformanceFrequency(&Frequency);
QueryPerformanceCounter(&BegainTime) ;
//要测试的代码放在这里
DP();
QueryPerformanceCounter(&EndTime);
//输出运行时间(单位:s)
cout<<" 运行时间(单位:s):"<<(double)( EndTime.QuadPart - BegainTime.QuadPart )/ Frequency.QuadPart<<endl<<endl;
随机数据的测量结果:
当数据量为20的物品数和200的背包容量的时候。动态规划快于排序优化的回溯法快于回溯法快于分支限界法。
(可以根据输出的随机数据判断时候得到正确的算法)
当数据量加大到40的物品数和400的背包容量的时候。此时分支限界法和动态规划法的效率依旧不错,时间增长的并不是太多。而回溯法和排序加速的回溯法则时间上升很快。
(为截图方便,此次将随机生成的数据不输出)
以下进行算法分析
//========================================
首先说下动态规划法:
用子问题定义状态:即f[i][v]表示前i件物品恰放入一个容量为v的背包可以获得的最大价值。则其状态转移方程便是:
f[i][v]=max{f[i-1][v],f[i-1][v-c[i]]+w[i]}
把这个过程理解下:在前i件物品放进容量v的背包时,
它有两种情况:
第一种是第i件不放进去,这时所得价值为:f[i-1][v]
第二种是第i件放进去,这时所得价值为:f[i-1][v-c[i]]+w[i]
(第二种是什么意思?就是如果第i件放进去,那么在容量v-c[i]里就要放进前i-1件物品)
最后比较第一种与第二种所得价值的大小,哪种相对大,f[i][v]的值就是哪种。
(这是基础,要理解!)
当然,这只是比较容易理解的一种,时间上似乎没有什么办法继续优化了,但是空间上却可以进一步优化。可以用一维数组实现。
关于一维数组的实现和讲解在这篇blog里有讲,讲的非常详细易懂。
本文为了输出所选择的物品的编号,还是选择了二维数组。实现起来非常方便(详见代码及注释)。
//========================================
接着说回溯法:
首先说一下解空间树(也称状态空间树):
树的根节点位于第1层,表示搜索的初始状态。
第2层的节点表示对解向量的第一个分量做出选择后达到的状态,第1层到第2层的边上标出对第一个分量选择的结果。
……
所有从根到叶子节点的路径就构成了解空间的一个可能解。
例子:
三个物品重量:20, 15, 10。
三个物品价值:20, 30, 20。
参照下图理解: 0表示不选第i个物品,1表示选择(i表示层数)。
图和文字选自这里。
这不重点,只是为了引出回溯法,若还有些不理解的,可以自己去Google下(个人更喜欢用这个),后面的理解要建立在这个基础上。
在回溯法之前,还有一点准备活动——蛮力法。
所谓蛮力法,是对整个解空间树中的所有可能的解进行穷举搜索的一种方法(即每个叶子都代表一种解)。
但是,我们可以对其进行剪枝。
剪枝,就是在搜索至树中的任意一个结点时,先判断该结点对应的部分是否满足约束条件,或者是否超出目标函数的值,也就是判断该结点是否包含问题的(最优)解,如果肯定不包含,则跳过对以该结点为根的子树的搜索。
以下对照图和上图对比理解。
其实,把整个解空间树当成一棵真正的树(倒过来看),剪枝这个词的由来和含义就比较好理解了。
好了,这就是回溯法,也就是深度优先搜索代(码中递归实现和输出选择的物品数)。
其实想想,回溯法也就是对一颗搜索树进行剪枝而来的。
回溯法关于0-1背包的再次优化
当然,最朴素的回溯法已经包含了一定的剪枝,那有没有什么办法,让回溯法在不进行大幅度改动的情况下进一步提高效率呢——答案是肯定的,是的,排序。
在理解上面回溯法的基础上,其实可以比较容易的想到,对体积进行从小到大的排序,注意,是体积,后面的分支限界法用的是单位价值。
对体积排序之后,当小体积的放入已经超出背包容量的时候,自然在其后的稍微大一点或者相等体积的都不用再次尝试了。因此,可以进一步的减少运算量,而实际生成的随机数据此种优化方法速度快了一半以上(由于回溯法时间复杂度的原因,未能进行大一些的随机检查,但是,很明显的趋势是,数据越多,体积排序后的时间优化效果越明显(从代码中也能看出,几乎和普通的代码量和操作难度一样)。
//========================================
最后出场的是分支限界法:
虽然回溯法求0-1背包问题,运用剪枝已经极大的减少了搜索空间,但是整个空间都是按深度优先搜索策略机械地进行,这种搜索太过盲目,能不能让电脑像人一样的思考呢?
这里,我推荐一个讲A*算法的blog。
懂分支限界法思想的朋友可以继续看这个例子,不懂的可以点这里。
其实分支限界法理解了也很好实现,举一个《算法设计与分析》上的例子。
例:0/1背包问题。假设有4个物品,其重量分别为(4, 7, 5, 3),价值分别为(40, 42, 25, 12),背包容量W=10。首先,将给定物品按单位重量价值从大到小排序,结果如下:
我们使用的启发式函数为
通过这个启发式函数得到的一个解空间树如下图:
可以对照一下步骤,具体的搜索过程如下:(红色表示我的代码实现)
(1)在根结点1,没有将任何物品装入背包,因此,背包的重量和获得的价值均为0,根据限界函数计算结点1的目标函数值为10×10=100;
(计算完之后推入队列,作为起始点)。
(2)在结点2,将物品1装入背包,因此,背包的重量为4,获得的价值为40,目标函数值为40 + (10-4)×6=76,将结点2加入待处理结点表PT中;在结点3,没有将物品1装入背包,因此,背包的重量和获得的价值仍为0,目标函数值为10×6=60,将结点3加入表PT中;
(推出结点1,对选择和不选择物品1分别计算ub值,并推入队列)
(3)在表PT中选取目标函数值取得极大的结点2优先进行搜索;
(出队ub值大的点)
(4)在结点4,将物品2装入背包,因此,背包的重量为11,不满足约束条件,将结点4丢弃;在结点5,没有将物品2装入背包,因此,背包的重量和获得的价值与结点2相同,目标函数值为40 + (10-4)×5=70,将结点5加入表PT中;
(重复结点1的操作并入队新结点)
(5)在表PT中选取目标函数值取得极大的结点5优先进行搜索;
(6)在结点6,将物品3装入背包,因此,背包的重量为9,获得的价值为65,目标函数值为65 + (10-9)×4=69,将结点6加入表PT中;在结点7,没有将物品3装入背包,因此,背包的重量和获得的价值与结点5相同,目标函数值为40 + (10-4)×4=64,将结点6加入表PT中;
(7)在表PT中选取目标函数值取得极大的结点6优先进行搜索;
(8)在结点8,将物品4装入背包,因此,背包的重量为12,不满足约束条件,将结点8丢弃;在结点9,没有将物品4装入背包,因此,背包的重量和获得的价值与结点6相同,目标函数值为65;
(9)由于结点9是叶子结点,同时结点9的目标函数值是表PT中的极大值,所以,结点9对应的解即是问题的最优解,搜索结束。
(判断结束并跳出)
我实现的方法是先按单位密度排序优先队列,每次踢出ub值最大的,对排在这个点之后的点选择或者不选择分别进行一次计算,得出相应的ub,放入优先队列中。
跳出的条件设定了两个:
1、当踢出的最大ub在叶子结点上时,结束。
2、当踢出的最大ub和v值相等时,结束。
第一点显而易见,现在说说第二点。
当ub和v相等的时候,可以这么理解,此时背包已经被完全装满了,因此完全不用再继续试下去了,对于剩下的物品,直接全都不选即可,不用再进行计算。
//==============================================
//author: FreeAquar
//data:2011-12
//==============================================
#include <iostream>
#include <cstdio>
#include <algorithm>
#include <cstring>
#include <ctime>
#include <windows.h>
#include <queue>
#include <time.h>
#include <cmath>
#define eps 1e-7
#define nMax 1000
using namespace std;
int n; //总共的物品数量
int W; //背包容量
int cw; //背包中物品总容量
int cp; //背包中物品总价值
double bestP;//0-1背包中最大价值
int record[nMax][nMax]; //0-1背包中当前最大值
bool x[nMax];
bool y[nMax];
struct Node
{
int cw; //背包体积
int cv; //背包总价值
int cnt; //背包中试过多少种物品,当cnt==n时为叶子结点
bool x[nMax]; //选中的物品
Node()
{
memset(x, 0, sizeof(x));
cnt=1;
cw=0;
cv=0;
}
double ub; //启发函数的结果
friend bool operator< (Node n1, Node n2) //按启发函数建立大顶堆
{
return n1.ub < n2.ub;
}
}node[nMax], maxn;
struct Item
{
int w; //物品的重量
int v; //物品的价值
int i; //物品的编号
double p; //物品的单位价值
}item[nMax];
priority_queue<Node> Q; //优先队列
double Max_ub;
double Min_ub;
int Rand(int x); //随机数生成函数
void input(); //读入数据
bool cmp1(Item a, Item b); //按密度排序
bool cmp2(Item a, Item b); //按体积排序
//动态规划法
void DP(); //动态规划
void get_DP(); //找路径
//分支限界法
double calculate_ub(int v, int w, double p); //启发函数
void Insert(bool flag, int i, int Cv, int Cw, double ub, Node temp);//判断和入队
void bfs(); //优先队列搜索
//回溯法
void BackTrack1(int i); //体积排序回溯
void BackTrack2(int i); //回溯
//输出函数
void output_way(bool x[]);
int main()
{
input();
LARGE_INTEGER BegainTime ;
LARGE_INTEGER EndTime ;
LARGE_INTEGER Frequency ;
QueryPerformanceFrequency(&Frequency);
QueryPerformanceCounter(&BegainTime) ;
//要测试的代码放在这里
DP();
QueryPerformanceCounter(&EndTime);
printf("动态规划: %d\n", record[n][W]);
printf(" DP背包选择的物品编号:\n ");
get_DP();
output_way(x);
//输出运行时间(单位:s)
cout<<" 运行时间(单位:s):"<<(double)( EndTime.QuadPart - BegainTime.QuadPart )/ Frequency.QuadPart<<endl<<endl;
maxn.cv=0;
QueryPerformanceFrequency(&Frequency);
QueryPerformanceCounter(&BegainTime) ;
//要测试的代码放在这里
sort(item+1, item+n+1, cmp1);
bfs();
QueryPerformanceCounter(&EndTime);
printf("分支限界法:%d\n", maxn.cv);
printf(" 分支限界法选择的物品编号:\n ");
output_way(maxn.x);
//输出运行时间(单位:s)
cout<<" 运行时间(单位:s):"<<(double)( EndTime.QuadPart - BegainTime.QuadPart )/ Frequency.QuadPart<<endl<<endl;
bestP=0;
cp=cw=0;
memset(x, 0, sizeof(x));
memset(y, 0, sizeof(y));
QueryPerformanceFrequency(&Frequency);
QueryPerformanceCounter(&BegainTime) ;
//要测试的代码放在这里
sort(item+1, item+n+1, cmp2);
BackTrack1(1);
QueryPerformanceCounter(&EndTime);
printf("按体积排序的回溯法:%.0lf\n", bestP);
printf(" 回溯法选择的物品编号:\n ");
output_way(x);
//输出运行时间(单位:s)
cout<<" 运行时间(单位:s):"<<(double)( EndTime.QuadPart - BegainTime.QuadPart )/ Frequency.QuadPart<<endl<<endl;
bestP=0;
cp=cw=0;
memset(x, 0, sizeof(x));
memset(y, 0, sizeof(y));
QueryPerformanceFrequency(&Frequency);
QueryPerformanceCounter(&BegainTime) ;
//要测试的代码放在这里
BackTrack2(1);
QueryPerformanceCounter(&EndTime);
printf("回溯法:%.0lf\n", bestP);
printf(" 回溯法选择的物品编号:\n ");
output_way(x);
//输出运行时间(单位:s)
cout<<" 运行时间(单位:s):"<<(double)( EndTime.QuadPart - BegainTime.QuadPart )/ Frequency.QuadPart<<endl<<endl;
return 0;
}
//=====================================
//随机数,比较函数和读入数据
//=====================================
int Rand(int x)
{
int t=x,sum=0;
while (t>0)
{
sum+=rand()%t;
t-=32767;
}
return sum;
}
bool cmp1(Item a, Item b)
{
return a.p>b.p;
}
bool cmp2(Item a, Item b)
{
return a.w<b.w;
}
void input()
{
n=40; W=400;
//scanf("%d%d", &n, &W);
cout<<"物品数目为:"<<n<<endl;
cout<<"背包容量为:"<<W<<endl;
cout<<"------------------------------------------"<<endl;
double Min=1e20;
double Max=-1;
for(int i=1; i<=n; i++)
{
//每个物品的状态
//scanf("%d%d", &item[i].w, &item[i].v);
item[i].w=Rand(100);
item[i].v=Rand(1000);
item[i].p=1.0*item[i].v/item[i].w;
item[i].i=i;
Min=min(Min, item[i].p);
Max=max(Max, item[i].p);
//启发值的边界
}
//for(int i=1; i<=n; i++)
//cout<<item[i].w<<" "<<item[i].v<<endl;
Max_ub=W*Max;
Min_ub=W*Min;
}
//=====================================
//读入结束
//动态规划法开始
//=====================================
void DP()
{
for(int i=0; i<=n; i++)
record[i][0]=0;
for(int j=0; j<=W; j++)
record[0][j]=0;
for(int i=1; i<=n; i++)
for(int j=1; j<=W; j++)
{
if(j<item[i].w)
record[i][j]=record[i-1][j];
else
record[i][j]=max(record[i-1][j], record[i-1][j-item[i].w]+item[i].v);
}
}
void get_DP()
{
int j=W;
for(int i=n; i>0; i--)
{
if(record[i][j]>record[i-1][j])
{
x[i]=1;
j=j-item[i].w;
}
else
x[i]=0;
}
}
//=====================================
//动态规划结束
//分支限界法开始
//=====================================
double calculate_ub(int v, int w, double p) //计算启发函数
{
return v+(W-w)*p;
}
void Insert(bool flag, int i, int Cv, int Cw, double ub, Node temp)
{
if((ub<Max_ub+eps && ub>Min_ub-eps) && (Cw<=W)) //结点符合要求
{
//cout<<ub<<endl;
Node cm;
cm.cw=Cw;
cm.cv=Cv;
cm.ub=ub;
for(int j=1; j<=n; j++)
{
cm.x[j]=temp.x[j];
}
if(flag)
cm.x[item[i].i]=1;
cm.cnt=i+1;
Q.push(cm);
}
}
void bfs() //广搜
{
Node node;
node.cw=0;
node.cv=0;
node.ub=calculate_ub(0, 0, item[1].p);
Q.push(node);
while(!Q.empty())
{
Node temp=Q.top();
Q.pop();
//当前值与启发式函数结果相同或者叶子结点启发式结果最大
if(abs(temp.ub-temp.cv)<eps || temp.cnt>n)
{
if(maxn.cv<temp.cv+eps)
maxn=temp;
break;
}
int Cw=temp.cw;
int Cv=temp.cv;
for(int flag=0; flag<2; flag++)
//flag=0时,计算不放第temp.cnt+1件物品
//flag=1时,计算放入第temp.cnt+1件物品
{
int i=temp.cnt;
if(flag)
{
Cw+=item[i].w;
Cv+=item[i].v;
}
double ub=calculate_ub(Cv, Cw, item[i+1].p);
Insert(flag, i, Cv, Cw, ub, temp);
}
}
return ;
}
//=====================================
//分支限界法结束
//体积排序回溯开始
//=====================================
void BackTrack1(int i)
{
if(i>n || (W<cw+item[i].w))
{
if(bestP<cp)//更新
{
bestP=cp;
for(int i=1; i<=n; i++)
x[i]=y[i];
}
return ;
}
//cout<<cw<<" "<<item[i].c<<" "<<item[i].w<<" "<<cp<<endl;
cw=cw+item[i].w; //搜索
cp=cp+item[i].v;
y[item[i].i]=1;
BackTrack1(i+1); //左子树
y[item[i].i]=0;
cw=cw-item[i].w; //回溯
cp=cp-item[i].v;
BackTrack1(i+1); //右子树
}
//=====================================
//体积排序回溯结束
//回溯法开始
//=====================================
void BackTrack2(int i)
{
if(i>n)
{
if(bestP<cp) //更新
{
bestP=cp;
for(int i=1; i<=n; i++)
x[i]=y[i];
}
return ;
}
//cout<<cw<<" "<<item[i].c<<" "<<item[i].w<<" "<<cp<<endl;
if(W>=cw+item[i].w) //判断背包是否能放下
{
cw=cw+item[i].w; //搜索
cp=cp+item[i].v;
y[item[i].i]=1;
BackTrack2(i+1); //左子树
y[item[i].i]=0;
cw=cw-item[i].w; //回溯
cp=cp-item[i].v;
}
BackTrack2(i+1); //右子树
}
//=====================================
//所有方法结束
//输出路径
//=====================================
void output_way(bool x[])
{
for(int i=1; i<=n; i++)
{
if(x[i])
printf("%d ", i);
}
cout<<endl;
}