详细讲解 C++ 状压 DP(状态压缩动态规划)
目标
通过系统讲解与大量实例,让你在 C++ 开发中能迅速掌握并运用 状态压缩 DP(State‑Compression DP) 这一强大的算法思想,解决组合、图论、字符串、背包类等众多经典问题。
1. 什么是状态压缩 DP?
状态压缩 DP 也是"记忆化搜索 + 位运算" 的组合。它的基本思路是:
- 把问题的本质状态映射 为一个整数(或几个整数),利用其二进制位来表示"是否已选择 / 已覆盖 / 已完成"的信息。
- 使用位运算(
& | ^ ~ << >>)快速检查 / 修改 这些位。 - 递推 / 搜索 从一个"全未完成"状态往"全完成"状态或反之。
- 记忆化 (
dp[mask])避免重复计算。
什么时候适用?
- 状态信息的元素数目不超过 15~20(2^N 超过 1e6 仍然可处理,但需要视情况而定)。
- 状态只能是 离散的(0 或 1)。若需要多值状态,往往需要把多值拆解成多个位或用数组嵌套。
- 状态转移的 复杂度 可以被压缩为 O(1) 或 O(N)(具体取决于问题)。
举例
子集和问题、环形行走问题、作业匹配、宫格填充、TSP 等。
2. 二进制表示状态的基本思路
核心 :用 1 位表示一个"是否已选"或"是否已被覆盖"的状态。
例如:
- 第 0 位 代表元素 0
- 第 1 位 代表元素 1 ...
示例:3 位掩码 000000 ~ 111111
| 位 | 说明 | 掩码 | 代表状态 |
|---|---|---|---|
| 2 | 代表第 2 个元素 | 100 | 元素 2 已经被选 |
| 1 | 代表第 1 个元素 | 010 | 元素 1 已经被选 |
| 0 | 代表第 0 个元素 | 001 | 元素 0 已经被选 |
| 0 | 代表全部 0 | 000 | 未选中任何元素 |
转换
cpp
bool isChosen(int mask, int pos) { return mask & (1 << pos); }
int setChosen(int mask, int pos) { return mask | (1 << pos); }
int resetChosen(int mask, int pos){ return mask & ~(1 << pos); }
3. 状态转移方程的通用框架
通用模板(以最大化为例):
cpp
const int INF = 1e9;
int N; // 项目数
vector<int> val(N); // 每个项目价值
int dp[1 << N]; // 当考虑前 i 个子集时的最优答案
void init() {
memset(dp, -INF, sizeof(dp));
dp[0] = 0; // 选无元素时价值为 0
}
void solve() {
for(int mask = 0; mask < (1 << N); ++mask) {
for(int i = 0; i < N; ++i) {
if(!(mask & (1 << i))) { // i 未被选
int next = mask | (1 << i);
dp[next] = max(dp[next], dp[mask] + val[i]); // 递推
}
}
}
}
解释
- 外层循环遍历所有子集
mask。- 内层循环尝试添加未在
mask中出现的元素i。- 状态转移只需一次
|与一次+,时间复杂度 O(N×2N)O(N×2N)。
改造若是 最小化 或 可达性 问题,只改
max为min或dp[next] = true。
4. 实现细节:位运算技巧
| 操作 | 代码 | 描述 |
|---|---|---|
| 取单个位 | (mask >> pos) & 1 |
判断第 pos 位是否为 1 |
第 k 个 1 的索引 |
__builtin_ctz(mask & -mask) |
找到最低位 1 的索引 |
| 切换所有 1 到 0(取反) | mask ^= ((1 << n) - 1) |
对前 n 位取反 |
| 产生下一个子集 | sub = (sub-1)&mask |
递归枚举 mask 的所有子集 |
| 统计 1 的个数 | __builtin_popcount(mask) |
获得已选元素数量 |
技巧
mask & -mask在掩码不为 0 时得到最低位 1 的位值(即2^k)。
sub = (sub-1) & mask通过这条语句可以 高效 遍历某掩码的全部子掩码,时间复杂度 O(3N)O(3N) 对特殊问题(如匹配、括号校验)有用。
5. 经典案例一:子集 DP(Mask DP)
5.1 问题描述(案例 A)
给定 N 个数
a[0..N-1],你可以选 或不选 每个数,要求总和最接近 目标值S(不超过)。输出最接近的可达和。
5.2 思路
- 只需关心 已选集合。
- 设
dp[mask] = true表示一个 mask 代表的子集和能达。 - 用 位配合求和 :将每个数放入
dp按位累加。
5.3 实现
cpp
int N, S; // 读入
int a[20];
bool dp[1 << 20];
int main() {
cin >> N >> S;
for (int i=0;i<N;i++) cin >> a[i];
memset(dp, 0, sizeof(dp));
dp[0] = true; // 空集和为 0
for (int i=0;i<N;i++) {
// 遍历从大到小保证同一元素不被复用
for (int mask = (1<<N)-1; mask>=0; --mask) {
if (!dp[mask]) continue;
int next = mask | (1<<i);
dp[next] = true;
}
}
int ans = 0;
for (int mask=0; mask<(1<<N); ++mask) {
if (!dp[mask]) continue;
int sum = 0;
for (int i=0;i<N;i++) if (mask&(1<<i)) sum += a[i];
if (sum <= S && sum > ans) ans = sum;
}
cout << ans << endl;
}
复杂度
- DP 数组尺寸 2N2N
- 执行转换 O(N×2N)O(N×2N)。(N ≤ 20 简单可行)
可改进把
sum的计算放到 DP 转移阶段,维护sum[mask]并即时更新,使得整体 O(N × 2^N)。
6. 经典案例二:图着色、匹配
6.1 图着色(最小染色数)
题目 :给定无向图
G(V,E),求最少颜色染色数(Graph Coloring)。
思路:
- 由于约束为
N ≤ 20,可以用 DP 而非图着色 NP-hard 路径。- 定义
dp[mask]表示 已使用 k 个颜色 的小技巧。
6.2 代码(Mask + DP for Minimum Coloring)
(此处用 布尔 dp 枚举子集求颜色可行性)
cpp
int N, M;
vector<int> adjMask; // 邻接表转换成掩码
int INF = 1e9;
int dp[1 << 20];
int main() {
cin >> N >> M;
adjMask.assign(N, 0);
for (int i=0;i<M;i++) {
int u, v; cin >> u >> v; --u; --v;
adjMask[u] |= 1 << v;
adjMask[v] |= 1 << u;
}
dp[0] = 0;
for (int mask=1; mask<(1<<N); ++mask) {
int sub = mask;
for (int x=mask; x; x = (x-1)&mask) { // 枚举子集
// 子集 x 为一染色块(独立集)
if ((sub & adjMask[x]) == 0) { // x 内不存在相邻顶点
dp[mask] = min(dp[mask], dp[mask^x] + 1);
}
}
}
// dp[(1<<N)-1] 就是最少染色数
}
核心:
- 子集枚举 (
x)- 验证独立集 :
(x & adjMask[mask]) == 0- 递推求最小染色数
复杂度由于子集枚举,时间复杂度 O(3N)O(3N) 或 O(N⋅2N)O(N⋅2N) - 这里是 3^N。
对于 N ≤ 15 一般可用。
6.3 匹配(Maximum Bipartite Matching via DP)
题目 :在 bipartite graph
(U,V,E)中求最大匹配数。|U|=|V|=N ≤ 20。
DP 思路
- 以 "左侧已匹配到的右侧节点集合" 作为状态。
- 视右侧为
mask。
cpp
int N;
vector<int> adjMask; // 对于每个左节点,右节点的邻接掩码
int dp[1 << 20];
int main(){
cin >> N;
adjMask.resize(N);
for(int i=0;i<N;i++){
int k;cin>>k;
int mask=0;
for(int j=0;j<k;j++){
int v;cin>>v;--v;
mask|=1<<v;
}
adjMask[i]=mask;
}
memset(dp,-1,sizeof(dp));
dp[0]=0; // 无匹配 -> 0
for(int i=0;i<N;i++){
for(int mask=0;mask<(1<<N);mask++){
if(dp[mask]==-1||dp[mask]!=i) continue;
int avail = adjMask[i] & ~mask;
for(int v=0;v<N;v++) if(avail&(1<<v)){
dp[mask| (1<<v)] = i+1;
}
}
}
int best = 0;
for(int mask=0;mask<(1<<N);mask++){
best = max(best, __builtin_popcount(mask));
}
cout << best << endl;
}
复杂度
O(N⋅2N)O(N⋅2N)。
典型的 匹配 DP 经典案例。
7. 经典案例三:Travelling Salesman(TSP)
7.1 问题描述
给定 N (≤20) 个城市,城市之间距离矩阵
dist[i][j],求最短路径,使得
- 从城市 0 出发
- 访问每个城市恰好一次
- 回到城市 0
7.2 DP 状态
dp[mask][last]:在访问完mask(含0 )集合后,最后 停留在城市last的最短路径长度。
7.3 代码
cpp
int N;
int dist[20][20];
int dp[1 << 20][20];
int main(){
cin >> N;
for(int i=0;i<N;i++) for(int j=0;j<N;j++) cin >> dist[i][j];
const int INF = 0x3f3f3f3f;
for(int mask=0; mask<(1<<N); ++mask)
for(int i=0; i<N; ++i) dp[mask][i] = INF;
dp[1][0] = 0; // 只访问城市0,路径长度 0
for(int mask=1; mask<(1<<N); ++mask){
if(!(mask & 1)) continue; // 必须包含 base city 0
for(int last=0; last<N; ++last){
if(!(mask & (1<<last))) continue;
if(dp[mask][last]==INF) continue;
for(int nxt=0; nxt<N; ++nxt){
if(mask & (1<<nxt)) continue; // 已访问
int nmask = mask | (1<<nxt);
dp[nmask][nxt] = min(dp[nmask][nxt], dp[mask][last] + dist[last][nxt]);
}
}
}
int fullMask = (1<<N)-1;
int answer = INF;
for(int last=0; last<N; ++last){
if(last==0) continue;
answer = min(answer, dp[fullMask][last] + dist[last][0]);
}
cout << answer << endl;
}
复杂度
- 状态数:2N×N2N×N
- 转移:每个状态会尝试 N 次 → O(N2×2N)O(N2×2N)。
适用于 N ≤ 20。
7.4 记忆化搜索版本(递归 + memo)
cpp
int dp[1<<20][20];
int solve(int mask, int last){
if(dp[mask][last]!=-1) return dp[mask][last];
if(mask==(1<<N)-1)
return dp[mask][last] = dist[last][0];
int best = INF;
for(int nxt=0; nxt<N; ++nxt) if(!(mask & (1<<nxt))){
best = min(best, dist[last][nxt] + solve(mask|1<<nxt, nxt));
}
return dp[mask][last] = best;
}
记忆化搜索往往比循环实现更直观。
8. 经典案例四:DP 与图的整体枢纽
8.1 经典题型:"最小区间覆盖"(最少子集覆盖)
给 N 个区间,求最少选几个区间使得整个 [0,M] 区间被覆盖。
采用 [0,1] DP 或 状态压缩 结合区间划分方案:
- 把所有端点 离散化。
- 用
dp[pos][mask]:在已完成的位置pos,任意状态mask表示 "哪些区间被选",记录最小个数。 - 这类问题把 区间索引 变成掩码来做判断。
细节
为节省空间,只需 记住最快到达 的
mask的最小区间数。
8.2 "树形 DP + 状态压缩"
有些树形 DP(如 DP on Trees using Bitmask)使用掩码来记忆"当前子树中哪些节点已被'开启'"。
具体实现请参考 知识库:"树上匹配 + Bitmask"。
9. 性能优化与技巧
| 问题 | 解决方案 |
|---|---|
| 大 N (如 25) 产生 33M DP 状态 | - 只保留必要的 DP (稀疏 DP)。<br>- 用 vector<int> 只存活状态。 |
| 转移中需要路径/原因信息 | 直接 存储 predecessor pre[mask],在后续做重构。 |
| 需要多位状态(多种属性) | - 拆分 用多掩码(如 `mask1 |
| 需要 求子集或上一位 | 用 __builtin_popcount 或 __builtin_ctz/__builtin_clz。 |
| 接口不好 | Rust/ C++98 等,std::bitset 或 unsigned long long。 |
| 边长代表 mask 的位数 > 64 | 选 uint128_t (g++) 或 数组。 |
| 递归深度溢出 | 用 迭代 或 尾递归移斩。 |
常用库函数
cpp
int popcount = __builtin_popcount(mask); // 计数
int lowbit = mask & (-mask); // 最低位 1
int next = (mask-1) & base; // 枚举子集
int lowpos = __builtin_ctz(mask); // 位序
10. 常见误区 & 调试技巧
| 误区 | 正确做法 | 调试技巧 |
|---|---|---|
mask&(1<<i) 会溢出 |
先 右移:(mask>>i)&1;或使用 UINT64_MAX |
关注 数据类型 (int vs long long) |
子集枚举忘记 mask ≥ base |
检查 if(!(mask & base)) continue; |
逐步输出 mask 二进制 |
| 记忆化数组未初始化 | memset(dp, -1, sizeof(dp)); |
检查 dp[mask] == -1 |
dp过拟合溢出 |
用 int64_t 或 double |
对 INF 值做大保安全 |
调试小技巧
- 用
cout << bits(mask) << endl;打印掩码(bits自定义转换为字符串)。- 用
std::bitset<64>直接可读。- 在递归版本中,可用
cout打印 路径 与 当前掩码。
11. 练习题与答案
下面给出 3 个练习,并随附思路/代码,帮助你巩固状态压缩 DP。
1. 最大子串匹配(最长公共子序列)
题目 :给 2 条字符串
S,T(|S|,|T| ≤ 20),求 最长公共子序列 长度。思路 :用掩码表示
T的子集,转化为「是否可以匹配 S 中的某个子序列」。
cpp
dp[mask] = 1(可行) / 0(不可行)
代码(略,读者可自行实现)。
2. 点两两连线覆盖(最小点集覆盖)
给一个无向图
G(N ≤ 20),求最少点集,使每条边至少有一个端点在该集合中。
思路 :主题是 最小点覆盖 ;用 DP 维护子集mask是否覆盖所有边。
cpp
dp[mask] = true/false
代码同样借用
mask+col。
3. 子集最小化加权和
给 N 个数,每个数有权重
w[i]。选择子集使得 和为 S 且 总权重最小。思路 :
dp[mask] = min(totalWeight)(如果缺失则 INF)。先计算
sum[mask],如果sum[mask]==S记录权重。
答案 (完整实现):请补充到自己笔记。
提示: 使用 双重循环 :
for (int i=0;i<N;i++) for (int mask=0; mask<(1<<N); ++mask)。
12. 总结
- 状态压缩 DP 是把集合 状态映射到整数 (掩码),利用位操作极大地压缩内存与时间。
- 关键要点:
- 明确 状态 单个位代表的意义。
- 设计 转移 (往后/往回)并保持 递推/记忆化。
- 控制 时间复杂度 :通常 O(N×2N)O(N×2N) 是可接受的;超过 3~4 时更要用稀疏或 divide‑and‑conquer 等技巧。
- 常见组成要素:
- 位运算 (
& | ^ ~ << >>) - 子集枚举 (
sub = (sub-1)&mask) - Precomputation (如
sum[mask],popcount[mask])。
- 位运算 (
- C++ 与其标准库提供多种工具:
std::bitset、内置位运算、__builtin_*,能帮你写出更清晰、可维护的代码。 - 练习是通往大师级的必经之路。建议反复阅读经典题目,尝试把一年中学过的任何 DP 变成掩码版。
希望这份 4000+ 字的详解能帮你在算法面试、程序竞赛或日常工程中快速上手状态压缩 DP,开启 "状态压缩" 的想象力大门。 🚀