如果说普通DP是"用空间换时间"的优雅舞蹈,那么状态压缩DP就是"用二进制撬动组合爆炸"的暴力美学------它把看似不可能的集合枚举问题,压缩进了几个int变量里,让指数级复杂度在小数据范围内变得可行。
引言
你是一个旅行商,需要从家出发,恰好经过N个城市各一次,最后回到起点。N=5时,方案数有(5-1)!=24种,穷举没问题;但当N=15时,方案数暴增到87178291200种------穷举?这辈子都算不完。
但如果N≤20呢?我们有没有办法在1秒内算出答案?
这就是状态压缩DP(状压DP) 登场的舞台。它的核心思想极其暴力却又极其巧妙:用一个二进制整数表示"哪些元素已经被使用过",把集合状态压缩进一个数字里,然后在这个数字上进行DP。
"如果说集合是算法竞赛中的'百宝箱',那么状压DP就是给这个百宝箱装上了二进制'密码锁'------你只需要一个int,就能精准地控制箱子里每个元素的取舍状态。"
前置知识
在阅读本文之前,建议你熟悉以下概念:
-
动态规划(DP) :理解"最优子结构"和"重叠子问题"的基本思想。
-
位运算 :
&(与)、|(或)、^(异或)、<<(左移)、>>(右移)------状压DP的"基本功"。 -
集合与子集:理解用二进制位表示集合的方法(第i位为1表示元素i在集合中)。
-
旅行商问题(TSP) :经典的NP-hard问题,状压DP的"招牌应用题"。
-
时间复杂度分析:理解 O(n⋅2^n) 和 O(2^n) 级别的复杂度。
第一章:为什么需要状态压缩------从集合到整数
1.1 传统DP的"盲区"
普通的线性DP,状态通常是一维或二维的,比如dp[i]表示前i个物品的最优值,dp[i][j]表示第i行第j列的最优值。但有一类问题的状态天然是集合------"已经选过哪些元素"、"哪些城市已经被访问过"。集合无法用简单的整数下标表示,传统DP束手无策。
举个例子:有N个物品,每件选或不选,有 2N2N 种组合。如果N=20,220=1,048,576220=1,048,576,这个数量级完全可以在程序中枚举。但如何用数组下标来表示一个集合呢?
1.2 二进制的天然优势
答案就是二进制。一个N位的二进制数,每一位代表一个元素的状态:
-
第i位 = 1,表示元素i被选中/已访问/可用
-
第i位 = 0,表示元素i未被选中/未访问/不可用
这样一来,一个集合就变成了一个0到 2N−12N−1 之间的整数 。我们可以用dp[S]表示"集合S状态下的最优值",数组大小只需要 2N2N。
1.3 一个生活化类比
想象你有5个行李箱要打包上飞机,每个行李箱都有自己的重量和价值,但飞机只允许带总重量不超过W的行李。你想知道:哪些箱子组合价值最大?
-
不压缩:5个箱子,每个箱子"选/不选",穷举需要写5层循环。
-
压缩后:用一个5位二进制数00000~11111表示选择方案,第i位为1表示带第i个箱子。遍历这32个数字,每个数字就是一个完整的"选择方案"。
当N=20时,遍历100万个数字,现代计算机不到0.1秒。这就是状压的威力。
第二章:状压DP的"三板斧"
2.1 核心三要素
任何一个状压DP题目,都离不开这三个要素:
-
状态定义 :
dp[S],其中S是一个二进制整数,表示当前已选元素的集合。 -
状态转移 :从
dp[S]转移到dp[S | (1 << i)],即往集合S中加入一个新元素i。 -
初始状态和答案 :通常
dp[0] = 0,答案可能是dp[(1<<N)-1](全部选完)。
2.2 位运算操作速查表
| 操作 | 位运算表达式 | 含义 |
|---|---|---|
| 检查第i位是否为1 | S & (1 << i) |
判断元素i是否在集合S中 |
| 将第i位设为1 | `S | (1 << i)` |
| 将第i位设为0 | S & ~(1 << i) |
从集合S中移除元素i |
| 将第i位取反 | S ^ (1 << i) |
翻转元素i的状态 |
| 枚举S的子集 | for (sub = S; sub; sub = (sub-1) & S) |
枚举S的所有非空子集 |
| 去掉S的最低位的1 | S & (S - 1) |
常用于统计1的个数 |
2.3 转移的"起手式"
状压DP的转移通常有两种写法:
写法一:正向递推(推荐)
cpp
for (int S = 0; S < (1 << N); S++) {
if (dp[S] == INF) continue;
for (int i = 0; i < N; i++) {
if (!(S & (1 << i))) { // i不在S中
int nxt = S | (1 << i);
dp[nxt] = min(dp[nxt], dp[S] + cost[S][i]);
}
}
}
写法二:记忆化搜索
cpp
int dfs(int S) {
if (S == (1 << N) - 1) return 0;
if (dp[S] != -1) return dp[S];
int ans = INF;
for (int i = 0; i < N; i++) {
if (!(S & (1 << i))) {
ans = min(ans, dfs(S | (1 << i)) + cost[S][i]);
}
}
return dp[S] = ans;
}
两种写法本质相同,正向递推适合所有题目,记忆化搜索在转移条件复杂时更灵活。
第三章:实战前的理论准备------复杂度与优化
3.1 空间与时间的天花板
状压DP的复杂度通常是:
-
状态数:O(2N)O(2N)
-
转移数:每个状态枚举 O(N)O(N) 个未选元素
-
总复杂度:O(N⋅2N)O(N⋅2N)
当 N≤20N≤20 时,N⋅2N≈20×106N⋅2N≈20×106,完全可行。
当 N=25N=25 时,25×225≈8.4×10825×225≈8.4×108,已经接近极限。
当 N≥30N≥30 时,状压DP基本失效,需要考虑其他算法(如搜索+剪枝、启发式算法等)。
3.2 常见优化手段
-
预处理合法状态:很多题目中,不是所有 2N2N 个状态都合法。我们可以先DFS或枚举所有合法状态存入数组,只在这些状态上做DP。
-
预处理转移代价 :如果转移代价
cost[S][i]计算复杂,可以提前预处理成数组,避免在DP循环中重复计算。 -
滚动数组降维:如果只依赖上一层的状态,可以用滚动数组优化空间。
-
子集枚举优化 :某些题目需要枚举当前集合的子集,此时用
for (sub = S; sub; sub = (sub-1) & S)可以达到 O(3^N),但 NN 较小时可以接受。
3.3 什么时候用状压DP?
-
N ≤ 20(通常)
-
状态天然是"子集选择型"
-
每个元素的取舍或访问顺序对结果有影响
-
可以设计出清晰的"从子集到超集"的转移关系
第四章:经典例题精解------洛谷 P1171 售货员的难题
4.1 题目呈现
题目来源:洛谷 P1171 售货员的难题
题目描述 :
某乡有 nn 个村庄(1<n≤20),有一个售货员,他要到各个村庄去售货,各村庄之间的路程 ss 是已知的。售货员从村庄1出发,经过所有村庄恰好一次,最后回到村庄1。问:最短的行程是多少?
输入格式:
-
第一行:一个整数 n
-
接下来 n 行,每行 n 个整数,第 i 行第 j 列表示村庄 i 到村庄 j 的距离
输出格式:
- 一个整数,表示最短行程
输入样例:
3
0 2 1
1 0 2
2 1 0
输出样例:
3
4.2 解题思路推导
这是最经典的 TSP问题(旅行商问题) ,也是状压DP的"hello world"。
状态定义 :
dp[S][i] 表示:已经访问过的村庄集合为 S,当前所在村庄为 i 时,从村庄1出发到达此状态的最短路径长度。
初始状态 :
dp[1][0] = 0,即只访问了村庄1(下标为0),当前在村庄1,路程为0。
状态转移 :
从当前状态 (S, i) 出发,如果村庄 j 还没有被访问(!(S & (1<<j))),则可以走到 j:
dp[S | (1<<j)][j] = min(dp[S | (1<<j)][j], dp[S][i] + dist[i][j])
最终答案 :
访问完所有村庄后,还要回到起点村庄1:
ans = min(dp[(1<<n)-1][i] + dist[i][0]),其中 ii 是最后一个访问的村庄。
4.3 完整AC代码
#include <bits/stdc++.h>
using namespace std;
const int MAXN = 20;
const int INF = 0x3f3f3f3f;
int n;
int dist[MAXN][MAXN];
int dp[1 << MAXN][MAXN];
int main() {
ios::sync_with_stdio(false);
cin.tie(0);
cin >> n;
for (int i = 0; i < n; i++) {
for (int j = 0; j < n; j++) {
cin >> dist[i][j];
}
}
// 初始化:所有状态赋为无穷大
memset(dp, INF, sizeof(dp));
dp[1][0] = 0; // 起点:只访问了村庄0(即村庄1)
// 枚举所有状态
for (int S = 0; S < (1 << n); S++) {
// 如果当前状态不包含起点村庄0,则状态不合法,跳过
if (!(S & 1)) continue;
for (int i = 0; i < n; i++) {
// 如果i不在已访问集合中,或者dp[S][i]不可达,跳过
if (!(S & (1 << i))) continue;
if (dp[S][i] == INF) continue;
// 尝试从i走到下一个未访问的村庄j
for (int j = 0; j < n; j++) {
if (S & (1 << j)) continue; // j已经访问过了
int nxtS = S | (1 << j);
dp[nxtS][j] = min(dp[nxtS][j], dp[S][i] + dist[i][j]);
}
}
}
// 从任意最后访问的村庄回到起点0
int ans = INF;
for (int i = 0; i < n; i++) {
ans = min(ans, dp[(1 << n) - 1][i] + dist[i][0]);
}
cout << ans << "\n";
return 0;
}
4.4 代码详解与注意事项
第20行 :为什么只从S包含起点0的状态开始转移?因为售货员从村庄1出发,所有合法路径都必须包含起点。如果某个状态不包含起点,它不可能从起点转移而来,直接忽略可以节省时间。
第30行 :dp[1<<n - 1][i] 是访问完所有村庄后最后一个村庄为i的状态。要回到起点,需要加上dist[i][0]。
复杂度分析:
-
状态数:n⋅2^n=20×1,048,576=20,971,520
-
每个状态需要枚举下一个村庄 j,平均 n/2 次
-
总转移次数约 O(n^2⋅2^n)≈4×108 次,在 n=20 时稍显紧张。
-
优化:可以预处理每个状态中"最后一个访问的村庄",只枚举最后一个村庄 ii,减少一层循环。但上述代码已经能在1秒内通过洛谷数据。
4.5 空间优化:滚动数组(进阶)
如果 nn 接近25,dp[1<<n][n] 会爆内存。我们可以用滚动数组按状态数量分层 ,或者直接用 map / unordered_map 存储有效状态。
但在 n≤20n≤20 的场景下,dp[1<<20][20] = 20MB 左右,完全可接受。
第五章:状压DP的经典变种与拓展
5.1 变种一:求方案数(计数DP)
有些题目不要求最优值,而是问有多少种方案 。此时把min换成+=即可。
例题:POJ 2441 Arrange the Bulls
-
N头牛,M个谷仓,每头牛有自己喜欢的一些谷仓,求所有牛都分配到谷仓的方案数。
-
状态:
dp[S]表示谷仓占用状态为S时的方案数,第i头牛尝试分配到未被占用的喜欢谷仓。
5.2 变种二:二进制枚举子集(子集DP)
有些题目需要枚举"一个集合的所有子集",如集合划分问题。
例题:洛谷 P3052 USACO12MAR Cows in a Skyscraper
-
N个物品,每个物品有重量,电梯有最大载重W,求最少需要多少趟电梯运完。
-
状态:
dp[S]表示运输完集合S所需的最少趟数,rest[S]表示最后一趟的剩余容量。 -
转移:从
S转移到一个超集S | T,其中T是S补集的一个子集,且T的总重量不超过W。
5.3 变种三:三进制状态(罕见但有趣)
有些元素有三种状态(如"未选""选了A""选了B"),此时需要三进制压缩。用两个二进制位表示一个元素的状态,或者直接用 vector 存储状态。算法竞赛中较少见,但偶尔会出现。
5.4 与记忆化搜索的对比
| 维度 | 正向递推 | 记忆化搜索 |
|---|---|---|
| 代码量 | 简洁 | 略长 |
| 适用范围 | 所有DAG转移 | 所有DAG转移 |
| 效率 | 略快(无函数递归开销) | 略慢 |
| 可读性 | 需要理解转移顺序 | 直观,与DFS思路一致 |
| 推荐场景 | 常规状压题 | 转移条件复杂、需要剪枝时 |
总结
状态压缩DP是算法竞赛中"以时间换空间"思维的极致体现。它用二进制这把钥匙,打开了集合型问题的大门,让原本指数级难度的组合问题在小数据范围内变得可解。无论是经典的TSP问题,还是各类排列组合计数、集合划分问题,状压DP都提供了一套清晰、统一、可复用的解题框架。
三个关键点:
-
核心是把集合压缩成整数:一个N位二进制数表示一个子集,dpSdpS 存储该状态下的最优值。
-
转移本质是"向集合中加元素":从子集到超集,用位运算判断元素是否已在集合中。
-
N≤20是黄金适用区间:O(N⋅2N)O(N⋅2N) 的复杂度在 N=20N=20 时约为2000万级别,C++可轻松通过。
"状压DP教会我们:当问题足够小时,暴力不再是一种鲁莽,而是一种艺术------用二进制去枚举所有可能,用DP去筛选最优答案,这就是算法竞赛中最美的'以力破巧'。"
参考文献与延伸阅读
-
《算法竞赛入门经典(第二版)》 ------ 刘汝佳,第9章(动态规划)
-
OI-Wiki:状态压缩DP
-
洛谷 P1171 售货员的难题
-
洛谷 P3052 USACO12MAR Cows in a Skyscraper
-
POJ 2441 Arrange the Bulls
-
《挑战程序设计竞赛》 第2章------状态压缩DP