问题引入
旅行商问题(TSP)是组合优化中最著名的问题之一:给定N个城市和两两之间的距离,求从起点出发经过所有城市恰好一次并返回起点的最短路径。该问题是NP-hard,但当N较小时(N≤20),可以使用状态压缩动态规划(简称状压DP)在O(N^2 * 2^N)时间内精确求解。本文用Java完整实现TSP的状压DP解法,并深入讲解子集枚举的高效位运算技巧。
算法原理
状态设计
用二进制数mask表示已访问的城市集合,第i位为1表示城市i已访问。dp[mask][i]表示从起点出发,已访问城市集合为mask,且当前位于城市i的最短路径长度。
状态转移方程:
dp[mask | (1 << j)][j] = min(dp[mask | (1 << j)][j], dp[mask][i] + dist[i][j])
其中j是mask中未访问的城市。
子集枚举优化
遍历所有状态时,只需枚举mask中包含起点的合法状态,并通过 submask = mask; submask = (submask - 1) & mask 技巧枚举子集。在TSP中,通过预计算每个mask中已设置的位数(popcount),可以快速判断状态是否合法。
完整Java实现
package algorithm.tsp;
import java.util.*;
/**
* 旅行商问题(TSP)求解器
* 使用状态压缩动态规划(状压DP)
*/
public class TSPBitmaskDP {
private final int n;
private final int[][] dist;
private final int INF = Integer.MAX_VALUE / 4;
private int[][] dp;
private int[][] parent; // 用于路径回溯
public TSPBitmaskDP(int[][] dist) {
this.n = dist.length;
this.dist = dist;
}
/**
* 求解TSP最短回路(起点固定为0号城市)
* @return 最短回路长度
*/
public int solve() {
int fullMask = (1 << n) - 1;
dp = new int[1 << n][n];
parent = new int[1 << n][n];
for (int[] row : dp) {
Arrays.fill(row, INF);
}
// 初始状态:从城市0出发
dp[1][0] = 0;
// 枚举所有状态
for (int mask = 1; mask <= fullMask; mask++) {
// 只处理包含起点(城市0)的状态
if ((mask & 1) == 0) continue;
for (int i = 0; i < n; i++) {
if (dp[mask][i] == INF) continue;
if ((mask & (1 << i)) == 0) continue; // i不在当前状态中
// 尝试前往未访问的城市j
for (int j = 0; j < n; j++) {
if ((mask & (1 << j)) != 0) continue; // j已访问
int newMask = mask | (1 << j);
int newCost = dp[mask][i] + dist[i][j];
if (newCost < dp[newMask][j]) {
dp[newMask][j] = newCost;
parent[newMask][j] = i;
}
}
}
}
// 找最短回路:从任意城市返回城市0
int minCost = INF;
int lastCity = -1;
for (int i = 1; i < n; i++) {
int cost = dp[fullMask][i] + dist[i][0];
if (cost < minCost) {
minCost = cost;
lastCity = i;
}
}
return minCost;
}
/**
* 获取最短路径(城市编号序列)
*/
public List<Integer> getPath() {
int fullMask = (1 << n) - 1;
int minCost = INF;
int lastCity = -1;
for (int i = 1; i < n; i++) {
int cost = dp[fullMask][i] + dist[i][0];
if (cost < minCost) {
minCost = cost;
lastCity = i;
}
}
List<Integer> path = new ArrayList<>();
int mask = fullMask;
int city = lastCity;
while (city != -1 && mask != 0) {
path.add(city);
int prev = parent[mask][city];
mask ^= (1 << city);
city = prev;
}
Collections.reverse(path);
path.add(0); // 返回起点
return path;
}
/**
* 扩展:求解从起点到终点的最短Hamilton路径(不返回起点)
*/
public int solvePath(int start, int end) {
int fullMask = (1 << n) - 1;
int[][] dpPath = new int[1 << n][n];
for (int[] row : dpPath) Arrays.fill(row, INF);
dpPath[1 << start][start] = 0;
for (int mask = 0; mask <= fullMask; mask++) {
if ((mask & (1 << start)) == 0) continue;
for (int i = 0; i < n; i++) {
if (dpPath[mask][i] == INF) continue;
if ((mask & (1 << i)) == 0) continue;
for (int j = 0; j < n; j++) {
if ((mask & (1 << j)) != 0) continue;
int newMask = mask | (1 << j);
dpPath[newMask][j] = Math.min(dpPath[newMask][j], dpPath[mask][i] + dist[i][j]);
}
}
}
return dpPath[fullMask][end];
}
/**
* 位运算子集枚举示例:枚举mask的所有非空子集
*/
public static void enumerateSubsets(int mask) {
System.out.println("枚举子集 of " + Integer.toBinaryString(mask) + ":");
for (int sub = mask; sub > 0; sub = (sub - 1) & mask) {
System.out.println(" " + Integer.toBinaryString(sub));
}
}
public static void main(String[] args) {
// 示例1:4个城市的距离矩阵
int[][] dist1 = {
{0, 10, 15, 20},
{10, 0, 35, 25},
{15, 35, 0, 30},
{20, 25, 30, 0}
};
TSPBitmaskDP tsp1 = new TSPBitmaskDP(dist1);
int cost1 = tsp1.solve();
System.out.println("最短回路长度: " + cost1);
System.out.println("路径: " + tsp1.getPath());
// 示例2:5个城市的随机距离矩阵(对称)
int[][] dist2 = {
{0, 2, 9, 10, 7},
{2, 0, 6, 4, 3},
{9, 6, 0, 8, 5},
{10, 4, 8, 0, 1},
{7, 3, 5, 1, 0}
};
TSPBitmaskDP tsp2 = new TSPBitmaskDP(dist2);
int cost2 = tsp2.solve();
System.out.println("\n5城市最短回路长度: " + cost2);
System.out.println("路径: " + tsp2.getPath());
// 示例3:Hamilton路径(不返回起点)
int pathCost = tsp2.solvePath(0, 4);
System.out.println("从城市0到城市4的Hamilton最短路径: " + pathCost);
// 子集枚举演示
System.out.println();
enumerateSubsets(0b1011);
}
}
复杂度分析
| 指标 | 暴力搜索 | 状压DP |
|---|---|---|
| 时间 | O(N!) | O(N^2 * 2^N) |
| 空间 | O(N) | O(N * 2^N) |
当N=20时,N! ≈ 2.4×10^18,而N^2 * 2^N ≈ 4.2×10^8,差距极为悬殊。状压DP的核心优势在于将指数级状态空间从N!降到2^N,通过子集位图避免了重复计算相同城市集合的不同访问顺序。
关键技巧总结
mask | (1 << j)是添加城市j到位图的标准写法mask & (1 << j)的结果非零表示城市j已在集合中(sub - 1) & mask是枚举mask所有非空子集的高效位运算技巧Integer.bitCount(mask)可快速计算集合大小(Java内置popcount)- 对于N>20的情况,状压DP空间会爆炸,此时需要改用启发式算法(如模拟退火、遗传算法)