分支限界算法 (Branch and Bound)
像"带导航的迷宫搜索"一样,不仅会试探(分支),还会计算一个"预期代价"(限界)。如果某条路还没走完,但预期代价已经超过了当前已知的最优解,就直接放弃这条路(剪枝),优先去探索更有希望的路径。
经典问题推演:0-1背包问题(求最大价值)
分支:对每个物品,产生"放入"和"不放入"两个分支结点。
限界(计算上界):使用贪心思想,假设剩余容量可以装下部分物品,算出当前结点能达到的理论最大价值。
剪枝:如果某个结点的理论最大价值,还不如我们已经找到的一个实际可行解的价值,直接丢弃该结点。
搜索策略:总是优先扩展理论价值最高的结点(优先队列/最大堆)。
旅行商问题 (Traveling Salesman Problem)
问题描述:给定 n 个城市以及它们两两之间的距离。一名推销员需要从起点出发,访问每个城市恰好一次,最后回到起点。请问如何规划路线,使得总行驶距离最短?
示例:4个城市,距离矩阵如下。最优路线为 0 -> 1 -> 3 -> 2 -> 0,总距离为 10 + 25 + 30 + 15 = 80。
text
0 1 2 3
0 | 0 10 15 20 |
1 | 10 0 35 25 |
2 | 15 35 0 30 |
3 | 20 25 30 0 |
为什么用分支限界法?
TSP 是一个著名的 NP-Hard 问题。如果用普通的回溯法(深度优先搜索),需要遍历所有的全排列,时间复杂度是阶乘级 \(O(n!)\) ,数据量稍微大一点就会算到地老天荒。而分支限界法通过计算"下界(乐观估计)",能够提前砍掉那些"注定不可能成为最优解"的分支,从而极大提高搜索效率。
核心思想:分支、限界与优先队列
分支(Branch):从当前城市出发,将所有未访问的城市作为下一步的可能选择,生成多个子节点。
限界(Bound):为每个节点计算一个"下界"。下界 = 当前已走路径的成本 + 剩余未访问城市各自的最小出边之和。这是一个"极其乐观的估计",代表这条路未来最少还要花多少代价。
优先队列(Priority Queue):不再像回溯那样一条路走到黑,而是使用优先队列(最小堆)。每次总是优先扩展"下界最小(最有希望)"的节点。如果某个节点的下界已经大于等于当前已知的最优解,直接将其丢弃(剪枝)。
核心代码
c
#include <stdio.h>
#include <stdlib.h>
#include <limits.h>
#define N 4
// 节点结构体
typedef struct Node {
int path[N]; // 当前走过的路径
int level; // 当前路径包含的城市数量
int cost; // 当前路径的实际成本
int bound; // 该节点的下界(乐观估计)
} Node;
// 辅助函数:计算节点的下界
int computeBound(int path[], int level, int graph[N][N]) {
int bound = 0;
// 1. 加上已经走过的实际路径成本
for (int i = 0; i < level - 1; i++) {
bound += graph[path[i]][path[i + 1]];
}
// 2. 加上剩余未访问城市的最小出边(乐观估计)
for (int i = 0; i < N; i++) {
int visited = 0;
for (int j = 0; j < level; j++) {
if (path[j] == i) { visited = 1; break; }
}
if (!visited) {
int minEdge = INT_MAX;
for (int j = 0; j < N; j++) {
if (i != j && graph[i][j] < minEdge) minEdge = graph[i][j];
}
bound += minEdge;
}
}
return bound;
}
// 优先队列的比较函数(下界越小,优先级越高)
int cmp(const void *a, const void *b) {
return ((Node *)a)->bound - ((Node *)b)->bound;
}
// 分支限界法主函数
int tspBranchAndBound(int graph[N][N]) {
Node pq[1000]; // 模拟优先队列
int pqSize = 0;
int minCost = INT_MAX; // 记录全局最优解
// 初始化根节点(从城市0出发)
Node root = { {0}, 1, 0, 0 };
root.bound = computeBound(root.path, root.level, graph);
pq[pqSize++] = root;
while (pqSize > 0) {
// 1. 取出优先级最高(bound最小)的节点
qsort(pq, pqSize, sizeof(Node), cmp);
Node curr = pq[--pqSize]; // 弹出队首
// 2. 限界剪枝:如果当前节点的下界 >= 已知最优解,直接放弃
if (curr.bound >= minCost) continue;
// 3. 分支:尝试前往下一个未访问的城市
if (curr.level == N) {
// 走完了所有城市,加上回起点的边
int finalCost = curr.cost + graph[curr.path[N-1]][0];
if (finalCost < minCost) minCost = finalCost;
continue;
}
for (int i = 0; i < N; i++) {
int visited = 0;
for (int j = 0; j < curr.level; j++) {
if (curr.path[j] == i) { visited = 1; break; }
}
if (!visited) {
Node newNode = curr;
newNode.path[curr.level] = i;
newNode.level++;
newNode.cost += graph[curr.path[curr.level-2]][i];
newNode.bound = computeBound(newNode.path, newNode.level, graph);
// 只有下界小于当前最优解,才加入队列继续探索
if (newNode.bound < minCost) {
pq[pqSize++] = newNode;
}
}
}
}
return minCost;
}
int main() {
int graph[N][N] = {{0,10,15,20}, {10,0,35,25}, {15,35,0,30}, {20,25,30,0}};
printf("最短路径长度为:%d\n", tspBranchAndBound(graph));
return 0;
}
示例推演
以 4 个城市为例,从城市 0 出发:
第 1 步:初始化与根节点扩展
目标:建立初始搜索空间。
根节点:路径 0,实际成本 0。
计算下界:剩余城市 {1, 2, 3} 的最小出边分别为 10, 15, 20。下界 = 0 + 10 + 15 + 20 = 45。
分支:生成子节点 0,1, 0,2, 0,3。分别计算它们的下界并放入优先队列。
第 2 步:优先队列调度与剪枝目标:挑选最有希望的节点继续探索。
从优先队列中弹出下界最小的节点,假设是 0,1。
检查限界:如果 0,1 的下界已经 >= 我们之前偶然找到的一个完整路径的成本,直接丢弃(剪枝)。
如果未剪枝,继续分支:从城市 1 出发,生成 0,1,2 和 0,1,3,计算下界后重新入队。
第 3 步:找到可行解与持续优化目标:更新全局最优解,加速后续剪枝。
当某个节点扩展到包含所有 4 个城市时,加上回 0 的边,得到一个完整的环路成本。
更新全局最优解 minCost。
minCost 变小后,之前队列中那些下界大于等于新 minCost 的节点,将在下一次出队时被无情剪枝。
最终结果
优先队列被清空,返回 minCost = 80。
核心提炼
核心特征:基于广度优先搜索(BFS)或最小/最大消耗优先;核心动作是"分支 + 限界(剪枝)"。"用空间换时间,用预估换效率"
与回溯法的核心区别:
搜索方式:回溯法是深度优先(DFS);分支限界法通常是广度优先(BFS)或最小耗费优先(优先队列)。
求解目标:回溯法旨在找出所有解或任意一个可行解;分支限界法旨在快速找出最优解。
节点扩展:回溯法遇到死胡同会"回溯";分支限界法每个活节点只有一次成为扩展节点的机会,不回头,而是通过"限界"直接剪掉整棵子树。
两大核心要素:
活结点表的数据结构:通常使用优先队列(最小堆/最大堆)。
限界函数(Bound):这是算法的灵魂。最小化问题算下界,最大化问题算上界。限界函数越紧,剪枝效果越好。