回溯法求解0-1背包问题(细节分析)
论temp数组的必要性和判断cv>bestv的不必要性
回溯法
应用回溯法求解问题时,首先应明确定义问题的解空间,该解空间应至少包含问题的一个最优解。例如,对于有n种物品的 0-1 背包问题,其解空间由长度为n的 0-1 向量组成,该解空间包含了对变量的所有可能的0-1 赋值。当 n=3 时,其解空间是{ (0, 0, 0), (0, 0, 1), (0, 1, 0), (0, 1, 1), (1, 0, 0), (1, 0, 1), (1, 1, 0), (1, 1, 1) },对于这种情况来说,回溯法求解的是一个子集树问题,回溯法求解类似问题的优势是利用了解树的结构,但是代码过程中并没有生成一棵树,只是利用了树的逻辑结构,节省了空间。
问题描述
0/1背包问题:给定种物品和一个容量为的背包,物品的重量是,其价值为,背包问题是如何使选择装入背包内的物品,使得装入背包中的物品的总价值最大。其中,每种物品只有全部装入背包或不装入背包两种选择。
代码
#include <iostream>
#include<fstream>
#include <iomanip>
#define N 100
using namespace std;
int n;//物品数量
double c;//容量
double index[N],w[N],v[N]; //定义索引数组,物品质量数组,物品价值数组
double bestv;//最优价值
double cw,cv;//当前重量,当前价值
int x[N];//标记放与不放
int temp[N];
double tw[N],tv[N],wv[N];
void Rank(double arr[],int left,int right)//快速排序 从大到小
{
if(left>=right){
return ;
}
int i,j;
double temp;
i=left;
j=right;
temp=arr[left];//排序用的基准值,可自选
//比基准值大的放在右边,小的放在左边。
//可自选方法进行实现,最后结果要求比基准值小的在右边,比基准值大的在左边。
while(i!=j){
while(arr[j]<=temp && i<j){
j--;
}
while(arr[i]>=temp && i<j){
i++;
}
if(i<j){
//出现左边小于基准且右边大于基准的情况,两者交换后则满足左边大右边小
swap(arr[i],arr[j]);
}
}
//i左边的值都大于等于基准值,右边都小于基准值
//进行过交换的地方一定满足,所以循环终止情况只有i和j两者相遇
//且在这种方法中,终止的位置一定是满足小于等于,不一定满足大于,因为是先从右往左进行查找
swap(arr[left],arr[i]);
//基准值左边大于等于基准值,右边小于等于基准值,继续将左右区间进行快排。
Rank(arr,left,i-1);
Rank(arr,i+1,right);
}
double bound(int i)
{
double cleft = c-cw;//背包剩余容量
double bound= cv;
for(int j =0;j<n;j++)
{
tw[j]=w[j];
tv[j]=v[j];
wv[j]=v[j]/w[j];
}
Rank(wv,i,n-1); //数组a排好序,b记录了物品的下标
while(i<n&&w[i]<=cleft)
{
cleft-=w[i];
bound+=v[i];
i++;
}
if(i<n)
{
bound+=(cleft/w[i])*v[i];
}
return bound;
}
void Backtrack(int i)
{
if(i>=n)
{
if(cv>bestv)
/*这个判断其实不必要,因为对于每个可行解来说,只要解中存在0那就说明进行了bound判断,此时返回的bound值就是cv值,
在右子树的条件中已经判断了bound值大于bestv,因此此时这个比较不必要,对于全1的解来说,他就是最优解,没有必要判断,
因此综合两种情况讨论来说,这个判断条件不必要。*/
{
bestv=cv;
for(int j=0;j<n;j++)
{
x[j]=temp[j];
}
}
return;
}
if(cw+w[i]<=c)
{
temp[i]=1;
cw+=w[i];
cv+=v[i];
Backtrack(i+1);
cw-=w[i]; //回溯
cv-=v[i];
//temp[i]=0; 赋值0操作写在此处时,temp数组运算到最后会是全0状态。
}
if(bound(i+1)>bestv)
{
temp[i]=0; //回溯赋值还原,temp最后一次存储的是求解的一个过程值
Backtrack(i+1);
}
}
int main(){
ifstream input("input.txt");//打开输入文件
if(!input)cout<<"The file can not open!!"<<endl;//文件打开失败
input>>c;//读入背包容量
while(!input.eof())
{
input>>index[n]>>w[n]>>v[n];//按照顺序一次读取ID,物品价值,物品质量
n++;
}
Backtrack(0);
cout<<cv<<bestv<<n<<c<<endl;
cout<<"The result is in the output.txt!"<<endl;
ofstream output;
output.open("output.txt");
if(!output)cout<<"The file can not be open!!"<<endl;
output<<bestv<<endl;
for(int i=0;i<n;i++)
{
output<<i+1<<"\t"<<x[i]<<"\t"<<v[i]<<"\t";
output<<endl;
}
}
细节分析
回溯法求解0-1背包的常规思路在这里就不赘述了,本篇主要讨论一些代码中需要注意的细节部分。
1.代码中需要用一个数组temp[n]来存储每次产生的解,当是最优解的情况时,再把temp[]的值赋值给x[n],最后输出的最优解是x[n],原因是有可能最优解是在中间过程中得到的,因此只用x[n]时,即使得到了最优值也没有被保存下来,会被后面的值覆盖掉,因此temp[n]数组的必须的。
2.然后我们再来讨论一下temp[n]这个数组最后输出的是什么内容,如果temp[n]最后输出的内容与x[n]一样,那么就说明我们用temp[n]是不必要的。在此我们需要考虑的关键点在于,在得到最优解后,x[n]数组的值不会再发生变化,而对于temp[n]来说,在回溯过程中依然可能会被改变。这里我们分两种情况来讨论。
第一:回溯代码写在左子树里面,此时在每次回溯时,temp数组会被赋值为0,因此最后在所有的可行解都得出的情况下,在回退过程中temp会被全赋值为0,因此输出temp[n]会发现为全0;
第二:回溯代码写在右子树里面,此时赋值只有在进入右子树的情况下才能实现,因此在回溯过程中,在进入右子树的情况下,temp的值会被改变,不进入右子树的情况下不会赋值,因此temp最后保留下来的只是一个中间值,不一定是最后一次的可行解。此时的temp数组保留下的值其实没有实际意义。
3.然后我们讨论一下在backtrack函数中为什么还要写一下cv>bestv的条件。其实来说这个条件是不必要的。
这个判断其实不必要,因为对于每个可行解来说,只要解中存在0那就说明进行了bound判断,此时返回的bound值就是cv值,在右子树的条件中已经判断了bound值大于bestv,因此此时这个比较不必要,对于全1的解来说,他就是最优解,没有必要判断,因此综合两种情况讨论来说,这个判断条件不必要。至于我们为什么要写上这个条件,因为从逻辑严谨的角度考虑是需要这个条件的,并且证明这个条件不必要的过程很复杂,因此对于学习回溯算法结构来说,加上这个条件更有保证,结构更完整。
4.然后我们讨论一些为什么回溯里面又要用到贪心算法。其实贪心算法是为了提高算法的效率,我们不使用贪心算法也是也可实现的,只是代码的效率会大大下降。我们考虑,贪心算法是用在进入右子树时,贪心算法计算的就是剩下的背包容量所装的物品的价值的极限是多少,当这个极限大于当前最优解的价值时,就进入右子树判断,但是我们知道,贪心算法存在切割问题,不适用与0-1背包,因此我们利用贪心算法得到的值,最后不一定能取到,我们这样做的目的是为了减少一些判断情况。最简单的方式就是我们可以不用贪心算法,我们假设后面的物品全都取,如果全都取的价值大于当前的最优值,就进入右子树。这样来说也可以实现,但是会增加很多进入右子树的情况,代码效率会大大下降。因此来说,利用贪心算法就是要过滤掉很多种不必要进入右子树的情况,提高代码效率。
5.最后再讨论一下,这个算法不好的地方。本次实现过程中把排序操作写在了bound函数里面,这样做其实很不合理,因为这样以来每次判断bound就会进行一次排序,这样代码的效率大大下降,最后的做法是先对所有的物品根据性价比由高到低排序,然后再做回溯操作。希望大家不要被本篇的这种不太合理的写法误导。
总结
就我个人来看,回溯的思路很简单,真正理解代码种的细节才是最重要,希望本篇对你有所帮助!!