一点壮压总结 来源题单:https://www.luogu.com.cn/training/3121

壮压DP

1.炮兵阵地

#include <bits/stdc++.h>
using namespace std;

vector<int>st;
int dp[1025][1025];
int f[1025][1025];
int num[1025];
char ch[105][15];
int cal(int x){
	if(x==0) return 0;
	if(num[x]!=0) return num[x];
	int ans=0;
	while(x>0){
		ans+=(x&1);
		x>>=1;
	}
	return num[x]=ans;
}
int ans=0;
int n,m;
bool check1(int x,int st){//第x行 状态是st;
	int y=m;
	while(st>0){
		int now=st&1; st>>=1;
		if(now==1 && ch[x][y]=='H') return 0;
		y--;
	}
	return 1;
}
bool check2(int x,int y){
	if(x&y) return 0;
	return 1;
}

int main()
{
	cin>>n>>m;
	for(int i=1;i<=n;i++){
		for(int j=1;j<=m;j++){
			cin>>ch[i][j];
		}
	}
	for(int i=0;i<(1<<m);i++){
		if( (i & (i<<1))!=0) continue;
		if( (i & (i<<2))!=0) continue;
		if( (i & (i>>1))!=0) continue;
		if( (i & (i>>2))!=0) continue;
		st.push_back(i);
	}

	memset(dp,0,sizeof(dp));
	memset(f,0,sizeof(dp));
	for(auto v:st){
		if(check1(1,v)==1) dp[3][v]=cal(v);
	}

	if(n==1)  for(auto v:st) ans=max(ans,dp[3][v]);
	
	for(auto v:st){
		if(check1(1,v)==0) continue;
		for(auto u:st){
			if(check1(2,u)==0) continue;
			if(check2(v,u)==1){
				dp[v][u]=dp[3][v]+cal(u);
			}
		}
	}
	if(n==2) for(auto v:st){
		for(auto u:st){
			ans=max(ans,dp[v][u]);
		}
	}

	for(int i=3;i<=n;i++){
		memset(f,0,sizeof(f));
		for(auto v:st){
			if(check1(i-2,v)==0) continue;
			for(auto u:st){
				if(check1(i-1,u)==0) continue;
				if(check2(v,u)==0) continue;
				for(auto w:st){
					if(check1(i,w)==0) continue;
					if(check2(v,w)==0) continue;
					if(check2(w,u)==0) continue;
					f[u][w]=max(f[u][w],dp[v][u]+cal(w));
				}
			}
		}
		memcpy(dp,f,sizeof(dp));
	}


	for(auto v:st){
		for(auto u:st){
			ans=max(ans,f[v][u]);
		}
	}
	cout<<ans<<"\n";
	return 0;
}

\(dp[x][i][j]\)表示第x行状态是j,上一行状态是i的时候的方案数。
存储两行就可以了。因为第x行状态是j,上一行状态是i,在转移的时候就可以知道\(dp[x-1][i][j]\),此时的i相对于x行,过了两行,也就是需要判定可不可以的最上界。

多打一些check函数 有助于 节约时间清晰代码

2. [P3052]

题目:
给出n个物品,体积为w[i],现把其分成若干组,要求每组总体积<=W,问最小分组。(n<=18)

思路:
2的18次方是\(262144\)\(n*2^{18}\)是可以过的。

很明显可以有用\(i\)的每一个位来表示状态。
第一层枚举所有状态,第二层枚举每一个新放进去的物品。
简单地说:\(旧状态+枚举转移\) 这两者可以自动推理出来新状态,就更新了。
复杂度\(n*2^{18}\)

具体转移条件:

  • 当要搞进去的新的物品的时候,如果剩下的体积是直接可以放进去的,就直接更新。(更新的意思是可以放,但是可能放进去不是最优秀的,所以是指更新)。
  • 如果体积不可以,就直接默认需要新开一组来存放这个物品更新。(更新的解释同上文)。

貌似有些时候是明明不需要直接使用新的组别,是可能可以装进来其他物品的,但是我直接更新了。
为什么没有问题呢?因为现在强制要求要把当前看中的物品装进来。
而且,可以装进来其他物品的情况,在枚举看中的物品的时候,一定可以枚举到这个物品。也就是说最优解只可能晚点出现,但一定会出现。

\(g[i]\)表示状态为i的时候此时最后一组还剩余的最大体积。
最大体积在遍历,枚举转移的过程中,发挥很重要的作用。

代码:

#include <bits/stdc++.h>
using namespace std;

int a[20];
const int N=1<<18;
int f[N],g[N];
int main()
{   
    // cout<<(1<<18)<<endl;
    memset(f,0x7f,sizeof(f));
    memset(g,0,sizeof(g)); 
    int n,w;
    cin>>n>>w;
    for(int i=1;i<=n;i++){
        cin>>a[i];
    }
    sort(a+1,a+1+n);
    f[0]=0;
    g[0]=0;


    for(int i=0;i<(1<<n);i++){

        for(int j=1;j<=n;j++){
            int pos=1<<(j-1);
            if(i&pos) continue;
            if(g[i]>=a[j]){
                if(f[i|pos]>f[i] || (f[i|pos]==f[i] && g[i|pos]<g[i]-a[j])){
                    f[i|pos]=f[i];
                    g[i|pos]=g[i]-a[j];
                }
            }
            else{
               if(f[i|pos]>f[i]+1 || (f[i|pos]==f[i]+1 && g[i|pos]<w-a[j])){
                    f[i|pos]=f[i]+1;
                    g[i|pos]=w-a[j];
                }
            }   
        }
    }
    cout<<f[(1<<n)-1]<<"\n";
    

    return 0;
}

3.327E - Axis Walking

题意:
有n张卡牌,每一次随便选择一张扔掉,自己的坐标加上对应的数字。
有两个坐标是一定不能到达的。
问:有多少种把卡牌都扔掉的方案数?

思路:

n=24
壮压DP
\(f[i]\)表示现在状态为i的时候的方案数,用\(dis[i]\)表示状态为i时候的当前距离。
其中状态i,比如5:\(101\)代表的是第一张用了、第二张没有使用、第三张也使用了的情况。

前置知识:lowbit:
return x&(-x) ; 返回数字x在二进制下面的最后一个1的所有右边的数字,包含这个1.

方案数字记录:

for(int i=x;i>0;i^=j){
	 int j=i&(-i);
	 f[x]=(f[x]+f[x^j])%mod;
}

上面循环中用位运算枚举了现在的状态:\(x\)的上一个状态的所有情况,加上就可以统计答案。

每一次处理复杂度:\(O(logn)\)

状态转移:

for(int i=1;i<(1<<n);i++){
	int j=i&(-i);
	dis[i]=dis[i^j]+dis[j];
	if(dis[i]==b1 || dis[i]=b2) continue;
	cal(i);//统计i的答案。
}

CODE:

#include <bits/stdc++.h>
using namespace std;
int dis[1<<24],f[1<<24];
int a[25];
const int mod=1e9+7;
void cal(int x){
	int j;
	for(int i=x;i>0;i^=j){
		j=i&-i;
		f[x]=(f[x]+f[x^j])%mod;
	}
}

int main()
{
	cin.tie(0);
	ios::sync_with_stdio(false);
	int b1,b2; b1=b2=-1;
	int n;
	cin>>n;
	for(int i=0;i<n;i++){
		cin>>dis[1<<i];
	}
	int m; cin>>m;
	if(m>0) cin>>b1;
	if(m>1) cin>>b2;
	f[0]=1;
	for(int i=1;i<=((1<<n)-1);i++){
		int j=i&-i;
		//返回的是j的最后一个1开始从左往右的所有二进制数字组成的数。
		dis[i]=dis[i^j]+dis[j];
		if(dis[i]==b1 || dis[i]==b2) continue;
		cal(i);
	}
	cout<<f[(1<<n)-1]<<"\n";

	return 0;
}

小结:虽然每一次用的谁是会影响结果的,因为必须统计方案。

但是最后一个是谁 也不是很关键 因为 最后也可以特别的处理方案就行了。
在距离的时候认为一定是当前的最后一个1得来的就可以。

4.P2622 关灯问题II

题意:
给定10个灯,最开始都是开的。
\(m<=100\)个按钮,每一个按钮对于每一个灯都有对应的效果,分别为1,0,-1.题目里面直接给出效果,效果的具体含义为:
如果\(a_{i,j}\) 为 1,那么当这盏灯开了的时候,把它关上,否则不管;如果为 −1 的话,如果这盏灯是关的,那么把它打开,否则也不管;如果是 0,无论这灯是否开,都不管。
初始所有的灯都是开的,问最少要按动几次按钮才可以最终所有的灯都关闭?

思路:
壮压是很容易可以想到的,因为n只有10个,状态最多也就只有1024种。
用一个数字的二进制代表相应的位置上面现在灯光的开关状态。

考虑一般性的dp,枚举初始状态和操作的开关的种类。时间复杂度总是对不上。

之前的题目有一个共性的特点,如果用二进制表示里面的1代表这个东西是用过的,那么111.一定是由011 或者101 或者110 三者推理过来的。这样会导致从0开始往大的地方遍历是合理的,当前遍历到的点,一定已经把所有这个状态的前驱状态都推理过。
但是此题并没有如此特点,因为按动一次按钮之后所有位置都会发生不可预料的变化。

注意最后需要的是次数,并且状态最多只有1024种,考虑\(bfs\)跑最短路。
\(dis[st]\)表示状态为st,从所有灯都开到这种状态需要的最少次数。
每一个新的状态,枚举所有的开关进行改变,更新出新状态,压入队列即可。

#include <bits/stdc++.h>
using namespace std;

int n,m;
int w[105][15];
int dis[1<<10];
int main()
{
	memset(dis,0x3f,sizeof(dis));
	cin>>n>>m;
	for(int i=1;i<=m;i++){
		for(int j=1;j<=n;j++){
			cin>>w[i][j];
		}
	}
	int st=(1<<n)-1;
	dis[st]=0;
	queue<int>q;
	q.push(st);
	while(!q.empty()){
		int t=q.front();
		q.pop();
		for(int i=1;i<=m;i++){	
			int st2=0;
			for(int j=1;j<=n;j++){
				int mo=(t>>(j-1))&1;
				if(w[i][j]==1) mo=0;
				if(w[i][j]==-1) mo=1;
				st2|=(mo<<(j-1));				
			}
			if(dis[st2]>dis[t]+1){
				dis[st2]=dis[t]+1;
				q.push(st2);
			}
		}
	}
	if(dis[0]>1e8){ cout<<"-1\n";}
	else cout<<dis[0]<<"\n";
	return 0;
}

5.[P7098 凉凉

题意:
每一个地铁站在对应的深度开放站口的时候都会有一定的花费。
一个地铁线路是一个深度,但是如果两条地铁线路有重复的站点就不能在同一深度。
问合理安排之后,仅仅考虑建设地铁站的花费的最小值是多少?

代码:

#include <bits/stdc++.h>
using namespace std;
#define int long long
const int N=25;
const int M=1e5+6;
const int INF=1e17;
int n,m;
int dep[N][M];//深度为n的时候m种东西的花费。
int cnt[N],sub[N][M];
int cost[N][N];//cost[i][j] 表示线路i在深度j的时候的花费。
int f[N][200005];
int vis[N][N];//标记两个站能不能一起用?
int g[N][200005];
signed main()
{
	cin>>n>>m;
	for(int i=1;i<=n;i++){
		for(int j=1;j<=m;j++){
			cin>>dep[i][j];
		}
	}
	for(int i=1;i<=n;i++){
		cin>>cnt[i];
		for(int j=1;j<=cnt[i];j++){
			cin>>sub[i][j];
			for(int k=1;k<=n;k++){
				cost[i][k]+=dep[k][sub[i][j]];
			}
		}
		sort(sub[i]+1,sub[i]+1+cnt[i]);
	}
	for(int i=1;i<=n;i++){
		for(int j=i+1;j<=n;j++){
			vis[i][j]=vis[j][i]=1;
			int x=1; int y=1;
			while(x<=cnt[i] && y<=cnt[j]){
				if(sub[i][x] == sub[j][y]){
					vis[i][j]=vis[j][i]=0; break;
				}
				if(sub[i][x] < sub[j][y]){
					x++;
				}
				else y++;
			}
		}
	}
	int stk[25];
	for(int s=0;s<(1<<n);s++){
		int top=0;
		int st=s;
		bool flag=0;
		for(int j=1;j<=n && st>0;j++){
			if(flag) break;
			if(st&1){
				stk[++top]=j;
				for(int k=1;k<top;k++){
					if(vis[j][stk[k]]==0){
						for(int z=1;z<=n;z++)
									g[z][s]=INF;
						
						flag=1; break;
					}
				}

				for(int k=1;k<=n;k++){
					g[k][s]+=cost[j][k];
				}
			}
			st>>=1;
		}
	}
	for(int s=1;s<(1<<n);s++){
		f[1][s]=g[1][s];
	}

	for(int i=2;i<=n;i++){
		for(int S=0;S<(1<<n);S++){
			f[i][S]=f[i-1][S];
			for(int s=S;s;s=(s-1)&S){
				f[i][S]=min(f[i][S],f[i-1][s^S]+g[i][s]);
			}
		}
	}
	cout<<f[n][(1<<n)-1]<<"\n";
	return 0;
}

6.[宝藏]([NOIP2017 提高组] 宝藏)

题意:(题目本身没有看懂)
有n个点,\(n<=12\).
在一个有n个点组成的地图里面挖路。

最开始可以任意选择一个点作为起点不需要任何花费直接到达,并在此基础上再地图上面进行扩展。
目的是把n个点变得连通起来。

如果:接下来选择了从\(a->b\)这条路要挖通.
前提条件:存在一条已经挖好的道路从起点到达a。
花费:挖当前道路的花费为:从起点到a的路径上面经过的点的个数 (包括起点和结点a)\(num\)
花费为:\(num*len_{新挖的路}\)
问最小花费把所有点都挖通。

思路:

本身想要模仿上面的题目,但是发现,如果定义dp数组为前i个点的某种状态下的花费,因为点的位置是随机的,并不是一排排好的,用上面的数组,在注意上有很大的问题。

可以发现我们最终的结果一定是一颗树的形式。并且根就是我们选择的起点。

考虑图模型:

QAQ

图上的k是层数的意思。很明显越往上(越靠近起点,k越小)。引发思考之前数组里面的i,现在不用来表示编号的前i个,用来表示层数的前i层里面,最后地图挖通的状态下的最小花费。
\(dp[2][5]\)就表示在前两层里面,把点1和点3都挖通了之后的最小花费。(因为5的二进制表示就是第一位和第三位为1。)

这样就可以得到转移方程:

for(int i=2;i<=n;i++){
    for(int S=0;S<(1<<n);S++){
        for(int s=S;s;s=(s-1)&S){
            f[i][S]=min(f[i][S],f[i-1][s]+(从前i-1层的s状态到达第i层的S状态的花费));
        }
    }
}

接下来只需要预处理出来上面程序中的:从前i-1层的s状态到达第i层的S状态的花费即可。
如果限定了一定是从i-1层到的第i层,那么只需要知道从s->S 新增的路径长度再乘上 (i-1)即可。

对于从s->S的过程:
定义tmp=s^S;

tmp里面所有为1的位数,就代表这次要新增进来的结点编号。
可以从哪些点新增进来?一定是用 s里面现在拥有的结点中转移过来的。所以遍历一下记录最小的就可以。

#include <bits/stdc++.h>
using namespace std;

const int INF=1e8;
const int N=14;
int dis[13][13];
int tran[1<<12][1<<12];
long long f[13][1<<13];
signed main()
{
	int n,m;
	cin>>n>>m;
	for(int i=1;i<=n;i++){
		for(int j=1;j<=n;j++) dis[i][j]=INF;
	}
	for(int i=1;i<=m;i++){
		int x,y,z;
		cin>>x>>y; cin>>z;
		if(dis[x][y]>z) dis[x][y]=dis[y][x]=z;
	}
	memset(tran,0x3f,sizeof(tran));
	for(int i=0;i<(1<<n);i++){
		for(int s=i;s;s=(s-1)&i){
			tran[s][i]=0;
			//s是i的子集 模拟从s推理出来i.
			int tmp=s^i;//新增的。
			for(int j=1;j<=n;j++){
				if(((tmp>>(j-1))&1)==0) continue;
				int mina=INF;
				for(int o=1;o<=n;o++){
					if(((s>>(o-1))&1)==1){
						mina=min(mina,dis[o][j]);
					}
				}
				if(mina==INF){
					tran[s][i]=INF;
					break;
				}
				else tran[s][i]+=mina;
			}
		}
	}
//经过上面的书写 就把所有的从一个集合转变为另外一个集合的所有状态都搞出来了。
	// for(int i=0;i<(1<<n);i++){
	// 	for(int j=0;j<(1<<n);j++){
	// 		cout<<i<<" "<<j<<" "<<tran[i][j]<<endl;
	// 	}
	// }
	memset(f,0x3f,sizeof(f));
	for(int i=1;i<=n;i++){
		f[1][1<<(i-1)]=0;
	}
	for(int i=2;i<=n;i++){
		for(int S=0;S<(1<<n);S++){
			for(int s=S;s;s=(s-1)&S){
				if(tran[s][S]!=INF) 
				f[i][S]=min(f[i][S],f[i-1][s]+tran[s][S]*(i-1));
			}
		}
	}
	long long  ans=INF;
	for(int i=1;i<=n;i++){
		ans=min(ans,f[i][(1<<n)-1]);
	}
	cout<<ans<<"\n";
	return 0;
}

关键在于:状态转移方程从原先的前i个点的一些状态进行转移,转变为前i层的状态进行转移。
预处理以及最后的状态转移都是很好想到的。

总结:

  • 炮兵阵地:和互不侵犯差不多,更像是通过状态压缩信息之后进行模拟。

  • [327E - Axis Walking]:典型的1101 是从0101 1001 1100三种状态中的一种转移过来:
    典型的每次只会多一个的状态转移。
    这样的转移明显范围\([0,2^n]\)。且for循环从0开始逐渐\(++\)即可。
    small tips: 拿统计最后得到x的方案举例:

    void cal(int x){
    	int j;
    	for(int i=x;i>0;i^=j){
    		j=i&-i;
    		f[x]=(f[x]+f[x^j])%mod;
    	}
    }
    
  • P2622 关灯问题II]: 因为每一种操作会对n种物品会产生各自不一定相同的影响,而且n比较小,直接把n种物品的状态压起来用数的二进制表示即可。跑\(bfs\)最短路.

  • P7098 凉凉 和 宝藏\(dp[i][s]\)表示前i层已经处理的物品信息状态为s的情况下的最小花费。
    最后的转移方程,往往都是:

    for(int i=2;i<=n;i++){
    		for(int S=0;S<(1<<n);S++){
    			f[i][S]=f[i-1][S];
    			for(int s=S;s;s=(s-1)&S){
    				f[i][S]=min(f[i][S],f[i-1][s^S]+g[i][s]);
    			}
    		}
    }
    

    对于里面的\(g[i][s]\)往往需要预处理出来。
    宝藏里面还涉及到了从前i个的转移思想转换为前i层的转移思想。

  • 解释一下最常见的一种推理的dp里面出现的公式:

    int j;
    for(int i=x;i;i=i^j){
        j=i&(-i);
        //j代表的是当前x的最后一位的1和右边的东西。
        //之后i^j 相当于让i把最后一个1变为0。
        //这个过程就可以枚举出来1101 里面的:0001、0100、1000;
        //也就是所谓的当前状态,是由于原来的状态多了一个1推理出来。
        //并且 这个过程 往往是可以通过从0开始往上面递增把所有的情况都遇到的。
        //复杂度:logn.
    }
    

    另外一种:

    现在的状态是1101,但是每一次操作能够加进去的1都是没有任何限制条件的,因此:
    有:0001、0100、1000、1100、0101、1001、1101 7

    for(int S=0;S<(1<<n);S++){
    	for(int s=S;s;s=(s-1)&S){
            //s就会把所有的 S 的子集搞出来。
            f[S]=max(f[S],f[s^S]+g[s]);
        }
    }
    
posted @ 2023-08-19 23:57  橘赴亦梦人ω  阅读(12)  评论(0编辑  收藏  举报