回溯法实例详解(转)
- 概念
- 回溯算法实际上一个类似枚举的搜索尝试过程,主要是在搜索尝试过程中寻找问题的解,当发现已不满足求解条件时,就“回溯”返回,尝试别的路径。
回溯法是一种选优搜索法,按选优条件向前搜索,以达到目标。但当探索到某一步时,发现原先选择并不优或达不到目标,就退回一步重新选择,这种走不通就退回再走的技术为回溯法,而满足回溯条件的某个状态的点称为“回溯点”。
许多复杂的,规模较大的问题都可以使用回溯法,有“通用解题方法”的美称。
- 回溯算法实际上一个类似枚举的搜索尝试过程,主要是在搜索尝试过程中寻找问题的解,当发现已不满足求解条件时,就“回溯”返回,尝试别的路径。
- 基本思想
- 在包含问题的所有解的解空间树中,按照深度优先搜索的策略,从根结点出发深度探索解空间树。当探索到某一结点时,要先判断该结点是否包含问题的解,如果包含,就从该结点出发继续探索下去,如果该结点不包含问题的解,则逐层向其祖先结点回溯。(其实回溯法就是对隐式图的深度优先搜索算法)。
若用回溯法求问题的所有解时,要回溯到根,且根结点的所有可行的子树都要已被搜索遍才结束。
而若使用回溯法求任一个解时,只要搜索到问题的一个解就可以结束。
- 在包含问题的所有解的解空间树中,按照深度优先搜索的策略,从根结点出发深度探索解空间树。当探索到某一结点时,要先判断该结点是否包含问题的解,如果包含,就从该结点出发继续探索下去,如果该结点不包含问题的解,则逐层向其祖先结点回溯。(其实回溯法就是对隐式图的深度优先搜索算法)。
- 解题步骤
- (1)针对所给问题,确定问题的解空间:
首先应明确定义问题的解空间,问题的解空间应至少包含问题的一个(最优)解。
(2)确定结点的扩展搜索规则
(3)以深度优先方式搜索解空间,并在搜索过程中用剪枝函数避免无效搜索
- (1)针对所给问题,确定问题的解空间:
- 子集树,排列数及其他
- 子集树概念:当所给问题是从n个元素的集合S中找出S满足的某种性质的子集时,相应的解空间树称为子集树。例如,0-1背包问题,要求在n个物品的集合S中,选出几个物品,使物品在背包容积C的限制下,总价值最大(即集合S的满足条件<容积C下价值最大>的某个子集)。
另:子集树是从集合S中选出符合限定条件的子集,故每个集合元素只需判断是否(0,1)入选,因此解空间应是一颗满二叉树
- 回溯法搜索子集树的一般算法
void backtrack(int t)//t是当前层数 { if(t>n)//需要判断每一个元素是否加入子集,所以必须达到叶节点,才可以输出 { output(x); } else { for(int i=0;i<=1;i++)//子集树是从集合S中,选出符合限定条件的子集,故每个元素判断是(1)否(0)选入即可(二叉树),因此i定义域为{0,1} { x[t]=i;//x[]表示是否加入点集,1表示是,0表示否 if(constraint(t)&&bound(t))//constraint(t)和bound(t)分别是约束条件和限定函数 { backtrack(t+1); } } } }
- 回溯法搜索子集树的一般算法
- 排列树概念:当问题是确定n个元素满足某种性质的排列时,相应的解空间称为排列树。排列树与子集树最大的区别在于,排列树的解包括整个集合S的元素,而子集树的解则只包括符合条件的集合S的子集。
- 回溯法搜索排列数的一般算法:
void backtrack(int t)//t是当前层数 { if(t>n)//n是限定最大层数 { output(x); } else { for(int i=t;i<=n;i++)//排列树的节点所含的孩子个数是递减的,第0层节点含num-0个孩子,第1层节点含num-1个孩子,第二层节点含num-2个孩子···第num层节点为叶节点,不含孩子。即第x层的节点含num-x个孩子,因此第t层的i,它的起点为t层数,终点为num,第t层(根节点为空节点,除外),有num-t+1个亲兄弟,需要轮num-t+1回 { swap(x[t],x[i]);//与第i个兄弟交换位置,排列树一条路径上是没有重复节点的,是集合S全员元素的一个排列,故与兄弟交换位置后就是一个新的排列 if(constraint(t)&&bound(t))//constraint(t)和bound(t)分别是约束条件和限定函数 { backtrack(t+1); } swap(x[i],x[t]); } } }
- 回溯法搜索排列数的一般算法:
- 非子集树,非排列数
- 递归算法
void backtrack(int t) { if(t>n) { output(x); } else { for(int i=f(n,t);i<=g(n,t);i++) { x[t]=h(i); if(constraint(t)&&bound(t)) { backtrack(t+1); } } } }
- 递归算法
- 子集树概念:当所给问题是从n个元素的集合S中找出S满足的某种性质的子集时,相应的解空间树称为子集树。例如,0-1背包问题,要求在n个物品的集合S中,选出几个物品,使物品在背包容积C的限制下,总价值最大(即集合S的满足条件<容积C下价值最大>的某个子集)。
- 这类题目的解题方法:先根据题意去判断是什么类型的树(子集树,排列数,还是两者都不是),再去写代码。
- 实例详解
- 装载问题:有一批共n个集装箱要装上2艘载重量分别为c1和c2的轮船,其中集装箱i的重量为wi,且,装载问题要求确定是否有一个合理的装载方案可将这些集装箱装上这2艘轮船。如果有,找出一种装载方案。
例如:当n=3,c1=c2=50,且w=[10,40,40]时,则可以将集装箱1和2装到第一艘轮船上,而将集装箱3装到第二艘轮船上;如果w=[20,40,40],则无法将这3个集装箱都装上轮船。
基本思路: 容易证明,如果一个给定装载问题有解,则采用下面的策略可得到最优装载方案。
(1)首先将第一艘轮船尽可能装满;
(2)将剩余的集装箱装上第二艘轮船。
将第一艘轮船尽可能装满等价于选取全体集装箱的一个子集,使该子集中集装箱重量之和最接近C1。由此可知,装载问题等价于以下特殊的0-1背包问题。- 解题思路:画出解题思路图,得出这个解应该是一个子集树(0000,0001,0010,0011等等)。采用子集树的递归算法进行求解即可。
- 代码:
#include "stdafx.h" #include <iostream> using namespace std; template <class Type> class Loading { //friend Type MaxLoading(Type[],Type,int,int []); //private: public: void Backtrack(int i); int n, //集装箱数 *x, //当前解 *bestx; //当前最优解 Type *w, //集装箱重量数组 c, //第一艘轮船的载重量 cw, //当前载重量 bestw, //当前最优载重量 r; //剩余集装箱重量 }; template <class Type> void Loading <Type>::Backtrack (int i); template<class Type> Type MaxLoading(Type w[], Type c, int n, int bestx[]); int main() { int n=3,m; int c=50,c2=50; int w[4]={0,10,40,40}; int bestx[4]; m=MaxLoading(w, c, n, bestx); cout<<"轮船的载重量分别为:"<<endl; cout<<"c(1)="<<c<<",c(2)="<<c2<<endl; cout<<"待装集装箱重量分别为:"<<endl; cout<<"w(i)="; for (int i=1;i<=n;i++) { cout<<w[i]<<" "; } cout<<endl; cout<<"回溯选择结果为:"<<endl; cout<<"m(1)="<<m<<endl; cout<<"x(i)="; for (int i=1;i<=n;i++) { cout<<bestx[i]<<" "; } cout<<endl; int m2=0; for (int j=1;j<=n;j++) { m2=m2+w[j]*(1-bestx[j]); } cout<<"m(2)="<<m2<<endl; if(m2>c2) { cout<<"因为m(2)大于c(2),所以原问题无解!"<<endl; } return 0; } template <class Type> void Loading <Type>::Backtrack (int i)// 搜索第i层结点 { if (i > n)// 到达叶结点 { if (cw>bestw) { for(int j=1;j<=n;j++) { bestx[j]=x[j];//更新最优解 bestw=cw; } } return; } r-=w[i]; if (cw + w[i] <= c) // 搜索左子树 { x[i] = 1; cw += w[i]; Backtrack(i+1); cw-=w[i]; } if (cw + r > bestw) { x[i] = 0; // 搜索右子树 Backtrack(i + 1); } r+=w[i]; } template<class Type> Type MaxLoading(Type w[], Type c, int n, int bestx[])//返回最优载重量 { Loading<Type>X; //初始化X X.x=new int[n+1]; X.w=w; X.c=c; X.n=n; X.bestx=bestx; X.bestw=0; X.cw=0; //初始化r X.r=0; for (int i=1;i<=n;i++) { X.r+=w[i]; } X.Backtrack(1); delete []X.x; return X.bestw; }
-
0-1背包问题:有n件物品和一个容量为c的背包。第i件物品的价值是v[i],重量是w[i]。求解将哪些物品装入背包可使价值总和最大。所谓01背包,表示每一个物品只有一个,要么装入,要么不装入。
- 解题思路:这个问题存在一种最有解,用回溯法也可以解出来。他的类型应该是子集树。要么选入,要么不选入。在搜索状态空间树时,只要左子节点是可一个可行结点,搜索就进入其左子树。对于右子树时,先计算上界函数,以判断是否将其减去,剪枝啦啦!
上界函数bound():当前价值cw+剩余容量可容纳的最大价值<=当前最优价值bestp。
为了更好地计算和运用上界函数剪枝,选择先将物品按照其单位重量价值从大到小排序,此后就按照顺序考虑各个物品。 - 代码:
#include<iostream> #include<vector> #include<cstdlib> using namespace std; struct Node { int w; int p; }; vector<Node> v;//物品 vector<int> x;//当前方案 vector<int> bestx;//存储最佳方案 int num;//物品数 int c;//背包容量 int maxP;//最大价值 int random(int start,int end) { return start+rand()%(end-start); } void storage(int cp) { if(cp>maxP) { maxP=cp; for(int i=0;i<num;i++) { bestx[i]=x[i]; } } } void knapsack(int cw,int t,int cp) { if(t>=num) { return; } else { if(cw<=c) { for(int i=0;i<=1;i++) { x[t]=i; if(i==1) { cw+=v[t].w; cp+=v[t].p; } if(cw<=c) { storage(cp); knapsack(cw,t+1,cp); } } } } } int main() { maxP=-1; cin>>num>>c; for(int i=0;i<num;i++) { Node temp; temp.w=random(1,20); temp.p=random(1,100); v.push_back(temp); x.push_back(0); bestx.push_back(0); } knapsack(0,0,0); cout<<maxP<<endl; }
- 解题思路:这个问题存在一种最有解,用回溯法也可以解出来。他的类型应该是子集树。要么选入,要么不选入。在搜索状态空间树时,只要左子节点是可一个可行结点,搜索就进入其左子树。对于右子树时,先计算上界函数,以判断是否将其减去,剪枝啦啦!
- 旅行售货员问题:某售货员要到若干城市去推销商品,已知各城市之间的路程(旅费),他要选定一条从驻地出发,经过每个城市一遍,最后回到驻地的路线,使总的路程(总旅费)最小。
- 解题思路:这个题目分析之后他的解空间是一个排列数,如图所示。
- 代码:
#include<iostream> #include<vector> #include<climits> #include<algorithm> #include<cstdlib> using namespace std; vector< vector<int> > v; vector<int> x; int num; int costbest; int random(int s,int e) { return s+rand()%(e-s); } int countDis() { int cost=0; for(int i=1;i<x.size();i++) { cost+=v[x[i-1]][x[i]]; } return cost+v[x[0]][x[x.size()-1]]; } void Bttsp(int firstcity,int t) { if(t>=num) { int costx=countDis(); if(costx<costbest) { costbest=costx; } } else { for(int i=t;i<num;i++) { if(x[0]==firstcity) { swap(x[t],x[i]); Bttsp(firstcity,t+1); swap(x[t],x[i]); } } } } int main() { costbest=INT_MAX; cin>>num; for(int i=0;i<num;i++) { vector<int> temp; for(int j=0;j<num;j++) { temp.push_back(0); } v.push_back(temp); x.push_back(i); } for(int i=0;i<num;i++) { for(int j=i+1;j<num;j++) { int temp=random(1,50); v[i][j]=temp; v[j][i]=temp; cout<<"v["<<i<<"]["<<j<<"]="<<temp<<endl; } } Bttsp(0,0); cout<<costbest<<endl; }
- 类似的还有圆排列问题。
- 装载问题:有一批共n个集装箱要装上2艘载重量分别为c1和c2的轮船,其中集装箱i的重量为wi,且,装载问题要求确定是否有一个合理的装载方案可将这些集装箱装上这2艘轮船。如果有,找出一种装载方案。
- LeetCode实例分析
-
Combinations:Given two integers n and k,return all possible combinations of k numbersout of 1 ... n. For example, If n = 4 and k =2, a solution is: [ [2,4], [3,4], [2,3], [1,2], [1,3], [1,4], ]
- 题目分析:给你两个整数 n和k,从1-n中选择k个数字的组合。比如n=4,那么从1,2,3,4中选取两个数字的组合,包括图上所述的四种。leetcode给出的算法框架:
class Solution {
public:
vector<vector<int> > combine(int n, int k) {
}
};
他所返回的是一个二维数组,用来存储所有组合。这一类问题的套路教学:
现在进行套路教学:要求返回vector<vector<int> >,那我就给你一个vector<vector<int> >,因此 (1) 定义一个全局vector<vector<int> > result; (2) 定义一个辅助的方法(函数)void backtracking(int n,int k, vector<int>){} n k 总是要有的吧,加上这两个参数,前面提到vector<int>是数字的组合,也是需要的吧,这三个是必须的,没问题吧。(可以尝试性地写参数,最后不需要的删除)
(3) 接着就是我们的重头戏了,如何实现这个算法?对于n=4,k=2,1,2,3,4中选2个数字,我们可以做如下尝试,加入先选择1,那我们只需要再选择一个数字,注意这时候k=1了
(此时只需要选择1个数字啦)。当然,我们也可以先选择2,3 或者4,通俗化一点,我们可以选择(1-n)的所有数字,这个是可以用一个循环来描述?每次选择一个加入我们的链表list中,
下一次只要再选择k-1个数字。那什么时候结束呢?当然是k<0的时候啦,这时候都选完了 - 完整代码:
class Solution { public: vector<vector<int> > res; vector<vector<int> > combine(int n, int k) { //vector<vector<int> > res; vector<int> temp; backtrack(n, k, temp, 1); return res; } void backtrack(int n, int k, vector<int> temp, int start){ if (k < 0) return ; else if (k == 0){//所有的找完之后将这个数组进入二维数组中 res.push_back(temp); } else{ for (int i=start; i<=n; i++){ temp.push_back(i); backtrack(n, k-1, temp, i+1);//回溯法找到满足的元素 temp.pop_back();//回溯,弹出上一个满足的元素 } } } };
- 题目分析:给你两个整数 n和k,从1-n中选择k个数字的组合。比如n=4,那么从1,2,3,4中选取两个数字的组合,包括图上所述的四种。leetcode给出的算法框架:
- 实例分析二:给你一个正数数组candidate[],一个目标值target,寻找里面所有的不重复组合,让其和等于target,给你[2,3,6,7] 2+2+3=7 ,7=7,所以可能组合为[2,2,3],[7])
- 套路实现:基本框架
class Solution { public: vector<vector<int> > res; vector<vector<int> > combinationSum(vector<int> &candidates, int target) { int size = candidates.size(); vector<int> temp; backtrack(candidates, terget, temp, 1); } void backtrack(vector<int> &candidates, int target, int temp, int){ } };
- 然后就是分析算法了:对于所有可能的组合。我们回溯的进行记录,首先拿出一个数num,target就变成target-num,然后再从所有的数中挑出可以的数字。极限条件是target<0,输出结果条件是target=0;由于这个数字的选取是可以重复的所以下一个数也是重当前位置开始,但是不能使重复的排列,所以还必须有start参数,记录开始的位置多次从头开始遍历循环。
- 代码实现:
class Solution { public: vector<vector<int> > res; vector<vector<int> > combinationSum(vector<int> &candidates, int target) { vector<vector<int> > res; sort(candidates.begin(),candidates.end()); vector<int> temp; backtrack(res,candidates,temp,target,0); return res; } void backtrack(vector<vector<int> > &res,vector<int> &candidates, vector<int> &temp,int target,int start){ if (target < 0) return; else if (target == 0){ res.push_back(temp); //return ; } else{ for(int i=start;i<candidates.size();i++){ temp.push_back(candidates[i]); backtrack(res,candidates,temp,target-candidates[i],i); temp.pop_back(); } } } };
- 套路实现:基本框架
-
- 还有一些比较经典的回溯算法,大体框架都是如此的。