状压DP学习笔记

· · 个人记录

前言:

最近学了这个神奇的东东,真的好难啊~[仙女叹气]~~

感觉自己没大理解,就写笔记强迫自己学会吧QAQ

定义:

状态压缩动态规划,就是我们俗称的状压DP,是利用计算机二进制的性质来描述状态的一种DP方式。

自己的小理解:其实就是将状态压缩成二进制下的数(1/0)来表示不同情况,进行DP……

前置知识:

位运算介绍:

一个神奇的东西……

推荐博客

状压DP里主要用到的几个技巧见下图:

引入:

题目 :互不侵犯

题意简述

N×N的棋盘里面放K个国王,使他们互不攻击,共有多少种摆放方案。国王能攻击到他周围的8个格子。

题目分析

当前方案数仅与上一行有关$($ 每一行只需要判断与上一行是否攻击$)

状态转移方程:

dp[i][j][k] += dp[i - 1][s][k - cnt(s)] 最终答案: 再次枚举每一个状态: $ans += dp[n - 1][s][k]$; **完整代码**: ```cpp #include <cstdio> #include <cmath> #include <algorithm> #define ll long long//不开long long见祖宗 using namespace std; int n,k,cnt[1 << 15]; ll ans,dp[15][1 << 15][100];//状态:1表示该位置放了国王,0没有放 bool flag[1 << 15]; int Count(int x) { int res = 0; while(x) { res += x & 1; x >>= 1; } return res; } bool check1(int x) { for(int i = 0; i + 1 < n; i ++) { if((x & (1 << i)) && (x & (1 << (i + 1)))) return 0;//如果有相邻的国王,不符条件 } return 1; } bool check2(int x,int y) { for(int i = 0; i < n; i ++) { if(x & (1 << i)) { if(y & (1 << i)) return 0;//上为1 if(i + 1 < n && (y & (1 << (i + 1)))) return 0;//左上为1 if(i - 1 < n && (y & (1 << (i - 1)))) return 0;//右上为1 } } return 1; } signed main() { scanf("%d %d",&n,&k); int m = 1 << n; for(int i = 0; i < m; i ++) { flag[i] = check1(i); cnt[i] = Count(i); }//预处理,避免重复 for(int i = 0; i < n; i ++) { for(int j = 0; j < m; j ++) { if(!flag[j]) continue; if(!i) dp[i][j][cnt[j]] = 1; else { for(int h = Count(j); h <= k; h ++) {//枚举此时的国王总数 for(int a = 0; a < m; a ++) {//枚举上一行的状态 if(!flag[a] || !check2(j,a)) continue; dp[i][j][h] += dp[i - 1][a][h - cnt[j]]; } } } } } for(int i = 0; i < m; i ++) ans += dp[n - 1][i][k];//从0开始,共有n-1行 printf("%lld\n",ans); return 0; } ``` **总结**: 状压的入门题,思路挺好想的,只是要注意细节。 ------------ #### 正文: 题目一 :[炮兵阵地](https://www.luogu.com.cn/problem/P2704) **题意简述**:给定一个$N * M$的初始状态,规定了哪些位置可以放炮兵。要求每个炮兵前后左右**两**格不能有他人。求最多可以放多少兵。 数据范围:$1 \leq N \leq 100$, $1≤M≤10$. **思路**: 因为每一行的情况与前两行有关,就要定义三个状态,又因为**当前状态**可以有**前一行**转移而来,所以只用定义**当前状态**和**前一行**的状态,**前前行**的状态可以由**前一行**表示,所以定义: $dp[i][j][k]$,表示第$i-1$行状态为$j$,第$i$状态为$k$, 然后再循环枚举$dp[i - 1][h][j]$(表示第$i-2$行状态为$h$), 这样就可以确定三行的状态 又因为很多状态是不符合题意的,直接用$dp$存储状态太浪费空间。所以可以提前预处理出满足条件的状态,用$map[]$存下来,重新定义$dp[i][j][k]$为**第$i-1$行状态为$map[j]$,第$i$状态为$map[k]$** 时的最大值。这样就轻松多啦~ 其它讲解都在代码里啦~ **完整代码**: ```cpp #include<cstdio> #include<algorithm> using namespace std; int n,m,num,ans,cnt,sum[1050],map[1050],f[105],dp[2][1050][1050]; char s[15]; int c(int x) {//计算x状态中 1的个数 int res = 0; while(x) { if(x & 1) res++; x >>= 1; } return res; } int main() { scanf("%d %d",&n,&m); for(int i = 1; i <= n; i ++) { scanf("%s",s + 1); for(int j = 1; j <= m; j ++) f[i] = (f[i] << 1) + (s[j] == 'P');//预处理初始状态 } num = 1 << m; for(int i = 0; i < num; i ++) { //预处理满足条件的状态 if((i & (i << 1)) || (i & (i << 2)) || ( i & (i >> 1)) || (i & (i >> 2))) continue;//如果左右有相邻的情况,跳过 map[++cnt] = i; sum[cnt] = c(i); //记录满足条件的状态及其 1的个数 if((i & f[1]) == i) dp[1][0][cnt] = sum[cnt];//初始化第1行 //如果当前状态与第一行的状态相符,直接赋值 (上一行没有所以状态为0) } for(int i = 1; i <= cnt; i ++) {//初始化第2行 for(int j = 1; j <= cnt; j ++) { if(!(map[i] & map[j]) && (f[2] & map[j]) == map[j]) dp[0][i][j] += dp[1][0][i] + sum[j]; //如果 i状态和 j状态满足条件(同一位置不存在都有炮兵的情况) 当前行的值就可以加上 上一行的i状态的值 再加上 当前行j状态 1的数量 } } for(int i = 3; i <= n; i ++) {//从第三行开始 for(int j = 1; j <= cnt; j ++) {//枚举当前状态 if((f[i] & map[j]) != map[j]) continue;//如果当前情况不满足初始条件,跳过 for(int k = 1; k <= cnt; k ++) {//枚举上一行状态 if(map[j] & map[k]) continue;//如果两行不满足条件(同一位置都有炮兵),跳过 for(int h = 1; h <= cnt; h ++) {//枚举上上行的状态 if(!(map[k] & map[h]) && !(map[j] & map[h])) //判断是否满足情况 dp[i & 1][k][j] = max(dp[i & 1][k][j],dp[(i - 1) & 1][h][k] + sum[j]);//取最大值 } } } } for(int i = 1; i <= cnt; i ++) { for(int j = 1; j <= cnt; j ++) { ans = max(ans,dp[n & 1][i][j]);//枚举所有情况,取最大值 } } printf("%d",ans); return 0; } ``` ------------ **知识点小结**: 另外,这里用到了一个状压小技巧。很多时候题目会给定初始状态,就限制了之后的状态枚举。这里就再次用到了二进制小技巧。 这里就以这道题为例: ```cpp for(int j = 1; j <= m; j ++) f[i] = (f[i] << 1) + (s[j] == 'P'); ``` 如果当前点可以放炮兵,我们就在$f[i]$的值左移一位的基础上,再加1,最后$f[i]$的值就是当前行的最大值,枚举时的状态不能超过$f[i]$. 其实这里就是在模拟二进制的形成,计算出满足题意的最大状态值。 ------------ 题目二 :[Fish](https://www.luogu.com.cn/problem/CF16E) **题意简述**:有n条鱼,编号从1到n。每对鱼相遇的概率是一样的。如果两条标号为i和j的鱼见面,第一只吃了第二只的概率为$p[i][j]$,则第二只吃掉第一只的概率为$1 - p[i][j]$。求每只鱼最后存活在湖里的可能性。 **思路**: 概率 + 状压$dp

讲解都在代码里

完整代码

#include<cstdio>
#include<algorithm>
using namespace std;
int n;
double p[25][25],dp[1 << 20];//dp[i],出现i状态的概率(1:这条鱼活着/0:它被吃啦) 
int c(int x) {//计算1的个数 
    int res = 0;
    while(x) {
        res += (x & 1);
        x >>= 1;
    }
    return res;
}
int main() {
    scanf("%d",&n);
    for(int i = 1; i <= n; i ++)
        for(int j = 1; j <= n; j ++)
            scanf("%lf",&p[i][j]);
    int num = (1 << n) - 1;
    dp[num] = 1;//初始状态,全部鱼都活着的概率为1
    for(int i = num - 1; i; i --) {//倒序枚举状态,鱼越吃越少,1的数量也越来越少……这残忍的现实! 
        int cnt = c(i);//活着的鱼的数量 
        for(int j = 1; j <= n; j ++) {//枚举这一轮被吃到的鱼的序号 
            if((i & (1 << (j - 1)))) continue;//如果在当前状态下,j为1(鱼没有被吃了),跳过
            for(int k = 1; k <= n; k ++) {//枚举k条鱼吃掉的鱼的编号 
                if(!(i & (1 << (k - 1)))) continue;//如果在当前状态下,k为0(鱼已经被吃了,k吃不到j),跳过
                dp[i] += dp[i | (1 << (j - 1))] * p[k][j] / (1.0 * (cnt + 1) * cnt / 2.0);
                //否则,概率为 当前概率 加上 j位存活时的概率 * k条鱼吃掉j条鱼的概率 * 在所有活着的鱼中恰好选到j,k的概率。 
            }
        }
    }
    for(int i = 0; i < n; i ++) printf("%.6lf ",dp[1 << i]);//只有当前位为1的状态 
    return 0;
}

题目三 :奖励关

题意简述:共有K轮,有n种物品,每一轮出现每一种物品的概率\frac{1}{n},物品可选可不选,对于选每一种物品,必须要在前面的轮先选给定的部分物品,每一种物品的价格可正可负。求 k轮后按最优方案选择的期望价格。

数据范围:1\leq K \leq 100 ,1 \leq n \leq 15 1≤K≤100,1≤n≤15.

思路

首先看题,概率dp没得跑。

再看数据范围,哦豁!状压dp

这道题不同的是需要倒推,因为正推的话有些情况在转移时选择宝物的概率并不是平均的(有宝物合集的限制),这样就会导致结果出现问题,且最终答案的状态表示十分麻烦(不要问我是怎么知道的!),因此选择倒推。

另外,因为dp的转移至于当前局前一局有关,所以可以用滚动数组优化一下。

详细讲解都在代码里啦~

完整代码

#include<cstdio>
#include<algorithm>
using namespace std;
int K,n,x,w[20],num[20];
double p,dp[2][1 << 15];
int main() {
    scanf("%d %d",&K,&n);
    p = 1.0 / n;//选择宝物的平均概率 
    for(int i = 1; i <= n; i ++) {
        scanf("%d",&w[i]);//分值 
        while(~scanf("%d",&x) && x)
            num[i] += 1 << (x - 1);//用二进制数存储 宝物集合 
    }
    int maxn = 1 << n;
    for(int i = K; i >= 1; i --) { //游戏轮数 
        for(int j = 0; j < maxn; j ++) {
            dp[i & 1][j] = 0;//初始化(清空上一次循环的值) 
            for(int k = 1; k <= n; k ++) {//枚举这轮抽到的宝物 
                if((j & num[k]) == num[k])//如果满足k宝物的 宝物集合要求,
                    dp[i & 1][j] += max(dp[(i + 1) & 1][j | (1 << (k - 1))] + w[k],dp[(i + 1) & 1][j]);
                    //满足最优策略,在拿k宝物和不拿之间选最大值 
                else dp[i & 1][j] += dp[(i + 1) & 1][j];//不满足的话,该状态及为上一次的状态,没有变化 
            }
            dp[i & 1][j] *= p;//最终期望要乘上随机选择一个宝物的概率 
        }
    } 
    printf("%.6lf",dp[1][0]);
    //因为是倒推的,所以最后答案为游戏开始时,未得到任何一个宝物的情况 
    return 0;
} 

题目四 :Survival

题意简述:有nBoss,其中有n-1个小boss必须先全部打完才能打第n个大BOSS,打一个小boss要耗体能usei,打完后恢复一部分vali,一开始体能为100,在打的过程中也最多为100,问能打完全部的BOSS? 能 输出clear!!! 否则输出try again.

思路

很板的一道题

讲解都在代码里…… **完整代码**: ```cpp #include <cstdio> #include <cstring> #include <algorithm> using namespace std; int n, a[25], val[25], dp[1 << 21]; int main() { while (scanf("%d", &n) != EOF) { memset(dp, -1, sizeof(dp)); for (int i = 0; i < n - 1; i++) scanf("%d %d", &a[i], &val[i]); scanf("%d", &a[n - 1]); dp[0] = 100;//初始生命值为100 for (int i = 1; i < (1 << (n - 1)); i ++) {//枚举状态 dp[i] = -9999; for (int j = 0; (1 << j) <= i; j ++) {//枚举要打死的小怪 if (i & (1 << j) && dp[i - (1 << j)] >= a[j]) { //如果当前状态下小怪被打死而且打死小怪之前的生命值能够打死他 int k = dp[i - (1 << j)] - a[j] + val[j]; k = min(100, k);//生命值不超过100 dp[i] = max(dp[i], k);//取最大值 } } } if (dp[(1 << (n - 1)) - 1] >= a[n - 1]) printf("clear!!!\n");//如果打完小怪后的生命值可以打死大怪 else printf("try again\n"); } return 0; } ``` ___ 题目五:[愤怒的小鸟](https://www.luogu.com.cn/problem/P2831) 咕咕咕咕…… #### 题目讲解: 一道一道慢慢来……我太难了[暴风哭泣] 6.愤怒的小鸟 8.Arrange the Bulls 9.Kefa and Dishes 10.Corn Fields G 11.Vladik and cards 7.Compatible Numbers ___ #### 总结: 其实状压$DP$做到最后都是套路~~ 一般分为几个步骤: - 理解题意,确定$DP$状态 - 思考全面,状态转移方程 - 顺应思路,明确最终结果 状态定义翻来覆去也就那几个,重点和难点在于转移方程的转移和正确性以及对位运算的灵活运用……