\(\texttt{0x00}\):概念
状压 DP 是动态规划的一种,通过将状态压缩为整数来达到优化转移的目的。
$\text{----OI Wiki}$
简单说来,就是我们通过普通的状态表示无法直接推出状态转移方程了,这时候将当前状态拓展的“轮廓”作为状态表示的一维,而考虑到空间复杂度和计算机原理,主要使用二进制压缩,当然你也可以格局打开使用三进制乃至其他进制。
状态压缩 dp 有一个很明显的特征:数据范围比较小且一般在 \(20\) 以下。
\(\texttt{0x01}\):例题
1. 填充网格型
题目大意
给定一个 \(N\times M\) 的网格,求用 \(1\times 2\) 和 \(2\times 1\) 的长方形去铺满它有多少种方案。
数据范围:\(N,M\le 11\)
思路:
考虑怎么放才能刚好填满网格。
可以想到,如果先放横着的,再放竖着的,那么当我们将横着的都放完后,若竖着的恰好能刚好嵌进去,说明这是一个合法方案。
也就是说,放完横着的矩形后放竖着的矩形的方法的唯一确定的,那么:
求总的方案数其实就是求横着放且合法(使竖着的能嵌进去刚好铺满网格)的方案数。
因为放横着的矩形时拓展的方向是横向的,就是说我们放矩形时,当前放的这个矩形只影响到下一列,这启示我们将“列号”作为 dp 的阶段,同时由于上一列的放置情况会影响到当前这一列,所以我们需要将上一列伸出来的部分作为状态中的一维才能转移。
那么如何表示上一列那些地方伸出来了呢?
如果用 bool 数组来表示第 \(i\) 行有没有伸出来,不仅效率低下,而且不方便计算,这时候状态压缩就来了:采用二进制压缩,相当于将原来的 bool 数组变成一个二进制数 state,state 的第 \(i\) 位表示第 \(i\) 是否伸出,\(0\) 表示未伸出,\(1\) 表示伸出。
设 \(f(i, state)\) 表示已经摆完了前 \(i - 1\) 列,且从第 \(i - 1\) 列伸到第 \(i\) 列的方案数,那么状态转移方程为:
\[f(i, state) = \sum f(i - 1, last\_state) \]其中 \(last\_state\) 是第 \(i - 2\) 列伸到第 \(i - 1\) 的状态,且需要满足 \(last\_state\) 对于 \(state\) 合法。
\(last\_state\) 对于 \(state\) 合法当且仅当以下两个条件满足:
- state & last_state == 0,即第 \(i - 2\) 列伸到第 \(i - 1\) 的小方格和 \(i - 1\) 列放置的小方格不重复;
- 每一列,所有连续着空着的小方格必须是偶数个,因为竖着的矩形必须要能嵌入。
初始化:\(f(0,0) = 1\)。
按定义这里是:前第 \(-1\) 列都摆好,且从第 \(-1\) 列到第 \(0\) 列伸出来的状态为 \(0\) 的方案数。
首先,这里没有 \(-1\) 列,最少也是 \(0\) 列。
其次,没有伸出来,即没有横着摆的。即这里第 \(0\) 列只有竖着摆这 \(1\) 种状态。
目标:\(f(m,0)\)。
\(f(m, 0)\) 表示前 \(m - 1\) 列都处理完,并且第 \(m - 1\) 列没有伸出来的所有方案数。
即整个棋盘处理完的方案数。
再用集合划分的思想来解释一下。
首先要“化零为整”,即用一个集合表示一类情况,对于这道题,假设我们放矩形时是对于每列都从上往下放,那么可以根据当前放到了第几列来划分集合。
但这样划分我们无法找到各个集合之间的转移关系,所以还要再划分一次。
集合划分的依据就是寻找集合中元素的不同点,发现在摆完前 \(i - 1\) 列的方案中向第 \(i\) 列伸出的情况不同,所以可以以此来划分。
所以状态表示为:\(f(i, state)\) 表示已经摆完了前 \(i - 1\) 列,且从第 \(i - 1\) 列伸到第 \(i\) 列的方案数。
接着就是“化整为零”的过程,即状态计算,同上。
\(\texttt{Talk is cheap, show you the code.}\)
#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;
}
题目大意
在一个 \(n∗n\) 的图中选 \(k\) 个点,使得以每个点为中心的九宫格内有且只有它一个点,问方案数。
数据范围:\(1\le n\le 9,\space 0\le k\le n * n\)。
思路
题型和第一道例题差不多。
分析可以发现,如果我们从上往下一排一排地放国王,那么当前这一行放国王的方式只取决于上一行,所以行数可以作为状态的一维。
于是乎我们可以考虑把排数 \(i\) 作为动态规划的“阶段”进行线性 dp。
接着我们需要上一行的摆放状态来推出这一行,所以它也要作为一维。
题目要求摆放 \(k\) 个国王,所以摆放了多少个国王也应该作为一维。
综上所述,状态表示为 \(f(i, j, k)\),表示已经放了前 \(i\) 排,放了 \(j\) 个国王,第 \(i\) 排状态为 \(k\) 的方案数。
则状态转移方程为:
\[f(i, j, k) = \sum f(i - 1, j - cnt(k), state) \]其中 \(state\) 表示第 \(i - 1\) 排的状态,\(cnt(k)\) 表示 \(k\) 这个状态包含多少个国王,即第 \(i\) 排摆了多少国王。
如果直接枚举所有状态时间复杂度为 \(O(n ^ 3 * K * 4 ^ n)\),不能接受。
发现其实状态很多都是不合法的,所以可以提前预处理出合法状态,这样的枚举量就会小很多。
同时可以预处理出每个状态可以由哪些状态转移过来,进一步优化转移过程。
虽然状态很多,但是合法状态并不多,合法的转移状态更不多。
接下来就要看怎么判断状态合法。
用 \(1\) 代表放了国王,\(0\) 表示没放,所以很容易想到,在一排的状态中不能出现连续的 \(1\)。
预处理相邻排的状态时要注意国王不能位于另一个国王的九宫格内,所以国王的列号不能相邻。
设第 \(i\) 排的状态为 \(j\),第 \(i - 1\) 的状态为 \(k\),即 \(j,k\) 要满足:
- \(j,k\) 都没有连续的 \(1\);
- \(j \operatorname{or} k\) 没有连续的 \(1\)。
初始化:\(f(0, 0, 0) = 1\)。(什么都不摆也是一种方案)
目标:\(f(n + 1, m, 0)\)。(一个小 \(\texttt{tip}\),所有的方案数等价于摆完了 \(n + 1\) 排且摆了 \(m\) 个国王(第 \(n + 1\) 排什么都没摆))
\(\texttt{Talk is cheap, show you the code.}\)
#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;
}
题目描述
给定一个 \(N\times M\) 矩阵,矩阵中标有 \(H\) 字样的位置不能放置棋子,标有 \(P\) 字样的位置可以放置棋子。
棋子的攻击方向为上、下、左、右四个方向,攻击距离为 \(2\) 个格子。
现在在该棋盘上放置棋子,使得棋子之前不能被互相攻击到,能够放置棋子的最大个数。
数据范围:\(N\le 100, M\le 10\)。
思路
和上一道题很像,但这道题的攻击距离变成了 \(2\),所以我们还需要 \(i - 2\) 行的状态。
和上一道题一样先预处理出合法的状态,即满足每三位都不能有超过 \(2\) 个 \(1\),储存在 \(state\) 中。
从中可以看出这道题的合法状态比上一道题还要少。
还是可以考虑把行数 \(i\) 作为动态规划的“阶段”进行线性 dp。
状态表示:\(f(i, j, k)\) 表示已经摆完前 \(i\) 行,且第 \(i - 1\) 行的状态为 \(j\),第 \(i\) 行的状态为 \(k\) 的最大棋子放置数量。
状态转移方程为:
\[f(i, j, k) = \max\{f(i - 1, s, j) + cnt(b)\} \]其中 \(j,k\) 都是下标,\(s\) 是枚举的第 \(i - 2\) 行的状态的下标,\(b = state[k]\),\(cnt(b)\) 表示 \(b\) 这个状态含有多少个棋子。
定义 \(H\) 为 \(1\),\(P\) 为 \(0\),则整个地图可以看作 \(N\) 个 \(M\) 位的二进制数。
设 \(a = state(j), b = state(k), c = state(s)\),那么 \(a,b,c\) 都要合法才行,根据题意,它们应该满足以下条件:
a & b | a & c | b & c = 0
表示每个棋子都不能互相攻击到对方g[i - 1] & a | g[i] & b = 0
表示只有 \(P\) 才能放棋子(若在 \(H\) 上放棋子了,按位与结果一定不为 \(0\))
算一算,要开 \(100 * 1024 * 1024 = 104,857,600 \approx 10^8\) 的数组,空间直接爆炸!
那怎么办呢?
注意到第一维每次只会用到上一层的状态,所以可以滚动数组。
小技巧:在原来的基础上加上滚动数组只需要把那一维全加上 & 1
就行了。
初始化:\(f(0, 0, 0) = 0\)。(什么都不摆最大数量显然为 \(0\))
目标:\(f(n + 2, 0, 0)\)。(同上题:循环到 \(n + 2\) 且后两维都为 \(0\) 表示 \(n + 1\) 和 \(n + 2\) 行什么都没放,等价于在全局最大值)
\(\texttt{Talk is cheap, show you the code.}\)
#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. 集合划分型
题目描述
已经很清楚就不给了。
思路
容易想到一种朴素做法:枚举所有可能的方案即枚举 \(n\) 的全排列,然后算最短路径,时间复杂度为 \(O(n * n!)\)。
实际上这个过程可以用二进制状态压缩来优化。因为我们只关心方案最优解,而不关心具体方案,所以我们只需要记录当前这个方案的最优解即可,那么我们考虑的状态,就只有:在当前方案 \(i\) 中,目前抵达的点是 \(j\)。
也就是说,我们用一个状态代表一个集合,集合是一类方案的总和。
而这个方案就是目前哪些点走过,哪些还没走过,用一个 \(n\) 位的二进制数来表示。
综上所述,令 \(f(i, j)\) 来表示当前状态为 \(i\),停留的最后一个点为 \(j\) 的方案的最短距离,那么状态转移方程为:
\[f(i, j) = \min\{f(i, j), f(i\operatorname{xor}2 ^ j, k) + weight(k, j)\} \]初始化:\(f(1, 0) = 0\)。(从第一个点出发,状态为 \(1\),走过的距离为 \(0\))
目标:\(f(2 ^ n - 1, n - 1)\)。(下标从 \(0\) 开始,全部走完且最后停在第 \(n\) 个点)
\(\texttt{Talk is cheap, show you the code.}\)
#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;
}
题目大意
多组数据,在一个二维平面上,每组数据给出 \(n\) 只小猪的坐标,需要用小鸟去消灭他们,每只小鸟的运动轨迹都为一条过原点的抛物线,一只小猪被消灭当且仅当:至少一只小鸟的抛物线经过该小猪的坐标,小鸟的抛物线可以自定,问:最少需要几只小鸟才能将所有的小猪消灭。
数据范围:\(n\le 18\)。
思路:
抛物线的一般方程为 \(y = ax^2 + bx + c\)。
题目中的抛物线有两个特点:
- 过原点, 即 \(c = 0\)。
- 开口向下,即 \(a < 0\)。
因此抛物线方程为:\(y = ax ^ 2 + bx\),有两个未知数,因此两点即可确定一条抛物线。
设这两点为 \((x_1, y_1), (x_2, y_2)\),相当于解方程:
\[\begin{cases} ax_1^2 + bx_1 = 0 \\ ax_2^2 + bx_2 = 0 \end{cases} \]解得:
\[\begin{cases} a = \frac{\frac{y_1}{x_1} - \frac{y_2}{x_2}}{x_1 - x_2} \\ b = \frac{y_1}{x_1} - ax_1 \end{cases} \]因此最多有 \(n ^ 2\) 个不同的抛物线。接下来求出所有不同的抛物线,及其能覆盖的所有点的点集。
所以这道题就变成了“重复覆盖问题”。
首先想到暴搜思路:
我们可以先预处理出这 \(n ^ 2\) 条抛物线的解析式,并预处理出每一条抛物线能覆盖哪些小猪。
在搜索时若使用了一条抛物线,就把当前状态按位或上该抛物线能覆盖的小猪状态,表示当前那些小猪被覆盖了。
这样的话,对于一个还没被覆盖的小猪,我们只能开一条新的抛物线去覆盖它。
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);
}
然后我们会惊奇地发现,如果 \(state\) 一定,那么 dfs 的结果是一定的,那就启示我们可以用状压 dp 来优化这个过程。
根据搜索的思路,设 \(f(i)\) 表示小猪被覆盖的情况为 \(i\) 时需要的最少的小鸟数量。
状态计算:找到 \(i\) 状态下没有被消灭的小猪的编号 \(x\),枚举可消灭它的抛物线 \(path_{x, j}\) 并更新状态:
\[f(i\operatorname{or} path_{x,j}) = \min\limits_{0 < j < n}\{f(i) + 1\} \]初始化:\(f(0) = 0\) 其余都为正无穷。(没有消灭任何小猪的最少小鸟数显然为 \(0\),求最小值初始化成正无穷)
目标:\(f(2 ^ n - 1)\)。(全被消灭的最少小鸟数)
时间复杂度为 \(O(T(n ^ 3 + n * 2 ^ n))\)。
\(\texttt{Talk is cheap, show you the code.}\)
#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