
状压DP:从原理到实战的深度解析(对话版)
引言
状压DP(状态压缩动态规划)是动态规划中针对"状态数量有限且可编码为整数"场景的核心分支,其核心思想是用整数的二进制位表示离散的状态集合,通过位运算快速完成状态的转移与合并。它本质是线性DP/区间DP的扩展,专门解决"状态无法用少量维度表示,但总状态数可控(通常≤2²⁰)"的问题,比如旅行商问题(TSP)、集合覆盖、棋盘放置等。本文以"新手提问+导师解答"的形式,从核心概念到经典例题,帮你彻底吃透状压DP的逻辑与实现。
一、初识状压DP:它解决什么问题?
新手 :导师您好!我学完线性DP后,看到状压DP的"状态压缩"就懵了------到底什么是状态压缩?状压DP和普通DP的核心区别是什么?
导师:别急,咱们先从核心特征和适用场景入手:
1. 状压DP的核心定义
"状压"即状态压缩:将一个"离散的状态集合"(比如"选了哪些城市""棋盘上哪些格子放了棋子")编码为一个整数,用整数的二进制位表示每个元素的状态(0/1表示"未选/已选""未放置/已放置")。
举个直观例子:
- 若有4个城市,"选了第0、2号城市"这个状态,可编码为二进制
1010(从右到左对应0~3号城市),即十进制的10; - 若有5个格子,"第1、3号格子放了棋子"编码为二进制
1010(十进制10),第k位为1表示第k个元素处于"选中/激活"状态。
2. 状压DP的典型特征
- 状态是离散集合:问题的核心状态是"哪些元素被选中"(如城市、格子、物品),而非"第i个元素的状态";
- 总状态数可控:若有n个元素,总状态数为2ⁿ,通常要求n≤20(2²⁰≈1e6,可遍历;n=25时2²⁵≈3e7,需优化);
- 位运算为核心:通过与(&)、或(|)、异或(^)、移位(<<、>>)等操作快速修改/查询状态;
- 无后效性:压缩后的状态包含所有必要信息,当前状态的转移仅依赖前序状态。
3. 状压DP vs 普通DP
| 维度 | 普通线性DP | 状压DP |
|---|---|---|
| 状态表示 | dp[i](第i个元素的状态) | dp[mask](mask编码状态集合) |
| 状态维度 | 1~2维 | 1维(mask)或2维(mask+i) |
| 核心操作 | 数组遍历 | 位运算 |
| 适用场景 | 线性/二维结构 | 集合选择、排列组合类问题 |
| 时间复杂度 | O(n)~O(n²) | O(n×2ⁿ)(n为元素数) |
状压DP的前置知识
学习前必须掌握:
- 二进制与十进制转换:理解二进制位的含义(如第k位对应2ᵏ);
- 位运算基础 :
mask & (1 << k):判断第k位是否为1(查询状态);mask | (1 << k):将第k位设为1(添加元素到状态);mask & ~(1 << k):将第k位设为0(移除元素);__builtin_popcount(mask):GCC内置函数,统计mask中1的个数(需包含头文件);
- 动态规划核心思想:最优子结构、无后效性。
二、状压DP核心框架:四步走
新手 :状压DP有没有通用的实现框架?看起来全是位运算,感觉无从下手。
导师:状压DP的框架非常固定,核心是"状态编码→状态定义→初始化→状态转移",具体分四步:
步骤1:状态编码(核心前提)
将问题中的"离散状态集合"编码为整数mask:
- 为每个元素分配一个唯一的索引(0~n-1);
- 用mask的第k位表示"第k个元素是否处于目标状态"(0=否,1=是);
- 确定mask的取值范围(0 ~ 2ⁿ - 1)。
示例:n=3个元素(A、B、C,索引0、1、2):
- 空集:mask=0(二进制
000); - 仅选A:mask=1(
001); - 选A和C:mask=5(
101); - 选所有元素:mask=7(
111)。
步骤2:状态定义
根据问题要求,定义dp[mask]的含义:
- 计数类问题:
dp[mask]表示"达到mask状态的方案数"; - 最优解类问题:
dp[mask]表示"达到mask状态的最小/最大代价"; - 进阶场景:
dp[mask][k]表示"达到mask状态且最后一步在k位置的代价"(如TSP问题)。
关键原则:mask必须包含"推导下一步状态所需的所有信息",且无后效性。
步骤3:初始化
为初始状态赋合理值:
- 计数类:初始状态(如空集)
dp[0] = 1(表示"空状态有1种方案"); - 最优解类:初始状态
dp[0] = 0,其余状态初始化为无穷大/负无穷(如dp[mask] = INF); - 特殊场景:若初始状态是"选第k个元素",则
dp[1<<k] = 初始代价。
步骤4:状态转移
遍历所有可能的mask,对每个mask,通过位运算推导其可达的下一个状态new_mask,并更新dp[new_mask]:
- 遍历mask(0 ~ 2ⁿ - 1);
- 对每个mask,遍历所有元素k,判断k是否在mask中(
(mask & (1<<k)) != 0); - 若k不在mask中,构造
new_mask = mask | (1<<k); - 根据问题逻辑,更新
dp[new_mask](如dp[new_mask] += dp[mask]或dp[new_mask] = min(dp[new_mask], dp[mask] + cost))。
状压DP通用模板(伪代码)
cpp
// n:元素总数,max_mask = 1<<n(总状态数)
int n;
const int MAX_MASK = 1 << 20; // 最多支持20个元素
long long dp[MAX_MASK]; // 状态数组
int cost[20][20]; // 可选:元素间的转移代价
int main() {
// 步骤1:输入初始化(如cost数组)
cin >> n;
for (int i = 0; i < n; ++i) {
for (int j = 0; j < n; ++j) {
cin >> cost[i][j];
}
}
// 步骤2:状态初始化
memset(dp, INF, sizeof(dp)); // 最优解类初始化为无穷大
dp[0] = 0; // 空状态代价为0
// 步骤3:状态转移
for (int mask = 0; mask < (1 << n); ++mask) { // 遍历所有状态
if (dp[mask] == INF) continue; // 跳过无效状态
// 遍历所有元素,寻找可添加的元素k
for (int k = 0; k < n; ++k) {
if (mask & (1 << k)) continue; // k已在mask中,跳过
// 构造新状态
int new_mask = mask | (1 << k);
// 状态转移:根据问题更新dp[new_mask]
dp[new_mask] = min(dp[new_mask], dp[mask] + cost[__builtin_popcount(mask)][k]);
}
}
// 步骤4:提取答案(通常是dp[(1<<n)-1],即所有元素都选中的状态)
cout << dp[(1 << n) - 1] << endl;
return 0;
}
二、入门例题1:二进制枚举型状压DP------子集和问题
新手 :先从最简单的状压DP入手吧!子集和问题是入门级的,具体该怎么实现?
导师:子集和问题是状压DP的"Hello World",核心是用mask表示"选了哪些元素",遍历所有子集计算和,判断是否存在目标和。
问题定义
给定一个长度为n的整数数组nums和目标和target,判断是否存在一个子集,其元素和等于target(n≤20)。
解题思路
- 状态编码:mask的第k位表示"是否选第k个元素";
- 状态定义 :
dp[mask]表示"mask对应的子集的元素和"; - 初始化 :
dp[0] = 0(空子集和为0); - 状态转移 :
对每个mask,遍历元素k:若k未被选中,则dp[mask | (1<<k)] = dp[mask] + nums[k]; - 答案提取 :遍历所有mask,若存在
dp[mask] == target,返回true。
完整代码实现(C++)
cpp
#include <iostream>
#include <vector>
#include <cstring>
using namespace std;
bool subsetSum(vector<int>& nums, int target) {
int n = nums.size();
const int MAX_MASK = 1 << n;
vector<int> dp(MAX_MASK, -1); // -1表示未计算
// 初始化
dp[0] = 0;
// 状态转移
for (int mask = 0; mask < MAX_MASK; ++mask) {
if (dp[mask] == -1) continue; // 跳过无效状态
for (int k = 0; k < n; ++k) {
if (mask & (1 << k)) continue; // k已选,跳过
int new_mask = mask | (1 << k);
if (dp[new_mask] == -1) { // 未计算过才更新
dp[new_mask] = dp[mask] + nums[k];
}
// 提前判断:找到目标和直接返回
if (dp[new_mask] == target) {
return true;
}
}
}
// 遍历所有状态,确认是否存在目标和
for (int mask = 0; mask < MAX_MASK; ++mask) {
if (dp[mask] == target) {
return true;
}
}
return false;
}
int main() {
vector<int> nums = {3, 34, 4, 12, 5, 2};
int target = 9;
if (subsetSum(nums, target)) {
cout << "存在和为" << target << "的子集" << endl; // 输出:存在(4+5或3+2+4)
} else {
cout << "不存在" << endl;
}
return 0;
}
代码核心解析
- 状态编码 :mask的每一位对应数组元素的"选/不选",比如mask=5(
101)表示选第0、2号元素; - 提前剪枝 :计算过程中若发现
dp[new_mask] == target,直接返回true,无需遍历所有状态; - 空间优化 :用
vector<int>代替固定数组,适配不同n的情况; - 位运算关键 :
mask & (1<<k)快速判断k是否被选中,mask | (1<<k)快速构造新状态。
新手 :为什么n≤20?如果n=21,2²¹=2097152,会不会超时?
导师 :n=20时总状态数是1,048,576,遍历所有状态+每个状态遍历20个元素,总运算量约2e7,在C++中可轻松处理;n=21时运算量翻倍到4e7,可能超时(需优化);n=30时2³⁰≈1e9,完全无法遍历------这是状压DP的核心限制:仅适用于n≤20的场景。
三、入门例题2:路径型状压DP------旅行商问题(TSP)
新手 :TSP问题是状压DP的经典应用,看起来比子集和复杂,该怎么理解?
导师:TSP(旅行商问题)是状压DP的核心例题,问题描述是"给定n个城市和两两之间的距离,求从起点出发遍历所有城市并返回起点的最短路径",n≤15时用状压DP可解。
问题定义
给定n个城市(编号0~n-1),cost[i][j]表示从城市i到j的距离,求从城市0出发,遍历所有城市并返回0的最短路径长度。
解题思路
- 状态编码 :mask表示"已访问的城市集合",比如mask=5(
101)表示已访问0、2号城市; - 状态定义 :
dp[mask][u]表示"已访问mask中的城市,且当前在城市u的最短路径长度"; - 初始化 :
dp[1<<0][0] = 0(仅访问城市0,当前在0,路径长度0);- 其余状态初始化为无穷大(INF);
- 状态转移 :
对每个mask,遍历当前城市u(u在mask中),再遍历未访问的城市v:- 新状态
new_mask = mask | (1<<v); - 转移方程:
dp[new_mask][v] = min(dp[new_mask][v], dp[mask][u] + cost[u][v]);
- 新状态
- 答案提取 :遍历所有u,
dp[(1<<n)-1][u] + cost[u][0]的最小值(遍历所有城市后返回起点0)。
完整代码实现(C++)
cpp
#include <iostream>
#include <vector>
#include <cstring>
#include <algorithm>
using namespace std;
const int INF = 0x3f3f3f3f;
int tsp(vector<vector<int>>& cost) {
int n = cost.size();
const int MAX_MASK = 1 << n;
// dp[mask][u]:已访问mask,当前在u的最短路径
vector<vector<int>> dp(MAX_MASK, vector<int>(n, INF));
// 初始化:仅访问城市0,当前在0
dp[1 << 0][0] = 0;
// 状态转移:遍历所有mask
for (int mask = 0; mask < MAX_MASK; ++mask) {
// 遍历当前城市u(u必须在mask中)
for (int u = 0; u < n; ++u) {
if (!(mask & (1 << u)) || dp[mask][u] == INF) {
continue; // u不在mask中,或状态无效
}
// 遍历未访问的城市v
for (int v = 0; v < n; ++v) {
if (mask & (1 << v)) continue; // v已访问,跳过
int new_mask = mask | (1 << v);
// 更新新状态的最短路径
dp[new_mask][v] = min(dp[new_mask][v], dp[mask][u] + cost[u][v]);
}
}
}
// 提取答案:遍历所有城市后返回起点0
int full_mask = (1 << n) - 1; // 所有城市都访问的状态
int ans = INF;
for (int u = 0; u < n; ++u) {
ans = min(ans, dp[full_mask][u] + cost[u][0]);
}
return ans;
}
int main() {
// 测试案例:4个城市的距离矩阵
vector<vector<int>> cost = {
{0, 2, 6, 5},
{2, 0, 4, 4},
{6, 4, 0, 2},
{5, 4, 2, 0}
};
int min_path = tsp(cost);
cout << "TSP最短路径长度:" << min_path << endl; // 输出:13(0→1→2→3→0:2+4+2+5=13)
return 0;
}
核心难点解析
- 二维状态定义 :
dp[mask][u]同时记录"已访问的城市"和"当前位置",这是TSP的核心------仅用mask无法推导下一步的转移代价; - 状态转移逻辑:必须从"当前城市u"转移到"未访问城市v",保证路径的连续性;
- 答案提取:需加上"从最后一个城市返回起点0"的代价,才是完整的闭环路径;
- 复杂度分析:时间复杂度O(n²×2ⁿ),n=15时运算量=15²×2¹⁵=15²×32768=7,372,800,完全可解。
四、进阶例题:棋盘型状压DP------国王放置问题
新手 :棋盘类问题也是状压DP的常见场景,比如"国王放置",该怎么用状压DP解决?
导师:棋盘型状压DP的核心是"用mask表示一行的放置状态,通过位运算判断行内/行间的合法性",以"n×n棋盘放置国王,国王不能相邻(包括斜相邻),求最多放置数量"为例:
问题定义
在n×n的棋盘上放置国王,要求任意两个国王不能相邻(上下、左右、斜相邻),求最多能放置多少个国王(n≤10)。
解题思路
- 状态编码:mask的第k位表示"第k列是否放置国王";
- 预处理 :
- 行内合法性:mask不能有相邻的1(
(mask & (mask << 1)) == 0),避免左右相邻;
- 行内合法性:mask不能有相邻的1(
- 状态定义 :
dp[i][mask]表示"第i行用mask状态放置国王,前i行的最大放置数"; - 初始化 :第一行的所有合法mask,
dp[1][mask] = __builtin_popcount(mask); - 状态转移 :
对第i行的mask_i,遍历第i-1行的mask_j:- 行间合法性:
(mask_i & mask_j) == 0(无上下相邻)、(mask_i & (mask_j << 1)) == 0(无右下相邻)、(mask_i & (mask_j >> 1)) == 0(无左下相邻); - 转移方程:
dp[i][mask_i] = max(dp[i][mask_i], dp[i-1][mask_j] + cnt(mask_i))(cnt是mask_i中1的个数);
- 行间合法性:
- 答案提取 :第n行所有合法mask的
dp[n][mask]的最大值。
完整代码实现(C++)
cpp
#include <iostream>
#include <vector>
#include <cstring>
#include <algorithm>
using namespace std;
const int MAXN = 10;
const int MAX_MASK = 1 << MAXN;
int dp[MAXN + 1][MAX_MASK]; // dp[i][mask]:第i行状态mask的最大放置数
vector<int> valid_masks; // 存储所有行内合法的mask
// 预处理行内合法的mask(无相邻1)
void preprocess(int n) {
valid_masks.clear();
for (int mask = 0; mask < (1 << n); ++mask) {
if ((mask & (mask << 1)) == 0) { // 无左右相邻
valid_masks.push_back(mask);
}
}
}
int kingPlacement(int n) {
preprocess(n);
memset(dp, 0, sizeof(dp));
// 初始化第一行
for (int mask : valid_masks) {
dp[1][mask] = __builtin_popcount(mask);
}
// 状态转移:遍历第2~n行
for (int i = 2; i <= n; ++i) {
// 遍历第i行的合法mask
for (int mask_i : valid_masks) {
int cnt_i = __builtin_popcount(mask_i);
// 遍历第i-1行的合法mask
for (int mask_j : valid_masks) {
// 判断行间合法性:无上下、斜相邻
if ((mask_i & mask_j) == 0 && // 无上下相邻
(mask_i & (mask_j << 1)) == 0 && // 无右下相邻
(mask_i & (mask_j >> 1)) == 0) { // 无左下相邻
dp[i][mask_i] = max(dp[i][mask_i], dp[i-1][mask_j] + cnt_i);
}
}
}
}
// 提取答案:第n行所有合法mask的最大值
int ans = 0;
for (int mask : valid_masks) {
ans = max(ans, dp[n][mask]);
}
return ans;
}
int main() {
int n = 4;
int max_king = kingPlacement(n);
cout << n << "×" << n << "棋盘最多放置" << max_king << "个国王" << endl; // 输出:4(每行放1个,错开位置)
return 0;
}
核心解析
- 预处理优化 :提前筛选出所有行内合法的mask,避免遍历无效状态(比如mask=3(
11)有相邻1,直接跳过); - 行间合法性判断:通过三次位运算,确保国王无上下、斜相邻;
- __builtin_popcount:GCC内置函数,快速统计mask中1的个数(即当前行放置的国王数);
- 复杂度优化:n=10时,行内合法mask约200个,总运算量=10×200×200=4e4,效率极高。
五、状压DP的常见坑点与优化
新手 :学习状压DP容易踩哪些坑?有没有通用的优化技巧?
导师:状压DP的核心坑点集中在"位运算"和"状态有效性",常见问题和优化技巧如下:
常见坑点
- 位运算移位错误 :比如
1 << k写成1 << (k+1),导致状态编码错误; - 无效状态未跳过 :遍历mask时未跳过
dp[mask] = INF的状态,增加无意义计算; - 状态定义不全 :比如TSP中仅用
dp[mask]而不记录当前城市,导致无法转移; - 行间合法性遗漏:棋盘问题中忘记判断斜相邻,导致答案错误;
- 数据溢出 :未用
long long存储路径长度/方案数,导致int溢出。
通用优化技巧
- 预处理合法状态 :
- 提前筛选出所有行内/集合内合法的mask(如棋盘问题的无相邻1、子集和的有效子集),减少遍历次数;
- 状态压缩(二维→一维) :
- 若状态转移仅依赖前一行/前一状态,可用滚动数组(如
dp[2][MAX_MASK])代替二维数组,降低空间复杂度;
- 若状态转移仅依赖前一行/前一状态,可用滚动数组(如
- 位运算加速 :
- 用
__builtin_ctz(mask)(统计末尾0的个数)、__builtin_clz(mask)(统计开头0的个数)快速定位mask中的1; - 用
mask ^ (1 << k)快速翻转第k位状态;
- 用
- 剪枝优化 :
- 最优解类问题中,若
dp[new_mask]已小于当前计算值,直接跳过; - 计数类问题中,提前判断mask的和/数量是否超过目标,终止无效转移;
- 最优解类问题中,若
- 哈希表替代数组 :
- 若mask稀疏(大部分状态无效),用
unordered_map<int, int>存储dp,仅保存有效状态(如n=20时,有效状态可能仅1e5,远小于1e6)。
- 若mask稀疏(大部分状态无效),用
六、状压DP的经典应用场景
新手 :除了上述例题,状压DP还能解决哪些问题?
导师:状压DP的应用场景集中在"集合选择/排列"类问题,常见场景:
- 路径类 :
- 旅行商问题(TSP)、最短哈密顿路径(遍历所有节点的最短路径);
- 多源点路径规划(选哪些点作为中转站);
- 子集类 :
- 子集和、子集乘积、划分等和子集;
- 集合覆盖(选最少子集覆盖所有元素);
- 棋盘类 :
- 国王/皇后/车的放置问题(无冲突);
- 多米诺骨牌覆盖(进阶:轮廓线DP+状压);
- 组合类 :
- 组队问题(选哪些队员,满足属性约束);
- 任务分配(n个任务分配给n个人,最小代价)。
七、总结
核心要点回顾
- 状压DP核心:用整数的二进制位编码状态集合,通过位运算快速完成状态转移,解决"状态是离散集合且总数量可控"的问题;
- 通用框架 :
- 编码:将状态集合转为整数mask(二进制位表示元素状态);
- 定义:
dp[mask]或dp[mask][u]表示状态对应的最优解/计数; - 初始化:为初始状态(如空集、仅选第一个元素)赋合理值;
- 转移:遍历mask,通过位运算构造新状态,更新dp;
- 提取:取最终状态(如全选mask)的dp值;
- 关键技巧 :
- 预处理合法状态,减少无效遍历;
- 位运算快速判断/修改状态(
&/|/<</>>); - 二维状态转一维(滚动数组),优化空间;
- 避坑指南 :
- 确保位运算移位位数正确(第k位对应
1<<k); - 跳过无效状态(如
dp[mask] = INF); - 状态定义需包含"转移所需的所有信息"(如TSP的当前城市)。
- 确保位运算移位位数正确(第k位对应
学习建议
状压DP的核心是"二进制思维+位运算",建议你:
- 先吃透子集和、TSP这两个入门例题,手动推导小案例的mask转移(比如n=3的TSP),理解二进制位的含义;
- 练习棋盘放置问题,掌握"行内/行间合法性判断"的位运算技巧;
- 尝试优化解法(如滚动数组、哈希表存储dp),理解优化的原理;
- 结合真题(如LeetCode的"最短路径访问所有节点""划分等和子集")巩固,重点关注"状态编码"------只要编码正确,转移逻辑往往水到渠成。
记住:状压DP的核心不是"背模板",而是"理解如何用二进制表示状态"。从n=5的小案例入手,手动写出mask对应的状态,你会发现所有状压DP问题都能套入"编码→定义→初始化→转移→提取"的框架中。