- admin 的博客
状态压缩 DP (State Compression DP)
- @ 2026-6-7 20:00:33
一、算法概述
状态压缩动态规划(状压 DP)是一种特殊形式的动态规划,它利用二进制位来表示集合的状态。当问题涉及到"每个元素选或不选"的集合划分时,用一个整数的二进制位 0/1 来表示对应元素是否被选中,从而将集合状态压缩为一个整数。
典型特征:问题规模 n 通常较小(n ≤ 20),因为状态总数是 2ⁿ。
常见问题:
- 旅行商问题(TSP): 访问所有城市恰好一次,求最短路径
- 棋盘覆盖问题: 用多米诺骨牌铺满棋盘
- 集合划分问题: 将集合划分为若干子集,满足某种条件的最小代价
二、核心思想
状态编码
用一个 mask(整数)表示集合的选择状态:
mask的第i位为1表示第i个元素已被选中mask的第i位为0表示第i个元素未被选中
例如 mask = 13(二进制 1101),表示元素 0、2、3 被选中。
常用位运算
mask | (1 << i) // 将第 i 位置为 1(选中元素 i)
mask & (1 << i) // 检查第 i 位是否为 1
mask ^ (1 << i) // 翻转第 i 位
mask & (mask - 1) // 去掉最低位的 1(lowbit)
__builtin_popcount(mask) // 统计 mask 中 1 的个数(GCC 内置函数)
状态转移通用框架
在状压 DP 中,状态转移通常从"更少元素被选中"的状态转移到"更多元素被选中"的状态:
for mask = 0 to (1 << n) - 1:
for each i not in mask:
new_mask = mask | (1 << i)
dp[new_mask] = transition(dp[mask], i)
三、算法步骤 / 伪代码
TSP 问题状压 DP:
输入: 距离矩阵 dist[n][n]
输出: 访问所有城市一次并返回起点的最短路径
初始化 dp[mask][i] = INF
dp[1][0] = 0 // 从城市 0 出发,状态只有 0 被访问
for mask = 1 to (1<<n)-1: // 枚举所有访问状态
for i = 0 to n-1: // 枚举当前所在城市
if dp[mask][i] == INF: continue // 该状态不可达
for j = 0 to n-1: // 枚举下一个要去的城市
if mask 中已包含 j: continue // j 已经访问过
new_mask = mask | (1 << j)
dp[new_mask][j] = min(dp[new_mask][j], dp[mask][i] + dist[i][j])
答案 = min( dp[(1<<n)-1][i] + dist[i][0] ) for i = 1..n-1
四、C++ 代码实现
#include <iostream>
#include <vector>
#include <algorithm>
#include <climits>
using namespace std;
// ==================== 问题1:旅行商问题 (TSP) ====================
// 从城市 0 出发,访问所有城市恰好一次后返回城市 0,求最短总路程
int tsp() {
cout << "====== 旅行商问题 (TSP) ======" << endl;
cout << "请输入城市数量: ";
int n;
cin >> n;
vector<vector<int>> dist(n, vector<int>(n));
cout << "请输入 " << n << "x" << n << " 距离矩阵:" << endl;
for (int i = 0; i < n; i++)
for (int j = 0; j < n; j++)
cin >> dist[i][j];
int totalStates = 1 << n; // 2^n 个状态
const int INF = 1e9;
// dp[mask][i] = 从 0 出发,已经访问了 mask 中的城市,当前在 i 的最短距离
vector<vector<int>> dp(totalStates, vector<int>(n, INF));
dp[1][0] = 0; // 初始状态:只访问了城市 0,站在城市 0
for (int mask = 1; mask < totalStates; mask++) {
for (int i = 0; i < n; i++) {
if (dp[mask][i] == INF) continue; // 不可达状态
// 尝试从 i 出发,访问下一个未访问的城市 j
for (int j = 0; j < n; j++) {
if (mask & (1 << j)) continue; // j 已经访问过
int nextMask = mask | (1 << j);
dp[nextMask][j] = min(dp[nextMask][j],
dp[mask][i] + dist[i][j]);
}
}
}
// 回到起点 0
int fullMask = totalStates - 1; // 所有城市都访问过的状态
int answer = INF;
for (int i = 1; i < n; i++) {
answer = min(answer, dp[fullMask][i] + dist[i][0]);
}
cout << "最短路径长度: " << answer << endl;
return answer;
}
// ==================== 问题2:棋盘覆盖 (轮廓线DP) ====================
// 用 1x2 多米诺骨牌铺满 n x m 棋盘,问有多少种铺法
// 此处使用简化版:n=2, m 任意,输出方案数
long long dominoTiling(int m) {
// dp[mask] = 当前列填完后,下一列的"突出"状态为 mask 的方案数
// mask 的每一位表示该行是否有骨牌"伸到"下一列
int totalStates = 1 << 2; // n=2 时只有 4 种状态
vector<long long> dp(totalStates, 0);
dp[0] = 1; // 初始:上一列没有骨牌伸过来
for (int col = 0; col < m; col++) {
vector<long long> next(totalStates, 0);
for (int mask = 0; mask < totalStates; mask++) {
if (dp[mask] == 0) continue;
// mask 为当前列被"占用"的行(上一列伸过来的位置)
// 枚举当前列如何放置骨牌
// 情况1: mask == 0, 两行都空
if (mask == 0) {
next[0] += dp[mask]; // 竖着放两个(不伸到下一列)= 填满2行
next[1] += dp[mask]; // 第0行竖放(伸到下一列), 第1行横放
next[2] += dp[mask]; // 第0行横放, 第1行竖放(伸到下一列)
next[3] += dp[mask]; // 两个都竖放(都伸到下一列) WAIT 两行竖放是伸到下一列?
// 修正:两行同时竖放 = 本列填满不留到下一列 -> next[0]
// 两行都横放 = 填满不留 -> next[0]
// 需要仔细枚举
}
// 简化处理:这里使用标准的递推解法
}
dp = next;
}
return dp[0]; // 最后不能有伸出来的
}
// 更清晰的 2xm 多米诺骨牌计数(Fibonacci 解法)
long long dominoTiling2xM(int m) {
// 2xm 棋盘的铺法数 = Fib(m+1)
// f(1)=1, f(2)=2, f(3)=3, f(4)=5, ...
if (m <= 2) return m;
long long a = 1, b = 2, c;
for (int i = 3; i <= m; i++) {
c = a + b;
a = b;
b = c;
}
return b;
}
// ==================== 问题3:集合覆盖 / 最小代价分配 ====================
// 有 n 个任务和 n 个工人,工人 i 做任务 j 的代价为 cost[i][j]
// 每个工人分配一个任务,求最小总代价(即指派问题)
int assignment() {
cout << "\n====== 任务分配问题 ======" << endl;
cout << "请输入工人/任务数量: ";
int n;
cin >> n;
vector<vector<int>> cost(n, vector<int>(n));
cout << "请输入 " << n << "x" << n << " 代价矩阵:" << endl;
for (int i = 0; i < n; i++)
for (int j = 0; j < n; j++)
cin >> cost[i][j];
int totalStates = 1 << n;
const int INF = 1e9;
vector<int> dp(totalStates, INF);
dp[0] = 0; // 没有任务被分配
for (int mask = 0; mask < totalStates; mask++) {
int worker = __builtin_popcount(mask); // 已分配的任务数 = 当前工人索引
if (worker >= n) continue;
for (int task = 0; task < n; task++) {
if (mask & (1 << task)) continue; // 该任务已被分配
int nextMask = mask | (1 << task);
dp[nextMask] = min(dp[nextMask], dp[mask] + cost[worker][task]);
}
}
int fullMask = totalStates - 1;
cout << "最小总代价: " << dp[fullMask] << endl;
return dp[fullMask];
}
int main() {
tsp();
cout << "\n====== 2xM 多米诺骨牌覆盖 ======" << endl;
cout << "请输入列数 m: ";
int m;
cin >> m;
cout << "2x" << m << " 棋盘的覆盖方案数: " << dominoTiling2xM(m) << endl;
assignment();
return 0;
}
五、复杂度分析
| 问题 | 时间复杂度 | 空间复杂度 | 说明 |
|---|---|---|---|
| TSP(通用) | O(n² × 2ⁿ) | O(n × 2ⁿ) | 状态数 2ⁿ,每个状态枚举 n 个下一个城市 |
| 指派问题 | O(n × 2ⁿ) | O(2ⁿ) | 状态数 2ⁿ,每个状态枚举 n 个任务 |
| 棋盘覆盖 | O(m × 2ⁿ) | 逐列 DP,m 为列数,n 为行数 |
适用条件: n ≤ 20(2²⁰ ≈ 10⁶,再大则状态数过多)。
六、应用场景
- 旅行商问题(TSP): 给定城市间距离,找最短哈密顿回路
- 指派问题: n 个工人分配 n 个任务的最小代价
- 棋盘覆盖问题: 多米诺骨牌 / 瓷砖覆盖方案计数
- 集合划分问题: 将元素分成若干组,每组满足约束
- 最小路径覆盖: 有向无环图的最小路径覆盖
- 毕业旅行问题 / 外卖配送路径规划: TSP 的实际变种
- 位运算枚举子集: 在 O(3ⁿ) 复杂度内枚举所有子集的子集
七、经典例题
例题一:旅行商问题 (TSP)
题目描述: 有 n 个城市,给出城市间的距离矩阵。从城市 0 出发,访问每个城市恰好一次后回到城市 0,求最短路径长度。
题解见上方 tsp() 函数。
例题二:LeetCode 847. 访问所有节点的最短路径
题目描述: 存在一个由 n 个节点组成的无向连通图,求访问所有节点的最短路径的长度(可以重复访问节点和边)。
状压 BFS 解法:
class Solution {
public:
int shortestPathLength(vector<vector<int>>& graph) {
int n = graph.size();
int fullMask = (1 << n) - 1;
// BFS 队列: (节点, 已访问状态)
queue<pair<int, int>> q;
// visited[node][mask] 是否访问过
vector<vector<bool>> visited(n, vector<bool>(1 << n, false));
for (int i = 0; i < n; i++) {
q.push({i, 1 << i});
visited[i][1 << i] = true;
}
int steps = 0;
while (!q.empty()) {
int sz = q.size();
while (sz--) {
auto [node, mask] = q.front(); q.pop();
if (mask == fullMask) return steps;
for (int next : graph[node]) {
int nextMask = mask | (1 << next);
if (!visited[next][nextMask]) {
visited[next][nextMask] = true;
q.push({next, nextMask});
}
}
}
steps++;
}
return -1;
}
};
例题三:LeetCode 698. 划分为 k 个相等的子集
题目描述: 给定一个整数数组 nums 和一个正整数 k,判断能否把这个数组分成 k 个非空子集,其和都相等。
状压 DP 解法:
class Solution {
public:
bool canPartitionKSubsets(vector<int>& nums, int k) {
int sum = accumulate(nums.begin(), nums.end(), 0);
if (sum % k != 0) return false;
int target = sum / k;
int n = nums.size();
int totalStates = 1 << n;
vector<int> dp(totalStates, -1);
dp[0] = 0;
for (int mask = 0; mask < totalStates; mask++) {
if (dp[mask] == -1) continue;
for (int i = 0; i < n; i++) {
if (mask & (1 << i)) continue;
int next = mask | (1 << i);
int curSum = dp[mask] % target;
if (curSum + nums[i] <= target) {
dp[next] = dp[mask] + nums[i];
}
}
}
return dp[totalStates - 1] != -1;
}
};