1、单源最短路径问题: 给定一个带权有向图G=(V,E),其中每条边的权是一个非负实数,V={1,2,…,n}。设顶点v作为源顶点。要计算从源到所有其他各顶点的最短路径长度。
Dijkstra算法是解决这个问题的一个著名的贪心算法。这个问题也适合用分支限界法来求解。分支限界法类似于回溯法,也是在问题的解空间树(对这里的问题则为图)上搜索问题的解。但分支限界法采用广度优先或最小耗费优先的方式来搜索,即遍历到一个结点之后,接着就遍历其相邻的所有结点。这就意味着需要一个队列结构来完成树或图的遍历。初始时根结点入队,按结点的出队顺序进行遍历,遍历到一个结点时,就让其相邻的所有结点入队。搜索过程直到队列变空为止。对树来说,广度优先搜索就相当于层次遍历。常用的队列有先进先出(FIFO)队列、优先级队列。与一般的广度优先遍历不同,分支限界法通常可以使用限界函数(即剪枝函数)来计算结点的值,以剪去导致无效解或非最优解的子树,即让不可行的结点不入队,这样就不会搜索到以不可行结点为根的子树。由于使用队列结构来进行遍历,因此分支限界法通常不需要递归,这可以节省一些递归展开的时间开销。但它需要一个额外的队列结构,我们知道递归展开则使用了栈结构,可见与回溯法相比,分支限界法并不占太大的便宜。
(1)定义问题的解空间:这里的解是源顶点到其他各顶点的最短路径。我们可以用数组prev来记录从源到各顶点的最短路径上前驱顶点,即prev[i]是从源到顶点i的最短路径上i的前一个顶点。这样prev[i]=i1, prev[i1]=i2, …, prev[ik]=v(到达源顶点v),就可以找到从源顶点v到任意顶点的i的最短路径”1–>ik–>…–>i2–>i1–>i”。我们用数组dist记录源到各顶点最短路径的长度。初始时,dist[i]为源顶点v到顶点i的边上的权值,prev[i]为源顶点v,若没有直接相连的边,则(v,i)用一个充分大的值NoEdge表示,且prev[i]=0,表示无前驱。
(2)确定解空间的结构:这里的解空间树以源顶点为根,包含源顶点到每一个顶点的所有可能的路径。这里可以直接对图进行遍历。图用邻接矩阵表示。
(3)以广度优先方式搜索整个解空间,找出所要的解:这里是求从源到各顶点的最短路径,因此要使用最小堆表示的优先级队列来进行广度优先搜索。入队的顶点必须还要附带一个优先级域,队列根据这个域来判定结点的出队优先级。这里要使用从源到该顶点的最短路径长度作为优先级域。初始时源结点入队。对队列中所有活结点,每次总是选取从源到这些结点中路长最短的结点E出队,以作为当前扩展结点,然后检查与E相邻的所有结点,这时我们并不让它们都入队,而是通过限界函数来剪去一些结点。只有从顶点E到顶点j有边可达,且从源出发途经顶点E到j的路径长度小于当前最优路长(记录在dist[j]中),才会把这个顶点j插入到队列中,并设置j的前驱结点为E。不满足这个限界条件的相邻顶点都将被剪去。由于每次选取的途经结点E都是离源的路径长度最短,因此能保证最后得到的所有路径长度都是最短的。
(4)分支限界法的数据结构描述:整个问题包括解空间树的结点信息,用于构造最优解的数据成员、可选的限界函数,分支限界法搜索函数BranchAndBound(i),算法实现函数等。通常用一个类来描述这些信息,算法实现函数也可以一个独立的全局函数。优先级队列中的结点需要附带优先级信息,用一个独立的类MinHeapNode来描述队列中的结点信息。对于单源最短路径问题,数据结构描述如下:
[cpp]
view plain
copy
- //问题及其解空间描述
- template<class Type>
- class ShortestPathsProblem{
- public:
- friend void ShortestPaths(int,Type**,int*,Type*,int,Type); //算法实现函数
- void BranchAndBound(int); //分支限界搜索函数
- private:
- int n; //图G的顶点数
- int* prev; //前驱顶点数
- Type** c; //图G的邻接矩阵
- Type* dist; //最短距离数组
- Type NoEdge; //无边标记,一个充分大的值
- };
- //优先级队列的结点描述
- template<class Type>
- class MinHeapNode{
- public:
- friend ShortestPathProblem<Type>;
- operator int() const{ //优先级队列通过本函数来判定结点的优先级,以进行出队操作
- return length;
- }
- private:
- int i; //顶点
- Type length; //从源到本顶点的最短路长作为优先级
- };
限界函数:有边可达的条件为c[E.i][j]<NoEdge。从源出发途经顶点E到j的路径长度小于当前最优路长的条件为E.length+c[E.i][j]<dist[j],这两个条件同时满足时,结点j才会被插入到队列中,否则剪去结点j。这两个条件比较简单,可以在分支限界法的实现函数中直接使用,因此这里并没有设计成一个独立的函数。
分支限界搜索函数:源结点为初始的扩展结点,然后使用优先级队列来进行广度优先遍历,在遍历过程中用限界函数剪去导致无效解或非最优解的子树。函数实现如下:
[cpp]
view plain
copy
- //分支限界搜索函数,v为源顶点
- template<class Type>
- void ShortestPathProblem<Type>::BranchAndBound(int v){
- MinHeap<MinHeapNode<Type> > H(1000); //定义优先级队列(用最小堆表示)的容量为1000
- MinHeapNode<Type> E; //定义源结点为初始扩展结点
- E.i=v;
- E.length=0; //初始化当前最短路长
- dist[v]=0;
- while(true){ //搜索问题的解空间
- for(int j=1;j<=n;j++) //检查与当前扩展结点相邻的所有顶点
- if((c[E.i][j]<NoEdge) && (E.length+c[E.i][j]<dist[j])){ //若从当前扩展结点i到顶点j有边,且从源出发途经i到j的路长小于当前最优路长
- dist[j]=E.length+c[E.i][j]; //则修改当前最优路长并设置j的前驱结点为i
- prev[j]=E.i;
- MinHeapNode<Type> N; //然后将该顶点作为活动结点插入到优先级队列中
- N.i=j;
- N.length=dist[j];
- H.Insert(N);
- }
- try{
- H.DeleteMin(E); //取出最高优先级(即路径长度最短)的活结点作为当前扩展结点
- }catch(OutOfBoundException ex){ //优先队列为空,则搜索结束
- break;
- }
- }
- }
算法实现函数:这里数组prev和dist的长度应该为n+1,返回的值在prev[1]~prev[n], dist[1]~dist[n]中。
[cpp]
view plain
copy
- template<class Type>
- void ShortestPaths(int v,Type** c,int* prev,Type* dist,int n,Type NoEdge){
- ShortestPathsProblem<Type> alg;
- alg.c=c;
- for(int i=1;i<=n;i++){
- dist[i]=c[v][i]; //初始化dist数组
- //初始化prev数组
- if(dist[i]==NoEdge) //无前驱情况
- prev[i]=0;
- else
- prev[i]=v; //初始前驱为源顶点
- }
- alg.prev=prev;
- alg.dist=dist;
- alg.n=n;
- alg.NoEdge=NoEdge;
- alg.BranchAndBound(v); //从源顶点v开始搜索整个解空间
- }
2、最大团问题和最大独立子集问题: 给定无向图G=(V,E),V={1,2,…,n}。图U称为G的一个团,当且仅当U是G的一个完全子图(注:是G的子图且是完全图,即任意两顶点间都有边),且U不包含在G的更大完全子图中。G的最大团是指G中所含顶点数最多的团(即最大完全子图)。图U称为G的一个独立集,当且仅当U是G的一个空子图(注:是G的子图且任意两顶点间都没有边),且U不包含在G的更大空子图中。G的最大独立集是G中所含顶点数最多的独立集(即最大空子图)。设G的补图为GG,实际上,G的团与GG的独立集存在一一对应的关系。而且,U是G的最大团当且仅当U是GG的最大独立集。因此求图的最大独立集问题可转化为求其补图的最大团问题。
用分支限界法解最大团问题:
(1)定义问题的解空间:最大团问题要从顶点集V中选出一个子集,是子集选取问题。可用一个0-1向量x来表示问题的解,x[i]=1表示选取了顶点i,x[i]=0表示不选取顶点i。因此解空间由那些表示团的0-1向量组成。
(2)确定解空间树的结构:解空间树是一棵子集树。从树根到树叶的一条路径表示一个团。注意最大团问题的子集树并不一定是完全二叉树,因为在整个0-1向量空间中,只有一部分的0-1向量表示图G的团。由于子集树是在搜索的过程中动态创建的,因此在每创建一个儿子结点时都要判断从树根到该儿子点的路径是否表示一个团。若不是团,则该结点不会被插入到解空间树中。
(3)以广度优先方式搜索整个解空间,找出所要的解:这里是求顶点个数最多的团,因此要使用最大堆表示的优先级队列来进行广度优先搜索。解空间树中的结点带有两个域,即父结点指针、是左儿子还是右儿子的标志。优先级队列中的结点指向解空间树的相应结点,并带有三个域,即在解空间树中的层次、当前已选的顶点数(当前团的顶点数),可选的顶点数上界(为已选结点数加上到叶子的剩余层数)。用可选的顶点数上界作为优先级,每次总是从队列中选取优先级最高(即可选顶点数上界值最大)的结点N出队,以作为当前扩展结点。然后检查结点N与当前团中的所有结点是否有边相连,若有说明这个结点可行,加入到当前团中,即创建一个左儿子结点并插入到解空间树中,这条路径上就增加了一层。同时创建一个与这个左儿子对应的结点(修改它的所处层次域、优先级域等)并插入到优先级队列中。对于当前扩展结点的右儿子,我们使用限界函数,只有当可选顶点数的上界大于当前最大团的顶点数时,右子树才有可能找到更大的团,这时就创建右子树,否则剪去右子树。由于每次选取的扩展结点其可选的顶点数上界最大,这样能保证最终得到的团的顶点数最多。
[cpp]
view plain
copy
- //解空间树中的结点描述
- class bbnode{
- private:
- friend class MaxClique;
- bbnode* parent; //指向父结点的指针
- bool LChild; //左儿子结点标志,true是左儿子,false是右儿子
- };
- //优先级队列中的结点描述
- class CliqueNode{
- public:
- friend class MaxClique;
- operator int() const{ //优先级队列通过本函数来判定结点的优先级,以进行出队操作
- return un;
- }
- private:
- int cn; //当前团的顶点数
- int un; //可选的顶点数上界
- int level; //结点在子集树中所处的层次
- bbnode* ptr; //指向活结点在子集树中相应结点的指针
- };
- //问题及其解空间描述
- class MaxClique{
- public:
- friend int BBMaxClique(int**,int,int*); //算法实现函数
- int BranchAndBound(int* bestx); //分支限界搜索函数,最优解存放在bestx中
- private:
- //将当前构造出的活结点加入到子集树中并插入到优先级队列中
- void AddLiveNode(MaxHeap<CliqueNode>& H,int cn,int un,int level,bbnode E[],bool ch);
- int** a; //图的邻接矩阵
- int n; //图的顶点个数
- };
- //将活结点加入到子集树中并插入到优先级队列中
- void MaxClique::AddLiveNode(MaxHeap<CliqueNode>& H,int cn,int un,int level,bbnode E[],bool ch){
- bbnode* b=new bbnode;
- b->parent=E; //将该活结点插入到解空间树中(子集树)
- b->LChild=ch;
- CliqueNode N; //将该活结点插入到优先级队列中
- N.cn=cn;
- N.un=un;
- N.level=level;
- N.ptr=b;
- H.Insert(N);
- }
- //分支限界搜索函数:最大团的顶点集选取结果存放在bestx中,返回最大团的顶点数
- int MaxClique::BranchAndBound(int* bestx){
- MaxHeap<CliqueNode> H(1000); //定义最大堆容量为1000
- //初始化
- bbnode* E=0;
- int i=1; //当前扩展结点在解空间树中的层次
- int cn=0; //当前团的顶点数
- int bestn=0; //当前最大团的顶点数
- //搜索解空间树
- while(i!=n+1){ //非叶结点
- bool OK=true;
- bbnode* B=E;
- //检查顶点i与当前团中所有顶点之间是否有边相连
- for(int j=i-1;j>0;B=B->parent,j–)
- if(B->LChild && a[i][j]==0){ //是左儿子但没边相连,说明左儿子不可行
- OK=false;
- break;
- }
- if(OK){ //左儿子结点为可行结点
- if(cn+1>bestn)
- bestn=cn+1; //修正当前最大团的顶点数
- //构造这个左儿子结点加入到子集树中,并插入到优先级队列中
- AddLiveNode(H,cn+1,cn+n-i+1,i+1,E,true);
- }
- if(cn+n-i+1>bestn) //可选的顶点数上界大于bestn,则右子树可能含有最优解
- //构造这个右儿子结点加入到子集树中,并插入到优先级队列中
- AddLiveNode(H,cn,cn+n-i,i+1,E,false);
- //取出可选顶点数上界(为已选顶点数加到叶子剩余层数)最大的结点作为当前扩展结点
- CliqueNode N;
- H.DeleteMax(N); //堆非空
- E=N.ptr;
- cn=N.cn;
- i=N.level;
- }
- //构造当前最优解
- for(int j=n;j>0;j–){
- bestx[j]=E->LChild;
- E=E->parent;
- }
- return bestn;
- }
- //算法实现函数:最大团的顶点集选取结果存放在bestx中,返回最大团的顶点数
- //数组bestx的长度应该为n+1,结果存放在bestx[1]~best[n]中
- int BBMaxClique(int** a,int n,int* bestx){
- MaxClique alg;
- alg.a=a;
- alg.n=n;
- return alg.BranchAndBound(bestx);
- }
算法在扩展一个内部结点i时,先考察其左儿子,检查顶点i与当前团中所有顶点是否都有边相连,若有则说明左儿子结点可行。检查过程需要从当前扩展结点开始向根结点回溯,以判断团中的每个结点与顶点i的连接情况。若左儿子可行,则加入到子集树中并插入到优先级队列中。对右儿子,使用限界函数,只有可选顶点数的上界un(为当前团顶点数cn加上该结点到叶子结点的剩余层数n-i+1)大于当前最大团的顶点数bestn时,右子树才可能含有更大的团,这时就进入右子树,将右儿子加入到子集树中并插入到优先级队列中。当i到达一个叶结点(即n+1层)时,已选结点数cn达到上界,即cn=cn+n-i+1=un,不可能再得到更大的团了,搜索终止。由于每个图都有最大团,因此搜索时总会有结点入队,从队列中取出元素时不心测试队列是否为空。找到表示最大团的一条从树根到树叶的路径后,通过从树叶开始向树根回溯,构造出最优解。
分支限界法和回溯法深刻地体现了程序=数据结构+算法的思想,数据结构就是计算机组织和存储数据的各种结构,“定义问题的解空间”和“确定解空间的结构”实际上就是要选取合适的数据结构来描述问题的解空间,并对解空间进行合理的组织,这里将解空间组织成树型结构(或者图的结构)。算法是解决问题的流程和方法,它需要围绕数据结构来操作。由于解空间被组织成了树型结构,因此遍历就成了最合适的算法策略。树型结构的搜索比较适合使用递归,从而可以使算法实现简洁清晰。回溯法就使用了递归来实现深度优先搜索,当然我们可以使用栈结构来消除递归。分支限界法则使用一个队列结构来进行广度优先搜索,以避免递归。当然我们也可以实现栈式的分支限界法,由于使用广度优先搜索,因此与非递归版本的回溯法是不同的。
分支限界法基本步骤:定义问题的解空间、确定解空间的结构、以广度优先方式搜索整个解空间,找出所要的解(限界函数的使用)。
两种基本的分支限界法框架:优先级队列式分支限界法、FIFO队列式分支限界法。