【C++算法解析与复现】TSP(旅行推销员问题,Traveling Salesman Problem)(一):暴力穷举与动态规划

前言

  • 在机器人导航中,我们难免会遇到这样一个问题,机器人给定好几个目标点,必须依次经过每个点,并且最终回到起点,要求整个过程路程最短。

  • 这便是经典的TSP(旅行推销员问题,Traveling Salesman Problem),是组合优化中的经典问题之一,常用于算法、图论和运筹学的研究中。请添加图片描述

  • 那么我们今天就来分别看看,我们如何C++来通过下述几种方法分别来解决TSP问题。

方法特点复杂度
暴力穷举所有排列组合O(n!)
动态规划(如Held-Karp)利用子问题O(n²·2ⁿ)
贪心算法(最近邻等)快但非最优O(n²)
遗传算法 / 蚁群算法 / 模拟退火启发式搜索,适用于大规模问题高效近似解
分支限界减少搜索空间适中

1 问题描述及距离矩阵

1-1 TSP经典问题描述
  • 一个旅行推销员要访问若干个城市,每个城市只访问一次,最后回到出发城市
  • 问题是:如何选择访问城市的顺序,使总路程最短?
1-2 距离矩阵
  • 在具体分析TSP问题之前,我们需要引入一个方便计算的矩阵。
  • 距离矩阵是一个二维数组,用于表示 各个点之间的距离
  • 假设我们有 n 个点,那么距离矩阵就是一个 n × n 的矩阵 dist[i][j],表示 第 i 个点到第 j 个点的距离
1-2-1 举个例子:
  • 我们假设在直角坐标系上有四个点ABCD如下请添加图片描述

  • 这时的距离矩阵就是:

ABCD
A02√82
B202√8
C√8202
D2√820
  • dist[i][j] == dist[j][i],是对称的(因为 A→B 和 B→A 距离一样)
1-2-2 C++实现距离矩阵
  • 假设我们有个点集
vector<pair<int, int>> points = {
    {0, 0}, {2, 0}, {2, 2}, {0, 2}
};
  • 可以这样构建距离矩阵
int n = points.size();
vector<vector<double>> dist(n, vector<double>(n));

// 欧几里得距离函数
auto euclidean = [](pair<int, int> a, pair<int, int> b) {
    return sqrt(pow(a.first - b.first, 2) + pow(a.second - b.second, 2));
};

for (int i = 0; i < n; ++i) {
    for (int j = 0; j < n; ++j) {
        dist[i][j] = euclidean(points[i], points[j]);
    }
}


2 暴力穷举法(Brute Force)(那完了)

2-1 介绍
  • 暴力穷举就是尝试 所有可能的路径组合,然后从中找出一条总距离最短的路径。
  • 通常适合点的数量较少的情况(一般 ≤10 个点),否则计算量会爆炸式增长!
2-2 算法思路
  1. 构建距离矩阵 dist[i][j] 表示城市 i 到城市 j 的距离。
  2. 固定起点为城市 0。
  3. 对其余城市全排列,遍历所有可能的访问顺序。
  4. 计算每条路径的总距离(包括最后回到起点)。
  5. 记录最短路径及其对应距离。
2-3 代码实现
  • 老规矩先上代码后说明
#include <iostream>  
#include <vector>  
#include <cmath>  
#include <limits>  
  
using namespace std;  
  
// 欧几里得距离计算  
double calcDistance(pair<int, int> a, pair<int, int> b) {  
    return sqrt(pow(a.first - b.first, 2) + pow(a.second - b.second, 2));  
}  
  
// 构建距离矩阵  
vector<vector<double>> buildDistanceMatrix(const vector<pair<int, int>>& points) {  
    int n = points.size();  
    vector<vector<double>> dist(n, vector<double>(n));  
    for (int i = 0; i < n; ++i)  
        for (int j = 0; j < n; ++j)  
            dist[i][j] = calcDistance(points[i], points[j]);  
    return dist;  
}  
  
int main() {  
    // 城市坐标(可自行修改)  
    vector<pair<int, int>> cities = {{0, 0}, {1, 3}, {4, 3}, {6, 1}};  
    int n = cities.size();  
  
    vector<vector<double>> dist = buildDistanceMatrix(cities);  
  
    vector<int> nodes;  
    for (int i = 1; i < n; ++i) // 排除起点0  
        nodes.push_back(i);  
  
    double minPath = numeric_limits<double>::max();  
    vector<int> bestPath;  
  
    do {  
        double pathLen = 0;  
        int current = 0;  
  
        // 从城市0出发  
        for (int i = 0; i < nodes.size(); ++i) {  
            pathLen += dist[current][nodes[i]];  
            current = nodes[i];  
        }  
        pathLen += dist[current][0]; // 回到起点  
  
        if (pathLen < minPath) {  
            minPath = pathLen;  
            bestPath = nodes;  
        }  
  
    } while (next_permutation(nodes.begin(), nodes.end()));  
  
    // 输出结果  
    cout << "最短路径长度: " << minPath << endl;  
    cout << "访问顺序: 0 ";  
    for (int i : bestPath) cout << "-> " << i << " ";  
    cout << "-> 0" << endl;  
  
    return 0;  
}
  • 输出如下:
  • 请添加图片描述
2-4 代码分析
  • 这里根据城市点集合,计算距离矩阵
vector<pair<int, int>> cities = {{0, 0}, {1, 3}, {4, 3}, {6, 1}};
vector<vector<double>> dist = buildDistanceMatrix(cities);
  • 我们排除起点城市 0,因为我们固定从城市 0 出发。其余的城市编号放到 nodes 里,准备排列。
vector<int> nodes;
for (int i = 1; i < n; ++i)
    nodes.push_back(i);
  • 初始化最短路径长度为正无穷,方便后续比较。
double minPath = numeric_limits<double>::max(); // 初始设置为无穷大
vector<int> bestPath;
  • 下面的循环利用next_permutation生成排列组合:
    • 计算从城市 0 开始,经过 nodes 中排列的城市的路径总长度;
    • 最后加上返回城市 0 的距离。
    • 使用 next_permutation 自动产生下一个排列组合;
    • 每次如果找到更短的路径,就更新记录。
do {
    double pathLen = 0;
    int current = 0;

    for (int i = 0; i < nodes.size(); ++i) {
        pathLen += dist[current][nodes[i]];
        current = nodes[i];
    }
    pathLen += dist[current][0]; // 回到起点
    
    if (pathLen < minPath) {
        minPath = pathLen;
        bestPath = nodes;
    }
} while (next_permutation(nodes.begin(), nodes.end()));


2-5 注意!!!!!!!!
  • 算法时间复杂度是 O(n!),城市多了会非常慢;
  • 对 10 个城市,排列组合就是 9! = 362880 条路径;
  • 因此实际应用这边建议使用更高效的近似算法

3 动态规划(Dynamic Programming, DP):Held-Karp 算法

3-1 啥是动态规划
  • 动态规划(Dynamic Programming,简称 DP) 是一种将复杂问题==分解成子问题==并逐步求解的方法。它特别适合处理具有重叠子问题最优子结构的问题。
  • 一句话总结:
    • “把大问题拆成小问题,小问题只算一次,结果存起来,避免重复计算。”
    • 动态规划 = 记住子问题的解 + 推导新解
    • 每打过一只怪就记下经验值(子问题解),下次遇到类似怪就不用重打了!
3-2 动态规划经典问题:斐波那契数列
  • 虽然是题外话但是姑且提一嘴,咱们一般写斐波那契数列不都这样写吗:
int fib(int n) {
    if (n <= 1) return n;
    return fib(n - 1) + fib(n - 2);
}
  • 这样有啥不好呢?效率很低,举个例子:
    • 比如计算 fib(4) 时,它会分别计算 fib(3)fib(2),然后为了得到 fib(3) 又会计算 fib(2)fib(1),这样 fib(2) 被重复计算了两次,fib(3) 也会被多次计算。
  • 这就是所谓的"重叠子问题",这类问题会导致大量的冗余计算,从而降低效率。时间复杂度是 O(2^n),会随着 n 的增大呈指数级增长。
  • 为了避免上述问题,我们这样写:
int fib(int n) {
    vector<int> dp(n + 1);
    dp[0] = 0;
    dp[1] = 1;
    for(int i = 2; i <= n; i++)
        dp[i] = dp[i - 1] + dp[i - 2];
    return dp[n];
}
  • i = 2 开始,通过 dp[i] = dp[i-1] + dp[i-2] 来计算 fib(i)。此时,fib(i-1)fib(i-2) 的值已经存储在 dp[i-1]dp[i-2] 中,不需要再去递归计算它们。
  • 动态规划的关键在于 将计算过的结果存储在数组中,并且 自底向上 逐步计算,而不是每次都递归去计算子问题。

3-3 Held-Karp 算法
  • Held-Karp 算法是解决旅行商问题(TSP, Travelling Salesman Problem)的一种经典动态规划算法,能够有效地解决这个 NP-hard 问题,虽然它的时间复杂度仍然是指数级的,但比暴力枚举法要高效得多。
3-4 Held-Karp 算法的基本思想
  • 同动态规划的基础思想,Held-Karp 算法的基本思路也是通过逐步构建子问题的解来避免重复计算,并将解存储在一个表格中。
  • 回顾我们的第一个方法,穷举法中出现的问题便是重复,对于每一种顺序,都会计算路径的长度,并从中找到最短的路径。(因为不同的路径中可能有很多重复的部分。)
3-4-1 状态
  • 因此,为了避免重复计算,我们引入状态
dp(S, i)
  • dp(S, i) 表示从起点出发,经过城市集合 S,最终到达城市 i 的最短路径长度,
  • 其中
    • S 是一个包含已访问城市的子集,表示当前路径已经经过了哪些城市。
    • i 是当前城市,表示我们正在考虑最后访问的城市是 i
  • 这个状态的关键在于:
    • 状态 S 的大小为 2^n,也就是说,S 的数量是城市数 n 的所有可能子集。这样,子集的大小是指数级的,但每个子集的状态存储和更新可以通过动态规划来避免重复计算。
3-4-2 转移方程
  • 那么既然我们要想求出:最短的dp[S][i] ,那我们就要想:
  • 你是怎么来到城市 i 的?
  • 一定是:
    • 你之前已经走过了集合 S 中除了 i 的那些城市(我们称为 S' = S - {i}),
    • 然后从其中某个城市 j 走到了城市 i

  • 举个具体例子帮助看看,假设我们有:
    • 城市集合 S = {0, 1, 2, 3},也就是我们打算访问所有城市。
    • 我们要计算 dp[S][3],也就是访问完所有城市最后到达城市 3 的最短路径。
  • 那我们会尝试这些路径:
    • dp[{0,1,2}][0] + dist[0][3]
    • dp[{0,1,2}][1] + dist[1][3]
    • dp[{0,1,2}][2] + dist[2][3]
  • 三条路径都尝试一下,取最小值,就是 dp[{0,1,2,3}][3]

  • 那么上述问题,对于一个子集 S 和一个城市 i,可以通过以下方式来递推出最短路径:
dp(S, i) = min(dp(S - {i}, j) + dist(j, i)), 其中 j ∈ S 且 j ≠ i
  • dp(S - {i}, j) 表示从城市集合 S 中去掉城市 i 后,经过城市集合 S - {i} 的路径,最终到达城市 j 的最短路径。
  • dist(j, i) 是从城市 j 到城市 i 的直接路径距离。也就是说,dp(S, i) 是通过选择子集 S 中的某个城市 j,然后从 dp(S - {i}, j)(即前一部分的最短路径)加上从 ji 的距离来计算出来的。
  • 最终,我们需要遍历所有可能的城市 j,找到使 dp(S, i) 最小的路径。
  • 简而言之,dp(S, i) 是通过遍历所有可能的 j(即城市 i 前的城市),加上从 ji 的距离,来找到最短的路径。
3-4-3 初始状态和目标
  • 初始状态就是从起点(假设为城市 0)出发:
dp({0}, 0) = 0  # 起点到起点的距离是 0
  • 最终的目标是遍历完所有城市,并返回起点。最终的解是
`dp(all_cities, 0)`
  • 其中 all_cities 是包含所有城市的集合。

3-5 代码实现
  • 老规矩先上代码
#include <iostream>  
#include <vector>  
#include <cmath>  
#include <limits>  
#include <algorithm>  
  
using namespace std;  
  
// 欧几里得距离计算  
double calcDistance(pair<int, int> a, pair<int, int> b) {  
    return sqrt(pow(a.first - b.first, 2) + pow(a.second - b.second, 2));  
}  
  
// 构建距离矩阵  
vector<vector<double>> buildDistanceMatrix(const vector<pair<int, int>>& points) {  
    int n = points.size();  
    vector<vector<double>> dist(n, vector<double>(n));  
    for (int i = 0; i < n; ++i) {  
        for (int j = 0; j < n; ++j) {  
            dist[i][j] = calcDistance(points[i], points[j]);  
        }  
    }  
    return dist;  
}  
  
// Held-Karp 动态规划解决 TSP,返回最短路径长度并记录路径  
double heldKarp(const vector<vector<double>>& dist, int n, vector<int>& path) {  
    // dp[S][i] 表示访问城市集合 S 后,最后一个城市是 i 的最短路径  
    vector<vector<double>> dp(1 << n, vector<double>(n, numeric_limits<double>::infinity()));  
    vector<vector<int>> parent(1 << n, vector<int>(n, -1));  // 用来存储路径的前一个城市  
  
    // 起点到起点的距离为 0    dp[1][0] = 0;  
  
    // 遍历所有子集  
    for (int S = 1; S < (1 << n); ++S) {  
        for (int i = 0; i < n; ++i) {  
            // 如果 i 不在集合 S 中,跳过  
            if (!(S & (1 << i))) continue;  
  
            // 计算子集 S 中不包含城市 i 的子集 S',以及从 S' 到 i 的最短路径  
            for (int j = 0; j < n; ++j) {  
                if (S & (1 << j) && i != j) {  
                    double newDist = dp[S ^ (1 << i)][j] + dist[j][i];  
                    if (newDist < dp[S][i]) {  
                        dp[S][i] = newDist;  
                        parent[S][i] = j;  // 记录最短路径的前一个城市  
                    }  
                }  
            }  
        }  
    }  
  
    // 计算从所有城市回到起点的最短路径  
    double minPath = numeric_limits<double>::infinity();  
    int lastCity = -1;  
    for (int i = 1; i < n; ++i) {  
        double newDist = dp[(1 << n) - 1][i] + dist[i][0];  
        if (newDist < minPath) {  
            minPath = newDist;  
            lastCity = i;  
        }  
    }  
  
    // 反向回溯路径  
    path.clear();  
    int S = (1 << n) - 1;  
    while (lastCity != -1) {  
        path.push_back(lastCity);  
        int temp = lastCity;  
        lastCity = parent[S][lastCity];  
        S ^= (1 << temp);  // 更新子集 S    }  
    reverse(path.begin(), path.end());  // 因为路径是反向回溯的,所以需要反转  
  
    return minPath;  
}  
  
int main() {  
    // 城市坐标(可自行修改)  
    vector<pair<int, int>> cities = {{0, 0}, {1, 3}, {4, 3}, {6, 1}};  
    int n = cities.size();  
  
    // 构建距离矩阵  
    vector<vector<double>> dist = buildDistanceMatrix(cities);  
  
    // 调用 Held-Karp 算法  
    vector<int> path;  
    double minPath = heldKarp(dist, n, path);  
  
    // 输出结果  
    cout << "最短路径长度: " << minPath << endl;  
    cout << "访问顺序: 0 ";  
    for (int i : path) cout << "-> " << i << " ";  
    cout << "-> 0" << endl;  
  
    return 0;  
}
  • 输出:请添加图片描述
3-6 代码解读
  • 这部分不变,这里根据城市点集合,计算距离矩阵
vector<pair<int, int>> cities = {{0, 0}, {1, 3}, {4, 3}, {6, 1}};
vector<vector<double>> dist = buildDistanceMatrix(cities);

  • 这里需要补充一个小知识:
    • (1 << n)位运算,它表示 2^n,即 1 左移 n 位。
    • 这个操作常用于计算二进制数的幂,并且非常高效。
    • S 是一个子集:这个子集表示哪些城市已经被访问过,S 的每一位都代表一个城市是否被访问过。S 的大小是 n(城市数),所以一共有 2^n 个可能的子集(包括空集和所有城市的集合)。
    • 在这里**1 << n**:表示有 2^n 个子集,(1 << n) 即是表示所有子集数量的值。

  • dp[S][i]二维数组:表示访问城市集合 S 后,最后一个城市是 i 的最短路径。
    • 第一维 (1 << n):表示所有的城市子集(状态集合 S):
      • 共有 2^n 个子集,每个子集对应一个整数(用二进制编码表示包含哪些城市)。
    • 第二维 (n):表示终点城市 i,也就是当前在城市 i
vector<vector<double>> dp(1 << n, vector<double>(n, numeric_limits<double>::infinity()));
  • parent[S][i]:用来记录状态转移时,最短路径到达 i 的前一个城市。这个数组是为了帮助我们最后反向回溯路径。
vector<vector<int>> parent(1 << n, vector<int>(n, -1));  // 用来存储路径的前一个城市
  • 也就是这个函数的开头定义部分:
// Held-Karp 动态规划解决 TSP,返回最短路径长度并记录路径
double heldKarp(const vector<vector<double>>& dist, int n, vector<int>& path) {
    // dp[S][i] 表示访问城市集合 S 后,最后一个城市是 i 的最短路径
    vector<vector<double>> dp(1 << n, vector<double>(n, numeric_limits<double>::infinity()));
    vector<vector<int>> parent(1 << n, vector<int>(n, -1));  // 用来存储路径的前一个城市

  • dp[1][0] = 0:这是初始状态,表示从城市 0 出发时,路径长度为 0,只有城市 0 被访问。`
	// 起点到起点的距离为 0
    dp[1][0] = 0;

  • 接下来这一段是最魔法的地方:
 // 遍历所有子集
    for (int S = 1; S < (1 << n); ++S) {
        for (int i = 0; i < n; ++i) {
            // 如果 i 不在集合 S 中,跳过
            if (!(S & (1 << i))) continue;

            // 计算子集 S 中不包含城市 i 的子集 S',以及从 S' 到 i 的最短路径
            for (int j = 0; j < n; ++j) {
                if (S & (1 << j) && i != j) {
                    double newDist = dp[S ^ (1 << i)][j] + dist[j][i];
                    if (newDist < dp[S][i]) {
                        dp[S][i] = newDist;
                        parent[S][i] = j;  // 记录最短路径的前一个城市
                    }
                }
            }
        }
    }
  • 我们一个个看:
    • S表示城市的子集,1 << n 表示 2^n,即所有可能的城市组合子集数量。我们从 S = 1 开始跳过空集(因为空集没法从起点开始)。
for (int S = 1; S < (1 << n); ++S) {
  • i 是当前子集 S 中的“终点城市”。
	for (int i = 0; i < n; ++i) {
  • (S & (1 << i))是一个位与运算,判断城市 i 有没有在子集 S 里出现过
    • 如果 S 的第 i 位是 1:(S & (1 << i)) != 0,说明:城市 i 被访问过。
    • 如果 S 的第 i 位是 0:(S & (1 << i)) == 0,说明:城市 i 没有被访问。
		// 如果 i 不在集合 S 中,跳过
		    if (!(S & (1 << i))) continue;
  • 举个例子:
int S = 13; // 二进制是 1101,表示访问了城市 0、2、3
城市编号1 << i (二进制)S & (1 << i) 结果是否访问
000011101 & 0001 = 0001
100101101 & 0010 = 0000×
201001101 & 0100 = 0100
310001101 & 1000 = 1000

  • 然后是核心逻辑:
// 计算子集 S 中不包含城市 i 的子集 S',以及从 S' 到 i 的最短路径
            for (int j = 0; j < n; ++j) {
                if (S & (1 << j) && i != j) {
                    double newDist = dp[S ^ (1 << i)][j] + dist[j][i];
                    if (newDist < dp[S][i]) {
                        dp[S][i] = newDist;
                        parent[S][i] = j;  // 记录最短路径的前一个城市
                    }
                }
            }
  • 枚举 S 中的其他城市 j,作为到 i 的前一个城市
    • 这里我们尝试从子集 S 中的每个城市 j 转移到城市 i
    • 条件:城市 j 必须在 S 中,且不能等于 i(不能原地转移)。
for (int j = 0; j < n; ++j) {
    if (S & (1 << j) && i != j) {

  • 动态转移方程(核心逻辑)
    • S ^ (1 << i) 表示:从集合 S 中去掉城市 i,即得到 S'
    • dp[S ^ (1 << i)][j] 表示:从起点出发,走完 S 中除了 i 的城市,j 为终点的最短路径
    • dist[j][i] 是从城市 j 到城市 i 的距离。
    • 所以 newDist 表示:从起点走完 S' 并到达 j,再从 ji,得到一个新的候选路径。
double newDist = dp[S ^ (1 << i)][j] + dist[j][i];
  • 也就是上面概念说的-------你是怎么来到城市 i 的?
  • 一定是:
    • 你之前已经走过了集合 S 中除了 i 的那些城市(我们称为 S' = S - {i}),
    • 然后从其中某个城市 j 走到了城市 i
  • 再次看一遍例子,假设我们有:
    • 城市集合 S = {0, 1, 2, 3},也就是我们打算访问所有城市。
    • 我们要计算 dp[S][3],也就是访问完所有城市最后到达城市 3 的最短路径。
  • 那我们会尝试这些路径:
    • dp[{0,1,2}][0] + dist[0][3]
    • dp[{0,1,2}][1] + dist[1][3]
    • dp[{0,1,2}][2] + dist[2][3]
  • 三条路径都尝试一下,取最小值,就是 dp[{0,1,2,3}][3]

  • 随后更新最短路径和记录路径
    • 如果 newDist 小于当前的 dp[S][i],说明我们找到了一个更短的路径:
      • 就更新它。
      • 同时记录一下这个最短路径的前一个城市 j,用于回溯出完整路径。
if (newDist < dp[S][i]) {
    dp[S][i] = newDist;
    parent[S][i] = j;
}


  • 最终的路径计算
    • 我们计算从所有城市到回到起点(城市 0)的最短路径,即 dp[(1 << n) - 1][i] + dist[i][0](1 << n) - 1 表示所有城市都已经访问过的子集。
    • 通过遍历所有城市 i,找到最短的路径。
  • 路径回溯:
    • lastCity 开始,反向回溯路径,直到回到起点。每一步都通过 parent[S][lastCity] 获取当前城市的前一个城市。
    • 在回溯的过程中,S 会更新,去掉当前城市。
    • 最后通过 reverse 函数将路径反转,得到从起点到终点的正确访问顺序。
    // 反向回溯路径
    path.clear();
    int S = (1 << n) - 1;
    while (lastCity != -1) {
        path.push_back(lastCity);
        int temp = lastCity;
        lastCity = parent[S][lastCity];
        S ^= (1 << temp);  // 更新子集 S
    }
    reverse(path.begin(), path.end());  // 因为路径是反向回溯的,所以需要反转

    return minPath;
}


3-7 注意!!!!
  • 动态规划:通过状态转移避免了暴力枚举中的重复计算,显著提高了计算效率。
  • 时间复杂度O(n^2 * 2^n),适合处理较小规模的 TSP 问题,通常 n 不超过 20。

4 小结

  • 本期我们分别介绍了暴力穷举与动态规划来解决TSP(旅行推销员问题,Traveling Salesman Problem),并使用C++对齐进行复现。
  • 下一期我们将继续讲解剩下的贪心算法,遗传算法 / 蚁群算法 / 模拟退火,分支限界。
  • 如有错误,欢迎指出!
  • 感谢大家的支持!!!!!!
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值