一种非完全图下的TSP求解算法

旅行商问题(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。
我这里提供了三种策略:

  1. 如果unlinks的所有节点本身的连通分量都不是同一个,那么就剪枝。
  2. 如果和path的左右端点相连的节点个数为空,那么就剪枝。
  3. 如果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
Vivado2023是一款集成开发环境软件,用于设计和验证FPGA(现场可编程门阵列)和可编程逻辑器件。对于使用Vivado2023的用户来说,license是必不可少的。 Vivado2023的license是一种许可证,用于授权用户合法使用该软件。许可证分为多种类型,包括评估许可证、开发许可证和节点许可证等。每种许可证都有不同的使用条件和功能。 评估许可证是免费提供的,让用户可以在一段时间内试用Vivado2023的全部功能。用户可以使用这个许可证来了解软件的性能和特点,对于初学者和小规模项目来说是一个很好的选择。但是,使用评估许可证的用户在使用期限过后需要购买正式的许可证才能继续使用软件。 开发许可证是付费的,可以永久使用Vivado2023的全部功能。这种许可证适用于需要长期使用Vivado2023进行开发的用户,通常是专业的FPGA设计师或工程师。购买开发许可证可以享受Vivado2023的技术支持和更新服务,确保软件始终保持最新的版本和功能。 节点许可证是用于多设备或分布式设计的许可证,可以在多个计算机上安装Vivado2023,并共享使用。节点许可证适用于大规模项目或需要多个处理节点进行设计的用户,可以提高工作效率和资源利用率。 总之,Vivado2023 license是用户在使用Vivado2023时必须考虑的问题。用户可以根据自己的需求选择合适的许可证类型,以便获取最佳的软件使用体验。
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值