此篇整理自李老师上课PPT --- On one way by myself
(1)问题描述
有n个重量分别为{w1,w2,…,wn}的物品,它们的价值分别为{v1,v2,…,vn},给定一个容量为W的背包。设计从这些物品中选取一部分物品放入该背包的方案,每个物品要么选中要么不选中,要求选中的物品不仅能够放到背包中,而且重量和为W具有最大的价值。
输入:
3 // n个物品假设为3
16 45 // 第一个物品的重量和价值
15 25 // 第二个物品的重量和价值
15 25 // 第三个物品的重量和价值
30 // 背包容量W
输出:
0 1 1 // 第几个物品选中则为1,不选中则为0
50 // 最大价值
(2)回溯法与分支限界法比较(先区别这两个)
方法 | 解空间搜索方式 |
存储结点的数据结构 |
结点存储特性 |
常用应用 |
---|---|---|---|---|
回溯法 |
深度优先 |
栈 |
活结点的所有可行子结点被 遍历后才从栈中出栈 |
找出满足条件的所有解 |
分枝限界法 |
广度优先 |
队列,优先队列 |
每个结点只有一次成为活结点的机会 |
找出满足条件一个解或者特定意义的最优解 |
首先声明:此两种都有解空间树,问题的解空间树是虚拟的,并不需要在算法运行时构造一棵真正的树结构,然后再在该解空间树中搜索问题的解,而是只存储从根结点到当前结点的路径。
例如:以下求集合{a,b,c}的幂集的解空间树:
第一层为空,第二层为a的选择(左子树)与不选择(右子树),第三层为b的选择(左子树)与不选择(右子树),第四层为c的选择(左子树)与不选择(右子树);求解过程分为3步,分别对a、b、c元素做决策,该解空间的每个叶子结点都构成一个解(很多情况并非如此);在一个解空间中搜索解的过程构成搜索空间,上图中所有叶子结点都是解,所以该问题的解空间和搜索空间相同。
再比如:下图是四皇后问题的搜索空间,图中每个状态由当前放置的皇后的行列号构成。它给出了四皇后问题的全部搜索过程,只有18个结点,其中标有X号的结点无法继续扩展。
(1,*,*,*)表示第一行第一列放个皇后,其他待搜索;(2,4,1,3)表示第一行第二列、第二行第四列、第三行第一列和第四行第三列各放一个皇后,构成一个解;(3,1,4,2)表示第一行第三列、第二行第一列、第三行第四列和第四行第二列各放一个皇后,构成另一个解;
最后说明,活节点:指自身已生成但其孩子结点没有全部生成的结点;扩展节点:是指正在产生孩子结点的结点。
死节点:指由根结点到该结点构成的部分解不满足约束条件,或者其子结点已经搜索完毕
(3)回溯法
就是回退,如四皇后问题,最左侧,放完第一行第一列和第二行第三列后,无法再向下扩展,则向父节点回退,父节点如果还有向下扩展的其他节点则扩展,不能扩展则再向上回退;代码的世界也有哲学,前进的道路走不通时,是深邃骇人的断崖?是飞流急湍的河流?于我何干,我潇洒回退你耐我何?
回溯法搜索解空间时,通常采用两种策略避免无效搜索,提高回溯的搜索效率:
用回溯法解题的一般步骤如下:
非递归回溯框架:
int x[n]; //x存放解向量,全局变量 void backtrack(int n) //非递归框架 { int i=1; //根结点层次为1 while (i>=1) //尚未回溯到头 { if(ExistSubNode(t)) //当前结点存在子结点 { for (j=下界;j<=上界;j++) //对于子集树,j=0到1循环 { x[i]取一个可能的值; if (constraint(i) && bound(i)) //x[i]满足约束条件或界限函数 { if (x是一个可行解) 输出x; else i++; //进入下一层次 } } } else i--; //回溯:不存在子结点,返回上一层 } }
递归回溯框架:
int x[n]; //x存放解向量,全局变量 void backtrack(int i) //求解子集树的递归框架 { if(i>n) //搜索到叶子结点,输出一个可行解 输出结果; else { for (j=下界;j<=上界;j++) //用j枚举i所有可能的路径 { x[i]=j; //产生一个可能的解分量 … //其他操作 if (constraint(i) && bound(i)) backtrack(i+1); //满足约束条件和限界函数,继续下一层 } } }
说回0/1背包问题:
<1>问题表示及求解结果表示----全局变量
//问题表示 int n=0,W=0; vector<int> w;//={0,16,15,15}; //重量,下标0不用 vector<int> v;//={0,45,25,25}; //价值,下标0不用 //求解结果表示 int maxv=-9999; //存放最大价值,初始为最小值 int bestx[MAXN]; //存放最优解,全局变量 int total=1; //解空间中结点数累计,全局变量
<2> 主要函数
// main函数调用,用来初始化rw和op,以及dfs_back的入口 void bfs_back_main(); /* * 采用递归式调用 * 由于数组下表从1开始,则初始时i=1;tw与tv都为0; * rw为输入数据的总容量;op初始为全0,暂存解空间,然后赋值到bestx数组 * 此函数的内部,首先是到达叶子节点,也即递归的跳出条件,如果价值更优则更新bestx; * 然后是左剪枝和右剪枝操作了:扩展左孩子,需判定已扩展节点的容量+此节点的容量<=背包容量, 不满足则剪枝,然后回溯;扩展右孩子,需判定已扩展节点的容量+剩余节点的总容量>背包容量, 不然的话就没有扩展的必要,直接剪枝。不管扩展左右孩子,都得递归调用dfs_back */ void dfs_back(int i,int tw,int tv,int rw,int op[]);代码贴在最后
(4)分支限界-优先队列(STL)
采用分枝限界法求解的3个关键问题如下:
①如何确定合适的限界函数。②如何组织待处理结点的活结点表。③如何确定解向量的各个分量。
0/1背包:
// # 分支限界优先队列法 // 队列中的节点类型 struct NodeType {// 分支限界节点 int no; // 节点编号 int i; // 当前节点在搜索空间的层次 int w; // 当前节点的总重量 int v; // 当前节点的总价值 int x[MAXN]; // 当前节点包含的解向量 double ub; // 上界 bool operator<(const NodeType& node) const {// 优先队列按此方式排序 return ub < node.ub; // ub越大越优先出队 } }; /* 主干 * ->初始化根节点 * ->计算根节点上界及进队 * ->循环遍历队列,条件为非空:出一个节点, 计算左孩子节点剪枝条件,满足的左孩子计算上界及进队; 计算右孩子节点上界,符合上界条件的右孩子进队; (根据容量剪去左孩子,根据上界条件剪去右孩子) * */ void bfs(); // 进队----不是叶子节点就直接进队,是叶子节点则判断是否更优解,是的话则更新最优解 void EnQueue(NodeType e,priority_queue<NodeType> &qu); // 计算边界 就是根据剩余容量的大小,计算剩下全部物品装入的价值和装入部分物品的价值 // (部分物品按照单位容量内价值高低的顺序装入---这有点贪心的思想了) void bound(NodeType &e); // !# 分支限界优先队列法
(5) 动态规划法
int count=1; //累计调用的步骤 int Fib(int n) //算法 { printf("(%d)求解Fib(%d)\n",count++,n); if (n==1 || n==2) { printf(" 计算出Fib(%d)=%d\n",n,1); return 1; } else { int x=Fib(n-1); int y=Fib(n-2); printf(" 计算出Fib(%d)=Fib(%d)+Fib(%d)=%d\n", n,n-1,n-2,x+y); return x+y; } }运行上面的代码来计算n=100时的斐波那契数列的值,吃饭前一直闪着屏在计算,吃过饭围着操场走了两圈后回来,屏幕还在闪着。运行太慢,可以拿空间换时间,或者拿金钱换时间。拿金钱换时间,比如买个太湖之光之类的玩玩,短时间之内也能运行出来;拿空间换时间,就是动态规划,建一个动态规划数组,将之前已经计算的数组放进数组中,下次使用的时候直接从数据里取,不用再计算一遍了。
int dp[MAX]; //所有元素初始化为0 int count=1; //累计调用的步骤 int Fib1(int n) //算法1 { dp[1]=dp[2]=1; printf("(%d)计算出Fib(1)=1\n",count++); printf("(%d)计算出Fib(2)=1\n",count++); for (int i=3;i<=n;i++) { dp[i]=dp[i-1]+dp[i-2]; printf("(%d)计算出Fib(%d)=%d\n",count++,i,dp[i]); } return dp[n]; }运行上面的代码来计算n=100时的斐波那契数列的值,两三秒搞定,就是这么神奇。
动态规划是一种解决多阶段决策问题的优化方法,把多阶段过程转化为一系列单阶段问题,利用各阶段之间的关系,逐个求解。
能采用动态规划求解的问题的一般要具有3个性质:
最优性原理:如果问题的最优解所包含的子问题的解也是最优的,就称该问题具有最优子结构,即满足最优性原理。
实际应用中简化的步骤:
① 定义合适的动态规划数组 ② 找到合适的状态转移方程 ③ 从动态规划数组中找合适的解
0/1背包问题:
<1> 问题表示及求解结果表示同回溯法
额外定义动态规划数组
int dp[MAXN][MAXW]; // dp[i][r]表示背包剩余容量为r(1≤r≤W)
dp[i][0]=0(背包不能装入任何物品,总价值为0) 边界条件dp[i][0]=0(1≤i≤n)―边界条件
dp[0][r]=0(没有任何物品可装入,总价值为0) 边界条件dp[0][r]=0(1≤r≤W)―边界条件
dp[i][r]=dp[i-1][r] 当r<w[i]时,物品i放不下
dp[i][r]=MAX{dp[i-1][r],dp[i-1][r-w[i]]+v[i]} 否则在不放入和放入物品i之间选最优解
这样,dp[n][W]便是0/1背包问题的最优解。
例如,某0/1背包问题为,n=5,w={2,2,6,5,4},v={6,3,5,4,6}(下标从1开始),W=10。求dp:
<3> 从动态规划数组中找合适的解
<4> 函数说明
/* * 根据状态转移方程来构造动态 * 1>两个边界条件 * 2>由于动态规划数组为二维数组,则两层for循环里判断是否扩展活动节点 扩展则dp[i][r]=dp[i-1][r]; 不扩展则二者求最大 */ void dp_Knap(); /* * 动态规划数组已经填充完毕,逆着推出最优解 根据状态转移方程中的条件,判断每个物品是否选择 */ void buildx();代码最后面。
(6)贪心法
贪心法的基本思路是在对问题求解时总是做出在当前看来是最好的选择,也就是说贪心法不从整体最优上加以考虑,所做出的仅是在某种意义上的局部最优解。每一次贪心选择都将所求问题简化为规模更小的子问题,并期望通过每次所做的局部最优选择产生出一个全局最优解。所谓贪心选择性质是指所求问题的整体最优解可以通过一系列局部最优的选择,即贪心选择来达到。也就是说,贪心法仅在当前状态下做出最好选择,即局部最优选择,然后再去求解做出这个选择后产生的相应子问题的解。
贪心法求解问题的算法框架如下:
SolutionType Greedy(SType a[],int n)//假设解向量(x0,x1,…,xn-1)类型为SolutionType,其分量为SType类型 { SolutionType x={}; //初始时,解向量不包含任何分量 for (int i=0;i<n;i++) //执行n步操作 { SType xi=Select(a); //从输入a中选择一个当前最好的分量 if (Feasiable(xi)) //判断xi是否包含在当前解中 solution=Union(x,xi); //将xi分量合并形成x } return x; //返回生成的最优解 }
求解部分背包问题:与0/1背包问题的区别是,这里的每个物品可以取一部分装入背包。
既然贪心是选择当前最优(局部最优),则需要对数据按照一定的规则(求解的目的有关方面)排序,先选择最有利的一个,然后再扩展其他。
背包问题,有重量有价值,求得是利益最大化的,则按照单位重量重含有的价值从大到小排序,依次选择。
<1> 问题表示及求解结果表示同回溯法
又定义了
vector<NodeType_Knap> A; // 含有输入的数据和排序后的数据 double V = 0; // 价值,之前是int型,在这里为double double x[MAXN]; // 最优解double类型,可以选择部分,即一定的比例
<2> 函数说明
/* * 求单位重量的价值->按照自定义的格式排序->调用 Knap */ void knap_m(); /* * 排序后则贪心循环选择,如果剩余的容量还能容纳当前的,则放进去,不能的话跳出循环,选择部分放入 */ void Knap();
(7)蛮力法
(8)说明及代码
此篇文章根据WHU李老师课件整理,图片也是来之课件,为什么有我博客水印我也不清楚,代码也整理之课件;几个方法都放在一个文件中了,可能有点乱,不过在main函数之前都有声明,每一种都有注释
// # 回溯法 以此开头 // ... // !# 回溯法 以此结尾main函数中也有,如果用动态规划法则把其他的注释掉就行,输入输出别注释。
代码如下:
#include <iostream> //#include <algorithm> #include <queue> #include <algorithm> using namespace std; #define MAXN 50 //问题表示 int n=3,W=30; vector<int> w;//={0,16,15,15}; //重量,下标0不用 vector<int> v;//={0,45,25,25}; //价值,下标0不用 //求解结果表示 int maxv=-9999; //存放最大价值,初始为最小值 int bestx[MAXN]; //存放最优解,全局变量 int total=1; //解空间中结点数累计,全局变量 // # 分支限界优先队列法 // 使用n,W,w[],v[],maxv,bestv[] // 队列中的节点类型 struct NodeType {// 分支限界节点 int no; // 节点编号 int i; // 当前节点在搜索空间的层次 int w; // 当前节点的总重量 int v; // 当前节点的总价值 int x[MAXN]; // 当前节点包含的解向量 double ub; // 上界 bool operator<(const NodeType& node) const { return ub < node.ub; // ub越大越优先出队 } }; /* 主干 * ->初始化根节点 * ->计算根节点上界及进队 * ->循环遍历队列,条件为非空:出一个节点, 计算左孩子节点剪枝条件,满足的左孩子计算上界及进队; 计算右孩子节点上界,符合上界条件的右孩子进队; (根据容量剪去不满足要求的左孩子,根据上界条件剪去不满足要求的右孩子) * */ void bfs(); // 进队----不是叶子节点就直接进队,是叶子节点则判断是否更优解,是的话则更新最优解 void EnQueue(NodeType e,priority_queue<NodeType> &qu); // 计算边界 就是根据剩余容量的大小,计算剩下全部物品装入的价值和装入部分物品的价值 // (部分物品按照单位容量内价值高低的顺序装入---这有点贪心的思想了) void bound(NodeType &e); // !# 分支限界优先队列法 // # 回溯法 // 使用n,W,w[],v[],maxv,bestv[] // main函数调用,用来初始化rw和op,以及dfs_back的入口 void bfs_back_main(); /* * 采用递归式调用 * 由于数组下表从1开始,则初始时i=1;tw与tv都为0; * rw为输入数据的总容量;op初始为全0,暂存解空间,然后赋值到bestx数组 * 此函数的内部,首先是到达叶子节点,也即递归的跳出条件,如果价值更优则更新bestx; * 然后是左剪枝和右剪枝操作了:扩展左孩子,需判定已扩展节点的容量+此节点的容量<=背包容量, 不满足则剪枝,然后回溯;扩展右孩子,需判定已扩展节点的容量+剩余节点的总容量>背包容量, 不然的话就没有扩展的必要,直接剪枝。不管扩展左右孩子,都得递归调用dfs_back */ void dfs_back(int i,int tw,int tv,int rw,int op[]); // !# 回溯法 // # 贪心法----非0/1背包问题,而是部分背包问题 // 使用n,W,w[],v[] struct NodeType_Knap { double w; double v; double p; //p=v/w bool operator<(const NodeType_Knap &s) const { return p>s.p; //按p递减排序 } }; vector<NodeType_Knap> A; // 含有输入的数据和排序后的数据 double V = 0; // 价值,之前是int型,在这里为double double x[MAXN]; // 最优解double类型,可以选择部分,即一定的比例 /* * 求单位重量的价值->按照自定义的格式排序->调用 Knap */ void knap_m(); /* * 排序后则贪心循环选择,如果剩余的容量还能容纳当前的,则放进去,不能的话跳出循环,选择部分放入 */ void Knap(); // !# 贪心法 // # 斐波那契数列 int countf = 1; int Fib(int n); int dp_fib[MAXN]; //所有元素初始化为0 int Fib1(int n); // !# 斐波那契数列 // # 动态规划法 // 使用n,W,w[],v[],maxv,bestv[] // 动态规划数组 int dp[MAXN][MAXN]; /* * 根据状态转移方程来构造动态 * 1>两个边界条件 * 2>由于动态规划数组为二维数组,则两层for循环里判断是否扩展活动节点 扩展则dp[i][r]=dp[i-1][r]; 不扩展则二者求最大 */ void dp_Knap(); /* * 动态规划数组已经填充完毕,逆着推出最优解 根据状态转移方程中的条件,判断每个物品是否选择 */ void buildx(); // !# 动态规划法 int main() { // 输入格式 /* 3 n个物品假设为3 16 45 第一个物品的重量和价值 15 25 第二个物品的重量和价值 15 25 第三个物品的重量和价值 30 背包容量W */ cin >> n; int m,l; // 下表0不用,填充0 w.push_back(0); v.push_back(0); for (int j = 1; j <= n;j++) { cin >> m >> l; w.push_back(m); v.push_back(l); } cin >> W; // # 分支界限优先队列法 //bfs(); // !# 分支界限优先队列法 // # 回溯法 //bfs_back_main(); // !# 回溯法 // # 贪心法 //knap_m(); // !# 贪心法 // # 斐波那契数列 //Fib(W); //Fib1(W); // !# 斐波那契数列 // # 动态规划法 dp_Knap(); buildx(); // !# 动态规划法 cout << "最优解:"; for (int i = 1;i <= n;i++) { if (V > 0) {// 贪心法 输出的是double类型 cout << x[i] << " "; }else {// 分支限界和回溯法输出的是int型 cout << bestx[i] << " "; } } if (V > 0) {// 贪心法 输出的是double类型 cout << endl << "最大价值为:" << V << endl; }else {// 分支限界和回溯法输出的是int型 cout << endl << "最大价值为:" << maxv << endl; } return 0; } ////////////////////////////////////////////////////////////////////////// // 分支限界优先队列法 void bfs() { int j; NodeType e,e1,e2; //定义3个结点 priority_queue<NodeType> qu; //定义一个优先队列(大根堆) e.i=0; //根结点置初值,其层次计为0 e.w=0; e.v=0; e.no=total++; for (j=1;j<=n;j++) e.x[j]=0; bound(e); //求根结点的上界 qu.push(e); //根结点进队 while (!qu.empty()) //队不空循环 { e=qu.top(); qu.pop(); //出队结点e if (e.w+w[e.i+1]<=W) //剪枝:检查左孩子结点 { e1.no = total++; e1.i = e.i + 1; //建立左孩子结点 e1.w = e.w + w[e1.i]; e1.v = e.v + v[e1.i]; for (j=1;j<=n;j++) e1.x[j]=e.x[j]; //复制解向量 e1.x[e1.i]=1; bound(e1); //求左孩子结点的上界 EnQueue(e1,qu); //左孩子结点进队操作 } e2.no = total++; //建立右孩子结点 e2.i = e.i + 1; e2.w = e.w; e2.v = e.v; for (j=1;j<=n;j++) e2.x[j]=e.x[j]; //复制解向量 e2.x[e2.i]=0; bound(e2); //求右孩子结点的上界 if (e2.ub>maxv) //若右孩子结点剪枝 EnQueue(e2,qu); } } void EnQueue(NodeType e,priority_queue<NodeType> &qu) { //结点e进队qu if (e.i==n) //到达叶子结点 { if (e.v>maxv) //找到更大价值的解 { maxv=e.v; for (int j=1;j<=n;j++) bestx[j]=e.x[j]; } } else qu.push(e); //非叶子结点进队 } void bound(NodeType &e) //计算分枝结点e的上界 { int i=e.i+1; //考虑结点e的余下物品 int sumw=e.w; //求已装入的总重量 double sumv=e.v; //求已装入的总价值 while (i<=n && (sumw+w[i]<=W) ) { sumw+=w[i]; //计算背包已装入载重 sumv+=v[i]; //计算背包已装入价值 i++; } if (i<=n) //余下物品只能部分装入 e.ub=sumv+(W-sumw)*v[i]/w[i]; else //余下物品全部可以装入 e.ub=sumv; } ////////////////////////////////////////////////////////////////////////// // 回溯法 void bfs_back_main() { int *op = new int[n]; for (int j = 0;j < n;j++) {// 初始化为全0 op[j] = 0; } // 所有物品的总容量 int rw = 0; for (int j = 0;j < n;j++) { rw += w[j]; } dfs_back(1,0,0,rw,op); } //求解0/1背包问题 void dfs_back(int i,int tw,int tv,int rw,int op[]) { //初始调用时rw为所有物品重量和 int j; if (i>n) //找到一个叶子结点 { if (tw==W && tv>maxv) //找到一个满足条件的更优解,保存 { maxv=tv; for (j=1;j<=n;j++) //复制最优解 bestx[j]=op[j]; } }else { //尚未找完所有物品 if (tw+w[i]<=W) //左孩子结点剪枝 { op[i]=1; //选取第i个物品 dfs_back(i+1,tw+w[i],tv+v[i],rw-w[i],op); } op[i]=0; //不选取第i个物品,回溯 if (tw+rw>W) //右孩子结点剪枝 dfs_back(i+1,tw,tv,rw-w[i],op); } } ////////////////////////////////////////////////////////////////////////// // 贪心法 void knap_m() { for (int i=0;i<=n;i++) { NodeType_Knap k; k.w = w[i]; k.v = v[i]; A.push_back(k); } for (int i=1;i<=n;i++) //求v/w A[i].p=A[i].v/A[i].w; sort(++A.begin(),A.end()); //A[1..n]排序 Knap(); } // 求解背包问题并返回总价值 void Knap() { V=0; //V初始化为0 double weight=W; //背包中能装入的余下重量 int i=1; while (A[i].w < weight) //物品i能够全部装入时循环 { x[i]=1; //装入物品i weight -= A[i].w; //减少背包中能装入的余下重量 V += A[i].v; //累计总价值 i++; //继续循环 } if (weight > 0) //当余下重量大于0 { x[i] = weight / A[i].w; //将物品i的一部分装入 V += x[i] * A[i].v; //累计总价值 } } ////////////////////////////////////////////////////////////////////////// // 斐波那契数列 int Fib(int n) { printf("(%d)求解Fib(%d)\n",countf++,n); if (n==1 || n==2) { printf(" 计算出Fib(%d)=%d\n",n,1); return 1; } else { int x = Fib(n-1); int y = Fib(n-2); printf(" 计算出Fib(%d)=Fib(%d)+Fib(%d)=%d\n", n,n-1,n-2,x+y); return x+y; } } // 动态规划后的斐波那契数列 int Fib1(int n) //算法1 { dp_fib[1]=dp_fib[2]=1; printf("(%d)计算出Fib(1)=1\n",countf++); printf("(%d)计算出Fib(2)=1\n",countf++); for (int i=3;i<=n;i++) { dp_fib[i]=dp_fib[i-1]+dp_fib[i-2]; printf("(%d)计算出Fib(%d)=%d\n",countf++,i,dp_fib[i]); } return dp_fib[n]; } ////////////////////////////////////////////////////////////////////////// // 动态规划法 void dp_Knap() { int i,r; for(i = 0;i <= n;i++) //置边界条件dp[i][0]=0 dp[i][0] = 0; for (r = 0;r <= W;r++) //置边界条件dp[0][r]=0 dp[0][r] = 0; for (i = 1;i <= n;i++) { for (r = 1;r <= W;r++) if (r < w[i]) dp[i][r] = dp[i-1][r]; else dp[i][r] = max(dp[i-1][r],dp[i-1][r-w[i]]+v[i]); } } void buildx() { int i=n,r=W; maxv=0; while (i>=0) //判断每个物品 { if (dp[i][r] != dp[i-1][r]) { bestx[i] = 1; //选取物品i maxv += v[i]; //累计总价值 r = r - w[i]; } else bestx[i]=0; //不选取物品i i--; } }