前言
-
在机器人导航中,我们难免会遇到这样一个问题,机器人给定好几个目标点,必须依次经过每个点,并且最终回到起点,要求整个过程路程最短。
-
这便是经典的
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如下
-
这时的距离矩阵就是:
A | B | C | D | |
---|---|---|---|---|
A | 0 | 2 | √8 | 2 |
B | 2 | 0 | 2 | √8 |
C | √8 | 2 | 0 | 2 |
D | 2 | √8 | 2 | 0 |
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 算法思路
- 构建距离矩阵
dist[i][j]
表示城市 i 到城市 j 的距离。 - 固定起点为城市 0。
- 对其余城市全排列,遍历所有可能的访问顺序。
- 计算每条路径的总距离(包括最后回到起点)。
- 记录最短路径及其对应距离。
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
自动产生下一个排列组合; - 每次如果找到更短的路径,就更新记录。
- 计算从城市 0 开始,经过
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
的所有可能子集。这样,子集的大小是指数级的,但每个子集的状态存储和更新可以通过动态规划来避免重复计算。
- 状态 S 的大小为
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)
(即前一部分的最短路径)加上从j
到i
的距离来计算出来的。- 最终,我们需要遍历所有可能的城市
j
,找到使dp(S, i)
最小的路径。 - 简而言之,
dp(S, i)
是通过遍历所有可能的j
(即城市i
前的城市),加上从j
到i
的距离,来找到最短的路径。
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) 结果 | 是否访问 |
---|---|---|---|
0 | 0001 | 1101 & 0001 = 0001 | √ |
1 | 0010 | 1101 & 0010 = 0000 | × |
2 | 0100 | 1101 & 0100 = 0100 | √ |
3 | 1000 | 1101 & 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
,再从j
到i
,得到一个新的候选路径。
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
,找到最短的路径。
- 我们计算从所有城市到回到起点(城市 0)的最短路径,即
- 路径回溯:
- 从
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++对齐进行复现。
- 下一期我们将继续讲解剩下的贪心算法,遗传算法 / 蚁群算法 / 模拟退火,分支限界。
- 如有错误,欢迎指出!
- 感谢大家的支持!!!!!!