集合状压 / TSP
最短 Hamilton·吃奶酪
本课摘要
集合状压 / TSP课程回答“集合访问状态如何保证 Hamilton 路径不重不漏”。内容以最短 Hamilton·吃奶酪为主线,配合逐步推导、可编辑演示、例题与练习形成可复查的学习闭环。
- 判断集合状压 / TSP的适用条件与状态边界
- 围绕“最短 Hamilton·吃奶酪”推导转移与计算顺序
- 用演示、复杂度分析和配套题目校验实现
本课目录 · 0 节
正在整理目录…
走遍所有点,暴力为何不行
旅行商问题(TSP):从起点出发,不重不漏地走遍所有 个点,让总路程最短。最直白的想法是枚举点的排列—— 个点有 种走法, 已接近五亿, 就上万亿,彻底不可行。
但仔细想:走到某一步时,接下来怎么走最优,只跟两件事有关——「已经走过了哪些点」(一个集合)和「此刻站在哪个点」。至于这些点是按什么顺序走到的,对未来毫无影响。这就把 条路径,坍缩成了「已访问集合 × 当前点」这么多状态。
「已访问集合」用状态压缩再合适不过: 个点的子集,正好是一个 位二进制数 ,第 位为 表示点 已访问。集合共 个,配上 个「当前点」,状态总数 —— 也只有约 万,可以承受。
状态与转移:dp[S][i]
定状态。设 表示:已经走过的点集合恰为 、当前停在点 ( 必属于 )时,走出这条路径的最短总长。
转移。从 出发,选一个还没访问过的点 (即第 位在 里是 ),走过去。新集合是 点亮第 位,当前点变成 ,路程加上 :
边界:(从点 0 出发,只到过点 0,路程 0)。答案:走遍全集后停在终点 ,即 。 实现时按 从小到大枚举——因为并入新点后 ,保证每个状态被用到时,它依赖的子状态已经算好。
本质
状压把「已访问哪些点」这个集合编码成一个整数下标,于是「走过的历史」被压进 的第一维。 条排列坍缩为 个状态、每个状态 转移,总复杂度 ——这是 的 TSP 唯一可行的通用解法。
跟着算一遍
取 个点,起点为 ,看几个关键状态怎么被填出来。集合用 4 位二进制表示(最高位是点 3):
dp[S][i] 就是一张表
有两个下标,天生就是二维表格:把 个集合当作行、 个当前点当作列。每一步转移,都是从某个已算好的格子,指向「集合更大一位、当前点为 」的新格子。
0001 逐步点亮,最后一行取最小即答案。- 来源参与当前转移的依赖状态
- 当前正在计算或观察的状态
- 确定 / 最优已进入当前最优解或确定结果
- 已处理已经扫描、当前不再活跃的状态
- 非法越界、冲突或不可达状态
开环还是闭环:一个 +dist(i,0) 的差别
上面求的是Hamilton 路径——走遍所有点就结束,不必回到起点,答案是 。这是「最短 Hamilton 路径」和「吃奶酪」的形态。
但「售货员的难题」要求走一圈回到出发城市——这是闭环 TSP(Hamilton 回路)。状态转移一模一样,只有最后一步不同:停在 后还得补上回起点的边,答案变成 。
常见陷阱:开环 / 闭环、有向 / 无向别混
闭环忘了 会算成开环,答案偏小;开环误加了回边则偏大。另外「售货员」是有向图, 未必等于 ,转移里务必用方向正确的那条边。起点固定为 是惯例——回路从哪点断开都一样,固定起点可省去一层枚举。
例题
#include <iostream>
#include <cstring>
#include <algorithm>
using namespace std;
int n;
int w[25][25]; // 两点间边权
int f[1 << 20][25]; // f[S][i]:走过集合 S、当前停在 i 的最短路
int main()
{
cin >> n;
for (int i = 0; i < n; i++)
for (int j = 0; j < n; j++)
cin >> w[i][j];
memset(f, 0x3f, sizeof f);
f[1][0] = 0; // 只到过点 0、停在 0,路程 0
for (int S = 1; S < (1 << n); S++) // 枚举集合(升序保证子集先算)
for (int i = 0; i < n; i++)
{
if (!(S >> i & 1)) continue; // i 必须已在集合内
if (f[S][i] == 0x3f3f3f3f) continue;
for (int j = 0; j < n; j++)
{
if (S >> j & 1) continue; // ★j 必须尚未访问
int T = S | (1 << j); // 把 j 加入集合
f[T][j] = min(f[T][j], f[S][i] + w[i][j]);
}
}
cout << f[(1 << n) - 1][n - 1] << endl; // 走遍全集、停在点 n-1
return 0;
}#include <iostream>
#include <cmath>
#include <cstring>
#include <algorithm>
using namespace std;
int n;
double x[20], y[20];
double dist[20][20];
double f[1 << 16][16]; // 下标 0 代表原点 (0,0),1..n 为奶酪
int main()
{
cin >> n;
x[0] = y[0] = 0; // 原点当作第 0 个点
for (int i = 1; i <= n; i++)
cin >> x[i] >> y[i];
for (int i = 0; i <= n; i++)
for (int j = 0; j <= n; j++)
dist[i][j] = sqrt((x[i] - x[j]) * (x[i] - x[j])
+ (y[i] - y[j]) * (y[i] - y[j]));
int m = n + 1; // 连原点共 m 个点
for (int S = 0; S < (1 << m); S++)
for (int i = 0; i < m; i++) f[S][i] = 1e18;
f[1][0] = 0; // 从原点出发
for (int S = 1; S < (1 << m); S++)
for (int i = 0; i < m; i++)
{
if (!(S >> i & 1) || f[S][i] > 1e17) continue;
for (int j = 0; j < m; j++)
{
if (S >> j & 1) continue;
int T = S | (1 << j);
f[T][j] = min(f[T][j], f[S][i] + dist[i][j]);
}
}
double ans = 1e18;
for (int i = 1; i < m; i++) // 吃完所有奶酪,停哪都行
ans = min(ans, f[(1 << m) - 1][i]);
printf("%.2f\n", ans);
return 0;
}#include <iostream>
#include <cstring>
#include <algorithm>
using namespace std;
int n;
int w[25][25];
int f[1 << 20][25];
int main()
{
cin >> n;
for (int i = 0; i < n; i++)
for (int j = 0; j < n; j++)
cin >> w[i][j];
memset(f, 0x3f, sizeof f);
f[1][0] = 0; // 从 1 号城市(下标 0)出发
for (int S = 1; S < (1 << n); S++)
for (int i = 0; i < n; i++)
{
if (!(S >> i & 1) || f[S][i] == 0x3f3f3f3f) continue;
for (int j = 0; j < n; j++)
{
if (S >> j & 1) continue;
int T = S | (1 << j);
f[T][j] = min(f[T][j], f[S][i] + w[i][j]);
}
}
int ans = 0x3f3f3f3f;
for (int i = 1; i < n; i++) // ★闭环:末尾必须再回到起点 0
ans = min(ans, f[(1 << n) - 1][i] + w[i][0]);
cout << ans << endl;
return 0;
}
