状压 dp
状压dp
简介
状压 \(dp\) 就是把每个元素是否存在于一个集合中的情况用一个 二进制数表示,来作为 \(dp\) 的一个阶段,通常我们会利用计算机超快的位运算对这个集合进行操作。
状压 \(dp\) 主要分为以下两类:
1.集合类
2.棋盘类(轮廓线 \(dp\))
其中,集合类中有一类叫做子集枚举,会在后文讲到
集合类
luogu P10447 最短 Hamilton 路径
此题为经典的 \(NP\) 问题——旅行商问题,\(NP\) 问题没有多项式的复杂度解法
思路:
不难想到纯暴力解法:
求出 \(n\) 个点的全排列,每个全排列就是一条路径,一共 \(n!\) 条路径,每条路径计算距离总和需要加 \(n\) 次,复杂度为 \(O(n \times n!)\)。
题目中 \(n \le 20\),最多 \(20*20!=48,658,040,163,532,800,000 \approx 4.9 \times 10^{19}\),不难发现直接升天了,所以我们需要更好的解法。
定义 \(f_{i,s}\) 表示最后一个走到的点是 \(i\)(下标从 \(0\) 开始),已经走过的点所构成的集合二进制为 \(s\) 时,所走的最小距离。
转移(刷表法):后文的公式都是用以数学的表示法写的,具体的代码表示请看代码
初始化:\(f_{0,1}=0\)
答案:\(f_{n-1,2^n-1}\)
复杂度:一共会遍历 \(2^n\) 个集合状态,每个状态要枚举 存在以及不存在 \(s\) 中的点,为 \(n^2\),总复杂度:\(O(n^2 \times 2^n)\),当 \(n=20\) 时,\(20^2 \times 2^{20}=419,430,400 \approx 4 \times 10^8\),能够接受,然后就过了。
代码:
#include<bits/stdc++.h>
using namespace std;
const int N=20;
int n,ans=0x3f3f3f3f,dis[N][N],f[N][1<<N];
int main(){
cin>>n;
for(int i=0;i<n;i++){
for(int j=0;j<n;j++){
cin>>dis[i][j];
}
}
memset(f,0x3f,sizeof(f));
f[0][1]=0;
for(int s=1;s<1<<n;s++){
for(int i=0;i<n;i++){
if(s&(1<<i)) continue;//找一个不在 s 中的点 i
for(int j=0;j<n;j++){
if(!(s&(1<<j))) continue;//找一个在 s 中的点 j
f[i][s|(1<<i)]=min(f[i][s|(1<<i)],f[j][s]+dis[j][i]);
}
}
}
cout<<f[n-1][(1<<n)-1];
return 0;
}
luogu P1433 吃奶酪
几乎一样,只是初始化和答案略微不同
代码:
#include<bits/stdc++.h>
using namespace std;
const int N=15;
int n;
double ans,x[N],y[N],d[N][N],f[N][1<<N];
int main(){
cin>>n;
for(int i=0;i<n;i++) cin>>x[i]>>y[i];
for(int i=0;i<n;i++){
for(int j=0;j<(1<<n);j++){
f[i][j]=10000000.0;
}
}
ans=10000000.0;
for(int i=0;i<n;i++){
for(int j=0;j<n;j++){
d[i][j]=sqrt((x[i]-x[j])*(x[i]-x[j])+(y[i]-y[j])*(y[i]-y[j]));//预处理任意两点间的距离
}
}
for(int i=0;i<n;i++){
f[i][1<<i]=sqrt(x[i]*x[i]+y[i]*y[i]);//初始化:从(0,0)走到(x_i,y_i)
}
for(int s=0;s<1<<n;s++){
for(int i=0;i<n;i++){
if((s&(1<<i))==0) continue;
for(int j=0;j<n;j++){
if(s&(1<<j)) continue;
f[j][s|(1<<j)]=min(f[j][s|(1<<j)],f[i][s]+d[i][j]);
}
}
}
for(int i=0;i<n;i++) ans=min(ans,f[i][(1<<n)-1]);//以任意点为结尾都可以
printf("%.2lf",ans);
return 0;
}
接下来是两道 \(CF\) 的题(给出洛谷的链接,提交需至\(CF\))
AT_dp_o Matching
本题只需将任意一个性别作为集合即可,这里以所有女性为集合:
为了方便,记 \(cnt_s\) 表示 \(s\) 在二进制表示下的 \(1\) 的个数,题目中的好感度用 \(a_{i,j}\) 表示第 \(i\) 个男性与第 \(j\) 个女性的好感度:
定义 \(f_{i,s}\) 表示到第 \(i\) 个男性,且女性集合为 \(s\) 时的完美配对的总方案数
首先要明确一点,男性的顺序是没关系的,所以,我们可以假设到第 \(i\) 个男性就已经配对了 \(i\) 对男女,所以要求 \(cnt_s=i\) 时,才可以转移
转移:
事实上,我们可以用滚动数组滚掉 \(i\) 这一维,所以,转移变为:
这里用 \(a_{cnt_s,j}=1\) 是因为当 \(s\) 确定时,\(cnt_s\) 就唯一确定了,不用再去遍历 \(i\) 了
初始化:一个女性都没有的方案数为 \(1\):\(f_0=1\)
答案:所有 \(n\) 对男女都已配对:\(f_{2^n-1}\)
这里再多说一句:C++ 的 STL 中有 __builtin_popcount(s) (注意,最前面是两个 "_")表示 \(s\) 在二进制表示下的 \(1\) 的个数,即上文的 \(cnt_s\),复杂度为常数很大的 \(O(1)\),但其实没太大必要,手写一个求 \(cnt_s\) 的函数也没多难,复杂度为 \(O(logn)\)
记得取模!!!
代码:
#include<bits/stdc++.h>
using namespace std;
const int N=21,mod=1e9+7;
int n,a[N][N],f[1<<N];
int main(){
cin>>n;
for(int i=0;i<n;i++){
for(int j=0;j<n;j++){
cin>>a[i][j];
}
}
f[0]=1;
for(int s=0;s<(1<<n);s++){
int cnt=0,z=s;
while(z) cnt+=z&1,z>>=1;
for(int i=0;i<n;i++){
if(!(s&(1<<i))&&a[cnt][i]){
f[s|(1<<i)]=(f[s|(1<<i)]+f[s])%mod;
}
}
}
cout<<f[(1<<n)-1];
return 0;
}
AT_dp_u Grouping
这是一道 子集枚举 的板题,题目十分简单,但是需要介绍一下子集枚举,先说本题思路,再介绍子集枚举。
思路:
定义 \(f_S\) 表示选的兔子分成若干个组,这些兔子构成的二进制集合为 \(S\) 的最大得分
转移:
初始化:
答案:\(f_{2^n-1}\)
以上为本题思路,接着介绍子集枚举:
子集枚举
代码思路
从刚才的转移方程来看,我们要枚举 \(S\) 的所有子集 \(T\),才能进行状态转移,代码怎么实现?
有一个很巧妙的方法:for(int T = S ; T >= 0 ; T = (T-1) & S)
分析一下,首先,代表 \(T\) 的二进制数肯定比代表 \(S\) 的二进制数小,由子集的性质显然可知,所以初始 T=S,接着 \(T\) 肯定要不小于零,所以 T>=0。
最后是最重要的一步,我们已经知道了 \(T\) 肯定比 \(S\) 小,所以可以先让 \(T--\),但不难发现,要是 \(S=(110)_2\),令T=S, T--,\(T\) 就会变成 \((101)_2\),显然不是 \(S\) 的子集,怎么办呢?
只要 \(T\&=S\) 就好了,因为 \(\&\) 的性质,所以 \(T\) 就会变成只含 \(S\) 中的样子,刚才的例子就会变成:\(T=(100)_2\),所以就有了 \(T=(T-1)\&S\)
复杂度:\(O(3^n)\)
证明很有趣:
\(n\) 个数,先枚举其所有的集合,可以分为元素个数分别为 \(0,1,2,3...n\) 的集合,对于每种大小为 \(k\) 的集合,我们记为 第 \(k\) 类集合(作者自己编的名字),相当于从 \(n\) 个数中选了 \(k\) 个数,所以有 \(C_{n}^{k}\) 种选择,即第 \(k\) 类集合有这么多个
接着,对于每个第 \(k\) 类集合,它的子集个数一共有 \(2^k\) 个,所以,第 \(k\) 类集合一共要枚举 \(C_{n}^{k} \times 2^k\) 个子集,那么对于 \(k \in [0,n]\),一共要枚举的子集数就为:
不难发现,我们给原式凑了一个 \(\times 1^{n-k}\) 以后,原式的值不变,但是最后面的式子变成了我们所熟悉的 二项式定理,所以:
故复杂度为 \(O(3^n)\)
最后给出本题代码:
代码:
#include<bits/stdc++.h>
using namespace std;
typedef long long ll;
const int N=16;
int n;
ll a[N][N],f[1<<N];
int main(){
cin>>n;
for(int i=0;i<n;i++){
for(int j=0;j<n;j++){
cin>>a[i][j];
}
}
for(int s=0;s<(1<<n);s++){
for(int i=0;i<n;i++){
for(int j=0;j<i;j++){
if((s&(1<<i))&&(s&(1<<j))) f[s]+=a[i][j];//初始化
}
}
}
for(int s=1;s<(1<<n);s++){
for(int t=s;t;t=(t-1)&s){//子集枚举
f[s]=max(f[s],f[t]+f[s^t]);
}
}
cout<<f[(1<<n)-1];
return 0;
}
棋盘类(轮廓线dp)
首先,要知道,什么是轮廓线?
这里直接给出板题,方便解释:
luogu P1896 [SCOI2005] 互不侵犯
众所周知,经典的八皇后问题用到了 \(dfs\) 求解(luogu P1219 [USACO1.5] 八皇后 Checker Challenge),本题的限制要求和八皇后很像,但还是有很大区别,八皇后问题是求皇后的摆放方案,而本题是求合法方案数并且由于皇后和国王的走法不同,导致前者需用 \(dfs\) 求解,而后者可用状压 \(dp\) 求解。
给出下图:

上图中,红色圆圈表示放了国王的位置,蓝色方块表示国王的攻击范围(即不能放国王的位置),红色的折现就是下一个国王能放的位置的边界(要在红线的下面或右边),因此,红线就是轮廓线。轮廓线 \(dp\) 的阶段是行的编号,\(状态就是轮廓线\)。
不难发现,我们可以用一个 二进制数表示第 \(i\) 的轮廓线(第 \(j\) 表示的就是从右往左数第 \(j\) 个格子是否能放新的国王,这里的 \(j\) 是从 \(0\) 开始的)
所以,我们有了动规的的状态:
定义 \(f_{i,s,j}\) 表示第 \(i\) 行,这一行的轮廓线为 \(s\) ,且已经放了 \(j\) 个国王的方案数
转移:
上面的方程中,\(t\) 表示上一行的轮廓线,而 t&s==0 表示第 \(i\) 行的轮廓线 \(s\) 与上一行的轮廓线 \(t\) 没有冲突,即国王没有互相冲突,\(c\) 表示到了上一行已经放了 \(c\) 个国王,这里有点类似背包
初始化:一个国王都没放:\(f_{0,0,0}=1\)
答案:
因为我们不知道最后一行会摆成什么样子,所以要遍历最后一行的所有轮廓线,然后求和。
但是其实,上述的说法有问题。严格来说,我们枚举的 \(s\) 和 \(t\) 并不是轮廓线,而是每一行的国王摆放的情况,只不过,t&s==0 的这个条件,让相当于求出了轮廓线,这里需要注意,但是上文就这样吧,下文会改变说法的
然后本题就做完了,但别着急,对于这种轮廓线,我们有优化:
不难发现,对于同一行的状态 \(s\) ,有些肯定不合法,例如 \((1101101)_2\),(\(1\) 表示放了国王)很明显,按照题意,是不允许同一行的国王相邻的(事实上是八连通范围内都不能相邻),所以,我们可以在开始时,先处理出来所有可能的状态 \(s\),即把所以有相邻 \(1\) 的 \(s\) 全部去掉,接着每行的状态只需枚举预处理出的合法的 \(s\) 即可。
这样,我们就优化了时间,此时,\(f_{i,s,j}\) 中的 \(s\) 表示为第 \(s\) 个可行的状态 \(s\),事实上,空间的话,可以输出一下当 \(n=9\) 时,可行的 \(s\) 数,然后 \(f\) 数组的第二维开那么大就好了,这里直接给出,\(n=9\) 时,合法的状态数为 \(89\),和 \(2^9=512\) 差了一个数量级,当然,本题就算开了 \(2^9\) 也不会 \(MLE\),所以代码中直接开了 \(2^9\) 大小的数组。
下面给出的是刷表法,跟上面的填表法几乎一样(状压还是刷表更好)
还有一点要说,预处理时怎么判断 \(s\) 是否合法?即 \(s\) 的二进制表示是否有相邻的 \(1\)?只需判断 \(s \&(s>>1)\) 和 \(s \& (s<<1)\) 是否都为 \(0\) 即可!
代码:
#include<bits/stdc++.h>
using namespace std;
typedef long long ll;
const int N=9;
int n,k,cnt,ok[1<<N],cnts[1<<N];//用ok数组记录可行的s,cnts数组记录该可行状态s二进制下1的个数,即这种状态放了cnts个国王
ll f[N+1][1<<N][N*N+1],ans;
void init(){
for(int s=0;s<1<<n;s++){
if((s&(s<<1))||(s&(s>>1))) continue;//不合法
ok[cnt]=s;
int z=s;
while(z) cnts[cnt]+=1&z,z>>=1;
cnt++;
}
}
bool is(int a,int b){//判断两行的状态是否冲突
return (a&b)||((a<<1)&b)||((a>>1)&b);
}
int main(){
cin>>n>>k;
init();//预处理所有的合法状态
f[0][0][0]=1;
for(int i=0;i<n;i++){
for(int j=0;j<cnt;j++){
for(int r=0;r<cnt;r++){
if(is(ok[j],ok[r])) continue;
for(int c=0;c<=k;c++){
if(cnts[r]+c>k) continue;
f[i+1][r][cnts[r]+c]+=f[i][j][c];
}
}
}
}
for(int i=0;i<cnt;i++) ans+=f[n][i][k];
cout<<ans;
return 0;
}
luogu P1879 [USACO06NOV] Corn Fields G
来一道练习题,但有点不一样,本题给出一张地图,有些点不能放奶牛,怎么办?有个巧妙的方法,对于第 \(i\) 行给出的输入,记 \(t_i\) 在二进制下表示该行的空地和草地的情况,空地就让二进制的那一位为 \(1\),草地就为 \(0\)。枚举第 \(i\) 行的奶牛的摆放状态时若 (t[i]&ok[j])!=0,则说明,有奶牛在空地上,不合法,就跳过
代码:
#include<bits/stdc++.h>
using namespace std;
const int N=12,mod=1e8;
int n,m,cnt,ans,t[N+1],f[N+1][1<<N],ok[1<<N];//本题不用 cnts 数组,放几头奶牛都可以
void init(){
for(int s=0;s<1<<m;s++){
if((s&(s<<1))||((s>>1)&s)) continue;
ok[++cnt]=s;
}
}
int main(){
cin>>n>>m;
init();//依旧预处理所有可行的状态s
for(int i=1;i<=n;i++){
for(int j=0;j<m;j++){
int k;
cin>>k;
t[i]=t[i]<<1|(1-k);
}
}
for(int i=1;i<=cnt;i++) if(!(ok[i]&t[1])) f[1][i]=1;
for(int i=2;i<=n;i++){
for(int j=1;j<=cnt;j++){
if(ok[j]&t[i-1]) continue;
for(int k=1;k<=cnt;k++){
if((ok[j]&ok[k])||(ok[k]&t[i])) continue;
f[i][k]=(f[i][k]+f[i-1][j])%mod;
}
}
}
for(int j=1;j<=cnt;j++) ans=(ans+f[n][j])%mod;
cout<<ans;
return 0;
}
luogu P2704 [NOI2001] 炮兵阵地
本题是前几题的综合,很有价值,既要预处理所有合法的状态 \(s\),还要预处理每个 \(s\) 的二进制下 \(1\) 的个数,以及空间和时间优化。
本题要看前两行的状态,所以定义 \(f_{i,s1,s2}\) 表示到了第 \(i\) 行,这一行状态为 \(s1\),上一行状态为 \(s2\) 的最多能放的炮兵数。
用 \(is1(i,s)\) 表示状态 \(s\) 是否能满足第 \(i\) 行的地形,不满足返回\(true\),\(is2(s1,s2,s3)\) 表示 \(s1\),\(s2\) , \(s3\) 是否有至少有两个是冲突的,冲突则返回 \(true\)。\(cnt_{s}\) 表示 \(s\) 在二进制表示下 \(1\) 的个数
转移:
答案:
初始化,需要初始化第一行和第二行,具体实现的看代码吧,这里就不写出来了
注意,本题有 \(hack\) 数据:\(n=1\) ,需要特判。还有,直接开\(f[100][1<<10][1<<10]\) 就算是 \(int\) 也会 \(MLE\),所以这就要用到上文提到的只开合法的状态的数量大小,需要自己在本地输出一下最大的数量,然后开那么大,开 \(65\) 是够用的
代码:
#include<bits/stdc++.h>
using namespace std;
typedef long long ll;
const int N=10;
int n,m,cnt,ok[1<<N],t[105],cnts[1<<N],ans,f[105][65][65];
void init(){
for(int s=0;s<1<<m;s++){
if((s&(s>>2)||(s&(s>>1)))) continue;
ok[++cnt]=s;
int z=s;
while(z) cnts[cnt]+=1&z,z>>=1;
}
}
int main(){
cin>>n>>m;
init();//依旧可以预处理所有合法状态
for(int i=1;i<=n;i++){
for(int j=0;j<m;j++){
char c;
cin>>c;
t[i]=t[i]<<1|(c=='P'?0:1);
}
}
for(int i=1;i<=cnt;i++){
if(ok[i]&t[1]) continue;
f[1][i][0]=cnts[i];//预处理第一行
ans=max(f[1][i][0],ans);//处理n=1时的情况
if(ok[i]&t[1]) continue;
for(int j=1;j<=cnt;j++){
if(ok[j]&t[2]) continue;
if(ok[i]&ok[j]) continue;
f[2][j][i]=cnts[i]+cnts[j];
}
}
for(int i=1;i<=n;i++){
for(int s1=1;s1<=cnt;s1++){
if(ok[s1]&t[i]) continue;
for(int s2=1;s2<=cnt;s2++){
if(ok[s2]&t[i+1]) continue;
if(ok[s1]&ok[s2]) continue;
for(int s3=1;s3<=cnt;s3++){
if(ok[s3]&t[i+2]) continue;
if(ok[s1]&ok[s3]) continue;
if(ok[s2]&ok[s3]) continue;
f[i+2][s3][s2]=max(f[i+2][s3][s2],f[i+1][s2][s1]+cnts[s3]);
}
}
}
}
for(int i=1;i<=cnt;i++){
for(int j=1;j<=cnt;j++){
if(ok[i]&t[n]) continue;
if(ok[j]&t[n-1]) continue;
if(ok[i]&ok[j]) continue;
ans=max(ans,f[n][i][j]);
}
}
cout<<ans;
return 0;
}
最后,给出几道练习题:
luogu P3112 [USACO14DEC] Guard Mark G
luogu P2915 [USACO08NOV] Mixed Up Cows G
今天先更到这里,之后会继续做状压 \(dp\),遇到好题会继续写的,qwq。

浙公网安备 33010602011771号