旅行商问题(TSP)- 状态压缩DP

📌 定义

旅行商问题(Traveling Salesman Problem, TSP):给定 n 个城市和它们之间的距离,求访问所有城市恰好一次并返回起点的最短路径。

示例:
城市: 0, 1, 2, 3
距离矩阵:
     0   1   2   3
0 [  0  10  15  20 ]
1 [ 10   0  35  25 ]
2 [ 15  35   0  30 ]
3 [ 20  25  30   0 ]

最短路径: 0 → 1 → 3 → 2 → 0
总距离: 10 + 25 + 30 + 15 = 80

核心思路

使用状态压缩动态规划:

  • 状态表示:用 n 位二进制数表示访问城市的集合
  • DP定义:dp[mask][i] = 从起点出发,访问了 mask 中的城市,当前在城市 i 的最短路径
  • 状态转移:枚举下一个要访问的城市
状态压缩示例(4个城市):
mask = 1011 (二进制)
  表示访问了城市 0, 1, 3
  第0位=1: 访问了城市0 ✓
  第1位=1: 访问了城市1 ✓
  第2位=0: 未访问城市2
  第3位=1: 访问了城市3 ✓

复杂度分析

方法时间复杂度空间复杂度说明
暴力枚举O(n!)O(n)枚举所有排列
状态压缩DPO(n² × 2^n)O(n × 2^n)实际可行的最优解
近似算法O(n²) ~ O(n³)O(n)不保证最优

Go 代码

Go 实现

package main
 
import (
    "fmt"
    "math"
)
 
func tsp(dist [][]int) int {
    n := len(dist)
    INF := math.MaxInt32 / 2
 
    // dp[mask][i]
    dp := make([][]int, 1<<n)
    for i := range dp {
        dp[i] = make([]int, n)
        for j := range dp[i] {
            dp[i][j] = INF
        }
    }
 
    // 初始状态
    dp[1][0] = 0
 
    // 状态转移
    for mask := 1; mask < (1 << n); mask++ {
        for i := 0; i < n; i++ {
            if (mask & (1 << i)) == 0 {
                continue
            }
            if dp[mask][i] == INF {
                continue
            }
 
            for j := 0; j < n; j++ {
                if (mask & (1 << j)) != 0 {
                    continue
                }
 
                nextMask := mask | (1 << j)
                dp[nextMask][j] = min(
                    dp[nextMask][j],
                    dp[mask][i]+dist[i][j],
                )
            }
        }
    }
 
    // 计算结果
    finalMask := (1 << n) - 1
    result := INF
 
    for i := 1; i < n; i++ {
        result = min(result, dp[finalMask][i]+dist[i][0])
    }
 
    return result
}
 
func min(a, b int) int {
    if a < b {
        return a
    }
    return b
}
 
func main() {
    dist := [][]int{
        {0, 10, 15, 20},
        {10, 0, 35, 25},
        {15, 35, 0, 30},
        {20, 25, 30, 0},
    }
 
    result := tsp(dist)
    fmt.Printf("最短路径长度: %d\n", result)
}

思路展开

状态压缩详解

4个城市,mask用4位二进制表示:
mask = 1011

位    | 3 | 2 | 1 | 0
城市  | 3 | 2 | 1 | 0
值    | 1 | 0 | 1 | 1

含义: 访问了城市 0, 1, 3

状态转移详解

dp[mask][i] = 访问了mask中的城市,当前在城市i的最短路径

转移方程:
dp[mask | (1<<j)][j] = min(
    dp[mask | (1<<j)][j],
    dp[mask][i] + dist[i][j]
)

从城市i转移到城市j:
1. mask中包含i(已访问)
2. mask中不包含j(未访问)
3. 将j加入访问集合
4. 更新到达j的最短距离

完整示例

4个城市,dist矩阵:
     0   1   2   3
0 [  0  10  15  20 ]
1 [ 10   0  35  25 ]
2 [ 15  35   0  30 ]
3 [ 20  25  30   0 ]

=== 初始化 ===
dp[0001][0] = 0  (只访问城市0)

=== mask=0001 (只有城市0) ===
当前城市i=0,可以去城市1,2,3:
  dp[0011][1] = dp[0001][0] + dist[0][1] = 0 + 10 = 10
  dp[0101][2] = dp[0001][0] + dist[0][2] = 0 + 15 = 15
  dp[1001][3] = dp[0001][0] + dist[0][3] = 0 + 20 = 20

=== mask=0011 (访问了0,1) ===
当前城市i=1,可以去城市2,3:
  dp[0111][2] = dp[0011][1] + dist[1][2] = 10 + 35 = 45
  dp[1011][3] = dp[0011][1] + dist[1][3] = 10 + 25 = 35

=== mask=0101 (访问了0,2) ===
当前城市i=2,可以去城市1,3:
  dp[0111][1] = dp[0101][2] + dist[2][1] = 15 + 35 = 50
  dp[1101][3] = dp[0101][2] + dist[2][3] = 15 + 30 = 45

...(继续直到访问所有城市)

=== mask=1111 (所有城市) ===
从城市1,2,3返回城市0:
  result = min(
    dp[1111][1] + dist[1][0],  # 从1回0
    dp[1111][2] + dist[2][0],  # 从2回0
    dp[1111][3] + dist[3][0]   # 从3回0
  )

经典题目

LeetCode 问题

  • 旅行商问题 - LeetCode 943(最短超级串,类似TSP)
  • 访问所有节点的最短路径 - LeetCode 847
  • 最小化旅行的最大时间 - LeetCode 2065

TSP变体

  • 对称TSP vs 非对称TSP
  • 多旅行商问题(mTSP)
  • 带时间窗的TSP

⚖️ 优缺点

优点

  • ✅ 保证最优解:动态规划找到全局最优
  • ✅ 可处理中等规模:n ≤ 20 可以接受
  • ✅ 可扩展:容易修改处理变体问题

缺点

  • ❌ 指数级复杂度:n > 20 不可行
  • ❌ 空间消耗大:需要 O(n × 2^n) 空间
  • ❌ 大规模问题:需要使用近似算法

🎨 应用场景

  • 路线规划:城市、仓库或服务节点数量较少时,求访问全部节点的最短闭环。
  • 任务访问顺序:把“已完成任务集合 + 当前任务”作为状态,求最优执行顺序。
  • 小规模全排列优化:当 n 不大但暴力排列重复计算严重时,用状态压缩保存公共子问题。

💡 优化技巧

  • 遍历未访问城市时,用 remaining := fullMask ^ mask 取出候选集合,再用 bit := remaining & -remaining 每次删除一个最低位。
  • 对称距离矩阵可以固定起点,并利用反向路径等价性减少重复状态。
  • 当 n 超过状态压缩 DP 的适用范围,改用分支限界或近似算法,不要硬撑 O(n2^n)。

💡 近似算法

  • 最近邻:每次走向最近的未访问城市,速度快但不保证最优。
  • 最小生成树加倍:先求 MST,再通过遍历顺序构造一条可行回路,适合需要快速得到近似解的场景。
  • 局部搜索:对已有路线尝试 2-opt 等交换,持续消除明显的交叉和冗余。

相关主题


返回:位运算 | 算法学习导航