GESP 8级
状压DP
8级 · 高级数据结构与DP
状态压缩动态规划(状压DP)是一种用二进制位来表示"集合状态"的 DP 方法。当问题涉及"哪些元素已经被选过"、"哪些位置已经填过"这样的集合状态时,状压DP 是一种强大的工具。适用于 n ≤ 20 的情况。
💡 这是什么?
生活比喻:想象你在玩一个游戏——把 n 个棋子放在棋盘上,每个位置只能放一个棋子。你需要记录"哪些位置已经放了棋子"。
用二进制表示状态:
如果 n=4,用一个 4 位二进制数表示"哪些位置已使用":
S = 0000₂ → 没有位置被使用
S = 0001₂ → 只有第0个位置被使用
S = 0101₂ → 第0和第2个位置被使用
S = 1111₂ → 所有4个位置都被使用
二进制与集合的对应:
S 的第 i 位为 1 ↔ 第 i 个元素属于集合 S
比如 S = 1010₂(= 10) → 第1和第3个元素在集合中
为什么能用 DP?
集合状态最多有 2^n 种。如果 n ≤ 20,2^n ≈ 100万,可以接受。
dp[S] = 集合状态为 S 时的最优解。S 从小到大枚举,就是一个合法的 DP 顺序。
用二进制表示状态:
如果 n=4,用一个 4 位二进制数表示"哪些位置已使用":
S = 0000₂ → 没有位置被使用
S = 0001₂ → 只有第0个位置被使用
S = 0101₂ → 第0和第2个位置被使用
S = 1111₂ → 所有4个位置都被使用
二进制与集合的对应:
S 的第 i 位为 1 ↔ 第 i 个元素属于集合 S
比如 S = 1010₂(= 10) → 第1和第3个元素在集合中
为什么能用 DP?
集合状态最多有 2^n 种。如果 n ≤ 20,2^n ≈ 100万,可以接受。
dp[S] = 集合状态为 S 时的最优解。S 从小到大枚举,就是一个合法的 DP 顺序。
🌟 为什么重要?
• TSP(旅行商问题):最经典的状压DP应用,n≤20的NP问题可以求精确解
• 任务分配:把 n 个任务分给 n 个人,每人一个,求最小代价
• 棋盘覆盖:骨牌覆盖棋盘、放置车/皇后等问题
• GESP 8级常考:是DP章节的高难度考点
• 思维训练:培养"用二进制表示状态"的编程思维
• 任务分配:把 n 个任务分给 n 个人,每人一个,求最小代价
• 棋盘覆盖:骨牌覆盖棋盘、放置车/皇后等问题
• GESP 8级常考:是DP章节的高难度考点
• 思维训练:培养"用二进制表示状态"的编程思维
📋 前置知识(学这个之前你需要知道)
1. 位运算(GESP 2-3级):&, |, <<, >>, ~(取反),特别是判断第i位是否为1
2. 动态规划基础(GESP 5-6级):状态定义、转移方程、记忆化
3. 二进制表示:理解 n 位二进制可以表示 2^n 种状态
4. 枚举子集技巧:for (int sub = S; sub; sub = (sub-1) & S) 枚举S的所有非空子集
2. 动态规划基础(GESP 5-6级):状态定义、转移方程、记忆化
3. 二进制表示:理解 n 位二进制可以表示 2^n 种状态
4. 枚举子集技巧:for (int sub = S; sub; sub = (sub-1) & S) 枚举S的所有非空子集
📐 位运算常用技巧
// 判断第 i 位是否为 1:S & (1<<i) != 0
// 把第 i 位设为 1:S | (1<<i)
// 把第 i 位设为 0:S & ~(1<<i)
// 枚举 S 的所有非空子集:
for (int sub = S; sub; sub = (sub-1) & S)
// 统计 S 中 1 的个数:__builtin_popcount(S)
// 把第 i 位设为 1:S | (1<<i)
// 把第 i 位设为 0:S & ~(1<<i)
// 枚举 S 的所有非空子集:
for (int sub = S; sub; sub = (sub-1) & S)
// 统计 S 中 1 的个数:__builtin_popcount(S)
📐 DP数组怎么来的?(以TSP为例)
问题:n个城市,从城市0出发,经过所有城市恰好一次再回到0,求最短路径。
第1步:想清楚"状态"是什么
需要记录:① 哪些城市已经去过 ② 当前在哪个城市
→ dp[S][i] = 已经过的城市集合为 S,当前在城市 i 的最短路径
第2步:想清楚"转移"怎么做
在城市 i,可以去任何不在 S 中的城市 v:
→ dp[S | (1<<v)][v] = min(dp[S | (1<<v)][v], dp[S][i] + g[i][v])
第3步:初始条件
从城市0出发,只经过了城市0:
→ dp[1][0] = 0(1 = 000...001₂,第0位为1表示城市0已访问)
第4步:最终答案
所有城市都经过了,回到城市0:
→ ans = min(dp[(1<<n)-1][i] + g[i][0]) for all i
其中 (1<<n)-1 表示所有位都是1(全部城市都经过)
第1步:想清楚"状态"是什么
需要记录:① 哪些城市已经去过 ② 当前在哪个城市
→ dp[S][i] = 已经过的城市集合为 S,当前在城市 i 的最短路径
第2步:想清楚"转移"怎么做
在城市 i,可以去任何不在 S 中的城市 v:
→ dp[S | (1<<v)][v] = min(dp[S | (1<<v)][v], dp[S][i] + g[i][v])
第3步:初始条件
从城市0出发,只经过了城市0:
→ dp[1][0] = 0(1 = 000...001₂,第0位为1表示城市0已访问)
第4步:最终答案
所有城市都经过了,回到城市0:
→ ans = min(dp[(1<<n)-1][i] + g[i][0]) for all i
其中 (1<<n)-1 表示所有位都是1(全部城市都经过)
💻 TSP旅行商问题 完整代码(带详细注释)
#include <iostream>
#include <cstring>
#include <algorithm>
using namespace std;
const int N = 20;
const int INF = 0x3f3f3f3f;
int g[N][N]; // g[i][j] = 城市 i 到城市 j 的距离
int dp[1<<N][N]; // dp[S][i] = 经过的城市集合为S,当前在城市i的最短距离
int n; // 城市数量
int tsp() {
// 第1步:初始化——把所有状态设为"不可达"
memset(dp, 0x3f, sizeof(dp));
// 0x3f 在 memset 中表示把每个字节设为 0x3f
// 对于 int 数组,相当于每个元素设为 0x3f3f3f3f(一个很大的数)
// 第2步:初始状态——从城市0出发,只经过了城市0
// 1 = 1<<0 = 二进制 000...001,表示"只有第0位是1"
dp[1][0] = 0;
// 第3步:枚举所有城市集合 S
for (int S = 1; S < (1 << n); S++) {
// 枚举"当前在哪个城市 u"(u 必须在集合 S 中)
for (int u = 0; u < n; u++) {
// 如果 u 不在集合 S 中,跳过
if (!(S & (1 << u))) continue;
// 枚举"下一个去哪个城市 v"(v 必须不在集合 S 中)
for (int v = 0; v < n; v++) {
if (S & (1 << v)) continue; // v 已在 S 中,跳过
// 转移:从 u 去 v
// 新集合 = S 加入 v = S | (1<<v)
int newS = S | (1 << v);
dp[newS][v] = min(dp[newS][v],
dp[S][u] + g[u][v]);
// 含义:在状态 S 下从 u 出发去 v,
// 比已经记录的距离更短就更新
}
}
}
// 第4步:求答案——所有城市都经过,再回到城市0
int ans = INF;
int fullS = (1 << n) - 1; // 全1,表示所有城市都经过了
for (int u = 1; u < n; u++) {
// 从每个城市 u 出发,回到城市 0
ans = min(ans, dp[fullS][u] + g[u][0]);
}
return ans;
}
int main() {
cin >> n;
for (int i = 0; i < n; i++)
for (int j = 0; j < n; j++)
cin >> g[i][j];
cout << tsp() << endl;
return 0;
}
🔍 执行过程举例(n=4)
假设 4 个城市的距离矩阵已给出。
初始:dp[0001₂][0] = 0(只有城市0已访问,在城市0)
从 S=0001₂ 出发:
u=0(在S中),可以去 v=1,2,3
dp[0011₂][1] = dp[0001₂][0] + g[0][1]
dp[0101₂][2] = dp[0001₂][0] + g[0][2]
dp[1001₂][3] = dp[0001₂][0] + g[0][3]
从 S=0011₂ 出发(已访问0和1,在1):
u=1,可以去 v=2,3
dp[0111₂][2] = dp[0011₂][1] + g[1][2]
dp[1011₂][3] = dp[0011₂][1] + g[1][3]
... 继续枚举,最终 S=1111₂ 时所有城市都访问了
答案:min(dp[1111₂][i] + g[i][0]) for i=1,2,3
初始:dp[0001₂][0] = 0(只有城市0已访问,在城市0)
从 S=0001₂ 出发:
u=0(在S中),可以去 v=1,2,3
dp[0011₂][1] = dp[0001₂][0] + g[0][1]
dp[0101₂][2] = dp[0001₂][0] + g[0][2]
dp[1001₂][3] = dp[0001₂][0] + g[0][3]
从 S=0011₂ 出发(已访问0和1,在1):
u=1,可以去 v=2,3
dp[0111₂][2] = dp[0011₂][1] + g[1][2]
dp[1011₂][3] = dp[0011₂][1] + g[1][3]
... 继续枚举,最终 S=1111₂ 时所有城市都访问了
答案:min(dp[1111₂][i] + g[i][0]) for i=1,2,3
⏱ 复杂度分析
状态数:2^n × n(S 有 2^n 种,每种有 n 个"当前城市")
每个状态转移:O(n)(枚举下一个城市)
总复杂度:O(2^n × n²)
n=20 时:2^20 × 400 ≈ 4亿,在时间限制内勉强可以
n=25 时:2^25 × 625 ≈ 200亿,超时!所以状压DP 只适用于 n≤20
每个状态转移:O(n)(枚举下一个城市)
总复杂度:O(2^n × n²)
n=20 时:2^20 × 400 ≈ 4亿,在时间限制内勉强可以
n=25 时:2^25 × 625 ≈ 200亿,超时!所以状压DP 只适用于 n≤20
⚠️ 易错点
1. dp 数组大小 → 要开 (1<<N) × N,N=20 时大约 2000万,内存约 80MB(用 int)。别开太大溢出。
2. 初始化用 0x3f → memset(dp, 0x3f, sizeof(dp)) 把每个元素设为 0x3f3f3f3f,作为"无穷大"。不要用 0x7f(会溢出变成负数)。
3. S 的枚举范围 → 从 1 到 (1<<n)-1,不包括 0(空集)。
4. 别忘了判 u 是否在 S 中 → if (!(S & (1<<u))) continue; 这行很重要。
5. 最终答案要加 g[u][0] → 回到起点的距离别忘了加上。
6. 二进制位从0开始 → 第0位对应第0个城市,(1<<0)=1。
2. 初始化用 0x3f → memset(dp, 0x3f, sizeof(dp)) 把每个元素设为 0x3f3f3f3f,作为"无穷大"。不要用 0x7f(会溢出变成负数)。
3. S 的枚举范围 → 从 1 到 (1<<n)-1,不包括 0(空集)。
4. 别忘了判 u 是否在 S 中 → if (!(S & (1<<u))) continue; 这行很重要。
5. 最终答案要加 g[u][0] → 回到起点的距离别忘了加上。
6. 二进制位从0开始 → 第0位对应第0个城市,(1<<0)=1。
🎯 练习建议
入门练习:
• 洛谷 P1896 互不侵犯(状压DP入门,棋盘问题)
• 洛谷 P1011 车站(简单DP,感受状态表示)
进阶练习:
• 洛谷 P1001 旅行商问题(TSP经典,就是上面的模板题)
• 洛谷 P2704 [NOI2001]炮兵阵地(经典状压DP)
• 洛谷 P3272 [SCOI2010]幸运数字(状压DP + 优化)
学习方法:
① 先学会"用二进制表示集合":判断、添加、删除元素
② 再理解 dp[S] 的含义——S 就是你的"状态"
③ 从简单题入手(n≤12),手动模拟 dp 的填充过程
④ 理解后直接背 TSP 模板
• 洛谷 P1896 互不侵犯(状压DP入门,棋盘问题)
• 洛谷 P1011 车站(简单DP,感受状态表示)
进阶练习:
• 洛谷 P1001 旅行商问题(TSP经典,就是上面的模板题)
• 洛谷 P2704 [NOI2001]炮兵阵地(经典状压DP)
• 洛谷 P3272 [SCOI2010]幸运数字(状压DP + 优化)
学习方法:
① 先学会"用二进制表示集合":判断、添加、删除元素
② 再理解 dp[S] 的含义——S 就是你的"状态"
③ 从简单题入手(n≤12),手动模拟 dp 的填充过程
④ 理解后直接背 TSP 模板