开篇
0-1背包问题我们已经说过很多次了,这次是用最近学的分支限界法解决。分支限界法就是利用队列或者优先队列在储存解空间树的活结点,并每次弹出一个作为扩展结点,是一种广度优先遍历,区别于回溯法的深度优先遍历。而优先队列时间复杂度更低,因为我们每次加入一个活结点时,队列都会排序,所以我们出队的结点一定是优先级最高的。下面我们就来说一下如何利用分支限界法解决0-1背包问题。
分支限界法解决0-1背包问题
在回溯法中我们谈到了上界函数这个概念,在分支限界法中也有这个函数,这个函数主要是来约束右节点的。在回溯法中我们的上界函数是:剩余价值+当前价值>当前最优价值
,当这个函数满足时我们才有必要将右子节点加入到队列中。但是分支限界法这个上界函数有一些变化,具体什么变化可以往下看。
在使用分支限界的时候我们会首先考虑优先队列,因为优先队列往往能降低复杂度让算法更快。那么我们就要定义优先级。在我们谈论用分支限界法解决装载问题的时候我们也设定了优先级,当时我们的优先级设为当前重量+剩余重量
。但是在这里是0-1背包问题,并不是完全背包问题,即物品只能装或者不装,这里我们定义上界函数的时候,假设背包可以装一部分,即当背包装不下一整件物品时我们只装一部分。所以我们定义上界函数为当前重量+剩余物品中单位重量价值最大的物品的平均价值*剩余重量
,因此在这里我们可以想到,我们需要按照物体单位重量价值从大到小的顺序排列物品。
于是我们就可以推演出上界函数,如果当前重量+剩余物品中单位重量价值最大的物品的平均价值*剩余重量 > 当前最优价值
时说明我们有必要将右节点加入到队列中,否则不加入右节点。
慢慢地我们更新当前最优价值,当前价值,当前背包容量,取下一活结点作为扩展结点,继续进行遍历,直到我们到达了第一个叶结点,那就表明我们已经找到了最优解(因为优先队列每次都会弹出优先级最高的活结点)。
最后一个问题,我们怎样获得最优解路径呢?我们在写代码的时候会写一个bbnode类作为子集树中的结点类,它的成员有parent父节点和bool类型的lchild,指示左节点是否加入活结点。Lchild就是我们记录最优路径的主要标量,如果Lchild=1说明此时我们走了左节点,Lchild=0说明此时我们走了右节点。
但是这不是回溯法,而是分支限界法,我们的数据结构不是什么子集树,而是一个优先队列,怎么把子集树的结点和优先队列中的元素关联起来呢?答案是我们只需要创建一个最大堆,堆结点类中的数据成员包括堆结点当前重量、当前价值、最优价值、还有指向子集树结点的指针以及活结点在树中的层数。
最后我们直接返回最优解和最优解路径。下面我们来看代码里的其他细节,我都写好了注释。
代码
#include <iostream>
using namespace std;
//背包物品类
class Object
{
friend int Knapsack(int*,int*,int,int,int*);
public:
int operator<=(Object a) const{return (d >= a.d);}
private:
int ID;
float d;//单位重量价值
};
//子集树中的结点类
class bbnode
{
friend Knap<int,int>;
friend int Knap(int*,int*,int,int,int*);
private:
bbnode *parent;
bool Lchild;
};
//堆中堆结点类
class HeapNode
{
friend Knap<int,int>;
public:
operator int () const {return uprofit;}
private:
int uprofit;//结点的价值上界
int profit;//结点相应的价值
int weight;//结点相应的重量
int level;//活结点在子集树中所处的层序号
bbnode *ptr;//指向活结点在子集树中相应结点的指针
};
//背包类,记录当前背包的最大价值,当前价值以及容量
class Knap
{
friend int Knapsack(int*,int*,int,int,int*);
public:
int MaxKnapsack();
private:
MaxHeap<HeapNode<int,int>> *H;//创建一个最大堆
int Bound(int i);//计算上界函数
void AddLiveNode(int up,int cp,int cw,bool ch,int lev);//添加活结点进入优先队列
bbnode *E;//指向扩展结点的指针
int n;//物品总数
int *w;//物品重量数组
int *p;//物品价值数组
int cw;//当前重量
int c;//背包容量
int cp;//当前价值
int *bestx;//最优解数组
};
int Knap<int,int>::Bound(int i)//计算结点所相应的价值上界,贪心思想
{
int cleft = c - cw;//剩余容量
int b = cp;//价值上界,函数返回值
//以物品单位价值递减顺序排列 补充代码
while(i <= n && w[i] <= cleft)
{
b+=p[i];
cleft -= w[i];
i++
}
if(i<n)
b += p[i] / w[i] * cleft;
return b;
}
//将活结点加入优先队列中
void Knap<int,int>::AddLiveNode(int up,int cp,int cw,bool ch,int lev)
{
bbnode *b = new bbnode;
b->parent = E;//E指向当前的扩展结点
b->Lchild = ch;
HeapNode<int,int>N;
N.ptr = b;//指向子集树中活结点对应的结点
N.uprofit = up;//这是优先队列的优先级,当前加入的重量+剩余可装入的最大重量(此最大重量为可加入的单位最大重量)
N.profit = cp;
N.weight = cw;
N.level = lev;
H->insert(N);//将这个点加入到优先队列中
//整体结构为堆的结点指向子集树的结点,然后加入堆中
}
//按照优先级遍历子集树,将活结点加入到优先队列中
int Knap<int,int>::MaxKnapsack()
{
H = new MaxHeap<HeapNode<int,int>>(1000);//声明一个优先队列,内部成员类型为堆结点类型
bestx = new int [n+1];//记录最优解
cw = cp = 0;
int bestp = 0;
int up = Bound(1);
int i = 1;
E = 0;//当前扩展结点为0
while(i != n + 1)
{
//检查当前扩展结点的左儿子结点
int wt = cw + w[i];
if(wt <= c)
{
if(cp+p[i]>bestp)
{
bestp = cp+p[i];
}
AddLiveNode(up,cp+p[i],cw+w[i],true,i+1);
}
//这里就算的Bound只是为了给右节点一个约束,看是否有必要将右节点加入到活结点队列中
up = Bound(i+1);
if(up>=bestp)
{
//右节点有机会加入
AddLiveNode(up,cp,cw,false,i+1);
}
//取下一扩展结点
HeapNode<int,int> N;
H->DelMax(N);
up = N.uprofit;//更新最大价值
cp = N.profit;//更新当前价值,为新的扩展结点的价值
cw = N.weight;//更新当前重量,为新的扩展结点的重量
E = N.ptr;//下一扩展点,子集树中的点
i = N.lev;
}
for(int j = n;j > 0;j--)
{
bestx[j]=E->Lchild;//将路径解记录下来
E = E->parent;//逐渐向上遍历找出路径上的具体添加方案
}
}
//Knapsack函数完成对输入数据的预处理,我们输入的object类的物品,根据我们的最大上界函数即Bound函数可知
//我们需要将单位重量的价格从大到小排序,从而计算Bound,将其作为优先级
//所以这个函数的作用是将输入的Object类对象排序传递给Knap类对象,返回最大价值
int Knapsack(int p[],int w[],int c,int n,int bestx[])
{
//初始化
int W = 0;//装包物品重量
int P = 0;//装包物品价值
Object *Q = new Object [n];
for(int i = 0;i <= n;i++)
{
//单位重量价值数组
Q[i-1].ID = i;
Q[i-1].d = 1.0 * p[i] / w[i];
P += p[i];
W += w[i];
}
if(W <= c)
{
return P;
}
Sort(Q,n);//这里自定义一个函数依单位重量价值排序
//创建类Knap的数据成员
Knap<int,int> K;
K.p = new int [n+1];
K.w = new int [n+1];
for(int i = 1;i <= n;i++)
{
K.p[i] = p[Q[i-1].ID];
K.w[i] = w[Q[i-1].ID];//按照单位重量价值排好序的价值和重量数组
}
K.cp = 0;
K.cw = 0;
K.c = c;
K.n = n;
int bestp = K.MaxKnapsack();//调用函数求问题的最优解
for(int j = 1;j <= n;j++)
{
bestx[Q[j-1].ID] = K.bestx[j];
}
delete[] Q;
delete[] K.w;
delete[] K.p;
delete[] K.bestx;
return bestp;
}
总结
分支限界法确实是比回溯法更简单,因为我们广度优先遍历,而且每次找优先级最高的,当达到叶结点时一定是最优的。算法的时间复杂度主要是在计算最优解的过程,这取决于我们达到叶结点的时候经过了多少节点,有可能我们没有遍历完一层,下一层的优先级更高,我们就会更进一层,所以取决于我们中间经历过的结点。树的结点数目为2^n.
但是最后一层我们只会找到一个结点,而最后一层的结点数目应该是2^(n-1).
所以我们的时间复杂度最差应为2^(n-1)(2^n-2^(n-1)
),但基本不可能。