一、“0-1背包”问题描述:
给定n中物品,物品i的重量是wi,其价值为vi,背包的容量为c.问应如何选择装入背包中的物品,使得装入背包中的物品的总价值最大?
形式化描述:给定c>0,wi>0,vi>0,1≤i≤n,要求找一个n元0-1向量(x1,x2,...,xn),xi∈{0,1},1≤i≤n,使得∑wixi≤c,而且∑vixi达到最大。因此0-1背包问题是一个特殊的整形规划问题:
max ∑vixi
s.t ∑wixi≤c
xi∈{0,1},1≤i≤n
二、动态规划求解(两种方法,顺序或逆序法求解)
1.最优子结构性质
1.1 简要描述
顺序:将背包物品依次从1,2,...n编号,令i是容量为c共有n个物品的0-1背包问题最优解S的最高编号。则S'=S-{i}一定是容量为c-wi且有1,...,i-1项物品的最优解。如若不是,领S''为子问题最优解,则V(S''+{i})>V(S'+{i}),矛盾。这里V(S)=V(S')+vi.
逆序:令i是相应问题最优解的最低编号,类似可得。
1.2 数学形式化语言形式化的最优子结构
顺序(从前往后):设(y1,y2,...,yn)是所给问题的一个最优解。则(y1,...,yn-1)是下面相应子问题的一个最优解:
max ∑vixi
s.t ∑wixi≤c
xi∈{0,1},1≤i≤n-1
如若不然,设(z1,...,zn-1)是上述子问题的一个最优解,而(y1,...,yn-1)不是它的最优解。由此可知,∑vizi>∑viyi,且∑vizi+wnyn≤c。因此
∑viyi+vnyn>∑viyi(前一个范围是1~n-1,后一个是1~n) ∑vizi+wnyn≤c
这说明(z1,z2,...,yn)是一个所给问题的更优解,从而(y1,y2,...,yn)不是问题的所给问题的最优解,矛盾。
逆序:同样的,是设(y2,...,yn)是相应子问题的最优解,其他类似。
2、阶段决策过程描述(动态规划的标准形式,这里用顺序法表示)
2.1 共有n个阶段,阶段k=1,2,...,n,每个阶段便是需要作出一个决策的子问题部分。这里相当于可以将背包问题分解为n个子问题。
2.2 状态与状态变量xk:表示第k阶段背包的容量
2.3 决策变量uk:表示k阶段是否取物品k,即uk(xk)∈{0,1},是一个符号函数。
2.4 状态转移方程:xk+1 = xk+uk(xk)*xk
2.5 指标函数和最优指标函数,指标函数:衡量决策过程策略的优劣程度、数量指标。定义在全过程上的指标函数记为V1,k(或Vk,n,此时是逆序),有
V1,k=V1,k(x0,x1,u1,...,xk)
最优指标函数:当指标函数达到最优值时称其为最优指标函数,记为fk(xk).它表示从第初始状态x1起到状态xk过程(或xk→xn,逆‘序),采取最优策略时得到的指标函数值,如下所示:
fk(xk)=opt V1,k
此处的最优指标函数值fk(xk):表示共有1,2,...,k个物品背包容量为xk的最优值如下:
为了便于编程实现,用m[i,j]表示fk(xk),表示背包容量为j,可选择物品为1,2,...,i时0-1背包问题的最优解。则可得公式如下:
第1个公式表示初始值(边界条件,令其为0)或者背包容量为0则结果也为0,第2个公式表示物品i的重量大于背包容量不能装,第3个公式则是表示是否包含前物品i,前一个包含后一个不包含。
3.算法描述及其实现
3.1 自底向上的非递归算法如下:
3.2 源码如下:
#include#include using namespace std;//动态规划进行求解得出最优值m[n][c]中相应的值void dynamic_01knap(int* w, float* v, int n, int c,vector >& m){ //最简单的背包问题递归求解,w,v分别是重量、价值数组(从0开始) //n是物品个数,c是当前背包剩余的容量 //m是额外的表,存储相应的最优值 int i = 0, j = 0; for (; j <= c; ++j) m[0][j] = 0;//边界值 for (i = 1; i <= n; ++i) { m[i][0] = 0;//背包容量为空时 for (j = 1; j <= c; ++j){ if (w[i-1] <= j) //记住v,w是从0开始的 m[i][j] = m[i-1][j-w[i-1]]+v[i-1] > m[i-1][j] ? m[i-1][j-w[i-1]]+v[i-1] : m[i-1][j]; else m[i][j] = m[i-1][j]; cout << "m[" << i <<"][" << j << "]=" << m[i][j] << endl;//测试 } }}//找出相应的结果x[n]void traceback(int* w,float* v, int n,int c,vector >& m, vector & x){ //x是求解向量 int i = 0; for (i = n-1; i > 0; --i) { cout << "m[" << i <<"][" << c << "]=" << m[i][c] << //测试 ";m[" << i+1 <<"][" << c << "]=" << m[i+1][c] << endl; if (m[i][c] == m[i+1][c])//不取物品i+1,记住从0开始的 x[i] = false; else //取物品i+1,背包容量变小 { x[i] = true; c -= w[i]; } } x[0] = m[n][c] ? true : false;}int main(){ int w[] = { 10, 20, 30}, c = 50, i = 0, n = sizeof(w)/sizeof(w[0]); float v[] = { 60, 100, 120}; vector > m(n+1, vector (c+1)); vector x(n); dynamic_01knap(w,v,n,c,m); traceback(w,v,n,c,m,x); for (; i < n; ++i) cout << x[i] <<' '; cout << endl;}
注意,这里用的是顺序法,即最优值为m[n][c],所以在输出0-1数组(结果)的时候得从后往前输;相反的,如果用逆序法,这最优值为m[1][c],输出的时候则是从前往后输。
3.3 自顶向下的递归算法-做备忘录法(memoization)
memorized-0-1-knapsack(v, w, n, c)for i = 1 to n //初始化赋值为∞ for j = 1 to c m[i][j] = ∞; return lookup_max(v,w,n,c); lookup_max(v, w, i, j)if m[i][j] < ∞ return m[i][j]if i == 0 || j ==0 m[i][j] = 0;if w[i] <= j m[i][j] = lookup_max(v,w,i-1,j-w[i])+v[i] > lookup_max(v,w,i-1,j] ? lookup_max(v,w,i-1,j-w[i])+ v[i] : lookup_max(v,w,i-1,j];else m[i][j] = lookup_max(v,w,i-1,j];return m[i][j];
3.5 复杂度分析两种方法的优劣比较
两种算法的空间复杂度是一样的,都是O(nc),时间复杂度数量级上也是一样的,也为O(nc),但是因为背包问题有些子问题并不需要求解,所以第二种方法将第一种方法。
另外,上述方法有两个明显的缺点:
(1) 算法要求所给物品的重量是整数,而递归式中并无这要求;
(2) 当背包容量很大时,算法要求的计算时间较多。例如当c>2n时,需要Ω(n2n)。
针对这两种情况,可以适当的改进,改进算法略;
二、回溯法求解
1. 回溯法的基本思想
回溯法是一种穷举搜索方法,在明确问题的解空间(该问题解的所有情况,包括一些不满足要求的解)后,将解空间组织成数或图的形式,数的叶子结点的个数即是所有解的个数。
然后从开始结点(根结点)出发,以深度优先的方式搜索整个解空间。这个开始结点就成为一个活结点,同时也成为当前的扩展结点。在当前的扩展结点处,搜索向纵深方向移到一个新节点处。这个新节点就成为一个新的活结点,并成为当前扩展结点。
如果在当前的扩展结点处不能再向纵深方向移动,则当前的扩展结点就成为死结点。此时,应往回移动(回溯)至最近的一个活动结点处,并使这个货结点成为当前的扩展结点。回溯法即以这种方式递归地在解空间中搜索,直到找到所求的解或解空间已无或结点为止。
在用回溯法搜索解空间树时,通常采用两种策略(加上剪枝函数)来避免无效搜索,提高搜索效率:
1)用约束函数在扩展结点处减去不满足约束的子树;
2)用限界函数减去不能得到最优解的子树。
回溯法有两种基本形式:递归回溯和迭代回溯(这两种方法可以互相转换,因为一般回溯法的递归回溯是尾递归,所以迭代回溯一般也不用用栈)。
用回溯法解题的一个显著特征是问题的解空间是在搜索过程中动态产生的。在任何时刻,算法之保存从根结点到当前扩展结点的路径。如果解空间中从根结点到叶子结点的最长路径长度为h(n),则回溯法所需的计算空间通常为O(h(n)),另外回溯法的时间复杂度为叶子结点的个数。
解空间一般有两种形式:
1)子集树:所给问题是从n个元素的集合s中找出满足某种性质的子集时,相应的解空间树。相当于求结合s的幂集。这类子集树通常有2n个叶子结点,其结点总个数为2n+1-1,时间复杂度为Ω(2n);
2)排列数:同理,所给问题是确定n个元素满足某种性质的排列时的相应的解空间。遍历排列数通常需要Ω(n!).
2. 背包问题的回溯法求解
2.1 基本思路
显然可以用子集树表示其解空间,解的所有可能情况。用可行性约束减去不满足约束的子树,用上界函数剪去不能得到最优解的子树。
2.2 源程序如下:
#include#include #include using namespace std;class Goods{public: int id; float v;//商品价值 float w; //商品重量 float p; //商品单价 bool operator <= (Goods G) const { return (p >= G.p); }};class Knap{ friend float knapsack(vector Gs, float c,int n);//返回最优装载重量private: float bound(int i); //计算上界 void backtrack(int i); int n; //物品数 vector goods; //物品数组 float c,//背包容量 cw, //当前重量 cp, //当前价值 bestp; //当前最优价值 bool *x, //当前解 *bestx; //当前最优解};float Knap::bound(int i){ float cleft = c - cw;//剩余背包容量 float b = cp; //假设物品已经按单位重量价值递减排序装入物品 while (i n) //到达叶子结点 { if (cp >bestp) { bestp = cp; for (int j = 0; j < n; ++j) bestx[j] = x[j]; } return; } if(cw + goods[i-1].w <= c) //x[i] = 1搜索左子树 { x[i-1] = true; cw += goods[i-1].w; cp += goods[i-1].v; backtrack(i+1); //返回至该结点 cw -= goods[i-1].w; cp -= goods[i-1].v; } if (bound(i+1) > bestp) //x[i] = 0搜索右子树 { x[i-1] = false; backtrack(i+1); }}bool Upgreater ( Goods g1, Goods g2 ){ return (g1<=g2);}float knapsack(vector Gs, float c,int n) //返回最优装载重量{ //为Knap::backtrck初始化 int i = 0; float W = 0, P = 0; Knap K; K.goods = Gs; for (i = 0; i < n; ++i) { P += Gs[i].p; W += Gs[i].w; } if (W <= c) //装入所有物品 return P; sort(K.goods.begin(),K.goods.end(),Upgreater); K.cp = 0; K.cw = 0; K.c = c; K.n = n; K.bestp = 0; K.x = new bool[n]; K.bestx = new bool[n]; //回溯搜索 K.backtrack(1); cout << "装载情况为:" << endl; for (i = 0; i < n; ++i) cout << "物品id=" << K.goods[i].id <<":" << K.bestx[i] << endl; delete[] K.x; delete[] K.bestx; return K.bestp;}int main(){ int n = 4, i =0; float c = 7, bestp, w[] = { 3, 5, 2, 1}, v[] = { 9, 10, 7, 4}; vector Gs(n); for (i = 0; i < n; ++i) { Gs[i].id = i+1; Gs[i].w = w[i]; Gs[i].v = v[i]; Gs[i].p = v[i]/w[i]; } bestp = knapsack(Gs,c,n); cout << "该背包的最优装载价值为:" << bestp << endl; return 0;}