应群u要求水一篇状压dp的博客
动态规划(DP)是算法竞赛和编程面试中的常客,而状态压缩动态规划(状压DP)则是其中一种高级技巧,本文将带你从零开始学习状压DP,理解其核心思想,并通过C++代码示例掌握实现方法
状压DP是一种利用位运算来高效表示和转移状态的动态规划方法。它特别适用于状态可以用二进制位表示的问题,通常处理的是"选或不选"、"存在或不存在"这类的二元状态
常规DP在表示某些状态时需要大量空间
状态压缩可以极大减少内存使用
位运算操作非常高效,能提升算法速度 (其实就是因为这个)
考虑到有些小伙伴们可能对位运算不太熟悉,故在此写一些在状压中常用的位运算:
// 常用位运算操作
int S = 0; // 空集
S |= (1 << i); // 将第i位置为1(加入集合)
S & (1 << i); // 判断第i位是否为1
S &= ~(1 << i); // 将第i位置为0(从集合移除)
S ^= (1 << i); // 切换第i位的状态
S & (S - 1); // 清除最低位的1
__builtin_popcount(S); // 计算S中1的个数
其中__builtin_popcount(S)内置于gcc中 (其实这个煮啵也不常用,板子上写上来凑数的,记不住的小伙伴们可以借lowbit操作完成)
让我们以著名的旅行商问题为例,展示状压DP的应用 :
问题描述:给定n个城市和它们之间的距离,求从某个城市出发,经过所有城市恰好一次并返回起点的最短路径。
定义dp[s][i]
表示:
s:已经访问过的城市集合(用二进制位表示)
i
:当前所在的城市
值:表示达到这个状态的最小花费
#include
#include
#include
#include
using namespace std;
const int INF = INT_MAX / 2;
int tsp(const vector>& dist) {
int n = dist.size();
int size = 1 << n;
vector> dp(size, vector(n, INF));
// 初始化:从0号城市出发
dp[1][0] = 0;
for (int mask = 1; mask < size; ++mask) {
for (int last = 0; last < n; ++last) {
if (!(mask & (1 << last))) continue; // 必须包含last城市
for (int next = 0; next < n; ++next) {
if (mask & (1 << next)) continue; // 不能重复访问
int new_mask = mask | (1 << next);
dp[new_mask][next] = min(dp[new_mask][next],
dp[mask][last] + dist[last][next]);
}
}
}
// 最后需要返回起点0
int final_mask = (1 << n) - 1;
int res = INF;
for (int last = 1; last < n; ++last) {
res = min(res, dp[final_mask][last] + dist[last][0]);
}
return res;
}
int main() {
vector> dist = {
{0, 10, 15, 20},
{10, 0, 35, 25},
{15, 35, 0, 30},
{20, 25, 30, 0}
};
cout << tsp(dist) << endl;
return 0;
}
旅行商问题:给定一组城市和每对城市之间的距离,找到访问每个城市恰好一次并返回起点的最短路线。
代码主要由两部分组成:
tsp()
函数:实现动态规划算法
main()
函数:提供测试用例并调用tsp()
int n = dist.size();
int size = 1 << n;
vector> dp(size, vector(n, INF));
n:城市数量
size = 1 << n:计算所有可能的状态数,每个状态用一个n位的二进制数表示
dp表:dp[mask][last]表示:
mask:已经访问过的城市集合(二进制位为1表示已访问)
last:当前所在的最后一个城市
值:从起点出发,经过mask指定的城市,最后到达last城市的最短路径长度
dp[1][0] = 0;
初始状态:从城市0出发,只访问过城市0(mask=0001),路径长度为0
for (int mask = 1; mask < size; ++mask) {
for (int last = 0; last < n; ++last) {
if (!(mask & (1 << last))) continue; // 必须包含last城市
for (int next = 0; next < n; ++next) {
if (mask & (1 << next)) continue; // 不能重复访问
int new_mask = mask | (1 << next);
dp[new_mask][next] = min(dp[new_mask][next],
dp[mask][last] + dist[last][next]);
}
}
}
这部分是算法的核心:
外层循环遍历所有可能的状态mask
(从0001到1111)
对于每个状态,检查所有可能的最后城市last
if (!(mask & (1 << last))) continue
:确保last
城市确实在当前状态中
对于每个可能的下一城市next
:
if (mask & (1 << next)) continue
:确保不重复访问已访问的城市
计算新状态new_mask
(将next
城市标记为已访问)
更新dp[new_mask][next]
的值:比较当前值与从last
到next
的转移路径
int final_mask = (1 << n) - 1;
int res = INF;
for (int last = 1; last < n; ++last) {
res = min(res, dp[final_mask][last] + dist[last][0]);
}
final_mask = (1 << n) - 1
:所有城市都已访问的状态(如4个城市时,1111)
遍历所有可能的最后城市(除了起点0),计算从该城市返回起点的总距离
取最小值作为最终结果
vector> dist = {
{0, 10, 15, 20},
{10, 0, 35, 25},
{15, 35, 0, 30},
{20, 25, 30, 0}
};
这是一个4个城市的对称tsp实例,城市间的距离形成一个对称矩阵
时间复杂度:O(n²·2ⁿ) —— 这是TSP问题动态规划解法的标准复杂度
空间复杂度:O(n·2ⁿ)
对于n=4,算法会高效运行;但对于更大的n(如n>20),这种解法会因为指数级增长而变得不实用,但本篇只用以给小伙伴们入门,所以不再做后续优化
以给定的4城市为例,算法会:
从城市0出发 (状态0001)
考虑所有可能的下一步:城市1、2、3
逐步构建所有可能的部分路径
最终考虑所有城市都访问过的情况,并计算返回起点的距离
输出最短路径长度(在这个样例中结果是80)
分析问题:确定问题是否适合状压DP(通常是状态可以二进制表示的选择问题)
设计状态:确定dp数组的含义,通常包含状态集合和附加信息
状态转移:找出状态之间的关系和转移方程
初始化:设置初始状态的值
计算顺序:确定状态的遍历顺序(通常从小到大枚举状态)
结果提取:从最终状态中提取答案
问题描述:给定一个n×m的棋盘,用1×2的骨牌覆盖,问有多少种不重叠的覆盖方式。
#include
#include
#include
#include
using namespace std;
long long dominoTiling(int n, int m) {
// 通常我们让m较小以压缩状态
if (n < m) swap(n, m);
int total_states = 1 << m;
vector> dp(n + 1, vector(total_states, 0));
dp[0][0] = 1; // 初始状态
for (int row = 0; row < n; ++row) {
for (int mask = 0; mask < total_states; ++mask) {
if (dp[row][mask] == 0) continue;
int next_mask = (~mask) & (total_states - 1); // 反转mask得到空缺位置
// 生成所有可能的放置方式
function dfs = [&](int col, int current) {
if (col == m) {
dp[row + 1][current] += dp[row][mask];
return;
}
// 当前位置已被覆盖,跳过
if (next_mask & (1 << col)) {
// 尝试竖放(需要下一行同一列为空)
if (row + 1 < n) {
dfs(col + 1, current | (1 << col));
}
// 尝试横放(需要右侧位置也空)
if (col + 1 < m && (next_mask & (1 << (col + 1)))) {
dfs(col + 2, current);
}
} else {
dfs(col + 1, current);
}
};
dfs(0, 0);
}
}
return dp[n][0];
}
int main() {
int n = 2, m = 3;
cout << n << " " << m << " " << dominoTiling(n, m) << endl;
return 0;
}
骨牌平铺问题:给定一个n×m的棋盘,使用1×2或2×1的多米诺骨牌完全覆盖棋盘,计算有多少种不同的平铺方式。
这段代码主要有dominoTiling函数构成,主要目的是计算骨牌堆砌过程中不断变化而存储的过程
long long dominoTiling(int n, int m) {
// 通常我们让m较小以压缩状态
if (n < m) swap(n, m);
为了使状态压缩更高效,通常让m是较小的维度
如果n < m,交换它们以确保m是较小的值
int total_states = 1 << m;
vector> dp(n + 1, vector(total_states, 0));
dp[0][0] = 1; // 初始状态
total_states = 1 << m
:计算所有可能的状态数,每个状态用一个m位的二进制数表示
dp
表:dp[row][mask]
表示:
row
:当前处理的行号
mask
:当前行的覆盖状态(二进制位为1表示该位置被覆盖)
值:到达该状态的不同平铺方式数量
初始状态:第0行完全未被覆盖(mask=0),有1种方式(空棋盘)
for (int row = 0; row < n; ++row) {
for (int mask = 0; mask < total_states; ++mask) {
if (dp[row][mask] == 0) continue;
int next_mask = (~mask) & (total_states - 1); // 反转mask得到空缺位置
外层循环遍历每一行
内层循环遍历所有可能的状态
next_mask
:计算下一行的空缺位置(反转当前mask并限制在有效位数内)
function dfs = [&](int col, int current) {
if (col == m) {
dp[row + 1][current] += dp[row][mask];
return;
}
// 当前位置已被覆盖,跳过
if (next_mask & (1 << col)) {
// 尝试竖放(需要下一行同一列为空)
if (row + 1 < n) {
dfs(col + 1, current | (1 << col));
}
// 尝试横放(需要右侧位置也空)
if (col + 1 < m && (next_mask & (1 << (col + 1)))) {
dfs(col + 2, current);
}
} else {
dfs(col + 1, current);
}
};
dfs(0, 0);
这个DFS函数递归地生成所有可能的骨牌放置方式:
基本情况:当处理完所有列(col == m),更新下一行的状态计数
如果当前位置有空缺(next_mask & (1 << col)):
尝试竖放(2×1骨牌):标记下一行的当前位置为已覆盖
尝试横放(1×2骨牌):需要右侧位置也空缺,然后跳过下一列
如果当前位置已被覆盖,直接处理下一列
return dp[n][0];
最终结果存储在dp[n][0]
,表示处理完所有n行且最后一行完全被覆盖的状态
int n = 2, m = 3;
cout << n << " " << m << " " << dominoTiling(n, m) << endl;
测试一个2×3的棋盘,输出平铺方式的数量(正确结果为3)
时间复杂度:O(n × 2^m × f(m)),其中f(m)是DFS的复杂度
空间复杂度:O(n × 2^m)
由于m通常较小(m ≤ 12),这个算法在实际中是可行的。
以2×3棋盘为例:
初始状态:dp[0][000] = 1
处理第0行:
生成所有可能的放置方式
更新第1行的状态
处理第1行:
生成所有可能的放置方式
更新第2行的状态
最终结果:dp[2][000] = 3
滚动数组:当状态转移只依赖前一层时,可以压缩空间
预处理合法状态:提前计算并存储可能的转移状态
剪枝:跳过不可能达到的状态
双端BFS:对于某些对称问题,可以从两端同时进行状态转移
排列问题:如TSP、任务调度
覆盖问题:如棋盘覆盖、铺砖
子集问题:如子集和、集合划分
图论问题:如哈密尔顿路径、最小顶点覆盖
从简单问题开始,如力扣的状压DP题目
手动模拟小规模案例,理解状态转移过程
尝试将常规DP问题改写为状压DP版本
分析时间复杂度和空间复杂度
LeetCode 464. 我能赢吗
LeetCode 691. 贴纸拼词
LeetCode 1349. 参加考试的最大学生数
POJ 2411. Mondriaan's Dream
(如果大家觉得这些题目难度较高可以在评论区留言,如果小伙伴较多的话煮啵会再出个题解)
状压DP是一种强大的算法技巧,通过本文的学习,你应该已经掌握了(真的吗?):
状压DP的基本概念和适用场景
如何使用位运算表示和操作状态
经典问题的状压DP解法
状压DP的实现模式和优化方法
记住,掌握状压DP需要大量练习。开始时可能会觉得难以理解状态表示,但随着练习的增加,你会逐渐培养出对状态设计的直觉。
希望这篇教程对你有所帮助!如果有任何问题,欢迎在评论区留言讨论