基础算法 —— 5. 回溯法
方法论
回溯法和分支限界法都是搜索解空间的方法。两者都可以用剪枝函数优化加快搜索。
- 回溯法采取深度优先搜索,通常用于搜索所有可行解的情况。
- 分支限界法采取广度优先搜索,根据策略不同有先进先出分支限界和优先级分支限界,通常用于搜索最优解。
解空间
两种方法适用于解空间是两类树,子集树和排列树。
-
子集树:顾名思义其问题类似于子集问题,元素只有两种状态对应0 -1,叶节点个数为 \(2^n\) 。更一般的讲,叶节点个数为 \(m^n\) m 代表状态个数,n 代表元素个数。
-
排列树:顾名思义其问题类似排列问题,所以其叶子节点一般是 \(n!\) 。
-
根据上面两种组合更改的树。
回溯法的实现
一般来讲,用递归实现的回溯算法采取深度优先搜索。但也可以使用广度优先搜索算法。
void BackTrack_r(int level){ //子集树的递归实现
if(level>元素个数){
说明到达叶节点,得到一个可行解
}
else{
for(对当前每个备选方案){
if(满足约束&&满足限界){
进入下一层搜索
}
}
}
}
void BackTrack_r(int level){ //排列树的递归实现
if(level>元素个数){
说明到达叶节点,得到一个可行解
}
else{
for(对当前每个备选方案){
将当前的方案与第一个方案进行交换
if(满足约束&&满足限界){
进入下一层搜索
}
再交换一次恢复原状
}
}
}
void BackTrack(){//非递归实现
int level = 0;
while(level>0){
if(当前有备选方案){
for(对每个备选方案){
if(满足约束&&满足限界){
if(level>元素个数){
说明到达叶节点,得到一个可行解
}
else{
++level;
}
}
}
}
else{
--level;
}
}
}
分支限界法的实现
队列式分支限界法与解空间的广度搜索很相似。
优化
回溯法实际上算是另一种穷举法,所以效率一般不高,但我们可以用剪枝函数剪掉树中不可能的分支,从而减少搜索量,提高效率。
用剪枝函数,剪枝函数分为两类:1. 用约束函数剪去不满足约束的子树。2. 用限界函数剪去不满足最优解的子树。
其它
回溯法和动态规划有时很像。
我们应该了解到,对于某些子集问题,其几乎需要遍历所有解空间,那么我们可以用一个n位的二进制序列来模拟,从而进一步省下了递归所需的栈空间,又比非递归版简洁易懂。
问题集
最优装载问题变种
分析问题,显然选择子集树来表示解空间是合适的,两种状态对应于一个货物装的船号。不过我们先采取书上的方法,用两种状态表示货物是否装到1号船,后面我们会探究这两种不同的表示方式有何区别。
由于题目条件,货物总重量 <= 两艘船载重量之和,所以这个问题可以转化将第一艘船尽可能装满的问题。令\(w_i\) 表示第i个货物的重量和,\(x_i\) 取值 0-1 表示是否选择第i个货物到第一艘船,\(c_1\) 表示第一艘船的最大载重量。那么我们要求在满足 ${\sum \limits_{i=1}^{n} w_i*x_i}<= c_1 $ 条件下的 \(\max {\sum \limits_{i=1}^{n} w_i*x_i}\) 的 \(x\) 的集合。
接下来分析约束函数和限界函数。
- 我们的约束条件就是条件 \({\sum \limits_{i=1}^{n} w_i*x_i}<= c_1\),不满足这个条件的分支都应该被剪去。
- 限界函数分析的是否能得到最优解的情况。这块我们引入限制下界函数的限界函数,假设已经搜索到一个可行解,令maxw = 这个可行解,如果当前的载重量cw + 剩下所有货物的重量和 <= maxw 说明就算将剩下所有的货物都装到第一艘船,也没有前面的装法装的多所以剪去该分支,相当于限制了下界。
class Loading{
private:
static constexpr int N = 10;
int weight[N];
int c1,c2;
int maxw,r,cw;
int cw1,cw2;
bitset<N> bits;
public:
Loading(){
for_each(weight,weight+N,[](int &i){cout<<(i = rand()%9+1)<<' ';});
cout<<endl;
r = accumulate(weight,weight+N,0);
c2 = 26;
c1 = r-c2+1;
cw1=cw2=0;
maxw = cw = 0;
}
void maxLoading(int level = 1){
if(level > N){
maxw = std::max(maxw,cw);
cout<<cw<<'\t'<<bits.to_string()<<endl;
}
else{
int ccw = weight[level-1]; // currentCargoWeight
r -= ccw;
{
if(cw+ccw<=c1){
cw += ccw;
bits.set(level-1,true);
maxLoading(level+1);
bits.set(level-1,false);
cw -= ccw;
}
if(cw + r >= maxw){
maxLoading(level+1);
}
}
r += ccw;
}
}
void maxLoading2(int level = 1){
if(level > N){
maxw = max(maxw,cw1);
cout<<cw1<<'\t'<<bits.to_string()<<endl;
}
else{
int ccw = weight[level-1];
if(cw1+ccw<=c1){
bits.set(level-1,true);
cw1 += ccw;
maxLoading2(level+1);
cw1 -= ccw;
bits.set(level-1,false);
}
if (cw2+ccw<=c2){
cw2 += ccw;
maxLoading2(level+1);
cw2 -= ccw;
}
}
}
~Loading(){
cout<<maxw;
}
};
注意到,如果我们把左右两种状态对应于两种船号。这样做就不再是子集问题,也就是说最多只能使用约束函数,无法使用剪枝函数。其效率较低。
应该意识到,如果要求解最优解,那么使用限界函数就可以减少很多不必要的搜索。有了它效率能够提升一大截。
批作业调度
这个问题要的解是作业的序列,所以其解空间是一个排列树。因为题目没有其它约束条件,所以就没有约束函数和限界函数。
不过需要注意的是,完成时间的计算方法。
class FlowShop{
private:
int (*work)[2];
static constexpr int N = 3;
int mint,ct1,ct2;
public:
FlowShop(){
work = new int [N][2];
for_each(work,work+N,[](auto &i){i[0] = rand()%9+1;i[1] = rand()%9+1;});
mint = ct1 = ct2 = 0;
mint = INT_MAX;
}
void minPermutation(int level = 1){
if(level > N){
mint = min(mint,ct2);
cout<<ct2<<'\t';
for_each(work,work+N,[](auto&i){cout<<'('<<i[0]<<','<<i[1]<<')'<<' ';});
cout<<endl;
}
else{
for(int i=level-1;i<N;++i){
swap(work[level-1],work[i]);
int temp = max(
ct1 + work[level-1][0]+work[level-1][1],
ct2+work[level-1][1]
);
ct2 += temp;
ct1 += work[level-1][0];
minPermutation(level+1);
ct2 -= temp;
ct1 -= work[level-1][0];
swap(work[level-1],work[i]);
}
}
}
~FlowShop(){
cout<<mint;
delete[]work;
}
};
符号三角形
只要确定第一行就确定整个三角形,其解空间是子集树。
因为整个三角形符号总数为 \(\frac{n^2+n}{2}\) 所以约束函数是当前 ‘-’ ‘+’ 总数<= \(\frac{n^2+n}{2}\) 由于问题是统计所有的可行解,没有所谓的最优解,所以没有限界函数。
n皇后
分析问题,其解空间可以说是子集树,不过每个皇后有n个状态;也可以说是排列树,因为很明显皇后们不能在同一行或列。
约束函数就是皇后们不能在同一列或行或斜线。因为没有最优解,所以无限界函数。
0-1背包
虽然在之前我们用了动态规划的算法,这块我们考虑考虑回溯的解法。
其解空间是一颗二叉子集树。和上面的最优装载问题很相似,约束函数就是当前所装物品要小于背包容量。限界函数跟也跟其一样,当前的价值+剩下所有货物的价值 < 得到的最大价值,则说明不可能得到最优解。

浙公网安备 33010602011771号