状压DP学习笔记
2026-02-10
1 位运算
1.1 二进制表示集合
二进制不仅可以表示数字,还可以表示集合 :
假设我们有 \(n\) 个元素,可以用一个 \(n\) 位的二进制数来表示这些元素的选取情况
第 \(i\) 位为 \(1\) → 元素 \(i\) 被选中
第 \(i\) 位为 \(0\) → 元素 \(i\) 未被选中
例如,对于 \(3\) 个元素,二进制数 \(101\) 表示选择第 \(0\) 个和第 \(2\) 个元素。
1.2 常用位运算技巧
点击查看代码
// 1. 判断第i位是否为1 bool isSet(int x, int i) { return (x >> i) & 1; } // 2. 设置第i位为1 int setBit(int x, int i) { return x | (1 << i); } // 3. 设置第i位为0 int clearBit(int x, int i) { return x & ~(1 << i); } // 4. 去掉最低位的1 int lowbit(int x) { return x & (x - 1); } // 5. 统计二进制中1的个数 int countBits(int x) { return __builtin_popcount(x); }
2 什么是状压DP?
一种巧妙地将多维状态压缩到单个整数中的算法技术。当我们需要处理那些状态可以表示为 “选中/未选中” 等二元选择的问题时,这种算法尤为有效。
2.1 核心思想
用一个二进制位来表示一个集合的状态,每位代表一个元素的两种状态
通过位运算,我们可以高效地进行状态转移和集合操作
3 为什么需要状压?
3.1 传统方法的局限性
考虑一个典型问题:
有n个城市,需要找到一条经过所有城市恰好一次的最短路径。朴素的方法是枚举所有可能的排列,时间复杂度为 \(O(n·n!)\) 当 \(n=20\) 时, \(20! ≈ 2.4 * 10^{18}\),这个数字远超出计算机的处理能力
3.2 状压的优势
通过观察发现,如果我们已经访问过某些城市,现在停留在某个城市,那么具体以什么顺序访问这些城市并不重要,重要的是:
- 我们已经访问了哪些城市
- 我们现在在哪个城市
这正是状态压缩的用武之地
- 用 \(n\) 位二进制数表示 \(n\) 个城市的访问状态
- 这只需要 \(2^n\) 种状态,当 \(n=20\) 时,\(2^{20} ≈ 10^6\),这个数量级是计算机可以处理的
4 状压DP的三大核心要素
4.1 状态设计
状态设计是状态压缩DP的关键,好的状态设计应该
- 包含必要信息:状态必须包含所有影响未来决策的信息
- 尽可能简洁:不包含冗余信息
- 便于转移:能够方便地从其他状态转移而来
4.1.1 经典状态设计:
dp[s][i]=v
s:二进制数,表示已访问的集合i:当前所在位置v:达到这种状态的最小代价
4.2 状态转移
两种转移思路
填表法:
"我是从哪里来的?"
计算当前状态时,考虑所有可能到达当前状态的前驱状态刷表法:
"我可以去哪里?"
从当前状态出发,更新所有可能到达的后继状态两种方法各有优劣,填表法通常更直观,刷表法有时可以简化代码
5 从二进制到多进制
5.1 为什么需要多进制?
二进制只能表示两种状态,但有些问题需要更多状态:
- 三进制:每个元素有3种状态
- 例:每个城市最多访问2次 (0,1,2)
- 四进制:每个元素有4种状态
- 例:每个位置可以有4种不同的颜色
5.2 多进制状态的处理
由于计算机没有内置的多进制位运算,我们需要手动实现:
- 获取第i位的值:\((state / base^i) % base\)
- 设置第i位的值:先清除原值,再设置新值
- 状态转移:检查当前值是否允许转移,然后更新
2026-02-11
6 子集枚举
6.1 子集枚举的暴力方法
假设集合大小为 \(n\),用 \(0\) 到 \(2^n-1\) 的整数表示每个子集。要枚举某个集合 \(S\) 的所有子集
6.1.1 O(4^n)
最直接的想法是:
for (int s = 0; s < (1 << n); s++) { for (int t = 0; t < (1 << n); t++) { if ((s & t) == t) { // t 是 s 的子集 } } }内层循环遍历了所有 \(2^n\) 个集合,总复杂度为 \(O(4^n)\),当 \(n=15\) 时已经接近 \(1e9\)
6.1.2 优化枚举
只枚举那些确实是 \(S\) 的子集的 \(t\):
for (int s = 0; s < (1 << n); s++) { for (int t = s; t; t = (t - 1) & s) { // t 是 s 的非空子集 } // 空子集可以单独处理 }时间复杂度优化到 \(O(3^n)\) ,这使 \(n\) 可以取到 \(15\)
7 习题讲解
7.1 [USACO12MAR] Cows in a Skyscraper G
7.1.1 子集枚举DP
- 状态定义
设 \(dp[S]\) 表示已运送的奶牛集合为 \(S\) 时的最少趟数
- 状态转移
转移时,枚举最后一次运送的奶牛子集 \(T\subseteq S\)
- 初始化和复杂度
初始 \(dp[0]=0\),其余为无穷大
预处理所有合法子集的总重,然后子集枚举转移,复杂度 \(O(3^n)\)。对于 \(n=18\) 可通过
7.1.2 状压DP
- 状态定义
用 \(dp[i][j]\) 表示当前已经使用了 \(i\) 趟电梯,已经运送的奶牛集合为 \(j\),最后一趟电梯当前的载重量
- 状态转移
转移时枚举未运送的牛 \(k\)
- 初始化和复杂度
初始 \(dp[0][0]=0\),其余为无穷大
复杂度 \(O(2^n*n)\),适合 \(n=20\) 左右
7.1.3 搜索解法
DFS 枚举每头牛应当放入哪一部电梯
按顺序处理每一头牛
对于当前牛,枚举所有可能的放置选择
- 放入已有的某个电梯
- 新开一部电梯
- 复杂度
最坏为 \(O(n!)\) ,剪枝后远小于此,可高效通过 \(n=18\) 的数据
2026-02-12
8 习题讲解
8.1 Grouping
8.1.1 状态定义
\(dp[s]\) = 将集合 \(s\) 中的兔子任意分组所得最大得分
\(val[s]\) =将集合 \(s\) 中的所有兔子放在同组时获得得分
8.1.2 状态转移
对于每个状态,有两种情况
- 不拆:整个 \(mask\) 自成一组,得分就是 \(val[mask]\)
- 拆开:把 \(mas\) 分成两个子集 \(A\) 和 \(B\),那么总得分就是 \(dp[A] + dp[B]\)
枚举所有这样的拆分,取最大值
8.1.3 答案统计
答案在 \(dp[(1<<n)-1]\) 中
8.1.4 复杂度计算
枚举所有子集再枚举子集的子集,总复杂度是 \(O(3^n)\),其中 \(n<=16\) ,可通过
8.2 [SDOI2009] Bill的挑战
8.2.1 观察题目
数据范围小,但枚举 \(T\) 显然不行,突破口在于 \(n<=15\)
可以进行状态压缩
8.2.2 状态定义
\(dp[i][mask]\) = 已确定 \(T\) 的前 \(i\) 个字符,还能匹配这 \(i\) 个字符的集合为 \(mask\) 的方案数
8.2.3 状态转移
为了快速知道 “在某个位置填某个字母会刷掉哪些串” ,提前算好一个匹配表
\(f[i][ch]\) = 一个二进制,表示在第 \(i\) 位填字符 \(ch\) 时,哪些模式串合法
8.3 邦邦的大合唱站队
8.3.1 状态定义
\(dp[mask]\) = 已经排好的集合为 \(mask\),这些团体占据了队伍最前面的 \(len(mask)\) 个位置,且这个前缀已经满足 “每个团体连续” 的要求,此时最多能保留在原位的人数
8.3.2 状态转移
$dp[mask | (1<<i)] = max(dp[mask | (1<<i)], dp[mask] + $ 新增段中能保留的i团体人数 $ )$

学习状压DP时记的笔记
浙公网安备 33010602011771号