概述:为什么需要状态压缩 DP
前面我们讲过线性 DP、背包 DP 和区间 DP。
这些 DP 的状态通常比较直观:
text
dp[i]
dp[i][j]
但有些问题的状态不是一个位置、一个容量或一个区间,而是:
text
一组元素是否已经被选择
比如:
- 哪些任务已经完成
- 哪些城市已经访问
- 哪些数字已经使用
- 哪些人已经被分配工作
如果直接用数组或集合去表示这些状态,代码会很复杂。
这时就可以使用 状态压缩 DP。
状态压缩 DP 的核心思想是:
text
用一个整数的二进制位表示一组选择状态
本篇会重点讲:
- 什么是状态压缩
- 二进制状态如何表示集合
- 如何枚举状态和判断某一位
- 任务分配问题怎么写状态压缩 DP
- TSP 类问题的基本模板
- 常见坑点和复杂度分析
学完这篇,你应该能看懂 mask、1 << i、dp[mask] 这类写法,并能用二进制集合解决小规模选择问题。
核心概念:什么是状态压缩
状态压缩,就是把一个复杂状态压成一个整数。
假设有 4 个任务,编号为:
text
0, 1, 2, 3
我们可以用一个四位二进制数表示哪些任务已经完成。
text
0000 表示一个任务都没完成
0001 表示任务 0 已完成
0010 表示任务 1 已完成
0101 表示任务 0 和任务 2 已完成
1111 表示 4 个任务都已完成
这里每一位都代表一个元素的选择状态:
0:没有选1:已经选
为什么可以用整数表示
因为二进制位天然就是一组开关。
一个整数有很多二进制位,每一位都可以表示一个元素是否存在。
这就把"集合"变成了"数字"。
状态压缩就是用二进制位表示集合,用整数表示复杂选择状态。
位运算基础:状态压缩必须掌握的操作
状态压缩 DP 经常用到这些位运算。
1. 表示第 i 位
java
1 << i
例如:
text
1 << 0 = 0001
1 << 1 = 0010
1 << 2 = 0100
2. 判断第 i 位是否为 1
java
(mask & (1 << i)) != 0
如果结果不为 0,说明第 i 位是 1。
3. 把第 i 位设置为 1
java
mask | (1 << i)
这表示把元素 i 加入集合。
4. 把第 i 位设置为 0
java
mask & ~(1 << i)
这表示把元素 i 从集合中移除。
5. 判断集合是否包含某个元素
java
if ((mask & (1 << i)) != 0) {
// i 已经被选择
}
6. 枚举所有状态
如果有 n 个元素,总状态数是:
text
2^n
代码写成:
java
for (int mask = 0; mask < (1 << n); mask++) {
// 处理状态 mask
}
状态压缩 DP 的基础操作,就是检查某一位、添加某一位和枚举所有二进制状态。
为什么状态数量是 2^n
每个元素都有两种状态:
- 不选
- 选
如果有 n 个元素,那么总状态数就是:
text
2 * 2 * 2 * ... * 2 = 2^n
例如 n = 3 时,所有状态是:
text
000
001
010
011
100
101
110
111
一共 8 个。
状态压缩适合多大规模
因为状态数量是指数级,所以状态压缩 DP 一般适合:
text
n <= 20
如果转移里还要再枚举一个元素,复杂度通常是:
text
O(n * 2^n)
如果还要枚举两个元素,可能变成:
text
O(n^2 * 2^n)
状态压缩不是把指数级问题变成线性,而是把可控范围内的指数状态表示得更高效。
经典模型:任务分配的最小成本
先看一个很适合入门的状态压缩 DP。
题目描述:
有
n个工人和n个任务,cost[i][j]表示第i个工人做第j个任务的成本。每个工人只能做一个任务,每个任务也只能被一个工人做,求最小总成本。
例如:
text
cost = [
[9, 2, 7],
[6, 4, 3],
[5, 8, 1]
]
要给每个工人分配一个不同任务,使总成本最小。
为什么适合状态压缩
因为我们需要记录:
text
哪些任务已经被分配
如果任务数量不大,就可以用一个 mask 表示任务集合。
任务分配问题的核心状态,是已经使用过哪些任务。
状态定义:dp[mask] 表示什么
定义:
text
dp[mask] 表示已经分配了 mask 中这些任务时的最小成本
例如:
text
mask = 101
表示任务 0 和任务 2 已经被分配。
当前应该给哪个工人分配任务
如果 mask 里有 k 个 1,说明已经分配了 k 个任务。
那么下一个要分配任务的工人就是:
text
第 k 个工人
在 Java 里可以用:
java
int worker = Integer.bitCount(mask);
Integer.bitCount(mask) 会返回 mask 中 1 的个数。
mask 记录任务集合,bitCount(mask) 决定当前轮到哪个工人。
状态转移:枚举下一个任务
当前状态是 mask,当前工人是 worker。
我们要枚举一个还没有被分配的任务 job。
如果任务 job 没有被选过:
java
(mask & (1 << job)) == 0
那么可以把它分配给当前工人。
新状态是:
java
nextMask = mask | (1 << job)
转移方程是:
text
dp[nextMask] = min(dp[nextMask], dp[mask] + cost[worker][job])
初始状态
一开始没有任何任务被分配:
text
dp[0] = 0
其他状态初始化为一个很大的数。
最终答案
所有任务都被分配时:
text
mask = (1 << n) - 1
所以答案是:
text
dp[(1 << n) - 1]
从当前任务集合出发,选择一个没用过的任务加入集合,并更新最小成本。
Java 实现:任务分配最小成本
java
import java.util.Arrays;
class Solution {
public int minCost(int[][] cost) {
int n = cost.length;
int totalStates = 1 << n;
int[] dp = new int[totalStates];
Arrays.fill(dp, Integer.MAX_VALUE / 2);
dp[0] = 0;
for (int mask = 0; mask < totalStates; mask++) {
int worker = Integer.bitCount(mask);
if (worker >= n) {
continue;
}
for (int job = 0; job < n; job++) {
if ((mask & (1 << job)) == 0) {
int nextMask = mask | (1 << job);
dp[nextMask] = Math.min(
dp[nextMask],
dp[mask] + cost[worker][job]
);
}
}
}
return dp[totalStates - 1];
}
}
代码关键点
totalStates = 1 << n:总状态数是2^ndp[0] = 0:没有分配任何任务时成本为0Integer.bitCount(mask):根据已分配任务数确定当前工人nextMask:把新任务加入集合dp[totalStates - 1]:所有任务都被分配后的最小成本
任务分配模型是状态压缩 DP 的标准入门题,核心是用 mask 表示已经选过的任务。
用一个小例子走一遍状态
假设有 3 个任务。
初始状态:
text
mask = 000
dp[000] = 0
此时 bitCount(000) = 0,轮到工人 0。
可以选择任务 0、1、2:
text
000 -> 001
000 -> 010
000 -> 100
下一轮如果状态是:
text
mask = 010
说明任务 1 已经被分配。
此时 bitCount(010) = 1,轮到工人 1。
还能选择任务 0 或任务 2:
text
010 -> 011
010 -> 110
最终会走到:
text
111
也就是所有任务都已经分配完成。
每次转移都是从一个集合状态走向多选一个元素的新集合状态。
状态压缩 DP 的常见形式
状态压缩 DP 常见状态有两类。
一维状态:dp[mask]
这种状态只关心集合本身。
例如任务分配:
text
dp[mask] 表示已经选了 mask 这些任务时的最小成本
常见转移:
text
从 mask 添加一个新元素,得到 nextMask
二维状态:dp[mask][i]
这种状态不仅关心集合,还关心当前停在哪个元素。
例如旅行商问题:
text
dp[mask][i] 表示已经访问 mask 这些城市,并且当前停在城市 i 的最短距离
这里的 i 很重要,因为下一步从哪里出发会影响代价。
如果只关心选了哪些元素,用 dp[mask];如果还关心当前停在哪里,用 dp[mask][i]。
经典模型:TSP 旅行商问题
旅行商问题是状态压缩 DP 的经典模型。
题目简化描述:
有
n个城市,任意两个城市之间有距离。从城市0出发,访问每个城市一次,最后回到城市0,求最短路径。
状态定义
text
dp[mask][i] 表示已经访问 mask 中的城市,并且当前停在城市 i 的最短距离
初始化
从城市 0 出发:
text
dp[1][0] = 0
这里 1 的二进制是:
text
0001
表示只访问了城市 0。
状态转移
如果当前停在城市 i,下一步去城市 j,并且城市 j 没访问过:
text
nextMask = mask | (1 << j)
dp[nextMask][j] = min(dp[nextMask][j], dp[mask][i] + dist[i][j])
最终答案
访问完所有城市后,还要回到城市 0:
text
ans = min(dp[fullMask][i] + dist[i][0])
其中:
text
fullMask = (1 << n) - 1
TSP 需要 dp[mask][i],因为不仅要知道访问了哪些城市,还要知道当前在哪个城市。
Java 模板:TSP 状态压缩 DP
java
import java.util.Arrays;
class Solution {
public int tsp(int[][] dist) {
int n = dist.length;
int totalStates = 1 << n;
int[][] dp = new int[totalStates][n];
int inf = Integer.MAX_VALUE / 2;
for (int[] row : dp) {
Arrays.fill(row, inf);
}
dp[1][0] = 0;
for (int mask = 0; mask < totalStates; mask++) {
for (int i = 0; i < n; i++) {
if ((mask & (1 << i)) == 0) {
continue;
}
for (int j = 0; j < n; j++) {
if ((mask & (1 << j)) != 0) {
continue;
}
int nextMask = mask | (1 << j);
dp[nextMask][j] = Math.min(
dp[nextMask][j],
dp[mask][i] + dist[i][j]
);
}
}
}
int fullMask = totalStates - 1;
int ans = inf;
for (int i = 1; i < n; i++) {
ans = Math.min(ans, dp[fullMask][i] + dist[i][0]);
}
return ans;
}
}
为什么要判断城市 i 在 mask 里
因为 dp[mask][i] 的含义要求:
text
当前停在城市 i,并且城市 i 已经被访问
如果 i 不在 mask 中,这个状态本身就是无效的。
为什么城市 j 不能在 mask 里
因为旅行商问题要求每个城市访问一次。
如果 j 已经访问过,就不能再访问。
mask 控制已经访问的城市,i 控制当前所在城市,j 控制下一步去哪里。
子集枚举:状态压缩里的进阶技巧
有些题需要枚举一个集合的所有子集。
例如给定:
text
mask = 1110
你可能需要枚举它的所有非空子集。
常见写法是:
java
for (int sub = mask; sub > 0; sub = (sub - 1) & mask) {
// sub 是 mask 的一个非空子集
}
为什么这样能枚举子集
sub - 1 会改变二进制低位结构,再和 mask 做与运算,就能保证结果始终是 mask 的子集。
这个技巧在集合划分、分组 DP 中很常见。
包含空集怎么办
如果也要处理空集,可以在循环结束后单独处理:
java
// sub = 0
或者使用 do while,但初学阶段建议写清楚一点。
sub = (sub - 1) & mask 是枚举某个集合所有子集的经典写法。
状态压缩 DP 的常见题型
状态压缩 DP 常见于这些场景。
1. 选择集合类
题目会问:
- 哪些元素已经选过
- 哪些任务已经完成
- 哪些数字已经使用
常用状态:
text
dp[mask]
2. 路径访问类
题目会问:
- 已经访问了哪些点
- 当前停在哪个点
- 访问所有点的最小代价
常用状态:
text
dp[mask][i]
3. 分组划分类
题目会问:
- 把若干元素分成几组
- 每组满足某个条件
- 最少分组或最大收益
常用技巧:
text
枚举 mask 的子集
只要题目需要记录"哪些元素已经被使用",就可以优先考虑状态压缩。
常见错误点
1. 位下标和元素下标混乱
第 i 个元素对应:
java
1 << i
不要写成 1 << (i + 1),除非你明确从 1 开始编号。
2. 忘记初始化不可达状态
求最小值时,不能把所有 dp 默认成 0。
应该初始化成较大值:
java
Arrays.fill(dp, Integer.MAX_VALUE / 2);
否则很多不合法状态会被当成合法状态。
3. 溢出问题
如果用 Integer.MAX_VALUE,再加一个成本可能溢出。
所以常用:
java
Integer.MAX_VALUE / 2
4. mask 范围写错
总状态数是:
java
1 << n
循环范围是:
java
mask < (1 << n)
最终全集是:
java
(1 << n) - 1
5. 没判断当前状态是否合法
在 dp[mask][i] 里,要确认:
java
(mask & (1 << i)) != 0
否则可能从不存在的状态转移。
6. 数据规模太大还硬套状态压缩
如果 n = 30,状态数就是:
text
2^30
这通常已经不可接受。
状态压缩 DP 的错误多数来自位运算边界、初始化和状态合法性判断。
复杂度分析:指数级但可控
状态压缩 DP 的复杂度主要看状态数量。
dp[mask] 类型
状态数量是:
text
2^n
如果每个状态枚举一个元素,时间复杂度通常是:
text
O(n * 2^n)
空间复杂度是:
text
O(2^n)
dp[mask][i] 类型
状态数量是:
text
n * 2^n
如果转移还要枚举下一个点,时间复杂度通常是:
text
O(n^2 * 2^n)
空间复杂度是:
text
O(n * 2^n)
为什么仍然有价值
虽然是指数级,但它比暴力排列要好很多。
例如访问 n 个城市的暴力排列复杂度接近:
text
O(n!)
而状态压缩 DP 可以降到:
text
O(n^2 * 2^n)
对于小规模 n,这是非常大的优化。
状态压缩 DP 仍然是指数级,但通常能把排列级复杂度降到可接受范围。
标准思考流程:遇到状态压缩题怎么想
可以按下面顺序分析。
第一步:判断是否需要记录集合
如果题目要求记录:
- 哪些元素用过
- 哪些点访问过
- 哪些任务完成过
就可以考虑用 mask。
第二步:确定每一位代表什么
写清楚:
text
第 i 位表示第 i 个元素是否被选择
第三步:定义 dp
常见定义:
text
dp[mask]
dp[mask][i]
第四步:确定转移方向
通常有两种:
- 从旧集合添加一个元素
- 从当前集合删除一个元素
初学阶段更推荐"添加元素"的写法。
第五步:确定初始状态和最终状态
常见初始状态:
text
dp[0] = 0
dp[1 << start][start] = 0
常见最终状态:
text
dp[(1 << n) - 1]
dp[(1 << n) - 1][i]
状态压缩题先定义每一位的含义,再围绕 mask 设计状态和转移。
模板总结:状态压缩 DP 怎么写
一维集合模板
java
int totalStates = 1 << n;
int[] dp = new int[totalStates];
Arrays.fill(dp, inf);
dp[0] = 0;
for (int mask = 0; mask < totalStates; mask++) {
for (int i = 0; i < n; i++) {
if ((mask & (1 << i)) == 0) {
int nextMask = mask | (1 << i);
dp[nextMask] = Math.min(dp[nextMask], dp[mask] + cost);
}
}
}
二维路径模板
java
int totalStates = 1 << n;
int[][] dp = new int[totalStates][n];
for (int[] row : dp) {
Arrays.fill(row, inf);
}
dp[1 << start][start] = 0;
for (int mask = 0; mask < totalStates; mask++) {
for (int i = 0; i < n; i++) {
if ((mask & (1 << i)) == 0) {
continue;
}
for (int j = 0; j < n; j++) {
if ((mask & (1 << j)) != 0) {
continue;
}
int nextMask = mask | (1 << j);
dp[nextMask][j] = Math.min(dp[nextMask][j], dp[mask][i] + cost);
}
}
}
子集枚举模板
java
for (int sub = mask; sub > 0; sub = (sub - 1) & mask) {
// sub 是 mask 的非空子集
}
状态压缩 DP 的模板核心,就是围绕 mask 做判断、添加元素和更新状态。
总结
状态压缩 DP 看起来代码里有很多位运算,但本质仍然是动态规划。
你可以重点记住下面几句话:
- 状态压缩用二进制位表示集合
mask是一个整数,但可以看作一组选择状态1 << i表示第i个元素dp[mask]适合只关心集合本身的问题dp[mask][i]适合既关心集合又关心当前位置的问题- 总状态数通常是
2^n,适合小规模问题 - 初始化不可达状态时要用较大值,避免非法转移
- 子集枚举可以用
(sub - 1) & mask
状态压缩 DP 的难点不在于背代码,而在于先把"每一位表示什么"讲清楚。
只要 mask 的含义稳定,转移就会变得有迹可循。