状态压缩入门(附经典题目和题解)
前言
状态压缩是什么呢?
如果你还不知道,那么请看下面的例子。
路旁有一排100个路灯,他们其中有亮的,也有灭的,请问你该如何记录他们的状态呢?
有人会说,拿数组记录不就行了吗?
但是如果不只有100个路灯呢?有100000000个路灯该怎么记录呢?
这时,用数组就会超内存,怎么办呢?
其实,用一个二进制数就可以表示了。表示方法如下:
第i位为1表示第i个路灯是亮的,0表示是灭的,这样就能轻松记录了。
但得到了这个二进制数后,我们怎么快速知道每个灯的状态呢?
出来吧,位运算!
下面列举了一些常见的二进制位的变换操作。
功能 | 示例 | 位运算
去掉最后一位 | (101101->10110) | x shr 1
在最后加一个0 | (101101->1011010) | x shl 1
在最后加一个1 | (101101->1011011) | x shl 1+1
把最后一位变成1 | (101100->101101) | x or 1
把最后一位变成0 | (101101->101100) | x or 1-1
最后一位取反 | (101101->101100) | x xor 1
把右数第k位变成1 | (101001->101101,k=3) | x or (1 shl (k-1))
把右数第k位变成0 | (101101->101001,k=3) | x and not(1 shl (k-1))
右数第k位取反 | (101001->101101,k=3) | x xor (1 shl (k-1))
取末三位 | (1101101->101) | x and 7
取末k位 | (1101101->1101,k=5) | x and (1 shl k-1)
取右数第k位 | (1101101->1,k=4) | x shr (k-1) and 1
把末k位变成1 | (101001->101111,k=4) | x or (1 shl k-1)
末k位取反 | (101001->100110,k=4) | x xor (1 shl k-1)
把右边连续的1变成0 | (100101111->100100000) | x and (x+1)
把右起第一个0变成1 | (100101111->100111111) | x or (x+1)
把右边连续的0变成1 | (11011000->11011111) | x or (x-1)
取右边连续的1 | (100101111->1111) | (x xor (x+1)) shr 1
所以大家可以发现,状态压缩是一种在计算机里很重要的思想。
不只是dp,很多搜索,暴力用上它也会简单很多。
下面我找到了三道状态压缩dp入门题,掌握了他们,状态压缩就能很轻松理解了。
互不侵犯
题意:https://www.luogu.org/problemnew/show/P1896
概述:
在N×N的棋盘里面放K个国王,使他们互不攻击,共有多少种摆放方案。国王能攻击到它上下左右,以及左上左下右上右下八个方向上附近的各一个格子,共8个格子。
题解:
首先,看到这一题,就知道如果不是搜索,就是DP。当然搜索是过不了的,所以就应该尝试想出一个DP的解法。
DP的前提之一当然是要找出一个可以互相递推的状态。显然,目前已使用的国王个数当然必须是状态中的一个部分,因为这是一个限制条件。那么除此之外另外的部分是什么呢?
我们考虑到每行每列之间都有互相的约束关系。因此,我们可以用行和列作为另一个状态的部分(矩阵状压DP常用行作为状态,一下的论述中也用行作为状态)。
又看到数据范围: 1 <=N <=9。这里我们就可以用一个新的方法表示行和列的状态:数字。考虑任何一个十进制数都可以转化成一个二进制数,而一行的状态就可以表示成这样——例如:
10101010(2)
就表示:这一行的第一个格子没有国王,第二个格子放了国王,第三个格子没有放国王,第四个格子放了国王。而这个二进制下的数就可以转化成十进制:
1010(10)
于是,我们的三个状态就有了:第几行(用i表示)、此行放什么状态(用j表示)、包括这一行已经使用了的国王数(用s表示)。
考虑状态转移方程。我们预先处理出每一个状态(sit[x])其中包含二进制下1的个数,及此状态下这一行放的国王个数(gs[x]),于是就有:
f[i][j][s]=sum(f[i-1][k][s-gs[j]])f[i][j][s]=sum(f[i−1][k][s−gs[j]]),f[i][j][s]f[i][j][s]就表示在只考虑前i行时,在前i行(包括第i行)有且仅有s个国王,且第i行国王的情况是编号为j的状态时情况的总数。而k就代表第i-1行的国王情况的状态编号
其中k在1到n之间,j与k都表示状态的编号,且k与j必须满足两行之间国王要满足的关系。(对于这一点的处理我们待会儿再说)
这个状态转移方程也十分好理解。其实就是上一行所有能够与这一行要使用的状态切合的状态都计入状态统计的加和当中。其中i、j、s、k都要枚举。
再考虑国王之间的关系该如何处理呢?在同一行国王之间的关系我们可以直接在预处理状态时舍去那些不符合题意的状态,而相邻行之间的关系我们就可以用到一个高端的东西:位运算。由于状态已经用数字表示了,因此我们可以用与(∧)运算来判断两个状态在同一个或者相邻位置是否都有国王——如果:
sit[j]sit[j]&sit[k]sit[k](及上下有重复的king)
(sit[j]<<1)(sit[j]<<1)&sit[k]sit[k](及左上右下有重复king)
sit[j]sit[j]&(sit[k]<<1)(sit[k]<<1)(及右上左下有重复king)
这样就可以处理掉那些不符合题意的状态了。
总结一下。其实状压DP不过就是将一个状态转化成一个数,然后用位运算进行状态的处理。理解了这一点,其实就跟普通的DP没有什么两样了。
代码:
#include<iostream>
#include<cstdio>
using namespace std;
int n,k;
long long dp[10][15000][80];
long long state[1000005],king[1000005] ;
long long ans,sum;
inline void init()
{
int tot=(1<<n)-1;
for(int i=0;i<=tot;i++)
if(!((i<<1)&i))
{
state[++ans]=i;
int t=i;
while(t)
{
king[ans]+=t%2;
t>>=1;
}
}
}
int main()
{
scanf("%d%d",&n,&k);
init();
for(int i=1;i<=ans;i++)
if(king[i]<=k)
dp[1][i][king[i]]=1;
for(int i=2;i<=n;i++)
for(int j=1;j<=ans;j++)
for(int p=1;p<=ans;p++)
{
if(state[j] & state[p]) continue;
if(state[j] & (state[p]<<1)) continue;
if((state[j]<<1) & state[p]) continue;
for(int s=1;s<=k;s++)
{
if(king[j]+s>k) continue;
dp[i][j][king[j]+s]+=dp[i-1][p][s];
}
}
for(int i=1;i<=n;i++)
for(int j=1;j<=ans;j++)
sum+=dp[i][j][k];
cout<<sum;
return 0;
}
玉米田Corn Fields
题意:
https://www.luogu.org/problemnew/show/P1879
题解:
若二进制下第i位有赋值1,则一行的第i列有放牛
那么f[i][j]表示在前i行中(包括i)在j个状态下的最大方案数
易得f[i][j]=(f[i][j]+f[i-1][k])mod p(p=10^9,j是第i行的状态,k是第i-1行的状态)
所以我们还要再预处理一下,g[i]表示第i个状态是否存在,判断条件是
g[i]= !(i!(i&(i>>1))!(i(i>>1))
目标状态:f[n][i]全部相加
代码:
#include<cstdio>
#include<iostream>
#include<cstring>
#include<cmath>
#include<string>
#include<algorithm>
using namespace std;
const int mod=100000000;
int n,m;
int a[15][15];
int q[15];
int f[15][1<<20];
bool pd[1<<20];
void init()
{
scanf("%d%d",&m,&n);
for(int i=1;i<=m;i++)
for(int j=1;j<=n;j++)
{
scanf("%d",&a[i][j]);
q[i]=(q[i]<<1)+a[i][j];
}
for(int i=0;i<(1<<n);i++)
pd[i]=(!(i&(i<<1)))&&(!(i&(i>>1)));
}
void dp()
{
f[0][0]=1;
for(int i=1;i<=m;i++)
{
for(int j=0;j<(1<<n);j++)
{
if(pd[j]&&((j&q[i])==j))
{
for(int k=0;k<(1<<n);k++)
{
if((k&j)==0)
f[i][j]=(f[i][j]+f[i-1][k])%mod;
}
}
}
}
}
int main()
{
init();
dp();
int ans=0;
for(int i=0;i<(1<<n);i++)
ans=(ans+f[m][i])%mod;
printf("%d\n",ans);
return 0;
}
炮兵阵地
题意:
https://www.luogu.org/problemnew/show/P2704
题解:(转自https://sshoj.blog.luogu.org/solution-p2704)
这道题是一道状压 dp 的特别毒瘤的基础题(虽然我打了整整一个早上),但是因为每一个炮兵都会影响到之后的两行的放置,所以用状压去压两行,按行处理每一行的情况即可。每一行放置的时候也很简单,只需考虑这个位置前两行有没有放置炮兵以及这个位置是不是山丘即可。
那么首先,dp 方程可以很快推出来,dp[L][S][i]表示当前状态是 S,上一行的状态是 L,当前考虑到了第 i 行:
dp[L][S][i]=max(dp[L][S][i],dp[FL][L][i-1]+Sum[S]); 这里 FL 表示上上行的状态,Sum[S] 表示当前状态 S 里面包含几个 1。
那么有了这个 dp 方程后,就可以愉快的递推了,不过这道题有几个细节需要注意一下:
1.判断每个位置是不是山丘
这个很好解决,只要把每一行的输入都转成一个二进制数(平原是 0,山丘是1),然后直接跟待判断的状态做一次位运算即可,就是 S&a[i],如果位运算结果不是零,说明有些位置放在了山丘上,也就是说当前状态不合法。
2.判断每个状态有没有两个炮兵左右距离在两格之内
这个需要动脑想一下,我们发现一个神奇的结论,如果把表示当前状态的二进制数位运算左移一位,那么用这个结果与原状态做一次位运算与操作,如果结果不是 0,那么就一定存在两个炮兵左右距离在一格之内。同理,左移两位就可以判断左右距离在两格之内。这个过程也就是 S&(S<<1),S&(S<<2)。
3.判断每一列之前两行有没有炮兵
这个就直接用当前状态分别与之前的两行即可,就是 S&L,S&FL,如果与操作结果不为零,说明有若干列前两行有炮兵,也就是说当前状态不合法。
最后说一句,一定要用滚动数组(因为只用到每一行和前两行,所以只用滚动三行),否则会 MLE 0 (我当初就是这么惨)。。。
代码:
#include<iostream>
#include<cstring>
#include<cstdio>
#include<cmath>
#include<algorithm>
using namespace std;
int n,m,ans;
char iin;
int f[(1<<10)][(1<<10)][3],a[150],sum[(1<<10)];
int get(int s)
{
int cnt=0;
while(s)
{
if(s&1) cnt++;
s>>=1;
}
return cnt;
}
int Max(int a,int b)
{
if(a>=b) return a;
return b;
}
int main()
{
scanf("%d%d",&n,&m);
for(int i=0;i<n;i++)
for(int j=0;j<m;j++)
{
cin>>iin;;
a[i]<<=1;
a[i]+=(iin=='H'?1:0);
}
for(int i=0;i<(1<<m);i++)
sum[i]=get(i);
for(int i=0;i<(1<<m);i++)
for(int j=0;j<(1<<m);j++)
if(!(i&j || i&(i<<1) || i&(i<<2) || j&(j<<1) || j&(j<<2) || i&a[0] || j&a[1]))
f[i][j][1]=sum[i]+sum[j];
for(int k=2;k<n;k++)
for(int i=0;i<(1<<m);i++)
{
if(i&a[k-1] || i&(i<<1) || i&(i<<2)) continue;
for(int j=0;j<(1<<m);j++)
{
if(i&j || j&a[k] || j&(j<<1) || j&(j<<2)) continue;
for(int l=0;l<(1<<m);l++)
{
if(l&i || l&j || l&a[k-2] || l&(l<<1) || l&(l<<2)) continue;
{
f[i][j][k%3]=Max(f[i][j][k%3],f[l][i][(k-1)%3]+sum[j]);
}
}
}
}
for(int i=0;i<(1<<m);i++)
for(int j=0;j<(1<<m);j++)
ans=Max(ans,f[i][j][(n-1)%3]);
printf("%d",ans);
return 0;
}
总结
状态压缩dp其实就是把一个状态弄成二进制数,放到dp转移里面,运用位运算操作暴力枚举。
所以状态压缩dp真的不难,只要弄懂了核心,剩下的就是熟练运用位运算操作了。
这次题解希望对跟我一样在冲noip提高组的童鞋们有帮助,谢谢大家!