分支限界法
1. 基本思想
分支是使用广度优先策略,依次生成扩展结点的所有分支。
限界是在结点扩展过程中,计算结点的上界,搜索的同时剪掉某些分支。
分支限界法就是把问题的可行解展开,再由各个分支寻找最佳解。
与回溯法类似,分支限界法也是在解空间中搜索得到解;
不同的是,分支限界法会生成所有扩展结点,并舍弃不可能通向最优解的结点,然后根据广度优先/最小耗费优先,从活结点中选择一个作为扩展结点,使搜索向解空间上有最优解的分支推进。
2. 搜索策略
分支限界法首先生成当前扩展结点的所有分支,然后再从所有活结点中选择一个作为扩展结点。每一个活结点都要计算限界,根据限界情况判断是否剪枝,或选择最有利的结点。
分支限界法有两种不同的搜索空间树方式,分别为广度优先和最小耗费优先,它们对应两种不同的方法:
- 队列式分支限界法(FIFO)
常规的广度优先策略。按照先进先出的原则选取下一个扩展结点,以队列储存活结点。 - 优先队列式分支限界法/最小耗费优先分支限界法(LC)
按照优先队列中指定的优先级,选取优先级最高的结点作为下一个扩展结点,以优先队列储存。
分支限界法的具体搜索策略如下:
- 根结点入队;
- 根据使用的方法(FIFO或LC),令一个活结点出队,作为扩展结点;
- 对扩展结点,生成所有的分支;使用约束条件舍弃不可行的结点/不可能为最优解的结点,剩余的结点入队;
- 重复2和3,直到找到要求的解或队列为空。
方法 | 搜索策略 | 存储结点常用结构 | 结点存储特性 | 应用问题 |
---|---|---|---|---|
回溯法 | 深度优先 | 栈 | 结点可以多次成为扩展结点,所有可行子结点都遍历后才弹出 | 找出满足条件的所有解 |
分支限界法 | 广度/LC优先 | 队列/优先队列 | 结点只能成为一次扩展结点,剪枝或扩展后立刻出队 | 找出条件下的某个/最优解 |
3. 分支结点选择
所有界限满足上界/下界的结点都可以作为扩展结点。因此,必须有一个分支选择策略,FIFO法和LC法对应两种策略:
● 按顺序选择结点作为下一次的扩展结点。优点是节省空间,缺点是需要计算的分支数较多,时间花费大;
● 每次计算完限界后,找出限界最优的结点,作为下一次的扩展结点。优点是计算的分支数少,缺点是需要额外空间。
4. 限界函数
限界函数很大程度上决定了算法的效率。同一问题可以设计不同的限界函数。
FIFO分支限界法中,常以约束条件作为限界函数,满足约束条件才可入队,不满足约束条件的舍弃。
LC分支限界法中,还可以设计一个启发函数作为限界函数。
对于有约束的问题,FIFO法和LC法均可以求解;对于无约束问题, 宜使用LC法。
例题:单源最短路径
1. 问题描述
给定带权有向图G,每边的权值是一个正实数,表示点到点的路径距离。给定图中的一个源点V,求图G中所有点到源点V的最短路径。
2. 问题分析
除了用Dijkstra算法(贪心)解决该问题外,也可以使用分支限界法。由于要求的是最短的路径,我们考虑使用优先队列式分支限界法,以减少计算的分支数。显然,我们的限界就是源到目的点的路径长度:若源到同一个顶点有多条路径,将长路径的分支全部舍弃,而保存更短路径的分支。并且由于题目的贪心选择性质,每次从优先队列中取最短路径,最终得到的解也必然是最优的。
为了避免出队列可能造成的异常,并能更有规律地处理优先队列,我们为最小堆构造一个长度等同于顶点个数的结点数组。数组元素的下标对应顶点的编号;数组元素的编号为-1时,代表该结点被删除(出队列)。
3. 算法设计
- 生成根节点的所有分支,全部入队列并记录路径;
- 在队列中选择路径最短的分支作为扩展结点
- 逐个生成分支,并判断分支的路径是否小于记录的最短路径;
- 若不小于,舍弃该分支;
- 若小于,该分支入队列;
- 生成所有分支后,回到2;
- 当队列为空时,算法结束。
4. 算法实现
//单源最短路径
class Graph{ //带权有向图
private:
int n; //顶点个数
int **c; //邻接矩阵
int *dist; //记录路径
public:
void shortestPaths(int);
Graph(); //根据情况构造图
};
class MinHeapNode{ //最小堆的结点
friend Graph;
private:
int i; //结点对应的顶点编号
int length; //结点记录的最短路径
public:
int getI(){ return i; }
void setI(int i){ this->i = i; }
int getLength(){ return length; }
void setLength(int length){ this->length = length; }
};
class MinHeap{ //最小堆(虽然叫堆,但其实并不是用堆实现的)
friend Graph;
private:
int length; //最小堆的长度,等同于顶点个数
MinHeapNode *nodes; //结点数组
public:
MinHeap(int n)
{
this->length = n;
nodes = new MinHeapNode[n];
}
void deleteMin(MinHeapNode&); //令当前节点出队列,并给出下一个扩展结点
void insertNode(MinHeapNode N) //结点入队列,将原结点的内容替换即可
{
nodes[N.getI()].setI(N.getI());
nodes[N.getI()].setLength(N.getLength());
}
bool outOfBounds() //检查队列为空
{
for(int i = 0;i < length;i++)
if(nodes[i].getI() != -1)
return false;
return true;
}
};
void MinHeap::deleteMin(MinHeapNode &E)
{
int j = E.getI();
nodes[j].setI(-1);
nodes[j].setLength(-1); //标记为出队列
int tmp = INT_MAX;
for(int i = 0;i < length;i++){ //给出路径最短的结点作为扩展结点
if(nodes[i].getI() != -1 && nodes[i].getLength() < tmp){
E.setI(i);
E.setLength(nodes[i].getLength());
tmp = nodes[i].getLength();
}
}
}
void Graph::shortestPaths(int start)
{
MinHeap heap = MinHeap(n);
MinHeapNode E = MinHeapNode(); //别问,一开始还加了new,太久不写C++了
E.i = start;
E.length = 0;
dist[start] = 0; //初始为源点V,对应编号start
while(true){
for(int j = 0;j < n;j++){ //检查所有邻接顶点
if(c[E.i][j] != 0){ //是否邻接
if(E.length + c[E.i][j] < dist[j]){ //是否满足限界,当前路径小于记录的最短路径
dist[j] = E.length + c[E.i][j]; //更新
if(/*填入判断表达式*/){ //没有邻接顶点的点不入队,按情况调整,没有也可以,但会造成无效开销
MinHeapNode N = MinHeapNode(); //创建一个新的结点,并令其入队列
N.i = j;
N.length = dist[j];
heap.insertNode(N);
}
}
}
}
if(heap.outOfBounds()) //队列为空,结束
break;
heap.deleteMin(E); //该结点已经生成全部分支,出队列并取得下一扩展结点
}
}
为使队列为空,while循环总共需要取n个结点;每个结点要对所有结点都进行检查。因此算法的时间复杂度为O(n2)。
分支限界法的套路单一,就只写一道例题了,怎么可能是因为这两天沉迷骑砍呢😀