问题&&题目分析
假设有n个物品和1个背包,每个物品的重量为wi,价值为vi,每个物品只有1件,要么装入,要么不装,不可拆分,背包载重量一定,如何装使背包装入的物品价值最高?
算法
回溯法
首先来说几个小概念:
孩子:这个结点子树上的所有结点;
活结点:有自身,孩子未全部生成的结点;
死结点:孩子已全部生成的结点;
扩展结点:一个正在生孩子的结点;
我觉得回溯法就是深度优先搜索(DFS),先向纵深结点扩展,当不满足约束条件时回溯到最近的活结点,然后继续扩展,直至所有结点都变成死结点。说的通俗一点就是,在子集树(解空间构成的树)中,能进就进,不能进就换,再不行就退。
算法核心
本题的核心就是判断能否生成左子树使用约束条件,判断能否生成右子树使用限界条件,还有在进行扩展到死结点时向最近的活结点回溯。
约束条件就是判断能否得到可行解的条件,在本题中就是当前重量+下一级结点物品重量<背包容量,这样下一级结点的物品才可以放入背包,也就是可以生成左孩子结点,就是这个物品可以加入背包然后左孩子结点,之后继续扩展得到左子树。
限界条件就是判断能否得到最优解的条件,在本题中就是当前总价值+之后结点所有物品的总价值>之前计算的最大价值,因为只有这种情况下,继续扩展才有可能得到比之前最大价值更高的价值,如果之后的物品全都加入背包而不能比之前计算价值更高的话,那么这个分支就没有计算的必要了。如果满足限界条件,就是说这个物品不放进去也可能得到更高的价值,那么这个物品就可以不放入,也就是可以生成右孩子结点,之后继续扩展得到右子树。
约束条件和限界条件都是隐约束,也可以称为剪枝,就是在解空间中把得不到可行解和最优解的部分剪掉,这样可以提高搜索效率。也许你现在想问那什么是显约束呢,显约束就是对于解空间取值范围的限定,比如说本题中解空间是一个n元组{x1,x2,…,xn},每件物品只有装和不装两种状态,也就是xi只有0和1两个值,0表示不装,1表示装,这就是显约束。
最后一点就是回溯,回溯用递归实现,也就是在函数中调用自身。注意左子树和右子树的回溯是不一样的,因为二者对于当前价值和当前重量的影响是不一样的,左子树表示物品可以放入背包,那么当前价值会加上该物品的价值,当前重量也会加入该物品的重量,那么在进行完下一级的扩展之后,要在当前价值和当前重量中减去相应的值,说白了就是怎么加上的怎么减掉。右子树表示可以不放入背包,这里所说的可以不放入是满足限界条件之后的,也就是进行剪枝之后的,不可能得到最优解的部分之间剪掉,不予计算。但是物品不放入背包对当前价值和当前重量没有影响,所以回溯的时候没有什么特别的操作。
算法流程
代码实现
#include<iostream>
#include<cstring>
using namespace std;
const int maxn=105;
int n;//物品个数
int W;//背包容量
int w[maxn],v[maxn];//物品的重量和价值
int x[maxn];//当前路径的装法
int cv,cw;//当前价值,当前重量
int bestx[maxn];//最优解
int bestv;//可装的最大价值
int i;
int inf(int j)//计算从树根开始经过该结点的最大价值
{
int fv=0;//该结点之后的总价值
while(j<=n)
{
fv+=v[j];
j+=1;
}
return cv+fv;
}
void dfs(int t)
{
if(t>n)//到达叶子结点就记录解并返回
{
for(i=1; i<=n; ++i)
bestx[i]=x[i];
bestv=cv;
return ;
}
if(cw+w[t]<=W)//如果满足约束条件则搜索左子树
{
x[t]=1;
cw+=w[t];
cv+=v[t];
dfs(t+1);
cw-=w[t];
cv-=v[t];
}
if(inf(t)>bestv)//如果满足限界调节则搜索右子树
{
x[t]=0;
dfs(t+1);
}
}
void init()
{
memset(x,0,sizeof(x));
memset(bestx,0,sizeof(bestx));
cv=0;
cw=0;
bestv=0;
}
int main()
{
cout<<"请输入物品个数:";
cin>>n;
cout<<"请输入背包容量:";
cin>>W;
cout<<"请输入每个物品的重量和价值:";
for(i=1; i<=n; ++i)
cin>>w[i]>>v[i];
init();
dfs(1);
cout<<"可装入的最大价值是:"<<bestv<<endl;
cout<<"装法为:";
for(i=1; i<=n; ++i)
if(bestx[i])
cout<<" "<<i;
cout<<endl;
return 0;
}
优化
以上实现方法中限界函数求得的价值上界是当前价值加上该结点之后所有结点的价值,这个上界过高了,因为还要考虑背包容量,剩余物品不一定能够全部装入背包。所以我们假设物品可以分割,把物品按照单位重量的价值从大到小排序,然后限界函数不是加上之后所有物品的价值,而是加到背包装满为止,由于是物品是按照价值比排序的,所以这样求得的一定是从树根开始经过该结点装满背包的最大价值。这里体现了贪心的思想,可参考背包问题。这样做的好处就是减小上界,加快剪枝速度,提高搜索效率。
优化之后使用结构体存储物品的重量,价值还有序号,一定要注意还有序号,因为之后是要对物品进行重新排序的,但是这个顺序不一定是物品本来的顺序。在dfs()函数中也要注意,对x[]数组进行操作的下标不是t了,而是a[t].id,虽然对于x[]的操作不是连续的,但是不会影响后续的结果。
#include<iostream>
#include<cstring>
#include<algorithm>
using namespace std;
const int maxn=105;
struct item
{
int id;//物品的序号
double w;
double v;
} a[maxn]; //物品的重量和价值
int n;//物品个数
int W;//背包容量
int x[maxn];//当前路径的装法
double cv,cw;//当前价值,当前重量
int bestx[maxn];//最优解
double bestv;//可装的最大价值
int i;
int inf(int j)//计算从树根开始经过该结点至装满背包的价值上界
{
double fv=cv;
double fw=cw;
while(j<=n&&a[j].w+fw<=W)
{
fv+=a[j].v;
fw+=a[j].w;
++j;
}
if(j<n&&fw<W)
fv+=(W-fw)*a[j].v/a[j].w;
return fv;
}
void dfs(int t)
{
if(t>n)//到达叶子结点就记录解并返回
{
for(i=1; i<=n; ++i)
bestx[i]=x[i];
bestv=cv;
return ;
}
if(cw+a[t].w<=W)//如果满足约束条件则搜索左子树
{
x[a[t].id]=1;
cw+=a[t].w;
cv+=a[t].v;
dfs(t+1);
cw-=a[t].w;
cv-=a[t].v;
}
if(inf(t)>bestv)//如果满足限界调节则搜索右子树
{
x[a[t].id]=0;
dfs(t+1);
}
}
bool sort1(item i1,item i2)
{
return i1.v/i1.w>i2.v/i2.w;
}
void init()
{
memset(x,0,sizeof(x));
memset(bestx,0,sizeof(bestx));
cv=0;
cw=0;
bestv=0;
sort(a+1,a+n+1,sort1);//按单位重量的价值从大到小排序
}
int main()
{
cout<<"请输入物品个数:";
cin>>n;
cout<<"请输入背包容量:";
cin>>W;
cout<<"请输入每个物品的重量和价值:";
for(i=1; i<=n; ++i)
{
a[i].id=i;
cin>>a[i].w>>a[i].v;
}
init();
dfs(1);
cout<<"可装入的最大价值是:"<<bestv<<endl;
cout<<"装法为:";
for(i=1; i<=n; ++i)
if(bestx[i])
cout<<" "<<i;
cout<<endl;
return 0;
}
参考文献:《趣学算法》