n≤20 的 Hamilton 最短路径:用二进制集合表示已走点集做状压 DP,按集合大小递推出终点状态。
OJ: roj
题目 ID: 3003
难度:普及+/提高-
标签:动态规划状态压缩DP位运算python
创建: 2026-10-01 09:19
更新: 2026-10-07 13:50
形式化题目
给定
求从点
样例:
正解
思路
朴素想法:Hamilton 路径是点
关键观察(状态只需要 “走过哪些点 + 现在停在哪”):一条路径走到任意时刻,"接下来怎么走最优"只取决于两件事——
- 哪些点已经走过(剩下的点只能从中选);
- 当前停在哪个点。
至于是按什么顺序走到当前点的,不影响未来(路径里不许再走已走过的点,历史顺序被"已走集合"完全覆盖)。于是定义状态
转移:
边界是
为什么按整数大小枚举
DP 表(样例 )
下表按集合大小分层列出关键状态的
| 集合大小 | 已走集合,终点 |
转移来源 | |
|---|---|---|---|
| 1 | 0 | 起点 | |
| 2 | 2 (0) | ||
| 2 | 4 (0) | ||
| 2 | 5 (0) | ||
| 2 | 1 (0) | ||
| 3 | 8 (1) | ||
| 3 | 10 (2) | ||
| 3 | 5 (1) | ||
| 3 | 4 (4) | ||
| 3 | 7 (2) | ||
| 4 | 15 (3) | ||
| 4 | 17 (3) | ||
| 4 | 8 (4) | ||
| 4 | 11 (2) | ||
| 5 | 15 (4) | ||
| 5 | 15 (1) | ||
| 5 | 17 (3) | ||
| 5 | 18 (2),即答案 |
这张表有两处值得停下来看。其一在第三层:
沿最后一行
代码
Python 版:
#!/usr/bin/env python3
# Author by Rainboy blog: https://rainboylv.com github: https://github.com/rainboylvx
# rbook: -> https://rbook.roj.ac.cn https://rbook2.roj.ac.cn
# rainboy的学习导航网站: https://idx.roj.ac.cn
# create_at: 2026-10-01 09:19
# update_at: 2026-10-01 09:23
import sys
INF = 1 << 60 # 不可达哨兵:远大于任何合法路径长(最多 19 * 10^7)
def set_bits(mask: int) -> list[int]:
"""列出 mask 中为 1 的点号,升序。"""
return [k for k in range(20) if mask >> k & 1]
def hamilton_shortest(n: int, a: list[list[int]]) -> int:
"""返回从点 0 出发、不重不漏经过所有点、终点为 n-1 的最短路径长。
状态 dp[s][j]:走过的点集为 s(一定含点 0)、终点为 j 的最短路径长。
转移:s 的最后一个点是 j,去掉 j 后终点为 k,即 dp[s][j] = min(dp[s^1<<j][k] + a[k][j])。
s 按数值从小到大枚举时,s ^ (1 << j) < s,保证前驱已算好。
"""
# col[j][k] = a[k][j]:转移固定终点 j、枚举前驱 k,取的是 a 的第 j 列
col = [[a[k][j] for k in range(n)] for j in range(n)]
# 只枚举含点 0 的奇数集合,dp 用 mask >> 1 做下标(省一半状态)
size = 1 << (n - 1)
bits = [set_bits((h << 1) | 1) for h in range(size)] # bits[h]:集合 (h<<1)|1 中的点号
dp = [[INF] * n for _ in range(size)]
dp[0][0] = 0 # 集合 {0},停在起点
for h in range(size):
mask = (h << 1) | 1
row = dp[h]
# j 是"最后一个到达的点";j = 0 时集合里只有起点、没有前驱,跳过
for j in bits[h][1:]:
sub = (mask ^ (1 << j)) >> 1 # 去掉点 j 后的点集(半掩码下标)
prev = dp[sub]
crow = col[j]
# 前驱 k 必须在去掉 j 的集合里,取 k -> j 一步后的最小值
row[j] = min(prev[k] + crow[k] for k in bits[sub])
return dp[size - 1][n - 1]
def solve() -> None:
data = iter(map(int, sys.stdin.buffer.read().split()))
n = next(data)
a = [[next(data) for _ in range(n)] for _ in range(n)] # 邻接矩阵,a[i][j] = 点 i 到 j 的距离
print(hamilton_shortest(n, a))
if __name__ == "__main__":
solve()C++ 版(同一算法):
/**
* Author by Rainboy blog: https://rainboylv.com github: https://github.com/rainboylvx
* rbook: -> https://rbook.roj.ac.cn https://rbook2.roj.ac.cn
* rainboy的学习导航网站: https://idx.roj.ac.cn
* create_at: 2026-10-06 13:35
* update_at: 2026-10-06 13:35
*/
#include <bits/stdc++.h>
using namespace std;
typedef long long ll;
const int MAXN = 20;
const int MAXS = 1 << MAXN; // 点集总数 2^n,n <= 20
int n;
ll a[MAXN][MAXN]; // a[i][j]:点 i 到点 j 的距离
ll f[MAXS][MAXN]; // f[s][j]:走过点集 s(一定含点 0)、当前停在点 j 的最短路径长
int main() {
scanf("%d", &n);
for (int i = 0; i < n; i++)
for (int j = 0; j < n; j++)
scanf("%lld", &a[i][j]);
// 初始化为不可达;边界:只走过起点 {0}、停在起点,代价 0
memset(f, 0x3f, sizeof(f));
f[1][0] = 0;
// s 按二进制数值从小到大枚举:转移的前驱集合 s^(1<<j) < s,保证已算好
for (int s = 1; s < (1 << n); s++) {
if (!(s & 1)) continue; // 路径一定从点 0 出发,集合必须含点 0
for (int j = 0; j < n; j++) {
if (!((s >> j) & 1)) continue; // j 必须已在集合里
if (f[s][j] == 0x3f3f3f3f3f3f3f3f) continue;
// 枚举下一个要走的点 k(当前不在集合里),扩展路径
for (int k = 0; k < n; k++) {
if ((s >> k) & 1) continue;
int ns = s | (1 << k);
ll cand = f[s][j] + a[j][k];
if (cand < f[ns][k]) f[ns][k] = cand;
}
}
}
// 答案:走过全部点、恰好停在终点 n-1
printf("%lld\n", f[(1 << n) - 1][n - 1]);
return 0;
}复杂度
- 时间复杂度:状态
个,每个状态转移枚举 个前驱,共 。 时约 次基本运算,C++ 轻松通过;Python 常数大,本地实测最坏数据约 s(题面限时 s,本地校验按惯例放宽 10 倍),算法阶数与正解一致。 - 空间复杂度:
存 DP 表, 时约 个整数,本题实测峰值内存约 MB(Python 每个整数对象都有额外开销,是主因;C++ 用 int二维数组只需约MB),校验时允许 Python 超限,不计入正确性判定。
总结
- 朴素枚举
排列不可行;瓶颈在于同一点集的重复计算。 - 状压 DP 用"二进制集合 + 终点"作为状态:
表示走过集合 、停在 的最短路,转移枚举前驱 。 - 按集合的二进制数值从小到大递推,天然保证
先于 算好,无需拓扑排序。 - 答案固定为
;终点必须进状态,因为"最短已走集合"不带终点无法拼接。 - 本题的模型(“集合 + 最后位置”)是状压 DP 的通用模板,同样适用于旅行商、棋盘覆盖等
级别的问题。