状态压缩 dp
:概念
状压 DP 是动态规划的一种,通过将状态压缩为整数来达到优化转移的目的。
简单说来,就是我们通过普通的状态表示无法直接推出状态转移方程了,这时候将当前状态拓展的“轮廓”作为状态表示的一维,而考虑到空间复杂度和计算机原理,主要使用二进制压缩,当然你也可以格局打开使用三进制乃至其他进制。
状态压缩 dp 有一个很明显的特征:数据范围比较小且一般在
:例题
1. 填充网格型
题目大意
给定一个
数据范围:
思路:
考虑怎么放才能刚好填满网格。
可以想到,如果先放横着的,再放竖着的,那么当我们将横着的都放完后,若竖着的恰好能刚好嵌进去,说明这是一个合法方案。
也就是说,放完横着的矩形后放竖着的矩形的方法的唯一确定的,那么:
求总的方案数其实就是求横着放且合法(使竖着的能嵌进去刚好铺满网格)的方案数。
因为放横着的矩形时拓展的方向是横向的,就是说我们放矩形时,当前放的这个矩形只影响到下一列,这启示我们将“列号”作为 dp 的阶段,同时由于上一列的放置情况会影响到当前这一列,所以我们需要将上一列伸出来的部分作为状态中的一维才能转移。
那么如何表示上一列那些地方伸出来了呢?
如果用 bool 数组来表示第
设
其中
,即第 列伸到第 的小方格和 列放置的小方格不重复;- 每一列,所有连续着空着的小方格必须是偶数个,因为竖着的矩形必须要能嵌入。
初始化:
按定义这里是:前第
首先,这里没有
其次,没有伸出来,即没有横着摆的。即这里第
目标:
即整个棋盘处理完的方案数。
再用集合划分的思想来解释一下。
首先要 “化零为整”,即用一个集合表示一类情况,对于这道题,假设我们放矩形时是对于每列都从上往下放,那么可以根据当前放到了第几列来划分集合。
但这样划分我们无法找到各个集合之间的转移关系,所以还要再划分一次。
集合划分的依据就是寻找集合中元素的不同点,发现在摆完前
所以状态表示为:
接着就是 “化整为零” 的过程,即状态计算,同上。
#include <vector>
#include <cstring>
#include <iostream>
using namespace std;
const int N = 12, M = 1 << N;
typedef long long ll;
int n, m;
vector<int> tran[M];
ll dp[N][M];
bool st[M];
bool check(int x) { //判断该状态是否满足所有连续着空着的小方格必须是偶数个
int cnt = 0;
for(int i = 0; i < n; i++) {
if(x >> i & 1) {
if(cnt & 1) return false;
cnt = 0;
}
else ++cnt;
}
if(cnt & 1) return false;
return true;
}
int main() {
while(scanf("%d%d", &n, &m), n || m) {
for(int i = 0; i < 1 << n; i++) st[i] = check(i); //提前预处理那哪些状态是合法的
for(int i = 0; i < 1 << n; i++) {
tran[i].clear(); //多测清空
for(int j = 0; j < 1 << n; j++)
if(!(i & j) && st[i | j])
tran[i].push_back(j); //提前预处理出每个可行状态能由那些状态转移过来
}
memset(dp, 0, sizeof dp);
dp[0][0] = 1;
for(int i = 1; i <= m; i++)
for(int j = 0; j < 1 << n; j++)
for(int k = 0; k < tran[j].size(); k++)
dp[i][j] += dp[i - 1][tran[j][k]];
printf("%lld\n", dp[m][0]);
}
return 0;
}
题目大意
在一个
数据范围:
思路
题型和第一道例题差不多。
分析可以发现,如果我们从上往下一排一排地放国王,那么当前这一行放国王的方式只取决于上一行,所以行数可以作为状态的一维。
于是乎我们可以考虑把排数
接着我们需要上一行的摆放状态来推出这一行,所以它也要作为一维。
题目要求摆放
综上所述,状态表示为
则状态转移方程为:
其中
如果直接枚举所有状态时间复杂度为
发现其实状态很多都是不合法的,所以可以提前预处理出合法状态,这样的枚举量就会小很多。
同时可以预处理出每个状态可以由哪些状态转移过来,进一步优化转移过程。
虽然状态很多,但是合法状态并不多,合法的转移状态更不多。
接下来就要看怎么判断状态合法。
用
预处理相邻排的状态时要注意国王不能位于另一个国王的九宫格内,所以国王的列号不能相邻。
设第
都没有连续的 ; 没有连续的 。
初始化:
目标:
#include <vector>
#include <iostream>
using namespace std;
const int N = 12, M = N * N;
typedef long long ll;
int n, m;
int cnt[1 << N]; //储存每个状态中有多少个国王
vector<int> state; //记录所有的合法状态
vector<int> tran[1 << N]; //记录每一个合法状态能由那些状态转移到
ll dp[N][M][1 << N];
bool check(int x) {
for(int i = 0; i < n; i++)
if((x >> i & 1) && (x >> (i + 1) & 1)) return false;
return true;
}
int count(int x) {
int res = 0;
for(int i = 0; i < n; i++) res += x >> i & 1;
return res;
}
int main() {
scanf("%d%d", &n, &m);
for(int i = 0; i < 1 << n; i++)
if(check(i)) {
state.push_back(i);
cnt[i] = count(i);
}
for(int i = 0; i < state.size(); i++)
for(int j = 0; j < state.size(); j++) {
int a = state[i], b = state[j];
if((a & b) == 0 && check(a | b))
tran[i].push_back(j);
}
dp[0][0][0] = 1;
for(int i = 1; i <= n + 1; i++)
for(int j = 0; j <= m; j++)
for(int a = 0; a < state.size(); a++)
for(int b = 0; b < tran[a].size(); b++) {
int c = cnt[state[a]];
if(j >= c)
dp[i][j][state[a]] += dp[i - 1][j - c][state[tran[a][b]]];
}
printf("%lld", dp[n + 1][m][0]); //一个小 tip,所有的方案数等价于摆完了 n + 1 排且摆了 m 个国王(第 n + 1 排什么都没摆)
return 0;
}
题目描述
给定一个
棋子的攻击方向为上、下、左、右四个方向,攻击距离为
现在在该棋盘上放置棋子,使得棋子之前不能被互相攻击到,能够放置棋子的最大个数。
数据范围:
思路
和上一道题很像,但这道题的攻击距离变成了
和上一道题一样先预处理出合法的状态,即满足每三位都不能有超过
从中可以看出这道题的合法状态比上一道题还要少。
还是可以考虑把行数
状态表示:
状态转移方程为:
其中
定义
设
a & b | a & c | b & c = 0
表示每个棋子都不能互相攻击到对方g[i - 1] & a | g[i] & b = 0
表示只有 才能放棋子(若在 上放棋子了,按位与结果一定不为 )
算一算,要开
那怎么办呢?
注意到第一维每次只会用到上一层的状态,所以可以将原数组变为滚动数组。
小技巧: 在原来的基础上加上滚动数组只需要把那一维全加上 & 1
就行了。
初始化:
目标:
#include <vector>
#include <cstring>
#include <iostream>
using namespace std;
const int N = 110, M = 1 << 10;
int n, m;
int g[N];
vector<int> state;
int cnt[M];
int dp[2][M][M]; //滚动数组
bool check(int x) { //检查合法状态:每三位都不能有超过 2 个 1
for(int i = 0; i < m; i++)
if((x >> i & 1) && ((x >> i + 1 & 1) || (x >> i + 2 & 1))) return false;
return true;
}
int count(int x) { //统计每个状态中放棋子的个数
int res = 0;
for(int i = 0; i < m; i++) res += x >> i & 1;
return res;
}
int main() {
ios::sync_with_stdio(false);
cin.tie(nullptr);
cout.tie(nullptr);
cin >> n >> m;
char c;
for(int i = 1; i <= n; i++) {
for(int j = 0; j < m; j++) {
cin >> c; //遇到这种字符读入还没有空格的题,还是用 cin 吧
if(c == 'H') g[i] += 1 << j;
}
}
for(int i = 0; i < 1 << m; i++) //预处理合法状态
if(check(i)) {
state.push_back(i);
cnt[i] = count(i);
}
for(int i = 1; i <= n + 2; i++)
for(int j = 0; j < state.size(); j++) //注意 j, k, t 都是下标
for(int k = 0; k < state.size(); k++)
for(int t = 0; t < state.size(); t++) {
int a = state[j], b = state[k], c = state[t];
if(a & b | a & c | b & c) continue; //检查合法性
if(g[i - 1] & a | g[i] & b) continue;
dp[i & 1][j][k] = max(dp[i & 1][j][k], dp[i - 1 & 1][t][j] + cnt[b]);
}
cout << dp[n + 2 & 1][0][0] << '\n'; //同上题:循环到 n + 2 且后两维都为 0 表示 n + 1 和 n + 2 行什么都没放,等价于在全局最大值
return 0;
}
2. 集合划分型
题目描述
已经很清楚就不给了。
思路
容易想到一种朴素做法:枚举所有可能的方案即枚举
实际上这个过程可以用二进制状态压缩来优化。因为我们只关心方案最优解,而不关心具体方案,所以我们只需要记录当前这个方案的最优解即可,那么我们考虑的状态,就只有:在当前方案
也就是说,我们用一个状态代表一个集合,集合是一类方案的总和。
而这个方案就是目前哪些点走过,哪些还没走过,用一个
综上所述,令
初始化:
目标:
#include <cstring>
#include <iostream>
using namespace std;
const int N = 20;
int n;
int g[N][N];
int dp[1 << N][N];
int main() {
scanf("%d", &n);
for(int i = 0; i < n; i++)
for(int j = 0; j < n; j++)
scanf("%d", &g[i][j]);
memset(dp, 0x3f, sizeof dp);
dp[1][0] = 0;
for(int i = 1; i < 1 << n; i++)
for(int j = 0; j < n; j++)
if(i >> j & 1) //枚举的这个状态必须要经过 j 才行
for(int k = 0; k < n; k++)
if(i >> k & 1) //枚举的这个状态也必须要经过 k 才行
dp[i][j] = min(dp[i][j], dp[i - (1 << j)][k] + g[k][j]);
printf("%d", dp[(1 << n) - 1][n - 1]);
return 0;
}
题目大意
多组数据,在一个二维平面上,每组数据给出
数据范围:
思路:
抛物线的一般方程为
题目中的抛物线有两个特点:
- 过原点, 即
。 - 开口向下,即
。
因此抛物线方程为:
设这两点为
解得:
因此最多有
所以这道题就变成了 “重复覆盖问题”。
首先想到暴搜思路:
我们可以先预处理出这
在搜索时若使用了一条抛物线,就把当前状态按位或上该抛物线能覆盖的小猪状态,表示当前那些小猪被覆盖了。
这样的话,对于一个还没被覆盖的小猪,我们只能开一条新的抛物线去覆盖它。
void dfs(int state, int cnt) { // state 表示当前小猪被覆盖的情况,cnt 表示已经用的小鸟数量
if(cnt >= cnt) return ; //最优性剪枝
if(state == (1 << n) - 1) { //若全部覆盖,则更新答案
res = min(res, cnt);
return ;
}
枚举还没被覆盖的小猪
枚举能覆盖它的抛物线
dfs(state | 该抛物线能覆盖的小猪, cnt + 1);
}
然后我们会惊奇地发现,如果
根据搜索的思路,设
状态计算:找到
初始化:
目标:
时间复杂度为
#include <cmath>
#include <vector>
#include <cstring>
#include <iostream>
using namespace std;
const int N = 20, M = 1 << 18;
const double eps = 1e-9;
int T;
int n, m;
struct node{
double x, y;
}pig[N];
int path[N][N]; //过点i和j的抛物线过哪些点
int dp[M];
int check(double x, double y) { //浮点数存在精度误差,所以需要注意
if(fabs(x - y) < eps) return 0;
if(x < y) return -1;
return 1;
}
int main() {
scanf("%d", &T);
while(T--) {
scanf("%d%d", &n, &m);
for(int i = 0; i < n; i++) scanf("%lf%lf", &pig[i].x, &pig[i].y);
for(int i = 0; i < n; i++) {
path[i][i] = 1 << i; //初始态,单独生成一条只经过 (0, 0) 和 (x, y) 的抛物线
for(int j = i + 1; j < n; j++) {
double x1 = pig[i].x, y1 = pig[i].y;
double x2 = pig[j].x, y2 = pig[j].y;
if(!check(x1, x2)) continue;
double a = (y1 / x1 - y2 / x2) / (x1 - x2);
if(check(a, 0) >= 0) continue; // a < 0
double b = y1 / x1 - a * x1;
int state = 0;
for(int k = 0; k < n; k++) { //预处理当前这条抛物线能过哪些点
double x = pig[k].x, y = pig[k].y;
if(!check(a * x * x + b * x, y)) state += (1 << k);
}
path[i][j] = state;
}
}
memset(dp, 0x3f, sizeof dp);
dp[0] = 0;
for(int i = 0; i + 1 < 1 << n; i++) {
int x;
for(int j = 0; j < n; j++) //随便找一个未被覆盖的点
/*
q: 为什么只找一个呢,搜索时不是都要找并且都要搜一遍吗?
a: 因为我们采用了状压 dp,状态表示的是一类方案,并不关心它的顺序,而且反正我们最后都要覆盖,所以随便找一个就好了
*/
if(!(i >> j & 1)) {
x = j;
break;
}
for(int j = 0; j < n; j++)
dp[i | path[x][j]] = min(dp[i] + 1, dp[i | path[x][j]]);
}
printf("%d\n", dp[(1 << n) - 1]);
memset(path, 0, sizeof path); //记得多测清空
}
return 0;
}
最后浅浅地整理了一些题:
填充网格型
√P1879 [USACO06NOV] Corn Fields G
P5005 中国象棋 - 摆上马
P8756 [蓝桥杯 2021 省 AB2] 国际象棋
P1539 [TJOI2011] 01矩阵
集合划分型
√P3092 [USACO13NOV] No Change G (不用寻常思路的状态设计)
√P1433 吃奶酪
√P1278 单词游戏 (注意 dp 阶段即循环顺序)
√P3694 邦邦的大合唱站队 (还是要想想,思路不太明显)
P3118 [USACO15JAN] Moovie Mooving G
P5911 [POI2004] PRZ (枚举子集)
P2915 [USACO08NOV] Mixed Up Cows G
【推荐】国内首个AI IDE,深度理解中文开发场景,立即下载体验Trae
【推荐】编程新体验,更懂你的AI,立即体验豆包MarsCode编程助手
【推荐】抖音旗下AI助手豆包,你的智能百科全书,全免费不限次数
【推荐】轻量又高性能的 SSH 工具 IShell:AI 加持,快人一步
· 单线程的Redis速度为什么快?
· 展开说说关于C#中ORM框架的用法!
· Pantheons:用 TypeScript 打造主流大模型对话的一站式集成库
· SQL Server 2025 AI相关能力初探
· 为什么 退出登录 或 修改密码 无法使 token 失效