您的位置:首页 > 其它

0/1背包问题-----回溯法求解

2017-05-10 15:48 387 查看

问题描述

有n个物品和一个容量为c的背包,从n个物品中选取装包的物品。物品i的重量为w[i],价值为p[i]。一个可行的背包装载是指,装包的物品总重量不超过背包的重量。一个最佳背包装载是指,物品总价值最高的可行的背包装载。

我们要求出x[i]的值。x[i] == 1表示物品i装入背包,x[i] == 0表示物品i没有装入背包。

问题的公式描述是:

//总价值最高的可能的背包装载,x[i]只能等于0或者1
max{p[1] * x[1] + p[2] * x[2] + ... + p[i] * x[i] + ... + p
* x
}


约束条件

//装入的所有物品的重量之和不大于背包容量
size_t totalWeight = 0;
for(size_t i = 1; i <= n; ++i)
{
totalWeight += w[i] * x[i]; //x[i] = 0 || x[i] = 1
}
if(totalWeight <= c)
//约束条件成立
else
//约束条件不成立


回溯法

顾名思义,回溯法一个很显著的特征就是回溯,即在处理完某种情况或出现不能继续进行的情况时,要退回到之前的某个“交叉口”,处理另一种可能。

通常,回溯法会定义一个解空间,这个解空间通常是以图或树的形式呈现出来的。背包问题的解空间是树的形式,属于深度优先搜索。

问题分析

考察这样一个0/1背包问题: 总共4个物品,重量分别是w[1:4] = {8, 6, 2, 3},价值分别是p[1:4] = {8, 6, 2, 3},规定背包容量为12(即可以容纳的最大重量为12),求出获得最大价值的解情况。

回溯法首先要做的就是根据已知条件建立解空间树。



如图为根据4个物品的重量建立的解空间树,根节点A没有任何意义,每个节点的高度代表它是第几个节点(A节点高度为0)。对于一个节点Z,它的高度是H,则Z节点表示的就是物品H。左孩子表示装入物品H+1(权值是物品H+1的重量),右孩子表示不装入物品H+1(权值是0)。

可以理解为Z就是一个“交叉口”,向Z左边走是一种情况(即物品H+1装入背包的情况),向Z右边走是另一种情况(即物品H+1没有装入背包的情况)。

另外,如果Z的权值不是0,就证明Z的父节点的左孩子,表示物品H已经装入背包。

对于本题而言,起始位置在A点,当前可用容量为12.

1.因为12>8,所以可以向左孩子节点移动,此时位于B点,可用容量变为4,高度为1,表示第一个物品装入背包。

2.考虑B的左右孩子节点,因为当前可用容量不足以装入物品2,所以不能够移向D点,故移动到E点,可用容量仍为4,高度为2,表示第二个物品没有装入背包。

3.考虑E的左右节点J和K,因为当前可用容量可以装入物品3,所以可以移动到J点,可用容量变为2,高度为3,表示第三个物品装入背包。

4.考虑J的左右孩子节点,因为2<3,不足以装入物品4,所以只能够移动到J的右孩子结点。可用容量仍为2,高度为4,表示第四个物品没有装入背包。

5.到达叶子节点,这种情况下背包装入情况是[1,0,1,0],获得的总价值为10,记录此时的叶子节点。

6.开始向上回溯,即回到此时节点的父节点处,回到J点。可用容量为2,高度为3.

7.因为J的右孩子已经考虑过了,所以继续向上回溯,回到J的父节点E点。可用容量要加上J的重量,变为4,高度为2。这时回到了步骤3的交叉口(称为回溯)。

8.因为刚才移动到E的左孩子节点,所以回溯回来后移动到E的右孩子节点K。此时,可用容量不变,仍为4,高度为3。表示第三个物品没有装入背包。

9.考虑K的左右孩子节点,因为4>3,所以可以移动到K的左孩子节点,当前可用容量变为1,高度为4,表示第四个物品被装入背包。

10.到达叶子结点,这种情况下背包装入情况是[1,0,0,1],获得的总价值是11大于10,所以更新最优解的叶子节点。

11.继续向上回溯,直到处理完所有情况。

以上就是回溯法的大体思路,接下来会分别用递归和迭代两种方法求解。

递归求解

递归求解相对简单,不过要对回溯有比较好的理解。

为了减少递归调用传参的代价,可以把大部分的变量作为全局变量使用。

int *Weight; //Weight[i]表示物品i的重量
int *Profit; //Profit[i]表示物品i的价值量
int n; //物品个数


首先考虑如何构建解空间树,

一种方法是以树节点指针的形式描述,但是回溯起来需要另外添加父节点指针。

另一种方法是使用一维数组表示解空间树,对于某个节点Z,它在数组中的索引是index,则Z的左孩子索引是index * 2,Z的右孩子索引是index * 2 + 1,Z的父节点索引是index / 2。(这种描述方法和大根堆,小根堆一样)

这里采用一维数组描述解空间树,

1.需要事先算出树中节点个数。因为已知物品数量n,并且树中每一层代表一个物品,所以树的高度是n+1(算上根节点A)。利用等比数列求和公式可以求出节点个数为2的n+1次方-1。

2.初始化解空间树的一维数组。因为每一层的节点数量和该层的高度有关,所以可以事先算出这一层第一个节点的索引和最后一个节点的索引。然后一个赋值成Weight[i],一个赋值成0,每次i += 2。

初始化解空间树的代码如下:

//节点个数为2的n+1次方-1
size_t pNodeSize = 1;
for(size_t i = 1; i <= n+1; ++i)
pNodeSize *= 2;
pNodeSize--;

//根节点的索引为1,权值为0
int *pTree = new int[pNodeSize+1];
pTree[1] = 0;

for(size_t pHeight = 1; pHeight <= n; ++pHeight)
{
//高度为pHeight的第一个节点的索引为2的pHeight次方
size_t pStartNode = 1;
for(size_t i = 1; i <= pHeight; ++i)
pStartNode *= 2;

//高度为pHeight的最后一个节点的索引为pStartNode * 2 - 1
for(size_t i = pStartNode; i < pStartNode * 2; i += 2)
{
//高度为pHeight表示的就是第pHeight个物品,重量即为Weight[pHeight]
pTree[i] = Weight[pHeight]; //左孩子
pTree[i+1] = 0; //右孩子
}
}


由上面的图片发现,每一层的赋值实际上就是一个w[i],一个0,一个w[i],一个0…

w[i]永远是父节点左孩子的权值,如果到达这个节点就表示将物品i装入背包。

0永远是父节点右孩子的权值,如果到达这个节点就表示物品i没有装入背包。

到此为止,解空间树的初始化工作就完成了。现在添加全局变量

int n; //物品个数
int Capacity; //背包容量
int *Weight; //Weight[i]表示物品i的重量
int *Profit; //Profit[i]表示物品i的价值量
int *pTree; //解空间树,pTree[i]表示节点i的重量,同时又可以用于判断是否装入物品i
int pNodeSize; //节点数量
int pLastNode; //最优解的叶子结点索引
int pMaxProfit; //最优解的值,初始为0
int pHeight; //当前高度,初始为0,始终表示当前结点的高度
int pCurrentProfit; //当前价值量,初始为0
int pCurrentWeight; //当前加入背包的总重量,初始为0


递归程序核心步骤就是向左右孩子节点移动的过程,创建递归函数

void Backpack(size_t pCurrentNode);


递归函数表示的是在节点pCurrentNode这个位置,考虑向它左右孩子移动的问题(也就是选择物品H+1和不选择物品H+1的问题)。

pHeight始终是pCurrentNode的高度,也表示第几个物品。

1.如果向左孩子移动,即pCurrentNode * 2,表示第pHeight + 1个物品装入背包。则pCurrentProfit需要加上第pHeight + 1个物品的价值量,同时pCurrentWeight需要加上第pHeight + 1个物品的重量。

2.如果向右孩子移动,即pCurrentNode * 2 + 1,表示第pHeight + 1个物品不装入背包。pCurrentProfit和pCurrentWeight都不需要改变。

需要注意

1.pHeight在刚进入函数时表示的是pCurrentNode节点的高度,也表示当前考虑的是第几个物品。pHeight+1表示下一个物品,即pCurrentNode * 2和pCurrentNode * 2 + 1表示的物品。

2.只有背包剩余容量足够装入下一个物品时,才向左孩子节点移动。而向右孩子节点移动不需要考虑背包剩余容量是否足够装入,因为往右孩子移动意味着不装入下一个物品

另外考虑回溯的情况,当向左孩子移动之后经过一系列操作返回到该节点处,也就是说Backpack(pCurrentNode * 2);返回后,说明左孩子节点表示的物品(下一个物品)装入背包的情况已经考虑完了,需要把调用Backpack(pCurrentNode * 2);之前为pCurrentWeight和pCurrentProfit加上的值减去,因为它们加的值是下一个物品的重量和价值(它已经考虑完了,接下来需要考虑右节点,下一个物品没有装入背包的情况了)

所以两种情况的考虑代码如下:

void Backpack(int pCurrentNode)
{
//到达叶子节点,更新最优解,记录最优解的叶子结点
if(pCurrentNode * 2 > pNodeSize)
{
if(pCurrentProfit > pMaxProfit)
{
pMaxProfit = pCurrentProfit;
pLastNode = pCurrentNode;
}
return;
}

//高度加一,此时表示的是下一个物品(pCurrentNode左孩子和右孩子表示的物品)
pHeight++;

//如果当前背包装入的重量加上下一个物品的重量仍然小于等于背包容量
//则可以将下一个物品加入背包
if(pCurrentWeight + Weight[pHeight] <= Capacity)
{
//假设pCurrentNode表示的是物品i,则此时加上的是物品i+1的重量和价值
//因为物品i的重量和价值已经在上一层递归中加上了
pCurrentWeight += Weight[pHeight];
pCurrentProfit += Profit[pHeight];
Backpack(pCurrentNode * 2); //跳转到左孩子,表示将物品i+1(pHeight)装入背包
//回溯到这个节点后,物品i+1装入背包的情况已经考虑完,需要将物品i+1的重量和价值减去
//开始考虑物品i+1没有装入背包的情况
pCurrentWeight -= Weight[pHeight];
pCurrentProfit -= Profit[pHeight];
}

//跳转到右孩子节点,表示物品i+1没有装入背包(i + 1 == pHeight)
Backpack(pCurrentNode * 2 + 1);

//返回后,回溯到这个节点,物品i+1没有装入的情况也已经考虑完,需要向上回溯,高度减一
pHeight--;
}


大体的流程已经完成。现在考虑一个问题,在一系列操作之后,从节点Z的左孩子节点回溯到Z。假设节点Z表示的是物品i,此时

pCurrentWeight表示的是物品1,2,…,i装入背包情况的总重量,即前i个物品装入背包的重量。

pCurrentProfit表示的是前i个物品装入背包的总价值。

假设存在一个变量pRemainingProfit,它表示从物品i+2到物品n的价值总和。那么我们就可以根据pRemainingProfit和pCurrentProfit的大小来决定是否还有必要跳转到右孩子节点。

像这样,为跳转到右孩子增加一个限制条件。

...
if(pRemainingProfit + pCurrentProfit > pMaxProfit)
Backpack(pCurrentNode * 2 + 1);
...


注:因为上述是为了判断是否向右孩子跳转,又因为右孩子表示物品i+1不装入背包,所以剩余价值不包括物品i+1的价值。

优化后的代码如下(注意需要将pRemainingProfit加入到全局变量,初始化为所有物品的总价值和):

void Backpack(int pCurrentNode)
{
//到达叶子节点,更新最优解,记录最优解的叶子结点
if(pCurrentNode * 2 > pNodeSize)
{
if(pCurrentProfit > pMaxProfit)
{
pMaxProfit = pCurrentProfit;
pLastNode = pCurrentNode;
}
return;
}
//高度加一,此时表示的是下一个物品(pCurrentNode左孩子和右孩子表示的物品)
pHeight++;

//如果pCurrentNode表示物品i, 则加一后的pHeight表示物品i+1,pRemainingProfit应该把物品i+1的价值减掉
pRemainingProfit -= Profit[pHeight];

//如果当前背包装入的重量加上下一个物品的重量仍然小于等于背包容量
//则可以将下一个物品加入背包
if(pCurrentWeight + Weight[pHeight] <= Capacity)
{
//假设pCurrentNode表示的是物品i,则此时加上的是物品i+1的重量和价值
//因为物品i的重量和价值已经在上一层递归中加上了
pCurrentWeight += Weight[pHeight];
pCurrentProfit += Profit[pHeight];
Backpack(pCurrentNode * 2); //跳转到左孩子,表示将物品i+1(pHeight)装入背包
//回溯到这个节点后,物品i+1装入背包的情况已经考虑完,需要将物品i+1的重量和价值减去
//开始考虑物品i+1没有装入背包的情况
pCurrentWeight -= Weight[pHeight];
pCurrentProfit -= Profit[pHeight];
}

//跳转到右孩子节点,表示物品i+1没有装入背包(i + 1 == pHeight)
if(pRemainingProfit + pCurrentProfit > pMaxProfit)
Backpack(pCurrentNode * 2 + 1);

//返回后,回溯到这个节点,物品i+1没有装入的情况也已经考虑完,需要向上回溯,高度减一,pRemainingProfit加回
pRemainingProfit += Profit[pHeight];
pHeight--;
}


迭代求解

迭代求解就是将递归的每一步细化,但是考虑到回溯的问题,需要解决两个问题。

1.为了不重复回到已经到达的节点,需要一个一维数组pReach[],pReach[i] == 1表示达到过节点i,pReach[i] == 0表示没有到达过节点i。

2.回到父节点只需pCurrentNode /= 2即可,这也是用一维数组存储树节点的好处。

其他的就是细化递归代码的工作了。

首先考虑对每一个节点的处理(只处理之前没有到达过的节点)

1.如果该节点的权值不为0,表示将物品装入背包,则需要将pCurrentProfit和pCurrentWeight更新,然后标识为到达。

2.如果该节点的权值为0,表示不将该物品装入背包,则不需要做任何处理

pHeight始终表示pCurrentNode的高度,也表示当前考虑第几个物品。

if(pReach[pCurrentNode] == 0)
{
//只在装入该物品时才更新pCurrentWeight和pCurrentProfit
if(pTree[pCurrentNode] != 0)
{
pCurrentWeight += pTree[pCurrentNode];
pCurrentProfit += Profit[pHeight];
}
pReach[pCurrentNode] = 1;
}


其次考虑到达叶子节点的情况

1.如果需要更新最优解的值,则更新并记录最优解的叶节点索引

2.到达叶子节点之后,向上回溯。

if(pCurrentNode * 2 > pNodeSize)
{
if(pCurrentProfit > pMaxProfit)
{
pMaxProfit = pCurrentProfit;
pLastNode = pCurrentNode;
}
//回溯部分
}


然后考虑向左右节点移动的情况。

1.向左移动,首先之前没有到达过左孩子节点,然后剩余容量要足够装入下一个物品。

2.向右移动,之前没有到达过右孩子节点。

3.如果都不满足(即左右节点都已经考虑完),向上回溯。

//向左孩子移动
if(pReach[pCurrentNode * 2] == 0 &&
pCurrentWeight + pTree[pCurrentNode * 2] <= Capacity)
{
pCurrentNode *= 2;
pHeight++;
}
//向右孩子移动
else if(pReach[pCurrentNode * 2 + 1] == 0)
{
pCurrentNode = pCurrentNode * 2 + 1;
pHeight++;
}
//如果左右孩子都已经到达过,就不需要再次移动。
//这时需要向上回溯
else
{
//回溯部分
{


最后考虑回溯部分。

由对节点的处理部分可以得知,假设pCurrentNode表示的是物品i,那么pCurrentWeight和pCurrentProfit表示的是物品1,2,…,i的背包装入问题的当前重量和当前价值。

所以在回溯到父节点之前需要将物品i的重量和价值减去(如果物品i装入背包的话)。

同时改变当前节点令其表示其父节点,高度减一。

//回溯部分
//由解空间树可以看出,pTree[pCurrentNode]!=0表示选择了该物品
//只有当选择该物品时(第pHeight个物品),才需要改变当前重量和当前价值
if(pTree[pCurrentNode] != 0)
{
pCurrentWeight -= pTree[pCurrentNode];
pCurrentProfit -= Profit[pHeight];
}
//回溯到父节点,高度减一
pCurrentNode /= 2;
pHeight--;


接下来,利用递归程序中判断是否需要移动到右孩子节点的方法,在迭代程序中添加判断部分。假设pCurrentNode表示物品i,则只有当

从物品i+2到n的价值量(剩余价值量) + 当前价值量 > 当前最优解(pMaxProfit)时,才需要跳转到右孩子节点。因为只有这种情况,才有可能产生比当前最优解更优的解,否则不会产生更优的解,没有意义。

体现在程序中就是在跳转到右孩子节点的if判断中增加一条约束:

...
//增加价值量的判断
else if(pReach[pCurrentNode * 2 + 1] == 0 &&
pRemainingProfit + pCurrentProfit > pMaxProfit)
{
pCurrentNode = pCurrentNode * 2 + 1;
pHeight++;
}
...


假设pCurrentNode表示的是物品i,需要注意的是,pRemainingProfit始终表示的是i+2,i+3,…,n这几个物品的价值总和。因为向右跳转证明物品i+1没有被装入背包,它的价值和重量不会算在pCurrentWeight和pCurrentProfit中。

接下来可以明确的是,肯定是一个循环来执行上述求解过程,在第一次循环中pCurrentNode = 1,表示的是根节点(A),这个节点没有实际的意义。

为什么不是2呢,因为在选择第一个物品时有装入和不装入两种情况,而如果一上来就另pCurrentNode = 2,那么就相当于默认选择了物品1,不选择物品1的情况被忽略了。

然后考虑,假设从节点A跳转到左孩子节点B,然后在A的左半部分执行一系列操作回溯到A点后,开始向右半部份跳转,又执行一系列操作回溯到A点,这时A的左右孩子都已经考虑过了,需要执行“向左右孩子节点移动的情况”中else部分,即向上回溯,这时pCurrentNode /= 2,导致pCurrentNode = 0,在这时我们需要终止算法。所以很显然需要一个while循环,判断的条件就是pCurrentNode != 0。即

int pCurrentNode = 1;
while(pCurrentNode != 0)
{
...
}


将上面的代码组装起来

1.先计算解空间树的节点数量

2.初始化解空间树

3.初始化pReach数组,用于判断某个节点是否到达过

4.初始化各种变量,如pCurrentWeight,pCurrentProfit,pCurrentNode…

5.while循环开始回溯程序

注意不要忘记添加pRemainingProfit的修改代码:

void Backpack(int Weight[], int Profit, int n, int Capacity)
{
//计算解空间树的节点个数
size_t pNodeSize = 1;
for (size_t i = 1; i <= n + 1; ++i)
{
pNodeSize *= 2;
}
pNodeSize--;

//初始化解空间树,对于某一个节点Z,Z的下一层节点表示物品i
//Z的左孩子表示选择该物品,Z的右孩子表示不选择该物品
//相应的左孩子的权值就是物品i的重量,右孩子的权值为0
int *pTree = new int[pNodeSize + 1];
//pReach数组记录节点的到达情况,遇到过的节点值为1,反之为0.用于回溯判断
int *pReach = new int[pNodeSize + 1];
for (size_t pHeight = 1; pHeight <= n; ++pHeight)
{
//为解空间树赋值,每层都是weight[pHeight], 0, weight[pHeight], 0, .....
size_t pStartNode = 1;
for (size_t i = 1; i <= pHeight; ++i)
pStartNode *= 2;
for (size_t i = pStartNode; i < pStartNode * 2; i += 2)
{
pTree[i] = Demension[pHeight];
pTree[i + 1] = 0;
pReach[i] = pReach[i + 1] = 0;
}
}

//pRemainingProfit:记录剩余的总价值量,用于判断是否还需要在右子树中查找
//若当前高度为height,则pRemainingProfit记录的永远是从物品height+2到backCnt的总价值
size_t pRemainingProfit = 0;
for (size_t i = 1; i <= n; ++i)
pRemainingProfit += theProfit[i];

size_t pLastNode = 0; //当前最优解的最后一个节点
size_t pMaxProfit = 0; //当前最优解
size_t pCurrentWeight = 0;  //当前重量
size_t pCurrentProfit = 0;  //当前总价值
size_t pHeight = 0; //节点高度,同时也是物品的索引,表示第pHeight个物品
size_t pCurrentNode = 1; //当前节点

while (pCurrentNode != 0)
{
//如果之前没有到达过当前结点,则将当前结点的重量,价值加入
//更新节点到达情况
if(pReach[pCurrentNode] == 0)
{
if(pTree[pCurrentNode] != 0)
{
pCurrentWeight += pTree[pCurrentNode];
pCurrentProfit += theProfit[pHeight];
}
pReach[pCurrentNode] = 1;
}

//判断是否到达叶子节点
//如果到达叶子节点,同时当前的总价值大于上一次的最优解,则将最优解更新为当前的总价值
//同时记录最优解对应的最后一个节点位置
if (pCurrentNode * 2 > pNodeSize)
{
if (pCurrentProfit > pMaxProfit)
{
pMaxProfit = pCurrentProfit;
pLastNode = pCurrentNode;
}

//到达叶子节点,更新完数据后就应该向上回溯,回溯方法pCurrentNode /= 2,返回父节点。
//每次向上回溯,都应该将该层的重量和价值从当前价值和当前重量中减去
//注意只有当是左孩子的时候才减去价值
if(pTree[pCurrentNode] != 0)
{
pCurrentWeight -= pTree[pCurrentNode];
pCurrentProfit -= theProfit[pHeight];
}
pCurrentNode /= 2;
pHeight--;

//注:这里不需要更新pRemainingProfit,因为pRemainingProfit表示的是从当前结点的孙子开始计算的总价值
//而此时当前结点跳到叶节点的父节点处,没有孙子节点。
}
//没有到达叶子节点
else
{
//剩余价值量减去当前结点孩子的价值
pRemainingProfit -= theProfit[pHeight+1];

//当当前结点的左孩子没有达到过且容量足以满足左孩子的重量时,跳转到左孩子
if (pReach[pCurrentNode * 2] == 0 &&
pCurrentWeight + pTree[pCurrentNode * 2] <= theCapacity)
{
pCurrentNode *= 2;
pHeight++;
}
//当当前结点左孩子节点已经到达过,而右孩子没有到达过,
//同时当前价值量加上剩余价值量有大于当前最优解的可能时,跳转到右孩子处
//注:因为右孩子表示没有选择该物品,所以该物品的价值就无需考虑,
//这也正是为什么pRemainingProfit表示的是从孙子节点开始的价值量,因为右孩子节点的价值就是0
else if (pReach[pCurrentNode * 2 + 1] == 0 &&
pCurrentProfit + pRemainingProfit > pMaxProfit)
{
pCurrentNode = pCurrentNode * 2 + 1;
pHeight++;
}
else
{
//左右孩子都不满足条件,则继续向上回溯
if(pTree[pCurrentNode] != 0)
{
pCurrentWeight -= pTree[pCurrentNode];
pCurrentProfit -= theProfit[pHeight];
}
pCurrentNode /= 2;
//将孩子节点的价值加回
pRemainingProfit += theProfit[pHeight+1];
pHeight--;
}
}
}
}


输出背包装入情况

上面递归和迭代两个程序中,在更新最优解的同时记录了一个变量pLastNode,它表示的是最优解的叶子节点在数组pTree中的索引。又因为从根节点到该叶子节点只有一条路径,所以可以从叶子节点不断跳转到父节点(即不断的pLastNode /= 2),直到pLastNode等于1。在这个过程中,如果到达的节点权值为0,那么表示相应高度对应的物品没有装入背包,如果达到的节点权值不为0,那么表示相应高度对应的物品装入背包。所以在上述过程中,只需要根据pTree[pLastNode]的值就可以得到背包装入问题。

pHeight = n;
while(pLastNode != 1)
{
if(pTree[pLastNode] == 0)
std::cout << "Backpack" << pHeight << ": " << 0 << std::endl;
else
std::cout << "Backpack" << pHeight << ": " << 1 << std::endl;
pLastNode /= 2;
pHeight--;
}
内容来自用户分享和网络整理,不保证内容的准确性,如有侵权内容,可联系管理员处理 点击这里给我发消息