旅行商问题
旅行推销员问题(英语:Travelling salesman problem, TSP)是这样一个问题:给定一系列城市和每对城市之间的距离,求解访问每一座城市一次并回到起始城市的最短回路。它是组合优化中的一个NP难问题,在运筹学和理论计算机科学中非常重要。(来源于:百度百科)
C++源代码
二话不说,先放代码,具体分析往下滑 ↓↓
#include <iostream>
#include <vector>
#include <cstring>
#include <iomanip>
#include <queue>
#define NoEdge -1
#define NN 50 // 可执行的最大的顶点个数
using namespace std;
int n; // 图的顶点个数
int adjMatrix[NN][NN]; // 图的邻接矩阵
int v[50]; // 最优解
int bestC; // 最优值
int num = 0; // 节点序号
/*****************************************************************
* 函数描述: 数据输入以及内存的初始化
*****************************************************************/
void input()
{
cin >> n; // 输入顶点个数
int k;
memset(adjMatrix, NoEdge, sizeof(adjMatrix)); // 邻接矩阵的内存初始化
cin >> k; // 输入边的个数;
int p, q, len;
// 初始化邻接矩阵
for (int i = 1; i <= k; ++i)
{
cin >> p >> q >> len;
adjMatrix[p][q] = len;
adjMatrix[q][p] = len;
}
}
/*****************************************************************
* 函数描述: 格式化打印结果
* 参数描述: res,最优值
*****************************************************************/
void printTravel(int res)
{
if (res == NoEdge)
cout << "======= 无法形成回路 =======" << endl;
else
{
cout << "\n======================================\n最短路径为:" << res << endl;
for (int i = 1; i <= n; i++)
cout << v[i] << " ---> ";
cout << v[1];
}
}
/*****************************************************************
* 类描述:最小堆(队列中元素类型)
* 参数描述:
x,用于记录当前解;
s,表示节点在排列树中的层次,从排列树的根节点到该节点的路径为x[0:s],
需要进一步搜索的顶点是x[s+1:n-1]。
cc,表示当前费用,
lcost,是子树费用的下界,
rcost,是x[x:n-1]中顶点最小出边费用和。
*****************************************************************/
class MinHeapNode
{
public:
char name; // 节点的序号
int rcost, // x[s:n-1]中顶点最小出边费用和
lcost, // 子树费用的下界
cc; // 当前费用
int s, // 根节点到当前节点的路径为x[0:s]
*x; // 需要进一步搜索的顶点是x[s+1:n-1]
// 构造节点并递增序号
MinHeapNode()
{
num += 1;
name = num + 'A';
}
// 最小堆中使用下界排序
bool operator<(const MinHeapNode &MH) const
{
return lcost > MH.lcost;
}
// 打印节点信息
void printNode(priority_queue<MinHeapNode> pq)
{
cout << "============== Node: " << name << " ==============" << endl;
cout << "最小出边和(rcost):" << rcost << "\t子树费用的下界(lcost):" << lcost
<< "\t当前费用(cc):" << cc << " \t节点所在层(s):" << s << endl;
cout << "当前解是(x):";
for (int i = 0; i < n - 1; ++i)
cout << x[i] << "-";
cout << x[n - 1] << endl;
// 输出优先级队列
if (!pq.empty())
{
cout << "-- 当前优先队列:";
for (int i = 0; i < pq.size(); ++i)
{
cout << pq.top().name << "(" << pq.top().lcost << ")-";
pq.pop();
}
cout << pq.top().name << "(" << pq.top().lcost << ")" << endl;
}
else
{
cout << "(优先级队列为空)" << endl;
}
cout << "-- 当前最优值(bestC):" << bestC << endl;
}
};
/*****************************************************************
* 算法描述:核心算法
算法开始时创建一个最小堆,表示活节点优先队列。堆中每个节点的lcost
值是优先队列的优先级。接着计算出图中每个顶点的最小费用出边并用Minout记录。
如果所给的有向图中某个顶点没有出边,则该图不可能有回路,算法即告结束。
如果每个顶点都有出边,则根据计算出的Minout作算法初始化。
*****************************************************************/
int BBTSP()
{
priority_queue<MinHeapNode> pq; // 优先级队列
MinHeapNode E; // 最小堆节点
int cc, rcost, MinSum, *MinOut, b;
int i, j;
MinSum = 0; // 最小出边费用和
MinOut = new int[n + 1]; // 计算 MinOut[i] = 顶点i的最小出边费用
for (i = 1; i <= n; i++)
{
MinOut[i] = NoEdge; // 所有的出边初始化为无连接
// 遍历找出 MinOut[i] = 顶点i的最小出边费用
for (j = 1; j <= n; j++)
if (adjMatrix[i][j] != NoEdge && (adjMatrix[i][j] < MinOut[i] || MinOut[i] == NoEdge))
MinOut[i] = adjMatrix[i][j];
// 不存在与这个顶点相连接的边
if (MinOut[i] == NoEdge)
return NoEdge;
MinSum += MinOut[i];
}
// 初始化最小堆
E.s = 0; // 根节点到当前节点的路径为x[0:s]
E.cc = 0; // 当前费用为0
E.rcost = MinSum; // x[s:n-1]中顶点最小出边费用和
E.x = new int[n]; // 需要进一步搜索的顶点是x[s+1:n-1]
// 初始化为顺序搜索
for (i = 0; i < n; i++)
E.x[i] = i + 1;
bestC = NoEdge; // 初始化最优值为 NoEdge
E.printNode(pq);
//搜索排列空间树
while (E.s < n - 1) //非叶节点
{
if (E.s == n - 2) // 当前扩展节点是叶节点的父节点,判断构成的回路是否最优
{
if (adjMatrix[E.x[n - 2]][E.x[n - 1]] != NoEdge && adjMatrix[E.x[n - 1]][1] != NoEdge &&
(E.cc + adjMatrix[E.x[n - 2]][E.x[n - 1]] + adjMatrix[E.x[n - 1]][1] < bestC || bestC == NoEdge))
{ // 如果更优,则更新费用更小的路
cout << "\n||||||||||||||||||||| 到达叶子节点的父节点 ———— 并更新最优解 |||||||||||||||||||||"
<< endl;
E.printNode(pq);
bestC = E.cc + adjMatrix[E.x[n - 2]][E.x[n - 1]] + adjMatrix[E.x[n - 1]][1];
E.cc = bestC;
E.lcost = bestC;
E.s++;
pq.push(E);
}
else
{
cout << "\n||||||||||||||||||||||| 到达叶子节点的父节点 ———— 不更新 |||||||||||||||||||||||"
<< endl;
E.printNode(pq);
delete[] E.x; // 舍弃需要进一步搜索的节点
}
}
else // 产生当前扩展节点儿子节点
{
cout << "\n*************** 开始一个新节点扩展 ***************\n"
<< endl;
for (i = E.s + 1; i < n; i++)
{ // 广度优先搜索,进行子节点的扩展
MinHeapNode N;
if (adjMatrix[E.x[E.s]][E.x[i]] != NoEdge) // E.x[E.s] 是当前要扩展的父节点,E.x[i] 是被遍历的子节点
{ // 可行儿子节点
cc = E.cc + adjMatrix[E.x[E.s]][E.x[i]]; // 当前费用 = 之前费用 + 新增费用
rcost = E.rcost - MinOut[E.x[E.s]]; // 更新最小出边费用和
b = cc + rcost; // 下界(限界函数)
if (b < bestC || bestC == NoEdge) // 子树可能含最优解 节点插入最小堆
{
N.s = E.s + 1; // 进入下一层
N.cc = cc;
N.lcost = b;
N.rcost = rcost;
N.x = new int[n];
for (j = 0; j < n; j++)
N.x[j] = E.x[j];
// 获得新的路径【换位】
N.x[E.s + 1] = E.x[i];
N.x[i] = E.x[E.s + 1];
pq.push(N); // 加入优先队列
N.printNode(pq);
}
}
}
delete[] E.x; //完成节点扩展
}
if (pq.empty()) // 堆已空
break;
E = pq.top(); // 取下一扩展节点
pq.pop();
}
if (bestC == NoEdge) // 无回路
return NoEdge;
for (i = 0; i < n; i++) // 将最优解复制到v[1:n]
v[i + 1] = E.x[i];
while (pq.size()) // 释放最小堆中所有节点
{
E = pq.top();
pq.pop();
delete[] E.x;
}
return bestC;
}
int main()
{
input();
int res = BBTSP();
printTravel(res);
}
/*
4
6
1 2 30
1 3 6
1 4 4
2 3 5
2 4 10
3 4 20
*/
算法输入:
输入的第一行是节点数,第二行是边数k,之后的k行是边的信息,前两个数是边的两端的顶点,第三个数表示这两个节点之间的距离。
分支限界法
分支限界算法类似于回溯法,也是一种在问题的解空间树上搜索问题解的算法。但两者求解方法有两点不同:
- 回溯法只通过约束条件剪去非可行解,而分支限界法不仅通过约束条件,而且通过目标函数的限界来减少无效搜索,也就是剪掉了某些不包含最优解的可行解;
- 在解空间树上,回溯法以深度优先搜索,而分支限界法则以广度优先或最小耗费优先的方式搜索。
分支限界的搜索策略是,在扩展节点处,首先生成其所有的儿子节点(分支),然后再从当前的活节点表中选择下一个扩展结点。为了有效地选择下一扩展节点,以加速搜索进程,在每一活节点处,计算一个函数值(限界),并根据这些已计算出的函数值从当前活节点表中选择一个最有利的节点做为扩展,使搜索朝着解空间树上最优解的分支推进,以便尽快找出一个最优解。
分支限界法常以广度优先或以最小耗费优先的方式搜索问题的解空间树(问题的解空间树是表示问题皆空间的一颗有序树,常见的有子集树和排序树)。在搜索问题的解空间树时,分支限界法的每一个活结点只有一次机会成为扩展结点。活结点一旦成为扩展结点,就一次性产生其所有儿子结点。在这些儿子结点中,那些导致不可行解或非最优解的儿子结点将被舍弃,其余儿子结点被加入活结点表中。此后,从活结点表取出下一结点成为当前扩展结点,并重复上述扩展过程,直到找到最优解或活结点表为空时停止。
伪代码
解空间树
如下图所示为旅行商问题的解空间树,其中节点的标号(A~J)是按照节点生成顺序进行标注的。最优解使用红线标注。
过程分析
对于分支限界法解决旅行商问题,我认为重要的不是结果,而是剪枝和跳层过程的理解。为此我在输出时,把每一个节点的信息都输出在命令行中,如下所示,其中节点的命名序号与解空间树的节点序号一致,都是根据构造节点的先后顺序进行命名排列。从命令行的输出中可以清楚地看出活节点的进堆和出堆的情况,还可以看出解空间树的构建层次。
命令行输出如下:
============== Node: B ==============
最小出边和(rcost):18 子树费用的下界(lcost):-1 当前费用(cc):0 节点所在层(s):0
当前解是(x):1-2-3-4
(优先级队列为空)
-- 当前最优值(bestC):-1
*************** 开始一个新节点扩展 ***************
============== Node: C ==============
最小出边和(rcost):14 子树费用的下界(lcost):44 当前费用(cc):30 节点所在层(s):1
当前解是(x):1-2-3-4
-- 当前优先队列:C(44)-C(44)
-- 当前最优值(bestC):-1
============== Node: D ==============
最小出边和(rcost):14 子树费用的下界(lcost):20 当前费用(cc):6 节点所在层(s):1
当前解是(x):1-3-2-4
-- 当前优先队列:D(20)-C(44)
-- 当前最优值(bestC):-1
============== Node: E ==============
最小出边和(rcost):14 子树费用的下界(lcost):18 当前费用(cc):4 节点所在层(s):1
当前解是(x):1-4-3-2
-- 当前优先队列:E(18)-D(20)-C(44)
-- 当前最优值(bestC):-1
*************** 开始一个新节点扩展 ***************
============== Node: F ==============
最小出边和(rcost):10 子树费用的下界(lcost):34 当前费用(cc):24 节点所在层(s):2
当前解是(x):1-4-3-2
-- 当前优先队列:D(20)-F(34)-C(44)
-- 当前最优值(bestC):-1
============== Node: G ==============
最小出边和(rcost):10 子树费用的下界(lcost):24 当前费用(cc):14 节点所在层(s):2
当前解是(x):1-4-2-3
-- 当前优先队列:D(20)-G(24)-F(34)
-- 当前最优值(bestC):-1
*************** 开始一个新节点扩展 ***************
============== Node: H ==============
最小出边和(rcost):9 子树费用的下界(lcost):20 当前费用(cc):11 节点所在层(s):2
当前解是(x):1-3-2-4
-- 当前优先队列:H(20)-G(24)-F(34)
-- 当前最优值(bestC):-1
============== Node: I ==============
最小出边和(rcost):9 子树费用的下界(lcost):35 当前费用(cc):26 节点所在层(s):2
当前解是(x):1-3-4-2
-- 当前优先队列:H(20)-G(24)-F(34)-I(35)
-- 当前最优值(bestC):-1
||||||||||||||||||||| 到达叶子节点的父节点 ———— 并更新最优解 |||||||||||||||||||||
============== Node: H ==============
最小出边和(rcost):9 子树费用的下界(lcost):20 当前费用(cc):11 节点所在层(s):2
当前解是(x):1-3-2-4
-- 当前优先队列:G(24)-F(34)-I(35)
-- 当前最优值(bestC):-1
||||||||||||||||||||||| 到达叶子节点的父节点 ———— 不更新 |||||||||||||||||||||||
============== Node: G ==============
最小出边和(rcost):10 子树费用的下界(lcost):24 当前费用(cc):14 节点所在层(s):2
当前解是(x):1-4-2-3
-- 当前优先队列:H(25)-F(34)-I(35)
-- 当前最优值(bestC):25
======================================
最短路径为:25
1 ---> 3 ---> 2 ---> 4 ---> 1
通过这个输出可以实时了解到当前节点的最小出边和(rcost)、子树费用的下界(lcost)、当前费用(cc)、节点所在层(s)、当前解数组(x)、当前优先队列的情况和当前全局最优解的情况。
分支限界 VS 动态规划
- 分支限界法是从出发城市开始累加,是一种自顶向下的计算方式;而动态规划法是从目标城市往回计算走过的路程,是一种自底向上的方法。
- 动态规划其实也是一种穷举的过程,它需要将整个解空间树遍历后才能等到答案,但是分支限界法可以通过限界函数的剪枝大大减少计算次数,效率更高。
分支限界 VS 回溯法
分支限界算法,类似于回溯法,但是回溯法是深度优先搜索,且回溯法是一种无目的性的搜索,只是通过约束函数和限界函数进行剪枝,从而简化操作。但是分支限界法是一种广度优先搜索的算法,在本次实验中又使用了优先队列的方式,通过优先级的排序,使得本算法是一种有目的性的,保证每一步都是尽可能接近最优解的算法。