详细讲解 C++ 状压 DP

详细讲解 C++ 状压 DP(状态压缩动态规划)

目标

通过系统讲解与大量实例,让你在 C++ 开发中能迅速掌握并运用 状态压缩 DP(State‑Compression DP) 这一强大的算法思想,解决组合、图论、字符串、背包类等众多经典问题。


1. 什么是状态压缩 DP?

状态压缩 DP 也是"记忆化搜索 + 位运算" 的组合。它的基本思路是:

  1. 把问题的本质状态映射 为一个整数(或几个整数),利用其二进制位来表示"是否已选择 / 已覆盖 / 已完成"的信息。
  2. 使用位运算(& | ^ ~ << >>)快速检查 / 修改 这些位。
  3. 递推 / 搜索 从一个"全未完成"状态往"全完成"状态或反之。
  4. 记忆化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)。
    改造

若是 最小化可达性 问题,只改 maxmindp[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] 就是最少染色数
}

核心:

  1. 子集枚举 (x)
  2. 验证独立集(x & adjMask[mask]) == 0
  3. 递推求最小染色数
    复杂度

由于子集枚举,时间复杂度 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状态压缩 结合区间划分方案:

  1. 把所有端点 离散化
  2. dp[pos][mask]:在已完成的位置 pos,任意状态 mask 表示 "哪些区间被选",记录最小个数
  3. 这类问题把 区间索引 变成掩码来做判断。

细节

为节省空间,只需 记住最快到达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::bitsetunsigned 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_tdouble 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. 总结

  1. 状态压缩 DP 是把集合 状态映射到整数 (掩码),利用位操作极大地压缩内存与时间。
  2. 关键要点:
    • 明确 状态 单个位代表的意义。
    • 设计 转移 (往后/往回)并保持 递推/记忆化
    • 控制 时间复杂度 :通常 O(N×2N)O(N×2N) 是可接受的;超过 3~4 时更要用稀疏或 divide‑and‑conquer 等技巧。
  3. 常见组成要素:
    • 位运算& | ^ ~ << >>
    • 子集枚举sub = (sub-1)&mask
    • Precomputation (如 sum[mask]popcount[mask])。
  4. C++ 与其标准库提供多种工具:std::bitset、内置位运算、__builtin_*,能帮你写出更清晰、可维护的代码。
  5. 练习是通往大师级的必经之路。建议反复阅读经典题目,尝试把一年中学过的任何 DP 变成掩码版。

希望这份 4000+ 字的详解能帮你在算法面试、程序竞赛或日常工程中快速上手状态压缩 DP,开启 "状态压缩" 的想象力大门。 🚀

相关推荐
chaofan9801 小时前
GPT-5.5 全压力测试:为什么 API 聚合调度是解决“首字延迟”的技术关键?
开发语言·人工智能·python·gpt·自动化·api
William_wL_2 小时前
【C++】stack和queue的使用和实现(附加deque的简单介绍)
开发语言·c++
山甫aa2 小时前
二叉树遍历----从零开始的数据结构
数据结构·c++·二叉树
hhb_6182 小时前
D架构底层调度与性能优化实践指南
开发语言
秋92 小时前
Java AI编程工具全景解析:功能、收费与工单系统实战指南
java·开发语言·ai编程
会编程的土豆2 小时前
【go】 Go语言中的 defer:从入门到理解底层机制(讲透版)
开发语言·后端·golang
一只幸运猫.2 小时前
Google Mug库——一个现代的通用工具库
开发语言·python
民乐团扒谱机2 小时前
【附完整代码】Python爬取古筝网曲谱图片一键生成PDF(下·PDF生成与GUI篇)
开发语言·python·pdf
代码中介商3 小时前
C语言操作符深度解析:从基础到高级应用
c语言·开发语言