状压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 状压的优势

通过观察发现,如果我们已经访问过某些城市,现在停留在某个城市,那么具体以什么顺序访问这些城市并不重要,重要的是:

  1. 我们已经访问了哪些城市
  2. 我们现在在哪个城市

这正是状态压缩的用武之地

  • \(n\) 位二进制数表示 \(n\) 个城市的访问状态
  • 这只需要 \(2^n\) 种状态,当 \(n=20\) 时,\(2^{20} ≈ 10^6\),这个数量级是计算机可以处理的

4 状压DP的三大核心要素

4.1 状态设计

状态设计是状态压缩DP的关键,好的状态设计应该

  1. 包含必要信息:状态必须包含所有影响未来决策的信息
  2. 尽可能简洁:不包含冗余信息
  3. 便于转移:能够方便地从其他状态转移而来

4.1.1 经典状态设计:

dp[s][i]=v

  • s:二进制数,表示已访问的集合
  • i:当前所在位置
  • v:达到这种状态的最小代价

4.2 状态转移

两种转移思路

填表法

"我是从哪里来的?"
计算当前状态时,考虑所有可能到达当前状态的前驱状态

刷表法

"我可以去哪里?"
从当前状态出发,更新所有可能到达的后继状态

两种方法各有优劣,填表法通常更直观,刷表法有时可以简化代码

5 从二进制到多进制

5.1 为什么需要多进制?

二进制只能表示两种状态,但有些问题需要更多状态:

  1. 三进制:每个元素有3种状态
  • 例:每个城市最多访问2次 (0,1,2)
  1. 四进制:每个元素有4种状态
  • 例:每个位置可以有4种不同的颜色

5.2 多进制状态的处理

由于计算机没有内置的多进制位运算,我们需要手动实现:

  1. 获取第i位的值\((state / base^i) % base\)
  2. 设置第i位的值:先清除原值,再设置新值
  3. 状态转移:检查当前值是否允许转移,然后更新

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

链接 : Luogu: P3052 [USACO12MAR] Cows in a Skyscraper G

7.1.1 子集枚举DP

  1. 状态定义

\(dp[S]\) 表示已运送的奶牛集合为 \(S\) 时的最少趟数

  1. 状态转移

转移时,枚举最后一次运送的奶牛子集 \(T\subseteq S\)

  1. 初始化和复杂度

初始 \(dp[0]=0\),其余为无穷大

预处理所有合法子集的总重,然后子集枚举转移,复杂度 \(O(3^n)\)。对于 \(n=18\) 可通过

7.1.2 状压DP

  1. 状态定义

\(dp[i][j]\) 表示当前已经使用了 \(i\) 趟电梯,已经运送的奶牛集合为 \(j\)最后一趟电梯当前的载重量

  1. 状态转移

转移时枚举未运送的牛 \(k\)

  1. 初始化和复杂度

初始 \(dp[0][0]=0\),其余为无穷大

复杂度 \(O(2^n*n)\),适合 \(n=20\) 左右

7.1.3 搜索解法

DFS 枚举每头牛应当放入哪一部电梯

  1. 按顺序处理每一头牛

  2. 对于当前牛,枚举所有可能的放置选择

  • 放入已有的某个电梯
  • 新开一部电梯
  1. 复杂度

最坏为 \(O(n!)\) ,剪枝后远小于此,可高效通过 \(n=18\) 的数据

2026-02-12

8 习题讲解

8.1 Grouping

链接 : AtCoder: DP_U - 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的挑战

链接 : Luogu: P2167 [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 邦邦的大合唱站队

链接 : Luogu: P3694 邦邦的大合唱站队

8.3.1 状态定义

\(dp[mask]\) = 已经排好的集合为 \(mask\),这些团体占据了队伍最前面的 \(len(mask)\) 个位置,且这个前缀已经满足 “每个团体连续” 的要求,此时最多能保留在原位的人数

8.3.2 状态转移

$dp[mask | (1<<i)] = max(dp[mask | (1<<i)], dp[mask] + $ 新增段中能保留的i团体人数 $ )$

posted @ 2026-02-10 20:50  Sail-With-Dawn  阅读(55)  评论(0)    收藏  举报