序
旅行商问题(Traveling Salesman Problem,简称TSP)是组合优化中的一个经典问题,就是给定一组城市和城市之间的距离,找到一条最短路径使得每个城市只被访问一次后返回到起点。
一些传统的解法都是基于完全图的,我在网上也很少找到非完全图的解法,非完全图应该在实际应用中会更实用,这里我简单实现一个算法。
算法细节
与完全图的差异
非完全图意味着某些节点之间不相连,这样求解时就需要考虑这样情况,不能直接假设两个节点直连。
图的预处理
最开始要对图进行预处理,这里我们针对于该图使用Kruskal算法构建一颗最小生成树,之后针对该树进行分支删减,以达到最终路线是一个环路。
我这里使用的输入的形式是邻接矩阵,假设路线图是这样的:
对应的邻接矩阵则是
using GraphVec = std::vector<std::vector<int>>;
GraphVec graph_8 = {
{0, 3, 1, 1, 1, 0, 0, 0},
{3, 0, 1, 0, 0, 1, 0, 0},
{1, 1, 0, 1, 0, 0, 2, 0},
{1, 0, 1, 0, 0, 0, 0, 1},
{1, 0, 0, 0, 0, 1, 1, 1},
{0, 1, 0, 0, 1, 0, 0, 1},
{0, 0, 2, 0, 1, 0, 0, 1},
{0, 0, 0, 1, 1, 1, 1, 0},
};
最开始我们先来看下使用到的数据结构:
struct Edge{
Edge() = default;
Edge(int s, int e, int w) :
start(s),
end(e),
weight(w) {}
int start = -1;
int end = -1;
int weight = -1;
};
这里很简单,我就是将两个节点的边做了一下抽象
然后简单看一下最小生成树的构建过程:
using TreeDeque = std::deque<Edge>;
TreeDeque makeMSTByKruskal(const GraphVec& graph) {
std::priority_queue<Edge, std::vector<Edge>, decltype(compare)> queue(compare);
for (int i = 0; i < graph.size(); ++i) {
for (int j = 0; j < graph[0].size(); ++j) {
if (i < j && graph[i][j] > 0) {
queue.emplace(i, j, graph[i][j]);
}
}
}
std::set<int> visited;
TreeDeque tree;
while (!queue.empty()) {
auto edge = queue.top();
queue.pop();
if (visited.find(edge.start) != visited.end() && visited.find(edge.end) != visited.end()) {
continue;
}
visited.insert(edge.start);
visited.insert(edge.end);
tree.push_back(edge);
}
return tree;
}
这里使用了优先队列来对图的各个边按照权重及节点编号进行排序,然后从队列中依次取出权重最小的边来构造生成树,如果该边所连接的两个节点都已经被访问过了,则忽略该边,否则将该边加入到生成树中,同时将该边的两个节点标记为已访问。
最终得到一颗最小生成树返回,使用TreeDeque类型保存。
构造TSP环路
接下来就是使用这颗树来构造一个TSP环路了。
接着我们就是将树中节点的度大于2的权重最大的边剪断,这样则得到了两颗树,然后选择这两颗树的各自叶子节点中权重最小的相连。
如图所示:
step1中两个蓝色的节点都是度大于2的节点,然后我们选择权重最大的边断开,得到两颗树,其中另外一颗树只有一个叶子节点,然后这个叶子节点可以和原来的树的叶子节点相连,但是需要选择权重最小的边,这样完成了step1。step2同理
然后看下代码实现:
PathDeque buildTspPath(const GraphVec& graph, TreeDeque& tree) {
std::vector<int> degree(graph.size(), 0);
for (const auto &edge: tree) {
degree[edge.start]++;
degree[edge.end]++;
}
// ...
}
首先得到所有节点的度。
PathDeque buildTspPath(const GraphVec& graph, TreeDeque& tree) {
//...
std::set<int> unlinks;
int index = tree.size() - 1;
while (index >= 0) {
auto edge = tree[index];
int maybe_alone_ver = -1;
if (degree[edge.start] >= 3) {
maybe_alone_ver = edge.end;
}
else if (degree[edge.end] >= 3) {
maybe_alone_ver = edge.start;
}
if (maybe_alone_ver == -1) {
--index;
continue;
}
tree.erase(tree.begin() + index);
degree[edge.start]--;
degree[edge.end]--;
auto [ret, target] = connectTwoTree(graph, degree, tree, maybe_alone_ver);
if (ret) {
auto begin = target.begin();
int n1 = *begin;
int n2 = *(++begin);
tree.emplace_back(n1, n2, graph[n1][n2]);
degree[n1]++;
degree[n2]++;
--index;
continue;
}
unlinks.insert(target.begin(), target.end());
index = tree.size() - 1;
}
return createCircle(graph, unlinks, tree);
}
然后自后向前遍历生成树,也就是遍历的边的权重是自大到小的。
再然后就是判断边的start节点或者end节点是不是度大于2,如果是的话这里使用tree.erase
将该边删除,达到断开生成两颗树的效果。然后通过调用connectTwoTree
函数将两颗树连接起来。
这时候有个问题,如果两颗树的叶子节点都不可以相连,怎么办?
我这里的处理手段是如果不可以相连就将节点较少的树打散成点,之后再将这些未连接的点重新补充到这个环上。返回值ret
表示是否可以成功连接,target
如果成功连接表示连接边的两个端点,如果不能连接表示未连接的点的集合。可以看到最后会把不可连接的点都放到unlinks
这个数据结构里。
最后调用createCircle
组成一个环。参数自然也传递了unlinks和tree。这是这里的tree中的节点的度都是小于等于2的,也就是一个链状的结构了。
using PathDeque = std::deque<int>;
PathDeque createCircle(const GraphVec& graph, std::set<int>& unlinks, TreeDeque& tree) {
int left = tree[0].start;
int right = tree[0].end;
tree.erase(tree.begin());
PathDeque path;
path.push_back(left);
path.push_back(right);
int i = 0;
while (!tree.empty()) {
bool is_find = false;
if (tree[i].start == left) {
left = tree[i].end;
path.push_front(left);
is_find = true;
}
else if (tree[i].end == left) {
left = tree[i].start;
path.push_front(left);
is_find = true;
}
else if (tree[i].start == right) {
right = tree[i].end;
path.push_back(right);
is_find = true;
}
else if (tree[i].end == right) {
right = tree[i].start;
path.push_back(right);
is_find = true;
}
if (is_find) {
tree.erase(tree.begin() + i);
i = 0;
continue;
}
i++;
}
// ...
}
上边说到tree中是一个链状的结构了,但是顺序可能还是错乱的,且是以边为单位存放的,这里首先先把tree里的节点串成一个串到path里,且是以节点为单位的,方便我们最后的输出。
然后我们的path就变成了一个链状的节点链了,不过还没有成环,我们需要后续的操作才可能让path成为环。
PathDeque createCircle(const GraphVec& graph, std::set<int>& unlinks, TreeDeque& tree) {
// ...
while (true) {
bool rc = addUnlinkToPath(graph, path, unlinks);
if (rc) {
break;
}
if (path.empty()) {
return {};
}
unlinks.insert(path.back());
path.pop_back();
}
return path;
}
这里实现其实也很简单,将path和unlink连接起来,如果path的无法与unlink连接完,那么就需要从path中回退一个节点到unlink中重新连接,直到path成一个环退出。
然后就是看下path和unlink如何连接起来并成一个环。
bool addUnlinkToPath(const GraphVec& graph, PathDeque& path, std::set<int> unlinks)
{
int left = path.front();
int right = path.back();
if (unlinks.empty()) {
return graph[left][right] > 0;
}
if (unlinks.size() == 1) {
int n = *unlinks.begin();
if (graph[left][n] > 0 && graph[right][n] > 0) {
path.push_back(n);
return true;
}
return false;
}
if (unlinks.size() >= 2) {
if (pruneUnlink(graph, left, right, unlinks)) {
return false;
}
}
// ...
}
当unlinks没有节点时,判断path是否可以成环。
当unlinks只有一个节点时,判断path加上这个节点是否可以成环。
当unlinks有多个节点时,针对于这个未连接的节点做一下剪枝,加快速度,剪枝策略后边讨论。
bool addUnlinkToPath(const GraphVec& graph, PathDeque& path, std::set<int> unlinks)
{
// ...
std::vector<int> left_joints;
std::vector<int> right_joints;
for (auto& v: unlinks) {
if (graph[v][left] > 0) left_joints.push_back(v);
if (graph[v][right] > 0) right_joints.push_back(v);
}
std::sort(left_joints.begin(), left_joints.end(), [left, &graph](int v1, int v2){
return graph[left][v1] < graph[left][v2];
});
std::sort(right_joints.begin(), right_joints.end(), [right, &graph](int v1, int v2){
return graph[right][v1] < graph[right][v2];
});
for (auto& left_j: left_joints) {
path.push_front(left_j);
unlinks.erase(left_j);
for (auto& right_j: right_joints) {
if (left_j != right_j) {
path.push_back(right_j);
unlinks.erase(right_j);
bool rc = addUnlinkToPath(graph, path, unlinks);
if (rc) {
return true;
}
path.pop_back();
unlinks.insert(right_j);
}
}
path.pop_front();
unlinks.insert(left_j);
}
return false;
}
首先把可以与path的左右端点可以连接的点找出来,然后按照边的权重从小到大排序
然后依次从左右端点可以连接的集合中分别选择节点放入到path中,递归调用addUnlinkToPath
函数成环。
剪枝策略
另外上边说到剪枝的策略,这里边策略很多,能够很好的加快速度。这里我提供了简单的几种方式:
bool pruneUnlink(const GraphVec& graph, int left, int right, const std::set<int>& unlinks) {
std::vector<int> parent(graph.size(), -1);
for (const auto& v: unlinks) parent[v] = v;
std::function<void(int,int)> set_parent;
set_parent = [&set_parent, &parent](int index, int val) {
if (parent[index] == index) {
parent[index] = val;
return;
}
int old = parent[index];
parent[index] = val;
set_parent(old, val);
};
std::vector<int> left_joints;
std::vector<int> right_joints;
for (const auto& v1: unlinks) {
if (graph[v1][left] > 0) {
left_joints.push_back(v1);
}
if (graph[v1][right] > 0) {
right_joints.push_back(v1);
}
for (const auto& v2: unlinks) {
if (graph[v1][v2] > 0) {
set_parent(v2, v1);
}
}
}
int val = -1;
for (auto& p: parent) {
if (p == -1) {
continue;
}
if (val == -1) {
val = p;
}
else if (val != p) {
return true;
}
}
if (left_joints.empty() || right_joints.empty()) {
return true;
}
if (left_joints.size() == 1 && right_joints.size() == 1 && left_joints[0] == right_joints[0]) {
return true;
}
return false;
}
这里返回true表示剪枝成功,不需要继续递归调用addUnlinkToPath。
我这里提供了三种策略:
- 如果unlinks的所有节点本身的连通分量都不是同一个,那么就剪枝。
- 如果和path的左右端点相连的节点个数为空,那么就剪枝。
- 如果path的左右端点相连的节点个数都只有一个,且这个节点还是同一个,那么就剪枝。
其他应该还有很多,欢迎讨论。
测试
最终我们看下输出结果:
tsp path: 7 3 0 2 1 5 4 6
总结
本文提供了一种非连通图下的tsp的求解算法,比局部贪心好一点,但是也不是最优解。毕竟TSP问题是NP问题。
算法的全部代码我放到了这里:
https://github.com/leap-ticking/farrago/blob/main/algo/tsp_tree.cpp
Ref
- https://www.mdpi.com/2076-3417/11/1/177