状压 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_{i,s \cup \{i\}}=\min_{i\notin s \operatorname{and} j \in s} (f_{j,s}+dis_{j,i}) \]

初始化:\(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\) 时,才可以转移

转移:

\[f_{i+1,s \cup \{j\}}+=f_{i,s} (a_{i,j}=1 \operatorname{and} j \notin s \operatorname{and} cnt_s=i) \]

事实上,我们可以用滚动数组滚掉 \(i\) 这一维,所以,转移变为:

\[f_{s \cup \{j\}}+=f_{s} (a_{cnt_s,j}=1 \operatorname{and} j \notin s) \]

这里用 \(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_{S}=\max_{T \in S}(f_{T}+f_{ \complement _{\mathbb S}T} ) \]

初始化:

\[\forall S \in[0,2^n-1],f_{S}=\sum_{i \in S} \sum_{j \in S \operatorname{and} j<i} a_{i,j} \]

答案:\(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=ST--\(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]\),一共要枚举的子集数就为:

\[cnt =\sum_{k=0}^n C_{n}^{k} \times 2^{k} = \sum_{k=0}^{n} C_{n}^{k} \times 2^k \times 1^{n-k} \]

不难发现,我们给原式凑了一个 \(\times 1^{n-k}\) 以后,原式的值不变,但是最后面的式子变成了我们所熟悉的 二项式定理,所以:

\[cnt=(2+1)^n=3^n \]

故复杂度为 \(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\) 个国王的方案数

转移:

\[f_{i,s,j}= \sum_{t\cap s=\varnothing} \sum_{c=0}^{k}f_{i-1,t,c} \]

上面的方程中,\(t\) 表示上一行的轮廓线,而 t&s==0 表示第 \(i\) 行的轮廓线 \(s\) 与上一行的轮廓线 \(t\) 没有冲突,即国王没有互相冲突,\(c\) 表示到了上一行已经放了 \(c\) 个国王,这里有点类似背包

初始化:一个国王都没放:\(f_{0,0,0}=1\)

答案:

\[\sum_{s=0}^{2^n-1} f_{n,s,k} \]

因为我们不知道最后一行会摆成什么样子,所以要遍历最后一行的所有轮廓线,然后求和。

但是其实,上述的说法有问题。严格来说,我们枚举的 \(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\) 的个数

转移:

\[\forall i \le n-2 \operatorname{and}is1(i+2,s3) \operatorname{and} is2(s1,s2,s3),f_{i+2,s3,s2,}=\max_{is1(i,s1) \operatorname{and}(i+1,s2)} (f_{i+1,s2,s1}+cnt_{s3}) \]

答案:

\[ans=\max_{is1(n,s1) \operatorname{and} is1(n-1,s2) } f_{n,s1,s2} \]

初始化,需要初始化第一行和第二行,具体实现的看代码吧,这里就不写出来了

注意,本题有 \(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。

posted @ 2025-11-05 10:13  czh(抽纸盒)  阅读(6)  评论(0)    收藏  举报