在C++中,状态压缩动态规划(State Compression DP,简称状压DP) 是一种通过 二进制位运算 高效表示离散状态集合的动态规划方法,特别适用于解决 组合优化 和 排列选择 类问题。其核心思想是将多维状态压缩为整数,利用位操作快速进行状态转移。以下是状压DP的详细解析与实战指南:
DP_2">一、状压DP的核心思想
-
状态表示
用二进制数的每一位(bit)表示某个元素的 存在性 或 状态。例如:- 用
00101
表示第0位和第2位被选中(从右向左数) - 用
mask = 1 << k
表示第k
位被激活
- 用
-
适用场景
- 元素数量较少(通常
n ≤ 20
,因为2^20 ≈ 1e6
在内存可接受范围内) - 需要快速判断元素之间的组合关系(如覆盖、冲突、依赖)
- 元素数量较少(通常
-
优势
- 将多维状态压缩为一维整数,简化状态管理
- 利用位运算高效处理状态转移(与、或、异或、移位)
DP_19">二、状压DP的解题步骤
-
定义状态
确定状态变量dp[mask]
,其中mask
是一个二进制整数,表示当前已选/已访问的元素组合。 -
初始化状态
通常dp[0] = 0
或根据问题需求设置初始值。 -
状态转移方程
遍历所有可能的状态转移路径,更新dp[mask]
。例如:for (int mask = 0; mask < (1 << n); ++mask) {for (int i = 0; i < n; ++i) {if (!(mask & (1 << i))) { // 第i位未被选中int new_mask = mask | (1 << i);dp[new_mask] = min(dp[new_mask], dp[mask] + cost[i]);}} }
-
提取结果
最终答案通常位于dp[(1 << n) - 1]
(全选状态)或特定状态。
三、位运算技巧
1. 基本操作
操作 | 代码 | 说明 |
---|---|---|
检查第k 位是否为1 | mask & (1 << k) | 结果为非0表示存在 |
设置第k 位为1 | `mask | (1 << k)` |
设置第k 位为0 | mask & ~(1 << k) | 取消选中第k 个元素 |
切换第k 位状态 | mask ^ (1 << k) | 取反第k 位 |
统计1的个数 | __builtin_popcount(mask) | GCC内置函数 |
2. 高级操作
-
枚举子集:遍历所有子集(用于集合覆盖类问题)
for (int subset = mask; subset; subset = (subset - 1) & mask) {// 处理子集subset }
-
快速判断状态转移可行性:检查是否满足约束条件(如无冲突)
四、经典问题与代码实现
1. 旅行商问题(TSP)
问题:访问所有城市恰好一次,求最短路径。
状态定义:dp[mask][u]
表示已访问城市集合为mask
,当前位于城市u
的最短路径。
状态转移:dp[mask | (1 << v)][v] = min(dp[mask][u] + dist[u][v])
int tsp(vector<vector<int>>& dist) {int n = dist.size();vector<vector<int>> dp(1 << n, vector<int>(n, INT_MAX));// 初始化:从起点0出发dp[1][0] = 0; // mask=000...001,表示只访问了城市0for (int mask = 1; mask < (1 << n); ++mask) {for (int u = 0; u < n; ++u) {if (!(mask & (1 << u))) continue; // u不在当前路径中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] + dist[u][v]);}}}// 返回从终点回到起点的最短路径int final_mask = (1 << n) - 1;int res = INT_MAX;for (int u = 0; u < n; ++u) {if (dp[final_mask][u] != INT_MAX && dist[u][0] != INT_MAX) {res = min(res, dp[final_mask][u] + dist[u][0]);}}return res;
}
2. 棋盘覆盖问题(LeetCode 52. N皇后 II)
问题:在n x n
棋盘放置皇后,使得彼此不能互相攻击,求方案数。
状态定义:用位掩码表示列、主对角线、副对角线的占用情况。
int totalNQueens(int n) {int count = 0;function<void(int, int, int, int)> dfs = [&](int row, int cols, int diag1, int diag2) {if (row == n) {count++;return;}// 计算当前行可放置的位置int available = ((1 << n) - 1) & ~(cols | diag1 | diag2);while (available) {int pos = available & -available; // 取最低位的1available ^= pos; // 移除该位置dfs(row + 1, cols | pos, (diag1 | pos) << 1, (diag2 | pos) >> 1);}};dfs(0, 0, 0, 0);return count;
}
3. 最小顶点覆盖(LeetCode 943. 最短超级串)
问题:合并多个字符串为一个最短超级串,要求所有字符串都是其子串。
状态定义:dp[mask][i]
表示已选字符串集合为mask
,最后一个字符串为i
时的最小长度。
string shortestSuperstring(vector<string>& words) {int n = words.size();vector<vector<int>> overlap(n, vector<int>(n, 0));// 预处理计算两两字符串的重叠长度for (int i = 0; i < n; ++i) {for (int j = 0; j < n; ++j) {if (i == j) continue;int len = min(words[i].size(), words[j].size());for (int k = len; k >= 0; --k) {if (words[i].substr(words[i].size() - k) == words[j].substr(0, k)) {overlap[i][j] = k;break;}}}}// 状压DPvector<vector<int>> dp(1 << n, vector<int>(n, INT_MAX));vector<vector<int>> parent(1 << n, vector<int>(n, -1));// 初始化:只选一个字符串for (int i = 0; i < n; ++i) {dp[1 << i][i] = words[i].size();}// 状态转移for (int mask = 1; mask < (1 << n); ++mask) {for (int last = 0; last < n; ++last) {if (!(mask & (1 << last))) continue;for (int next = 0; next < n; ++next) {if (mask & (1 << next)) continue;int new_mask = mask | (1 << next);int new_len = dp[mask][last] + words[next].size() - overlap[last][next];if (new_len < dp[new_mask][next]) {dp[new_mask][next] = new_len;parent[new_mask][next] = last;}}}}// 回溯构造结果int min_len = INT_MAX, last = -1;int full_mask = (1 << n) - 1;for (int i = 0; i < n; ++i) {if (dp[full_mask][i] < min_len) {min_len = dp[full_mask][i];last = i;}}// 根据parent数组重建路径vector<int> path;int mask = full_mask;while (last != -1) {path.push_back(last);int prev = parent[mask][last];mask ^= (1 << last);last = prev;}reverse(path.begin(), path.end());// 拼接字符串string res = words[path[0]];for (int i = 1; i < path.size(); ++i) {int overlap_len = overlap[path[i-1]][path[i]];res += words[path[i]].substr(overlap_len);}return res;
}
DP_222">五、状压DP的优化技巧
-
预处理减少计算
提前计算必要信息(如字符串重叠长度、冲突关系),避免在DP循环中重复计算。 -
滚动数组优化空间
若状态转移仅依赖前一层的状态,可用两个数组交替使用减少内存占用:vector<int> prev(1 << n), curr(1 << n);
-
剪枝策略
跳过不可能达到更优解的状态(如当前路径已超过已知最短长度)。 -
对称性优化
利用问题对称性减少状态数量(如旋转、镜像对称的棋盘状态)。
六、常见错误与调试技巧
-
位运算优先级错误
使用括号明确运算顺序,例如mask & (1 << k) == 0
应写为(mask & (1 << k)) == 0
。 -
状态空间过大
当n > 20
时,考虑其他算法(如启发式搜索)或问题特定优化。 -
初始化遗漏
确保所有可能的初始状态正确设置(如dp[1<<i][i] = base_value
)。 -
调试方法
- 打印关键状态的值和转移路径
- 对小规模输入(如
n=3
)手动验证 - 使用断言检查中间结果合法性
七、典型应用场景
问题类型 | 特点 | 示例问题 |
---|---|---|
排列组合优化 | 选择元素的最优排列方式 | TSP、任务调度 |
棋盘覆盖 | 处理行列、对角线的冲突 | N皇后、数独 |
集合覆盖 | 选择子集满足覆盖条件 | 最小顶点覆盖、集合覆盖 |
位操作依赖 | 状态转移依赖位运算结果 | 子集生成、位掩码权限控制 |
八、实战训练建议
-
从简单问题入手
先掌握基础状态表示(如子集选择),再挑战复杂问题(如TSP)。 -
模板化代码结构
将状态循环、位操作封装为可复用的代码块。 -
理解问题本质
分析状态转移的物理意义,避免盲目套用模板。 -
练习经典题目
- LeetCode 464. 我能赢吗
- LeetCode 691. 贴纸拼词
- LeetCode 1434. 每个人戴不同帽子的方案数
状压DP的难点在于 将问题抽象为位掩码状态 和 设计高效的状态转移方程。通过大量练习和总结,能够逐步掌握这一高效算法的精髓。