最短Hamilton路径

GitHub跳转原题关系图返回列表

n≤20 的 Hamilton 最短路径:用二进制集合表示已走点集做状压 DP,按集合大小递推出终点状态。

OJ: roj

题目 ID: 3003

难度:普及+/提高-

标签:动态规划状态压缩DP位运算python

创建: 2026-10-01 09:19

更新: 2026-10-07 13:50

形式化题目

给定 nn 个点的带权无向完全图,点从 00 到 n−1n-1 标号,第 ii 行第 jj 列的整数 a[i,j]a[i,j] 表示点 ii 到点 jj 的距离(保证 a[x,x]=0a[x,x]=0、a[x,y]=a[y,x]a[x,y]=a[y,x],且距离满足 a[x,y]+a[y,z]⩾a[x,z]a[x,y]+a[y,z] \geqslant a[x,z])。

求从点 00 出发、到点 n−1n-1 结束、不重不漏地经过每个点恰好一次的路径中,总长度最短的那条的最短长度。

样例:n=5n=5,一条最短 Hamilton 路径为 0→1→3→2→40 \to 1 \to 3 \to 2 \to 4,长度 2+5+8+3=182+5+8+3=18。

正解

思路

朴素想法:Hamilton 路径是点 00 到点 n−1n-1 的一个全排列,暴力枚举全部 n!n! 个排列逐个求和取最小。n=20n = 20 时 20!≈2.4×101820! \approx 2.4 \times 10^{18},完全不可行。瓶颈在于:两个排列如果前半段走的是同一批点,朴素做法也会把它们当成两个毫无关系的方案从头算一遍。

关键观察(状态只需要 “走过哪些点 + 现在停在哪”):一条路径走到任意时刻,"接下来怎么走最优"只取决于两件事——

  1. 哪些点已经走过(剩下的点只能从中选);
  2. 当前停在哪个点。

至于是按什么顺序走到当前点的,不影响未来(路径里不许再走已走过的点,历史顺序被"已走集合"完全覆盖)。于是定义状态

f(s,j)=走过的点集恰为 s(一定含点 0)、当前停在点 j 的最短路径长f(s, j) = \text{走过的点集恰为 } s \text{(一定含点 } 0\text{)、当前停在点 } j \text{ 的最短路径长}

n⩽20n \leqslant 20,"走过哪些点"可以用一个整数的二进制位压缩表示:第 kk 位是 11 表示点 kk 已走过。状态总数 2n×n≈2×1072^n \times n \approx 2 \times 10^7,可以接受。

转移:f(s,j)f(s, j) 的最后一步必然是从某个已走过的点 kk 走到 jj,去掉 jj 之后点集变成 s∖{j}s \setminus \{j\},于是

f(s,j)=min⁡k∈s∖{j}{f(s∖{j}, k)+a[k][j]}f(s, j) = \min_{k \in s \setminus \{j\}} \left\{ f(s \setminus \{j\},\, k) + a[k][j] \right\}

边界是 f({0},0)=0f(\{0\}, 0) = 0,答案是 f(全部点, n−1)f(\text{全部点},\, n-1)。

为什么按整数大小枚举 ss 一定可行? 转移用到的集合 s∖{j}s \setminus \{j\} 是 ss 去掉一个二进制位,数值必然严格小于 ss。所以外层按 ss 从小到大循环(即整数 0,1,2,…0, 1, 2, \dots 顺序)时,每个状态的所有前驱都已经在之前算好了,不需要额外的拓扑排序,这也正是"集合状压 + 按数值递推"的标准套路。

DP 表(样例 n=5n=5)

下表按集合大小分层列出关键状态的 ff 值:"转移来源"一列写出最优前驱 kk 及对应转移式,终点加粗的是参与最终答案的分支:

集合大小 已走集合,终点 jj ff 值 (前驱 kk) 转移来源
1 {0}\{0\},终点 0 0 起点
2 {0,1}\{0,1\},终点 1 2 (0) f({0},0)+a[0][1]f(\{0\},0)+a[0][1]
2 {0,2}\{0,2\},终点 2 4 (0) f({0},0)+a[0][2]f(\{0\},0)+a[0][2]
2 {0,3}\{0,3\},终点 3 5 (0) f({0},0)+a[0][3]f(\{0\},0)+a[0][3]
2 {0,4}\{0,4\},终点 4 1 (0) f({0},0)+a[0][4]f(\{0\},0)+a[0][4]
3 {0,1,2}\{0,1,2\},终点 2 8 (1) f({0,1},1)+a[1][2]=2+6f(\{0,1\},1)+a[1][2]=2+6
3 {0,1,2}\{0,1,2\},终点 1 10 (2) f({0,2},2)+a[2][1]=4+6f(\{0,2\},2)+a[2][1]=4+6
3 {0,1,4}\{0,1,4\},终点 4 5 (1) f({0,1},1)+a[1][4]=2+3f(\{0,1\},1)+a[1][4]=2+3
3 {0,2,4}\{0,2,4\},终点 2 4 (4) f({0,4},4)+a[4][2]=1+3f(\{0,4\},4)+a[4][2]=1+3
3 {0,2,4}\{0,2,4\},终点 4 7 (2) f({0,2},2)+a[2][4]=4+3f(\{0,2\},2)+a[2][4]=4+3
4 {0,1,2,3}\{0,1,2,3\},终点 2 15 (3) f({0,1,3},3)+a[3][2]=7+8f(\{0,1,3\},3)+a[3][2]=7+8
4 {0,1,2,3}\{0,1,2,3\},终点 1 17 (3) f({0,2,3},3)+a[3][1]=12+5f(\{0,2,3\},3)+a[3][1]=12+5
4 {0,1,2,4}\{0,1,2,4\},终点 2 8 (4) f({0,1,4},4)+a[4][2]=5+3f(\{0,1,4\},4)+a[4][2]=5+3
4 {0,1,2,4}\{0,1,2,4\},终点 4 11 (2) f({0,1,2},2)+a[2][4]=8+3f(\{0,1,2\},2)+a[2][4]=8+3
5 {0,1,2,3,4}\{0,1,2,3,4\},终点 2 15 (4) f({0,1,3,4},4)+a[4][2]=12+3f(\{0,1,3,4\},4)+a[4][2]=12+3
5 {0,1,2,3,4}\{0,1,2,3,4\},终点 3 15 (1) f({0,1,2,4},1)+a[1][3]=10+5f(\{0,1,2,4\},1)+a[1][3]=10+5
5 {0,1,2,3,4}\{0,1,2,3,4\},终点 1 17 (3) f({0,2,3,4},3)+a[3][1]=12+5f(\{0,2,3,4\},3)+a[3][1]=12+5
5 {0,1,2,3,4}\{0,1,2,3,4\},终点 4 18 (2),即答案 f({0,1,2,3},2)+a[2][4]=15+3f(\{0,1,2,3\},2)+a[2][4]=15+3

这张表有两处值得停下来看。其一在第三层:f({0,2,4},2)=4f(\{0,2,4\},2)=4 走的是 0→4→20 \to 4 \to 2,而"经点 1、绕开点 4"的 f({0,1,2},2)=8f(\{0,1,2\},2)=8 贵了一倍——样例里点 4 离起点特别近,先绕去点 4 反而划算,这种"局部看起来绕路、全局更短"的取舍正是 DP 替代贪心的意义。其二在最后一层:终点 2、终点 3 的 ff 值只有 1515,都比 1818 小,但题目规定终点必须是 n−1=4n-1=4,所以它们不能作为答案。这正是把终点 jj 放进状态、而不是只记一个"已走集合的最短值"的原因:不记录停在哪,既无法拼出下一步的转移,也无法在最后挑出"恰好停在 n−1n-1"的那条。

沿最后一行 f(全部,4)=18f(\text{全部},4)=18 的前驱 k=2k=2 一路回溯,就还原出路径 0→1→3→2→40 \to 1 \to 3 \to 2 \to 4,长度 2+5+8+3=182+5+8+3=18,与样例输出一致。

代码

Python 版:

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++ 版(同一算法):

cpp
/**
 * 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;
}

复杂度

  • 时间复杂度:状态 2n×n2^n \times n 个,每个状态转移枚举 nn 个前驱,共 O(2nn2)O(2^n n^2)。n=20n = 20 时约 4×1084 \times 10^8 次基本运算,C++ 轻松通过;Python 常数大,本地实测最坏数据约 3.13.1s(题面限时 11s,本地校验按惯例放宽 10 倍),算法阶数与正解一致。
  • 空间复杂度:O(2nn)O(2^n n) 存 DP 表,n=20n = 20 时约 2×1072 \times 10^7 个整数,本题实测峰值内存约 400400MB(Python 每个整数对象都有额外开销,是主因;C++ 用 int 二维数组只需约 8080MB),校验时允许 Python 超限,不计入正确性判定。

总结

  • 朴素枚举 n!n! 排列不可行;瓶颈在于同一点集的重复计算。
  • 状压 DP 用"二进制集合 + 终点"作为状态:f(s,j)f(s,j) 表示走过集合 ss、停在 jj 的最短路,转移枚举前驱 kk。
  • 按集合的二进制数值从小到大递推,天然保证 s∖{j}s \setminus \{j\} 先于 ss 算好,无需拓扑排序。
  • 答案固定为 f(全部点,n−1)f(\text{全部点}, n-1);终点必须进状态,因为"最短已走集合"不带终点无法拼接。
  • 本题的模型(“集合 + 最后位置”)是状压 DP 的通用模板,同样适用于旅行商、棋盘覆盖等 n⩽20n \leqslant 20 级别的问题。