状态压缩 dp

0x00:概念

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

---OI Wiki

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

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

0x01:例题

1. 填充网格型

AcWing 291. 蒙德里安的梦想

题目大意

给定一个 N×M 的网格,求用 1×22×1 的长方形去铺满它有多少种方案。

数据范围:N,M11

思路:

考虑怎么放才能刚好填满网格。

可以想到,如果先放横着的,再放竖着的,那么当我们将横着的都放完后,若竖着的恰好能刚好嵌进去,说明这是一个合法方案。

也就是说,放完横着的矩形后放竖着的矩形的方法的唯一确定的,那么:

求总的方案数其实就是求横着放且合法(使竖着的能嵌进去刚好铺满网格)的方案数。

因为放横着的矩形时拓展的方向是横向的,就是说我们放矩形时,当前放的这个矩形只影响到下一列,这启示我们将“列号”作为 dp 的阶段,同时由于上一列的放置情况会影响到当前这一列,所以我们需要将上一列伸出来的部分作为状态中的一维才能转移。

那么如何表示上一列那些地方伸出来了呢?

如果用 bool 数组来表示第 i 行有没有伸出来,不仅效率低下,而且不方便计算,这时候状态压缩就来了:采用二进制压缩,相当于将原来的 bool 数组变成一个二进制数 statestate 的第 i 位表示第 i 是否伸出,0 表示未伸出,1 表示伸出。

f(i,state) 表示已经摆完了前 i1 列,且从第 i1 列伸到第 i 列的方案数,那么状态转移方程为:

f(i,state)=f(i1,last_state)

其中 last_state 是第 i2 列伸到第 i1 的状态,且需要满足 last_state 对于 state 合法。

last_state 对于 state 合法当且仅当以下两个条件满足:

  1. stateandlaststate=0,即第 i2 列伸到第 i1 的小方格和 i1 列放置的小方格不重复;
  2. 每一列,所有连续着空着的小方格必须是偶数个,因为竖着的矩形必须要能嵌入。

初始化: f(0,0)=1

按定义这里是:前第 1 列都摆好,且从第 1 列到第 0 列伸出来的状态为 0 的方案数。
首先,这里没有 1 列,最少也是 0 列。
其次,没有伸出来,即没有横着摆的。即这里第 0 列只有竖着摆这 1 种状态。

目标: f(m,0)

f(m,0) 表示前 m1 列都处理完,并且第 m1 列没有伸出来的所有方案数。
即整个棋盘处理完的方案数。

再用集合划分的思想来解释一下。

首先要 “化零为整”,即用一个集合表示一类情况,对于这道题,假设我们放矩形时是对于每列都从上往下放,那么可以根据当前放到了第几列来划分集合

但这样划分我们无法找到各个集合之间的转移关系,所以还要再划分一次。

集合划分的依据就是寻找集合中元素的不同点,发现在摆完前 i1 列的方案中向第 i 列伸出的情况不同,所以可以以此来划分。

所以状态表示为:f(i,state) 表示已经摆完了前 i1 列,且从第 i1 列伸到第 i 列的方案数。

接着就是 “化整为零” 的过程,即状态计算,同上。

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] 互不侵犯

题目大意

在一个 nn 的图中选 k 个点,使得以每个点为中心的九宫格内有且只有它一个点,问方案数。

数据范围:1n9, 0knn

思路

题型和第一道例题差不多。

分析可以发现,如果我们从上往下一排一排地放国王,那么当前这一行放国王的方式只取决于上一行,所以行数可以作为状态的一维。

于是乎我们可以考虑把排数 i 作为动态规划的 “阶段” 进行线性 dp。

接着我们需要上一行的摆放状态来推出这一行,所以它也要作为一维。

题目要求摆放 k 个国王,所以摆放了多少个国王也应该作为一维。

综上所述,状态表示为 f(i,j,k),表示已经放了前 i 排,放了 j 个国王,第 i 排状态为 k 的方案数。

则状态转移方程为:

f(i,j,k)=f(i1,jcntk,state)

其中 state 表示第 i1 排的状态,cnt(k) 表示 k 这个状态包含多少个国王,即第 i 排摆了多少国王。

如果直接枚举所有状态时间复杂度为 O(n3K4n),不能接受。

发现其实状态很多都是不合法的,所以可以提前预处理出合法状态,这样的枚举量就会小很多。

同时可以预处理出每个状态可以由哪些状态转移过来,进一步优化转移过程。

虽然状态很多,但是合法状态并不多,合法的转移状态更不多。

接下来就要看怎么判断状态合法。

1 代表放了国王,0 表示没放,所以很容易想到,在一排的状态中不能出现连续的 1
预处理相邻排的状态时要注意国王不能位于另一个国王的九宫格内,所以国王的列号不能相邻

设第 i 排的状态为 j,第 i1 的状态为 k,即 j,k 要满足:

  1. j,k 都没有连续的 1
  2. jork 没有连续的 1

初始化: f(0,0,0)=1。(什么都不摆也是一种方案)
目标: f(n+1,m,0)。(一个小 tip,所有的方案数等价于摆完了 n+1 排且摆了 m 个国王(第 n+1 排什么都没摆))

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×M 矩阵,矩阵中标有 H 字样的位置不能放置棋子,标有 P 字样的位置可以放置棋子。

棋子的攻击方向为上、下、左、右四个方向,攻击距离为 2 个格子。

现在在该棋盘上放置棋子,使得棋子之前不能被互相攻击到,能够放置棋子的最大个数。

数据范围:N100,M10

思路

和上一道题很像,但这道题的攻击距离变成了 2,所以我们还需要 i2 行的状态。

和上一道题一样先预处理出合法的状态,即满足每三位都不能有超过 21,储存在 state 中。
从中可以看出这道题的合法状态比上一道题还要少。

还是可以考虑把行数 i 作为动态规划的 “阶段” 进行线性 dp。

状态表示:f(i,j,k) 表示已经摆完前 i 行,且第 i1 行的状态为 j,第 i 行的状态为 k 的最大棋子放置数量。

状态转移方程为:

f(i,j,k)=max{f(i1,s,j)+cntb}

其中 j,k 都是下标s 是枚举的第 i2 行的状态的下标b=statekcntb 表示 b 这个状态含有多少个棋子。

定义 H1P0,则整个地图可以看作 NM 位的二进制数。

a=statej,b=statek,c=states,那么 a,b,c 都要合法才行,根据题意,它们应该满足以下条件:

  1. a & b | a & c | b & c = 0 表示每个棋子都不能互相攻击到对方
  2. g[i - 1] & a | g[i] & b = 0 表示只有 P 才能放棋子(若在 H 上放棋子了,按位与结果一定不为 0

算一算,要开 10010241024=104,857,600108 的数组,空间直接爆炸!

那怎么办呢?

注意到第一维每次只会用到上一层的状态,所以可以将原数组变为滚动数组
小技巧: 在原来的基础上加上滚动数组只需要把那一维全加上 & 1 就行了。

初始化: f(0,0,0)=0。(什么都不摆最大数量显然为 0)
目标: f(n+2,0,0)。(同上题:循环到 n+2 且后两维都为 0 表示 n+1n+2 行什么都没放,等价于在全局最大值)

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(nn!)

实际上这个过程可以用二进制状态压缩来优化。因为我们只关心方案最优解,而不关心具体方案,所以我们只需要记录当前这个方案的最优解即可,那么我们考虑的状态,就只有:在当前方案 i 中,目前抵达的点是 j

也就是说,我们用一个状态代表一个集合,集合是一类方案的总和。

而这个方案就是目前哪些点走过,哪些还没走过,用一个 n 位的二进制数来表示。

综上所述,令 f(i,j) 来表示当前状态为 i,停留的最后一个点为 j 的方案的最短距离,那么状态转移方程为:

f(i,j)=min{f(i,j),f(ixor2j,k)+weight(k,j)}

初始化: f(1,0)=0。(从第一个点出发,状态为 1,走过的距离为 0)
目标: f(2n1,n1)。(下标从 0 开始,全部走完且最后停在第 n 个点)

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 只小猪的坐标,需要用小鸟去消灭他们,每只小鸟的运动轨迹都为一条过原点的抛物线,一只小猪被消灭当且仅当:至少一只小鸟的抛物线经过该小猪的坐标,小鸟的抛物线可以自定,问:最少需要几只小鸟才能将所有的小猪消灭。

数据范围:n18

思路:

抛物线的一般方程为 y=ax2+bx+c

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

  1. 过原点, 即 c=0
  2. 开口向下,即 a<0

因此抛物线方程为:y=ax2+bx,有两个未知数,因此两点即可确定一条抛物线。

设这两点为 (x1,y1),(x2,y2),相当于解方程:

{ax12+bx1=0ax22+bx2=0

解得:

{a=y1x1y2x2x1x2b=y1x1ax1

因此最多有 n2 个不同的抛物线。接下来求出所有不同的抛物线,及其能覆盖的所有点的点集。

所以这道题就变成了 “重复覆盖问题”

首先想到暴搜思路:

我们可以先预处理出这 n2 条抛物线的解析式,并预处理出每一条抛物线能覆盖哪些小猪。
在搜索时若使用了一条抛物线,就把当前状态按位或上该抛物线能覆盖的小猪状态,表示当前那些小猪被覆盖了。

这样的话,对于一个还没被覆盖的小猪,我们只能开一条新的抛物线去覆盖它。

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,枚举可消灭它的抛物线 pathx,j 并更新状态:

f(iorpathx,j)=min0<j<n{f(i)+1}

初始化: f(0)=0 其余都为正无穷。(没有消灭任何小猪的最少小鸟数显然为 0,求最小值初始化成正无穷)
目标: f(2n1)。(全被消灭的最少小鸟数)

时间复杂度为 O(T(n3+n2n))

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 @   Brilliant11001  阅读(32)  评论(0编辑  收藏  举报
相关博文:
阅读排行:
· 单线程的Redis速度为什么快?
· 展开说说关于C#中ORM框架的用法!
· Pantheons:用 TypeScript 打造主流大模型对话的一站式集成库
· SQL Server 2025 AI相关能力初探
· 为什么 退出登录 或 修改密码 无法使 token 失效
点击右上角即可分享
微信分享提示