- 回溯法
- 分支限界法
回溯法
回溯法(探索与回溯法)是一种选优搜索法,又称为试探法,按选优条件向前搜索,以达到目标。但当探索到某一步时,发现原先选择并不优或达不到目标,就退回一步重新选择,这种走不通就退回再走的技术为回溯法。
基本思想:
在包含问题的所有解的解空间树中,按照深度优先搜索的策略,从根结点出发深度探索解空间树。当探索到某一结点时,要先判断该结点是否包含问题的解,如果包含,就从该结点出发继续探索下去,如果该结点不包含问题的解,则逐层向其祖先结点回溯。(其实回溯法就是对隐式图的深度优先搜索算法)。 若用回溯法求问题的所有解时,要回溯到根,且根结点的所有可行的子树都要已被搜索遍才结束。 而若使用回溯法求任一个解时,只要搜索到问题的一个解就可以结束。
一句话:回溯法最重要的是对解空间树有大致的了解,然后深度遍历解空间树,为了节省时间,还需要进行必要的剪枝操作。如下图所示,就是01背包问题n=3(3样物品时)的解空间树。
回溯法解题的时候常遇到两种类型的解空间树:子集树(选择问题)与排列树(排列问题)。当所给的问题是从n个元素的集合S中找出满足某种性质的子集时相应的空间就是子集树。当所给的问题是确定n个元素满足某种性质的排列时,相应的解空间树就是排列树。
用回溯法搜索子集树的一般算法课描述如下:
void Backtrack(int t)
{
if(t>n)
Output(x);
else
for(i=0;i<=1;i++)
{
x[t]=i;
//Constarint(t)和Bound(t)表示当前的约束函数和限界函数
if(Constarint(t)&&Bound(t))
Backtrack(t+1);
}
}
用回溯法搜索排列树的算法框架可描述如下:
void Backtrack(int t)
{
if(t>n)
Output(x);
else
for(i=t;i<=n;i++)
{
swap(x[t],x[i]); //swap作用是交换两个元素
//Constarint(t)和Bound(t)表示当前的约束函数和限界函数
if(Constarint(t)&&Bound(t))
Backtrack(t+1);
swap(x[t],x[i]);
}
}
在子集树的,t都是当前函数在解空间树搜索中相应的层数。
现在我们根据这两个模板分别举两个例子。
首先是子集树相关的定向子集求和问题。
描述: 给一个集合,求出和为某一固定数的子集
设计思想:利用回溯法,树的每一个节点包括1当前和2层数3剩余所有值之和。关键在于剪枝,若加上下一层的数的和小于所要求的和,则进入左子树,否则一定不可能,左子树便不生成。若减掉当前层数的再加上剩余所有数后的值依然小于所要求的和,那么右子树不生成,否则生成右子树,最后当某一层和等于所要求的和时成立并输出(中间用x[]数组来记录是否选中此数,0为未选中,1为选中)。
根据设计思想和子集树模板,我们可以写出如下算法代码:
//定向子集求和问题,假设给定W={5,10,12,13,15,18},S=30
#define maxsize 7
#include <IOSTREAM>
using namespace std;
int S=30;
int num=0;
int x[maxsize]={0};
int W[maxsize]={0,5,10,12,13,15,18}; //0号元素不用
void Backtrack(int sum,int k,int r) //sum为当前的和,k为层数(第几个数),r为剩余数的和
{
if (sum+W[k]==S)
{
num++;
cout<<"("<<num<<"):";
cout<<"S=";
for (int i=1;i<=k;i++)
{
if(x[i]==1)
{
cout<<W[i]<<"+";
}
else
continue;
}
cout<<W[k];
cout<<endl;
}
else if(sum+W[k]+W[k+1]<=S) //如果到下一层为止的数小于S,则进入左子树,否则一定不可能,左子树便不生成
{
x[k]=1;
Backtrack(sum+W[k],k+1,r-W[k]);
}
if (sum+r-W[k]>=S) //如果减掉当前层数的再加上剩余所有数后的值依然小于30,那么右子树不生成,否则生成
{
x[k]=0;
Backtrack(sum,k+1,r-W[k]);
}
}
int main()
{
cout<<"定向子集求和问题,给定物品重量W={5,10,12,13,15,18},找出和为S=30的子集。"<<endl;
Backtrack(0,1,73);
return 0;
}
接着我们再看一道关于排列树的例子,全排列。
问题描述:给定一串数字,给出这串数字的所有排列。(假设这串数字为0,1,2)(为了方便画解空间树,所以数字较少)。
题目的解空间树如下:
根据排列树的模板,我们可以写出如下代码:
#define n 4
#include <IOSTREAM>
using namespace std;
float A[n]={0,1,2};
void arrange(int k,int m)
{
int i;
int temp;
if(k==m)
{
for (i=0;i<=m;i++)
{
cout<<A[i]<<" ";
}
cout<<endl;
}
else
for (i=k;i<=m;i++)
{
temp=A[k];
A[k]=A[i];
A[i]=temp;
arrange(k+1,m);
temp=A[k];
A[k]=A[i];
A[i]=temp;
}
}
int main()
{
arrange(0,2);
return 0;
}
写到这里,我突然发现(写之前没有意识到)其实子集树和排列树的道理是一样的,排列树只是进行了非常隐蔽的剪枝操作。排列树模板中循环中i从t->n,而没有经过0->t,因为这一部分已经被隐蔽的剪枝了,在全排列中这0->t的选择是重复的。也就是说,对于排列树,我们也可以根据所有的选择进行0->n循环,只是要根据要求,在if中进行剪枝(比如写一个限制函数要求不能与前面的选择相同),最后得到的效果是相同的,但所花费的时间可能不同。
比如下面这道汉密尔顿环问题。
描述:给一个无向图,找出从其中一个点开始走,走过所有点,并且不走回路的路径
设计思想:定义一个邻接矩阵,1表示相连,0表示不相连。 限制条件是(1)不能和前面的元素重复(2)要保证必须和上一个点有连接,否则不继续向下生成。当k>maxsize,即到达最后一个点时,还要检查是否与最开始的点有连接,有连接则为汉密尔顿环,输出。
算法代码:
//hamilton环问题
#define maxsize 5
#include <IOSTREAM>
using namespace std;
int sum;
int y[maxsize];
int A[maxsize][maxsize]={{0,1,0,1,0},{1,0,1,1,1},{0,1,0,1,1},{1,1,1,0,1},{0,1,1,1,0}};//定义5*5的邻接矩阵
bool Check(int k) //保证不能和前面的元素重复
{
bool flag=true;
for(int i=0;i<k;i++)
{
if (y[i]==y[k])
{
flag=false;
}
}
return flag;
}
bool Check2(int k)
{
if (k==0)
{
return true;
}
else if (A[y[k]-1][y[k-1]-1]==1)
{
return true;
}
else
return false;
}
void Backtrack(int k)
{
if (k>maxsize-1)
{
if (A[y[0]-1][y[k-1]-1]==1) //第一个点和最后一个点是否有通路
{
sum++;
cout<<"("<<sum<<"):";
for (int i=0;i<maxsize;i++)
{
cout<<y[i]<<"->";
}
cout<<y[0];
cout<<endl;
}
}
else
{
for (int j=0;j<maxsize;j++)
{
y[k]=j+1;
if (Check(k)&&Check2(k)) //必须和上一个点有连接
{
Backtrack(k+1);
}
}
}
int main()
{
Backtrack(0);
return 0;
}
这道题虽然属于排列树问题,但也可以对所有的选择均进行判断(for循环从0-maxSize),然后if中check函数去掉已经被选择过的路。
分支限界法
回溯法的求解目标是找出解空间中满足约束条件的所有解,相比之下,分支限界法的求解目标则是找出满足约束条件的一个解,或是满足约束条件的解中找出使某一目标函数值达到极大或极小的解,即在某种意义下的最优解。
此外,回溯法以深度优先的方式搜索解空间,而分支界限法则以广度优先的方式或以最小耗费优先的方式搜索解空间。
搜索策略:在当前节点(扩展节点)处,先生成其所有的儿子节点(分支),然后再从当前的活节点(当前节点的子节点)表中选择下一个扩展节点。为了有效地选择下一个扩展节点,加速搜索的进程,在每一个活节点处,计算一个函数值(限界),并根据函数值,从当前活节点表中选择一个最有利的节点作为扩展节点,使搜索朝着解空间上有最优解的分支推进,以便尽快地找出一个最优解。
从活结点表中选择下一扩展结点的不同方式导致不同的分支限界法。最常见的有以下两种:
1.队列式(FIFO)分支限界法
队列式分支限界法将活节点表组织成一个队列,并将队列的先进先出原则选取下一个节点为当前扩展节点。
2.优先队列式分支限界法
优先队列式分支限界法将活节点表组织成一个优先队列,并将优先队列中规定的节点优先级选取优先级最高的下一个节点成为当前扩展节点。如果选择这种选择方式,往往将数据排成最大堆或者最小堆来实现。
根据这两种方法我们举两个例子。
首先是布线问题。
问题描述:如下图所示,找一条路把ab两点横平竖直最短连接起来,不能碰绿色的障碍物。
算法思路:
这里用的是队列式(FIFO)分支限界法。首先定义矩阵PCB[][],并将其初始化,infinite表示不通,0表示通,还有一个PCB2[][]是后面用来标记找路径的,结构体Pos指标是横纵坐标表示位置。一开始将a点入队,队列不空进入循环,利用HorizontalMove[4]={0,1,0,-1},VerticalMove[4]={1,0,-1,0}上下左右走,是在PCB上标记是0的就比当前位置加1,并入队,直到达到终点,这边注意的是a点因为初始值为0,会被修改,影响后面的找路径,所以改回0。然后找路径利用矩阵中周围比自己小1的是最短路径的特点,并在另一个矩阵中记录路径。最后利用所做的各种标记打印图形。(根据上下左右移动的顺序路径会有改变,但都为最短并满足要求的路径)。
//印刷电路板排线问题
#define maxsize 9
#define infinite 9999
#include <IOSTREAM>
using namespace std;
#include <STDLIB.H>
#include "Queue.h"
int HorizontalMove[4]={0,1,0,-1};
int VerticalMove[4]={1,0,-1,0};
int PCB[maxsize][maxsize]={0}; //用来找到路径的电路板矩阵
int PCB2[maxsize][maxsize]={0}; //用来标记路径的电路板矩阵
typedef struct //位置的结构体
{
int row; //行
int col; //列
}PosNode;
void Print()
{
/*省略*/
}
void InitializePCB() //初始化电路板的函数(按题目要求把障碍这些全部设置好,0可通,infinite表示不能通)
{
for (int i=0;i<maxsize;i++) //外面的围墙
{
for (int j=0;j<maxsize;j++)
{
if (i<=0||i>=maxsize-1||j<=0||j>=maxsize-1)
{
PCB[i][j]=infinite;
}
else
{
PCB[i][j]=0;
}
}
}
PCB[1][3]=infinite;
PCB[2][3]=infinite;
PCB[2][4]=infinite;
PCB[3][5]=infinite;
PCB[4][4]=infinite;
PCB[4][5]=infinite;
PCB[5][1]=infinite;
PCB[5][5]=infinite;
PCB[6][1]=infinite;
PCB[6][2]=infinite;
PCB[6][3]=infinite;
PCB[7][1]=infinite;
PCB[7][2]=infinite;
PCB[7][3]=infinite;
}
void Arrange(PosNode start,PosNode end)
{
int i;
int flag=0;//用来判断是否有解
PosNode tempnode;
PosNode nownode;
LinkQ Q; //初始化队列
InitQ(Q);
EnQ(Q,start);
while (!EmptyQ(Q))
{
DeQ(Q,tempnode);
for (i=0;i<4;i++) //上下左右都走一下
{
nownode.row=tempnode.row+HorizontalMove[i];
nownode.col=tempnode.col+VerticalMove[i];
if (PCB[nownode.row][nownode.col]==0)
{
PCB[nownode.row][nownode.col]=PCB[tempnode.row][tempnode.col]+1; //比上一个加一
if (nownode.row==end.row&&nownode.col==end.col) //到达终点
{
flag=1;
return;
}
EnQ(Q,nownode);
}
}
}
if (flag==0)
{
cout<<"无解"<<endl;
exit(0);
}
}
void FindPath(PosNode start,PosNode end) //找到那条路径,利用矩阵中周围比自己小1的是最短路径的特点
{
int temp;
PosNode tempnode;
PosNode tempnode2;
temp=PCB[end.row][end.col];
tempnode.row=end.row;
tempnode.col=end.col;
PCB2[end.row][end.col]=1;
int flag=0;
while(flag==0)
{
for (int i=0;i<4;i++)
{
tempnode2.row=tempnode.row+HorizontalMove[i];
tempnode2.col=tempnode.col+VerticalMove[i];
if ((temp-1)==PCB[tempnode2.row][tempnode2.col])
{
PCB2[tempnode2.row][tempnode2.col]=1; //在另一个矩阵中记录路径
temp--;
tempnode.row=tempnode2.row;
tempnode.col=tempnode2.col;
if (tempnode2.row==start.row&&tempnode2.col==start.col)
{
flag=1;
break;
}
break;
}
}
}
}
int main()
{
PosNode start;
start.row=3;
start.col=2;
PosNode end;
end.row=4;
end.col=6;
InitializePCB();
Print(start,end);
Arrange(start,end);
//##Arrgnge是碰到0就改变,因为起点在矩阵中的数值为0,会导致起点数值变2,影响了后面找路径,所以改为0.
PCB[start.row][start.col]=0;
FindPath(start,end);
cout<<endl<<endl;
Print();
return 0;
}
然后是关于优先队列分支限界法的著名旅行商问题:
该问题是在寻求单一旅行者由起点出发,通过所有给定的需求点之后,最后再回到原点的最小路径成本。
题目如下图所示:
算法思路:
首先,用小顶堆写一个优先队列,并按要求定义好矩阵。定义结构体Node(也是队列中的元素),里面nommoney表示当前的钱,这也是优先队列的优先级,k表示层数,x[]用来记录路径。然后写一个类似于树广搜的函数,一开始都赋0使Node进入队列,队列不空则进入循环如果不是叶子节点的父节点,那么这一层上有可能的值一个个试,如果满足1.不能和前面有重复2.要有连接3.加上钱这一层钱后要小于最优解,则修改结构体值后入队。然后试完后再出队的值就是队列中当前钱最少的那个节点,依次进行,直到到达叶节点的父节点,找出剩余的那个没走过的节点,加上当前节点到那个节点路的钱和那个节点到起点(这里设为1点)的钱是否小于最优解,小于则记录下来。
算法代码:
//旅行商问题,从1号点开始走
#define maxsize 5
#define infinite 9999
#include <IOSTREAM>
using namespace std;
#include "PriorQueue.h"
//在头文件中已经定义过了,不再重复定义
/*typedef struct
{
int nowmoney; //并作为优先级
int k; //层数
int x[maxsize]; //记录路径
}Node;
*/
int bestmoney=999;
int best[maxsize];
int x[maxsize]={0};
int A[maxsize][maxsize]={{infinite,20,30,10,11},{20,infinite,16,4,2},
\{30,16,infinite,6,7},{10,4,6,infinite,12},{11,2,7,12,infinite}};//定义5*5的邻接矩阵
bool Check(int content,Node temp) //保证不能和前面的元素重复
{
bool flag=true;
for(int i=0;i<temp.k;i++)
{
if (temp.x[i]==content)
{
flag=false;
}
}
return flag;
}
bool Check2(int content,Node temp)
{
if (A[content-1][temp.x[temp.k]-1]!=infinite)
{
return true;
}
else
return false;
}
void TSP()
{
int i,int j;
Node tempnode,tempnode2,node;
node.nowmoney=0;
node.k=0;
node.x[0]=1;
PriorQueue Q; //初始化优先队列
InitQueue(Q);
EnterQueue(Q,node);
while (!QueueEmpty(Q))
{
OutQueue(Q,tempnode);
if (tempnode.k==maxsize-2) //到达叶节点的父节点
{
int flag;
for (i=1;i<=maxsize;i++)
{
for (j=0;j<maxsize;j++)
{
if(tempnode.x[j]==i)
{
break;
}
}
if (j==maxsize)
{
break;
}
}
flag=i;
if (tempnode.nowmoney+A[tempnode.x[tempnode.k]][flag]+A[flag][0]<bestmoney)
{
bestmoney=tempnode.nowmoney+A[tempnode.x[tempnode.k]-1][flag-1]+A[flag-1][0];
for (i=0;i<=tempnode.k;i++)
{
best[i]=tempnode.x[i];
}
best[i]=flag;
}
}
else
{
for (i=1;i<=maxsize;i++)
{
//1.不能和前面有重复2.要有连接3.加上钱这一层钱后要小于最优解
if (Check(i,tempnode)&&Check2(i,tempnode)&&(tempnode.nowmoney+A[tempnode.x[tempnode.k]][i])<bestmoney)
{
tempnode2.k=tempnode.k+1;
tempnode2.nowmoney=tempnode.nowmoney+A[tempnode.x[tempnode.k]-1][i-1];
for (j=0;j<=tempnode.k;j++)
{
tempnode2.x[j]=tempnode.x[j];
}
tempnode2.x[tempnode2.k]=i;
EnterQueue(Q,tempnode2);
}
}
}
}
}
int main()
{
TSP();
cout<<"最优解为:"<<endl;
for (int i=0;i<maxsize;i++)
{
cout<<best[i]<<"->";
}
cout<<"1";
cout<<"\n花费为"<<bestmoney<<endl;
return 0;
}
总的来说,回溯法相当于深搜,而分支限界法相当于广搜,为了减小时间复杂度。回溯法通过剪枝,而分支限界法可以通过优先队列的择优选择来减少选择次数,但是确定哪一项数据的为优先级决定了分支限界法的成功与否。