状压DP入门
状态压缩DP
思想简述:DP的实质是在状态空间中的遍历,在部分题目中,DP在状态空间的轮廓需要我们很清晰的刻画出来,所以我们在DP过程中需要维护一个集合,来保存这个轮廓的详细信息。如果这个集合大小不超过\(N\),并且都不超过\(K\),我们就可以用一个\(N\)位\(K\)进制数来保存这个集合,用一个\([0,N^K-1]\)的十进制整数来存这个集合。这种将集合化为\(K\)进制数,作为DP的一维的动态规划算法被称作状态压缩动态规划,当然其他维度我们就存储比如当前节点,上一个节点之类的
如何发现可以用状压DP:
1.数据\(N,M\)在\([10,20]\)的时候可以考虑状压DP算法
2.有明显的动态规划特征且数据较小
复杂度:一般若采用\(k\)进制数,复杂度为\(O(nk^n)\)这只是一个下界
图形填充问题
来道经典的例题吧
题意简述:
给定一张\(N\times M\)的图,其中有一些方格不可以放置,求最多可以放置多少个\(2\times 3\)的矩形(可以横着放也可以竖着放)
这里因为竖着放是联系了上下三格的,所以为了代码的好写,我们使用三进制状态压缩
好的那么我们因为是矩形,我们可以规定2,1,0分别表示矩形的3,2,1层,那么\(2\times 3\)的矩形和\(3\times 2\)的矩形可以分别表示为:
此时我们就可得到一个动态规划算法:
设\(F_{i,j}\)表示第\(i\)行填写状态为\(j\)时,放置矩形的最多数量
每一个状态需要满足:
1.坏掉的格子只能填0
2.2的下面必须填1,1的下面必须填0
由于状态转移方程比较复杂,我们可以采用另一种实现方式:搜索
具体的:我们对于每一行分别搜索,对其按上述原则进行试填,填完后就得到一个状态,对这个状态更新答案即可
目标\(F_{n,0}\)
由于每一行的状态只与上一行有关,所以我们需要使用滚动数组优化空间
\(Code\)
#include<iostream>
#include<cstdio>
#include<cstring>
using namespace std;
int pow[15],a[155][155],n,m,f[2][200000];
void init(){
pow[0]=1;
for(int i=1;i<=14;i++)pow[i]=pow[i-1]*3;
}//这个操作很重要,是进行k进制运算的基础操作
int find(int x,int y){
return x%pow[y]/pow[y-1] ;
}
bool check(int x,int y,int last){
return !a[x][y]&&!find(last,m-y+1);
}
void dfs(int x,int last,int now,int pos,int cnt){
if(!pos){
f[x&1][now]=max(f[(x+1)&1][last]+cnt,f[x&1][now]);
return ;
}
if(find(last,pos)){
if(a[x][m-pos+1])return ;
dfs(x,last,now*3+find(last,pos)-1,pos-1,cnt);
return ;
}
dfs(x,last,now*3,pos-1,cnt);
if(pos>=2&&check(x,m-pos+2,last)&&check(x,m-pos+1,last))dfs(x,last,(now*3+2)*3+2,pos-2,cnt+1);
if(pos>=3&&check(x,m-pos+3,last)&&check(x,m-pos+2,last)&&check(x,m-pos+1,last))dfs(x,last,((now*3+1)*3+1)*3+1,pos-3,cnt+1);
}
int main(){
int k;
init();
int d;
scanf("%d",&d);
while(d--){
scanf("%d%d%d",&n,&m,&k);
memset(a,0,sizeof a);
for(int i=1;i<=k;i++){;
int x,y;
scanf("%d%d",&x,&y);
a[x][y]=1;
}
memset(f,0xcf,sizeof f);
f[0][0]=0;
for(int i=1;i<=n;i++){
for(int j=0j<pow[m]j++)f[i&1][j]=0xcfcfcfcf;
for(int j=0j<pow[m]j++){
if(f[(i-1)&1][j]>=0)dfs(i,j,0,m,0) ;
}
}
printf("%d\n",f[n&1][0]);
}
}
技巧与总结:
1.使用滚动数组优化空间
2.使用三进制,分清每一个数的代表含义
3.在无法填的地方填0不会影响答案
4.注意到a数组是顺序存,而dfs是从右往左
还有一些实现技巧:
1.当我们不是使用搜索而是使用朴素DP的时候,我们对于不可以填的格子也可以储存成一个k进制数,当判断的时候我们只需要一个按位&运算,当结果为0的时候就代表这个状态没有填到不可以填的位置上
2.多进制常数较大,所以常用二进制
总结一下图形填充问题的套路:
我们填充的图形都与若干行有关,所以很容易的我们就可以按照行号划分阶段,然后我们观察图形的特征(只考虑当前行及以前行的即可),以此来将图形按照行列寻找限制条件(题目本身限制也算),此时我们观察填充这个图形一共需要几个状态(一般我们以行为阶段,则有多少行就需要几进制数),这时候我们也可以通过一些奇淫技巧将进制数降低(比如动态规划的时候多加一个维度去存储\(i-1\)行的信息)综合时间空间以及编码难度来选择合适的DP方法
还有就是,有些时候,状态转移方程不容易定义出来,只能提炼出一个个限制条件,这时候因为有限制条件的存在,我们可以把这一轮DP换成搜索,反正时间复杂度也不会差,还能降低很多编码难度
其它类型
然后再来两道其他类型的状压DP
题意简述:给定一个无向连通图,有N个节点M条边,我们需要找到一个节点K,以K为根将这张图拉成一棵树,然后对于树中的每个节点\(i\)我们需要付出代价:\(i\)的深度-1(根节点深度为1)乘上\(i\)到其父节点的边的边权,求一个使代价最小的K并输出这个最小代价(\(n\le 12,m\le 1000\))
分析:
看到题目的一瞬间,一个词蹦出来了:二次扫描与换根,很可惜,这道题牵一发而动全身,这样做是不可以维护换根操作的,所以它死了
看到\(n\le 12\),很容易猜到这是一道状压
分析
解法1:二进制状态压缩
我们考虑:因为我们确定了根节点之后整棵树的代价也就确定了,以至于这个根节点就构成了DP的阶段。但是很显然,这个阶段并不具备有序性,所以它死了
对于此题我们发现:这些代价的计算与他们的深度有关系,所以我们不妨设:
\(F[i][j]\)表示在前i层状态为j(每一位的0表示没,1表示有)时代价的最小值
定义函数:
1.\(valid(k,j)\)表示状态k增加若干条道路可以扩展为状态j且只扩展一层
2.\(cost(k,j)\)表示由状态k扩展为状态j增加的道路的路长总和的最小值
3.\(expend(k)\)表示由状态k扩展一层所得到的所有点的集合(包括k)
4.\(road(k,x)\)表示从k中扩展一层打通到节点x的道路长度的最小值
则有DP方程式:
其中首先我们看valid函数怎么求
满足\(valid\)函数为真的充要条件是\(k\)是\(j\)的子集且\(j\)是\(expand(k)\)的子集
即j & k=k,   expand(k) & j = j
函数\(expand\)我们可以通过一次扫描节点得到,顺带就扣以求出\(road\)
至于\(cost\)的求法,就只需要在扫描一次,就扣以了
#include<iostream>
#include<cstdio>
#include<cstring>
#include<algorithm>
#include<vector>
using namespace std;
const int N=15,M=1<<12;
int n,m;
int a[N][N],f[N][M];
int expand[M],road[M][N];
vector<int>vaild[M],cost[M];
/*
vaild[i][j]表示由j状态扩展一层可以得到i
cost[i][j]表示j状态变为i状态的最短道路总长
road[i][j]表示从i状态扩展到j节点的最短路
expand[i]表示i状态多扩展一层得到的状态集合
*/
void init(){
memset(road,0x3f,sizeof road);
for(int k=0;k<1<<n;k++){
expand[k]=k;
for(int j=1;j<=n;j++)
if(k>>(j-1)&1){
road[k][j]=0;
for(int i=1;i<=n;i++)
if(a[i][j]!=0x3f3f3f3f){
expand[k]|=(1<<(i-1));
road[k][i]=min(road[k][i],a[i][j]);
}
}
}
for(int j=0;j<1<<n;j++)
for(int k=0;k<j;k++)
if((k&j)==k&&(expand[k]&j)==j){
vaild[j].push_back(k);
int sum=0;
int q=j^k;
for(int i=1;i<=n;i++)if(q>>(i-1)&1)sum+=road[k][i];
cost[j].push_back(sum);
}
}
int main(){
scanf("%d%d",&n,&m)
memset(a,0x3f,sizeof a);
for(int i=1;i<=m;i++){
int u,v,w;
scanf("%d%d%d",&u,&v,&w);
a[u][v]=a[v][u]=min(a[u][v],w);
}
init();
memset(f,0x3f,sizeof f);
for(int i=1i<=ni++)f[1][1<<(i-1)]=0;
int ans=f[1][(1<<n)-1];
for(int i=2;i<=n;i++){
for(int j=1;j<1<<n;j++){
int lenj=vaild[j].size();
for(int k1=0;k1<lenj;k1++){
int k=vaild[j][k1];
f[i][j]=min(f[i-1][k]+(i-1)*cost[j][k1],f[i][j]);
}
}
ans=min(ans,f[i][(1<<n)-1]);
}
printf("%d\n",ans);
}
当然其实这份代码是有问题的,但这个问题对答案并无影响,就是在k状态转移的时候我们无法知道k中到底是哪些节点是第i-1层的,哪些是更小的,但是这个并不影响答案,因为更小意味着更早扩展,这个答案早就被其他状态计算了,并不会影响最小值的计算,这也是最优化问题的常用等效手法
解法二 三进制状压
例题三:
题目:
给定一个地图,地图中包含很多岛和连接它们的桥。
汉密尔顿路径是指沿着桥访问每个岛屿恰好一次的路径。
在我们的地图上的每个岛都具有一个权值,它是一个正整数。
假设一共有 \(n\) 个岛,第 i 个岛的权值为\(V_i\)。
现在规定一个汉密尔顿路径的总价值为以下三个价值的和:
每个岛屿的权值之和。
汉密尔顿路径中,每一条边连接的相邻岛屿的权值乘积之和。
如果在汉密尔顿路径中存在相邻的三个岛屿可以构成环形,则将所有满足条件的相邻岛屿三元组权值的乘积相加求和。
任务一:请你找出汉密尔顿路径的总价值最大可以为多少。
任务二:请你找出满足最大总价值的汉密尔顿路径共有多少条。
分析:在这道题中,第一个价值直接无视,第二个价值我们可以维护二维状压DP记录两个相邻岛,第三个价值就需要我们在状压中记录当前节点,上一个节点,然后在转移的时候的下一个节点判断一下是否构成环即可
对于任务一就可以直接状压解决,任务二的话就对每一个状压状态开一个计数数组统计的时候加起来就行,为了更好处理,这里我们把节点从0开始编号
具体的我们设
\(F[i][j][a]\)表示状态为a,当前节点为j,上一个节点为i时的最大价值
我们采用以当前状态更新的策略:
其中\(road[i][j][k]\)表示i是路径中的一个很老的点,j和k构成路径的价值,以及判断i,j,k是否为环的价值
所以我们可以预处理出road,这样就很容易得出解
实现技巧:由于N极小,所以可以直接邻接矩阵存图
const int N=15,M=1<<13;
int f[N][N][M],cnt[N][N][M],n,m,T,val[N] ;
bool check[N][N];
int road[N][N][N] ;
int main(){
int t;
scanf("%d",&t);
while(t--){
memset(cnt,0,sizeof cnt);
memset(f,0xcf,sizeof f);
memset(check,0,sizeof check);
memset(road,0,sizeof road);
scanf("%d%d",&n,&m);
for(int i=0;i<n;i++)scanf("%d",&val[i]);
for(int i=1;i<=m;i++){
int u,v;
scanf("%d%d",&u,&v);
v--,u-- ;
check[u][v]=check[v][u]=1;
}
for(int i=0;i<n;i++)f[i][i][1<<i]=val[i],cnt[i][i][1<<i]=1;
for(int i=0;i<n;i++){
for(int j=0;j<n;j++){
for(int k=0;k<n;k++){
road[i][j][k]+=val[j]*val[k]+val[k];
if(check[i][j]&&check[j][k]&&check[k][i])road[i][j][k]+=val[i]*val[j]*val[k];
}
}
}
for(int a=0;a<1<<n;a++){
for(int i=0;i<n;i++){
for(int j=0;j<n;j++){
for(int k=0;k<n;k++){
if(a>>k&1||!check[j][k])continue;
if(f[j][k][a|(1<<k)]<f[i][j][a]+road[i][j][k]){
f[j][k][a|(1<<k)]=f[i][j][a]+road[i][j][k];
cnt[j][k][a|(1<<k)]=cnt[i][j][a];
}
else if(f[j][k][a|(1<<k)]==f[i][j][a]+road[i][j][k]){
cnt[j][k][a|(1<<k)]+=cnt[i][j][a];
}
}
}
}
}
int ans=0xcfcfcfcf,sum=0;
for(int i=0;i<n;i++){
for(int j=0;j<n;j++){
if(f[i][j][(1<<n)-1]>ans){
sum=cnt[i][j][(1<<n)-1];
ans=f[i][j][(1<<n)-1];
}
else if(f[i][j][(1<<n)-1]==ans) {
sum+=cnt[i][j][(1<<n)-1];
}
}
}
if(ans<0){
puts("0 0");
continue;
}
printf("%d %d\n",ans,sum/2);
}
return 0
}