龙空技术网

算法设计:回溯法-解决01背包问题

数字化与智能化 1968

前言:

如今我们对“算法分析01背包问题”可能比较关怀,你们都需要知道一些“算法分析01背包问题”的相关文章。那么小编在网上搜集了一些对于“算法分析01背包问题””的相关知识,希望大家能喜欢,各位老铁们快快来学习一下吧!

一、算法思想

回溯法(探索与回溯法)是一种选优搜索法,又称为试探法,按选优条件向前搜索,以达到目标。但当探索到某一步时,发现原先选择并不优或达不到目标,就退回一步重新选择,这种走不通就退回再走的技术为回溯法。有时会遇到这样一类题目,它的问题可以分解,但是又不能得出明确的动态规划或是递归解法,此时可以考虑用回溯法解决此类问题。回溯法的优点 再于其程序结构明确,可读性强,易于理解,而且通过对问题的分析可以大大提高运行效率。但是,对于可以得出明显的递推公式迭代求解的问题,还是不要用回溯 法,因为它花费的时间比较长

二、算法要素

1、解空间

用回溯法解决实际问题的时候,首先确定解的形式,定义问题解空间

(1)解的组织形式为一个n元组{x1,x2,x3…..,xn}

(2)解的取值范围

例如0-1背包问题:

0-1背包问题可以抽象成一个容量有限(容量设为W)的背包装东西,每个商品都有自己的体积w和价值v,目的就是用这个容量有限的背包装满价值总量最大的东西

例如有3个物品, 解的组织形式为{x1,x2,x3}。它的解分量xi的取值范围很简单,xi=0或者xi=1。xi=0表示第i个物品不放入包,xi=1表示第i个物品放入背包,因此

xiϵ{0,1}

3个物品的0-1背包问题,其所有可能解有:{0,0,0},{0,0,1},{0,1,0},{0,1,1},{1,0,0},{1,0,1},{1,1,0},{1,1,1}

解空间:所有可能解组成的空间,而我们需要根据问题的约束条件,在解空间中寻找最优解,如下图所示:

解空间越小,搜索效率越高。解空间越大,搜索效率越低

(3)搜索解空间

隐约束指对能否得到问题的可行解或最优解做出的约束

如果不满足隐约束,就说明得不到问题的可行解或最优解,那就没必要在沿着该节点的分支进行搜索了,相当于把这个分支减掉了。因此,隐约束也称为剪枝函数,实质不是减掉分支,而是不再搜索该分支

例如3个物品的0-1背包问题,如果前2个物品放入(x1=1,x2=1)后,背包超重了,那就没必要再考虑第3个物品是否放入背包的问题相当于剪枝了,如下图所示:

隐约束(剪枝函数)包括约束函数和限界函数

解空间的大小和剪枝函数的好坏直接影响搜索效率,因此这两项是搜索算法的关键,在搜索空间时,有几个术语需要说明:

(1)、扩展节点:一个正在生长孩子的节点

(2)、活节点:一个自身已生成,但孩子还没有全部生成的节点

(3)、死节点:一个所有孩子都已经生成的节点

(4)、子孙:节点E的子树所有节点都是E的子孙

(5)、祖宗:从节点E到树根路径上的所有的节点都是E的祖宗

2、解题步骤:

(1)定义解空间

因为解空间的大小对搜索效率有很大影响,因此使用回溯法首先定义合适的解空间,确定解空间包括解的组织形式和显约束

解的组织形式:解的组织形式都规范为一个n元组{x1,x2,…….,xn},只是具体表达含义不同而已

显约束:对解取值范围的限定,通过显约束可以控制解空间的大小

(2)确定解空间的组织形式

解空间的组织结构通常用解空间树形象的表达,根据解空间的不同,解空间分为子集树、排列树、m叉树

(3)搜索解空间

回溯法是按照深度优先搜索策略,根据约束(约束函数和限界函数),在解空间中搜索问题的可行解或最优解,当发现当前节点不满足求解条件时,就回溯尝试其它路径

如果问题只是要求可行解,则只需设定约束函数即可,如果要求最优解,则需要设定约束函数和限定函数

解的组织形式都是通用的n元组形式,解的组织结构是解空间的形象表达。解空间和隐约束是控制搜索效率的关键。显约束可以控制解空间

三、算法设计过程

以01背包问题为例:假设有n个物品,每个物品i对应的价值为vi,重量为wi,购物车容量为W,每个物品只有1件,要么装入要么不装入不可拆分,如何装入物品的总价值最大?

2、设计过程

(1)定义问题解空间

每个物品有且只有2种状态,要么装入,要么不装入。我们用变量xi表示第i个物品是否被装入购物车的行为,如果用"0"表示不装入背包,用"1"表示装入背包,则xi的取值为0或者1,i=1,2,3,4,5….,第i个物品装入购物车xi=1;不装入购物车xi=0。该问题的解形式是一个n元组,且每个分量的取值为0或1

由此得到问题解空间为{x1,x2,x3,……xi,…..,xn},其中,显约束xi=0或1,i=1,2,3….n

(2)确定解空间的组织结构

问题的解空间描述了2的n次方可能解,也就是说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}。该问题有2的3次方个可能解

(3)搜索解空间

【1】约束条件

解空间包含2的n次方种可能解,存在某种或某些物品无法装入的情况,因此需要设置约束条件,判断装入物品总重量是否超过总容量,如果超出,为不可行解;否则为可行解。搜索过程不再搜索那些导致不可行解的节点及其孩子节点,约束条件为:

w1x1 + w2x2 + w3x3+….<=W

【2】界限条件

可行解可能不止一个,问题的目标是找一个装入购物车的物品总价值最大的可行解,即最优解。因此,需要设置界限条件来加速该最优解的速度

根据解空间的组织结构,对于任何一个中间节点z(中间状态),从根节点到z节点的分支所代表的状态已经确定,从z到其子孙节点的分支的状态是不确定的。也就 是说,如果z在解空间树中所处的层次是t,说明第1种物品到第t-1种物品的状态已经确定了。我们只需要沿着z的分支扩展很容易确定第t种物品的状态。那么前t种物品的状态就确定了。但第t+1种物品到第n种物品的状态还不确定。这样,前t种物品的状态确定后,当前已装入购物车的物品的总价值,用c p表示。已装入物品的价值高不一定就是最优的,因为还有剩余物品未确定

我们还不确定第t+1种物品到第n种物品的实际状态,因此只能用估计值。假设第t+1种物品到第n种物品都装入购物车,第t+1种物品到第n种物品的总价值用rp来表示,因此cp + rp是所有从根出发经过中间节点z的可行解的价值上界,如图:

如果价值上界小于或者等于当前搜索到的最优值(最优值用bestp表示,初始值为0),则说明从中间节点z继续向子孙节点搜索不可能得到一个比当前更优的可行解,没有继续搜索的必要,反之,则继续向z的子孙节点搜索,界限条件为:

cp + rp >bestp

3、搜索过程

从根节点开始,以深度优先地进行搜索。根节点首先成为活节点,也是当前的扩展节点。由于子集树中约定左分支上的值为"1",因此沿着扩展节点的左分支扩展,则代表装入物品。此时,需要判断是否能够装入该物品,即判断约束条件是否成立,如果成立,即生成左孩子节点,左孩子节点成为活节点,并且成为当前的扩展节点,继续向纵深节点扩展;如果不成立,则剪掉扩展节点的左分支,沿着其右分支扩展,右分支代表物品不装入购物车,肯定有可能导致可行解。但是沿着右分支扩展有没有可能得到最优解呢?这一点需要由界限条件来判断。如果界限条件满足,说明有可能导致最优解,即生成右孩子节点,右孩子节点成为活节点,并成为当前扩展节点,继续向纵深节点扩展;如果不满足限界条件,则剪掉扩展节点的右分支,向最近的祖宗活节点回溯。搜索过程直到所有活节点变成死节点结束

四、算法案例

(一)、案例-1

假设现有4个物品,每个物品的重量w为(2,5,4,2),价值v为(6,3,5,4),只能装入袋子的总容量W为10,问把哪些物品装入袋子,才能获得最大价值?

1、算法设计过程

(1)、初始化

sumw和sumv分别用来统计所有物品的总重量和总价值。sumw = 2 + 5 + 4 +2 =13,

sumv = 6 + 3 + 5 +4 =18 ,sumv > W因此不能全部装完,需要搜索求解。初始化当前放入购物车的物品重量cw = 0;当前放入购物车的物品价值cp = 0;当前最优值bestp = 0

(2)、开始搜索第1层(t = 1)

扩展1号节点,首先判断cw + w[1] = 2 < W,满足约束条件,扩展左分支,

令x[1] = 1 ,cw = cw + w[1] = 2,cp = cp + v[1] = 6,生成2号节点,如图所示:

(3)扩展2号节点(t = 2)

首先判断cw + w[2] = 7 < W,满足约束条件,扩展分支,令x[2] = 1,

cw = cw + w[2] = 7,cp = cp + v[2] = 9,生成3号节点,如图所示:

(4)扩展3号节点(t = 3)

首先判断cw + w[3] = 11 > W,超过了购物车容量,第3个物品不能放入。那么判断bound( t + 1 )是否大于bestp。bound(4)中剩余物品只有第4个,rp = 4,

cp + rp = 13,bestp = 0,因此满足界限条件,扩展右子树。令x[3] = 0,生成4号节点,如图所示:

(5)扩展4号节点(t = 4)

首先判断cw + w[4] = 9 < W,满足约束条件,扩展左分支,令x[4] = 1,令

cw = cw + w[4] = 9 ,cp = cp + v[4] = 13,生成5号节点

(6)扩展5号节点(t = 5)

t > n ,找到一个当前最优解,用bestx[]保存当前最优解{1,1,0,1},保存当前最优解bestp = cp = 13,5号节点成为死节点

(7)回溯到4号节点(t =4),一直回溯到2号节点

向上回溯到4号节点,回溯时cw = cw – w[4] = 7,cp = cp – v[4] = 9,怎么加上的,怎么减回去。4号节点右子树还没生成,考察bound(t+1)是否大于bestp,bound(5)中没有剩余物品,rp = 0,cp + rp = 9,bestp = 13,因此不满足界限条件,不能在扩展4号右子树节点。4号节点成为死节点

向上回溯,回溯到3号节点,3号节点的左右孩子均已考察过,是死节点

向上回溯到2号节点,回溯时cw = cw – w[2] = 2,cp = cp – v[2] =6。怎么加上去怎么减去

如下图所示:

(8)扩展节点2(t = 2)

2号节点右子树还未生成,考察bound(t + 1)是否大于bestp,bound(3)中剩余物品为第3、第4个,rp = 9 , cp + rp =15 ,bestp = 13,因此满足界限条件,扩展右子树。令x2 = 0生成6号节点

(9)扩展6号节点(t = 3)

首先判断cw + w[3] = 6 <W,满足约束条件,扩展左分支,令x[3] =1,

Cw= cw + w[3] = 6,cp = cp + v[3] =11,生成7号节点,如图所示:

(10)扩展7号节点(t = 4)

首先判断 cw + w[4] = 8 < W,满足约束条件,扩展左分支,令x[4] =1,

cw = cw + w[4] = 8,cp = cp + v[4] = 15,生成8号节点,如图:

(11)扩展8号节点(t =5)

t > n(n=4),找到当前个最优解,用bestx[]保存当前最优解{1,0,1,1},保存当前最优解值bestp = cp = 15 , 8号节点成为死节点

向上回溯到7号节点,回溯时cw = cw – w[4] = 6、cp = cp –v[4] =11,怎么加的怎么减去

(12)扩展7号节点(t =4)

7号节点的右孩子树还没有生成,考察bound(t + 1)是否大于bestp,bound(5)中没有剩余产品, rp =0、 cp + rp =11,bestp = 15,因此不满足界限条件,不再扩展7号节点的右子树。7号节点成为死节点

向上回溯,回溯到6号节点,回溯时cw = cw – w[3] = 2、cp=cp-v[3]=6怎么加的怎么减

(13)扩展6号节点(t =3)

6号节点的右子树还未生成,考察bound(t+1)是否大于bestp,bound(4)中剩余物品是第4个,rp = 4、cp + rp = 10,bestp=15因此不满足界限条件,不再扩展6号节点的右子树

向上回溯,回溯到2号节点,2号节点的左右孩子均已考察过,是死节点

向上回溯1号节点,回溯时cw=cw-w[1]=0、cp=cp-v[1]=0,怎么加的,怎么减

(14)扩展1号节点(t = 1)

1号节点的右子树还未生成,考察bound(t+1)是否大于bestp,bound(2)中剩余物品为第2、3、4个,rp =12、cp+rp=12、bestp=15,因此不满足限界条件,不再扩展1号节点的右子树,1号节点为死节点,所有节点都是死节点,算法结束。如图所示:

2、代码实现

(1)伪代码

【1】计算上界的函数:

计算上界是指计算已装入物品价值cp与剩余物品的总价值rp之和。我们已经知道已装入物品价值cp,剩余物品我们不确定要装入哪些,我们按照假设都装入的情况估算,即按最大值计算(剩余物品总价值),因此得到的值是可装入物品价值的上界

//计算上界:已装入物品价值+剩余物品总价值

double bound(int i )

{

int rp = 0;//剩余物品为第i- n种物品

while( i <= n )//依次计算剩余物品的价值

{

rp + = v[i];

i++ ;

}

return cp + rp ;//返回上界

}

【2】按约束条件和限界条件搜索求解函数

t表示当前扩展节点在第t层,cw表示当前已放入物品的重量,cp表示当前已放入物品的价值

如果t > n表示已经到达叶子节点,记录最优值最优解,返回。否则,判断满足约束条件,满足则搜索左子树,因为左子树表示放入该物品,所以令x[t]=1,表示放入第t个该物品。cw + = w[t],表示当前已放入该物品的重量增加w[t]。cp + = v[t],表示当前已放入物品的价值增加v[t]。backTrack(t+1)表示递推,深度优先搜索第t+1层。回归时即向上回溯时,要把增加的值减去,cw- =w[t],cp-=v[t]

判断是否满足限界条件,满足则搜索右子树。因为右子树表示不放入该物品,所以令x[t]=0。当前已放入物品的重量、价值均不改变。backTrack(t+1)表示递推,深度优先搜索第t+1层

//表示当前扩展节点在第t层

void backTrack(int t)

{

if(t > n)//已经到达叶子节点

{

for(j = 1 ; j < = n ; j++)

{

bestx[j]=x[j];

}

//保存当前最优解

bestp = cp;

return ;

}

//如果满足约束条件则搜索左子树

if(cw + w[t] <= W)

{

x[t] = 1;

cw + = w[t];

cp + = v[t];

backTrack(t+1);

cw - = w[t];

cp - = v[t];

}

//如果满足限界条件则搜索右子树

if(bound(t + 1) > bestp)

{

x[t] = 0;

backTrack(t+1);

}

}

完整程序代码如下:

#include <iostream>

#include <string>

#include <algorithm>

#define M 105

using namespace std;

//n表示n个物品,W表示购物车的容量

int i , j , n , W;

//w[i]表示第i个物品的重量,v[i]表示第i个物品的价值

double w[M] , v[M];

//表示第i个物品是否放入购物车

bool x[M];

//当前重量

double cw;

//当前价值

double cp;

//当前最优价值

double bestp;

//当前最优解

bool bestx[M];

//计算上界,即剩余物品总价值

double bound (int i )

{

//剩余物品为i--n种物品

int rp = 0;

//以物品为单位重量价值重量价值递减的顺序装入物品

while(i <= n )

{

rp + = v[i];

i ++;

}

return cp + rp;

}

//用于搜索空间数,t表示当前扩展节点在第t层

void backTrack( int t )

{

if( t > n )//已经到达叶子节点

{

for(j = 1;j < = n; j++)

{

//保存当前最优解

bestx[j] = x[j];

}

bestp = cp;//保存当前最优值

return;

}

//如果满足限制条件则搜索左子树

if(cw + w[t] <= W)

{

x[t] = 1;

cw + = w[t];

cp + = v[t];

backTrack(t + 1);

cw - = w[t];

cp - = v[t];

}

//如果满足限制条件则搜索右子树

if( bound(t + 1) > bestp)

{

x[t] = 0;

backTrack( t + 1);

}

}

void demo(double W , int n)

{

//初始化当前放入购物车的物品重量为0

cw = 0;

//初始化当前放入购物车的物品价值为0

cp = 0;

//初始化当前最优解

bestp = 0;

//用来统计所有物品的总重量

double sumw = 0.0;

//用来统计所有物品的总价值

double sumv = 0.0;

for(i = 1; i <= n;i++)

{

sumv + = v[i];

sumw + = w[i];

}

if(sumw <= W)

{

bestp = sumv;

count << "放入购物车的物品最大价值为:" <<bestp<<endl;

cout <<"所有的物品均放入购物车";

return;

}

backTrack(1);

cout <<"放入购物车的物品最大价值为:"<<bestp<<endl;

cout <<"放入购物车的物品序号为:";

for(i = 1;i < = n; i ++)

{

if(bestx[i] ==1)

cout <<i<<" ";

}

cout<<endl;

}

int main()

{

cout<<"请输入物品的个数n:";

cin>>n;

cout<<"请输入购物车的容量W:";

cin>>W;

cout<<"请依次输入每个物品的重量w和价值v,用空格分开" ;

for(i = 1;i <= n;i++)

{

cin>>w[i]>>v[i];

}

demo(W,n);

return 0;

}

五、算法时间复杂度和空间复杂度分析

(1)时间复杂度

回溯法的运行时间取决于它在搜索过程中生成的节点数。而限界函数可以大大减少所生成的节点个数,避免无效搜索,加快搜索速度

左节点需要判断约束函数,右节点需要判断限界函数,那么最坏有多少个左节点和右节点呢?我们看规模为n的子集树,最坏情况下的状态如图:

【1】总的结点个数:

【2】左右孩子结点个数

总的结点个数减去根节点再除2就得到左右孩子结点的个数,左右孩子结点的个数为:

约束函数的时间复杂度为O(1),限界函数时间复杂度为O(n)。最坏情况下有

个左孩子结点调用约束函数

个右孩子结点调用限界函数,故回溯法的时间复杂度为

(2)空间复杂度

回溯法会产生解空间,在搜索的任何时刻,仅保留从开始结点到当前扩展结点的路径,从开始结点起最长的路径为n。程序中我们使用bestp[]数组记录最长路径作为最优解,所以该算法的空间复杂度为O(n)

六、算法优化

在上面程序中上界函数是当前价值cp与剩余物品总价值rp之和,这个估值过高了,因为剩余物品的重量很可能是超过购物车容量的。因此我们可以缩小上界,从而加快剪枝速度,提高搜索效率

上界函数bound():当前价值cp+剩余容量可容纳的剩余物品的最大值brp

为了更好地计算和运用上界函数剪枝,先将物品按照其单位重量价值(价值/重量)从大到小排序,然后按照排序后的顺序考察各个物品

#include <iostream>

#include <string>

#include <algorithm>

#define M 105

using namespace std;

//n表示物品个数,W表示购物车容量

int i , j , n , W;

//w[i]表示第i个物品重量,v[i]表示第i个物品价值

double w[M] , v[M];

//x[i]=1表示第i个物品放入购物车

bool x[M];

double cw;//当前重量

double cp;//当前价值

double bestp;//当前最优值

double bestx[M];//当前最优解

/**

*计算上界:

*将剩余物品装满剩余的背包容量时所能获得的最大价值

*/

double bound(int i)

{

//剩余物品为第i--n种物品

double cleft = W - cw;//剩余容量

double brp =0.0;

while(i <= n && w[i] < cleft)

{

cleft -=w[i];

brp +=v[i];

i++;

}

//采用切割的方式装满背包,这里是求上界,求解时不允许切割

if(i <=n)

{

brp+=v[i]/w[i]*cleft;

}

return cp + brp;

}

//用于搜索空间数,t表示当前扩展结点在第t层

void backTrack(int n)

{

if(t > n)//已经到达叶子节点

{

for(j = 1;j <=n;j++)

{

bestx[j]=x[j];

}

bestp=cp;//保存当前解

return;

}

//如果满足限制条件则搜索左子树

if(cw + w[t]<=W)

{

x[t]=1;

cw+=w[t];

cp+=v[t];

backTrack(t+1);

cw-=w[t];

cp-=v[t];

}

//如果满足限制条件则搜索右子树

if(bound(t+1) > bestp)

{

x[t] = 0;

backTrack(t+1);

}

}

//定义物品结构,包括序号和单位重量价值

struct Object

{

int id; //物品序号

double d;//单位重量价值

};

//按物品单位重量价值由大到小排序

bool cmp(Object a1,Object a2)

{

return a1.d>a2.d;

}

void demo(int W,int n)

{

//初始化

//初始化放入物品重量为0

cw=0;

//初始化放入物品价值为0

cp=0;

//初始化当前最优值为0

bestp=0;

//用来统计所有物品总重量

double sumw = 0;

//用来统计所有物品总价值

double sumv=0;

//物品结构体类型,用于按单位重量价值(价值/重量比)排序

Object Q[n];

//辅助数组,用于把排序后的重量和价值传递给原来的重量价值数组

double a[n+1] , b[n+1];

for(i = 1;i <=n ;i++)

{

Q[i-1].id = i;

Q[i-1].d = 1.0*v[i]/w[i];

sumv+=v[i];

sumw+=w[i];

}

if(sumw <= W )

{

bestp = sumv;

cout<<"放入物品最大价值为:"<<bestp<<endl;

cout<<"所有物品均放入";

return;

}

//按单位重量价值(价值/重量比)从大到小排序

sort(Q ,Q + n,cmp);

for(i=1;i<=n;i++)

{

//把排序后的数据传递给辅助数组

a[i]=w[Q[i-1].id];

b[i]=v[Q[i-1].id];

}

for(i=1;i<=n;i++)

{

//把排序后的数据传递给w[i]

w[i] = a[i];

v[i] = b[i];

}

backTrack(1);

cout<<"放入的物品最大价值为:"<<bestp<<endl;

cout<<"放入购物车的物品序号为";

for(i =1;i <= n;i++)

{

if(bestx[i]==1)

cout<<Q[i-1].id<<"";

}

cout<<endl;

}

int main()

{

cout<<"请输入物品的个数n:";

cin >>n;

cout<<"请输入购物车的容量W:";

cin >>W;

cout<<"请依次输入每个物品的重量w和价值v,用空格分开";

for(i=1;i<=n;i++)

{

cin>>w[i]>>v[i];

}

demo(W,n);

return 0;

}

(1)时间复杂度:约束函数时间复杂度为O(1),限界函数时间复杂度为O(n)。最坏情况下有O(2的n次方)个左孩子调用约束函数,有O(2的n次方)个右孩子调用界限函数,回溯算法backTrack需要计算时间为O(n*2的n次方)。排序函数时间复杂度为O(n Log n),这是考虑最坏情况。实际上,经过上界函数优化后,剪枝速度很快,根本不需要生成所有节点

(2)空间复杂度:除了记录最优解数组外,还是用一个结构体数组用于排序,两个辅助数组传递排序后的结果,这些数组的规模都是n,因此空间复杂度仍为O(n)

标签: #算法分析01背包问题