状态压缩 dp

\(\texttt{0x00}\):概念

状压 DP 是动态规划的一种,通过将状态压缩为整数来达到优化转移的目的。

\(\text{---OI Wiki}\)

简单说来,就是我们通过普通的状态表示无法直接推出状态转移方程了,这时候将当前状态拓展的“轮廓”作为状态表示的一维,而考虑到空间复杂度和计算机原理,主要使用二进制压缩,当然你也可以格局打开使用三进制乃至其他进制。

状态压缩 dp 有一个很明显的特征:数据范围比较小且一般在 \(20\) 以下。

\(\texttt{0x01}\):例题

1. 填充网格型

AcWing 291. 蒙德里安的梦想

题目大意

给定一个 \(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\) 合法当且仅当以下两个条件满足:

  1. \(state \operatorname{and} last_state = 0\),即第 \(i - 2\) 列伸到第 \(i - 1\) 的小方格和 \(i - 1\) 列放置的小方格不重复;
  2. 每一列,所有连续着空着的小方格必须是偶数个,因为竖着的矩形必须要能嵌入。

初始化: \(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;
}

P1896 [SCOI2005] 互不侵犯

题目大意

在一个 \(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\) 要满足:

  1. \(j,k\) 都没有连续的 \(1\)
  2. \(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;
}

P2704 [NOI2001] 炮兵阵地

题目描述

给定一个 \(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\) 都要合法才行,根据题意,它们应该满足以下条件:

  1. a & b | a & c | b & c = 0 表示每个棋子都不能互相攻击到对方
  2. 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. 集合划分型


P10447 最短 Hamilton 路径

题目描述

已经很清楚就不给了。

思路

容易想到一种朴素做法:枚举所有可能的方案即枚举 \(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;
}

P2831 [NOIP2016 提高组] 愤怒的小鸟

题目大意

多组数据,在一个二维平面上,每组数据给出 \(n\) 只小猪的坐标,需要用小鸟去消灭他们,每只小鸟的运动轨迹都为一条过原点的抛物线,一只小猪被消灭当且仅当:至少一只小鸟的抛物线经过该小猪的坐标,小鸟的抛物线可以自定,问:最少需要几只小鸟才能将所有的小猪消灭。

数据范围:\(n\le 18\)

思路:

抛物线的一般方程为 \(y = ax^2 + bx + c\)

题目中的抛物线有两个特点:

  1. 过原点, 即 \(c = 0\)
  2. 开口向下,即 \(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

posted @ 2024-07-24 22:06  Brilliant11001  阅读(3)  评论(0编辑  收藏  举报