回溯法求解0-1背包问题(细节分析)

回溯法求解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就会进行一次排序,这样代码的效率大大下降,最后的做法是先对所有的物品根据性价比由高到低排序,然后再做回溯操作。希望大家不要被本篇的这种不太合理的写法误导。

总结

就我个人来看,回溯的思路很简单,真正理解代码种的细节才是最重要,希望本篇对你有所帮助!!

回溯法0_1背包问题时,会用到状态空间树。在搜索状态空间树时,只要其左儿子结点是一个可行结点,搜索就进入其左子树。当右子树有可能包含最优时才进入右子树搜索,否则将右子树剪去。设r是当前剩余物品价值总和;cp是当前价值;bestp是当前最优价值。当cp+r≤bestp时,可剪去右子树。计算右子树中的上界可以用的方法是将剩余物品依其单位重量价值排序,然后依次装入物品,直至装不下时,再装入该物品的一部分而装满背包。由此得到的价值是右子树中的上界,用此值来剪枝。 为了便于计算上界,可先将物品依其单位重量价值从大到小排序,此后只要顺序考察各物品即可。在实现时,由MaxBoundary函数计算当前结点处的上界。它是类Knap的私有成员。Knap的其他成员记录了空间树种的节点信息,以减少函数参数的传递以及递归调用时所需要的栈空间。在空间树的当前扩展结点处,仅当要进入右子树时才计算上界函数MaxBoundary,以判断是否可以将右子树减去。进入左子树时不需要计算上界,因为其上界与父结点的上界相同。 在调用函数Knapsack之前,需要先将各物品依其单位重量价值从达到小排序。为此目的,我们定义了类Objiect。其中,运算符与通常的定义相反,其目的是为了方便调用已有的排序算法。在通常情况下,排序算法将待排序元素从小到大排序。 在搜索状态空间树时,由函数Backtrack控制。在函数中是利用递归调用的方法实现了空间树的搜索
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值