正文:
旅行推销员问题(英语:Travelling salesman problem, TSP)是这样一个问题:给定一系列城市和每对城市之间的距离,求解访问每一座城市仅一次并回到起始城市的最短回路。它是组合优化中的一个NP难问题,在运筹学和理论计算机科学中非常重要。
这一问题中,将城市视为节点,将城市间的距离视为边,则一个有n个节点的旅行商问题可视为n*n的邻接矩阵。
例:
题目要求从其中一个节点出发,遍历所有且只读取一次节点,并最终回到最初出发节点上。
分析:
TSP问题显然是NP问题(关于NP问题,可以查看https://blog.csdn.net/csdnnews/article/details/100111395可获较清晰明确的理解):任意选择一个节点作为初始节点,由于路径是一个闭环,因此这与步骤并不重要,但在其n-1步,都会将存在n-1种选择,因此,若以暴力破解,其时间成本为n!,显然是不可取的,因此需要其他方法。
方法:
1、动态规划法
我们从闭环上的一点s出发,设函数d(i,v)表示从节点i出发,穿针引线的经过且只经过点集V中的每一个节点一次,最终返回出发点s的最短路径。那么,以Cij表示节点i到节点j的距离,则函数d(i,v)有:
1)当V为空集(此时表示所有节点已经经过了一次),那么最后的节点应当与初始节点相连以形成闭环,则此时d(i,v)=Cis;
2)当V为非空集,则我们需要在点集V中选择一点k,并使Cik+d(i,v-{k})为所有可行方案的最优解(求这个最优解就是本身这个问题分割下来的同结构且规模较小的子问题)。
因此,函数d(i,v)可以表示为:
以4节点为例,需要做的计算为:
为什么用动态规划要好呢?仅从文字上看,似乎也是需要遍历所有可能。
实际上,据我个人理解与看法,动态规划与我们平常刷题用的打表法有异曲同工之妙,同样的将已完成的计算存储在一旁,并用作为下一步计算的其中一个组成。而在动态规划中这个“计算”被替换成“决策”而已。
动态规划将问题逐步分割为子问题,并将子问题的解作为上一层递归返回的一部分。在这里最底层的两点间的距离是可以直接得知的,因此我们需要一个dp表记录其他层的记录:
在这里纵行表示选择的节点i,横行表示剩下的集合,整个表是由左往右,由上往下推导的,而不存在的组合一0表示,比如d(1,{1,2})。(需要连接的节点包含自身是不可能的)
以d(1,{2,3})为例:
d(1,{2,3})=min(C12+d(2,{3}),C13+d(3,{2})),因此我们找到d(2,{3})和d(3,{2});
而d(2,{3})=C23+d(3,{}),我们找到上表的C23=2,下表的d(3,{})=3,因此d(2,{3})=5,同理d(3,{2})=11;
因此,d(1,{2,3})=min(C12+d(2,{3}),C13+d(3,{2}))=min(2+5,3+11)=7
代码实现
不难看到,这里实现的要点是建立dp表,我们可以用二进制的方法表示点集成员如111表示的就是{1,2,3},而我们可以用移位操作制造二进制表示:m=1<<(n-1)
而检查点集是否存在元素k则可以根据:m>>(k-1)
,k-1是因为我们要去到第k位,因此前k-1位需要消除。
而在集合中取子集合,即我们需要消除第k节点,我们可以用与或^操作:j^(1<<(k-1))
代码(参考:https://blog.csdn.net/qq_39559641/article/details/101209534):
#include <iostream>
#include <vector>
#include <algorithm>
#include <cmath>
#include <cstring>
using namespace std;
//定义节点数量
#define N 4
//设定一个大数,使其表示两节点无法连接,并在选取最小值时被筛选掉
#define INF 10e7
//依照节点数转换为集合组合可能数
static const int M = 1 << (N - 1);
//存储城市间的距离
int g[N][N] = {
{
INF,3,6,7},
{
5,INF,2,3},
{
6,4,INF,2},
{
3,7,5,INF}};
//dp表,由于存储d(i,V)的值
int dp[N][M];
//保存路径
vector<int> path;
//求两者最小值
int Min(int a,int b)
{
return (a > b) ? b : a;
}
//核心代码
void TSP()
{
//初始化dp[i][0],因为头一列数都是该点直接到出发点s的距离
for(int i=0;i<N;i++)
{
dp[i][0] = g[i][0];
}
//开始推dp表,自左向右,自上向下更新
//我们第0行已初始化
for(int j=1;j<M;j++)
{
for(int i=0;i<N;i++)
{
//为了取最小值,所以应该先 往里面填写较大的数
dp[i][j] = INF;
//d(i,V)中,i不应存在于集合V内,若存在,则应该跳过
if(((j>>(i-1))&1)==1)
{
continue;
}
//从大集合{1,2,3}中选择,若i存在于V'中,则访问
for(int k=1;k<N;k++)
{
//如果该点不存在则跳过
if(((j >> (k - 1)) & 1) == 0)
{
continue;
}
//对比大小
dp[i][j] = Min(dp[i][j], g[i][k] + dp[k][j ^ (1 << (k - 1))]);
}
}
}
cout << "最短路劲长度为:"<<dp[0][M - 1]<<endl;
}
//判断节点是否已访问,不包括0号
bool isVisited(bool visited[])
{
for(int i=1;i<N;i++)
{
if(visited[i]==false)
{
return false;
}
}
return true;
}
//获取最优路劲,保存在path中,根据动态规划反向找出最短路径节点
void getPath()
{
//建立访问标记数组
bool visited[N] = {
false };
//前驱节点编号
int pioneer = 0, min = INF, S = M - 1, temp;
//把初始节点0添加到里面
path.push_back(0);
//当存在节点为访问时
while (!isVisited(visited))
{
//判断子问题中的最优解
for(int i=1;i<N;i++)
{
//节点未访问且剩余集非空集
if(visited[i]==false&&(S&(1<<(i-1)))!=0)
{
//寻找最优解
if (min > g[i][pioneer] + dp[i][(S ^ (1 << (i - 1)))])
{
min = g[i][pioneer] + dp[i][(S ^ (1 << (i - 1)))];
temp = i;
}
}
}
//压入子问题最优解
pioneer = temp;
path.push_back(pioneer);
//标记节点已访问
visited[pioneer] = true;
//从剩余节点中去除已访问节点
S = S ^ (1 << (pioneer - 1));
min = INF;
}
}
//输出路径
void printPath() {
cout << "最小路径为:";
vector<int>::iterator it = path.begin();
for (it; it != path.end();it++) {
cout << *it << "--->";
}
//单独输出起点编号
cout << 0;
}
int main()
{
TSP();
getPath();
printPath();
return 0;
}
结果:
最短路劲长度为:10
最小路径为:0--->1--->2--->3--->0