状态压缩动态规划学习笔记

状态压缩动态规划学习笔记


放在前面的话

还有十几天就要考复赛了,抓紧这次停文化课的机会刷了一波状压dp,总结一下状态压缩动态规划的一些简单思路与解法。由于时间紧迫,许多话没说清楚,有些思路只可意会,不可言传……
在这里插入图片描述


状态压缩

按我个人的理解来看,状态压缩是将复杂的状态转化为简单的状态,比如写汉字是复杂的状态,而编码是简单的状态,显然,对于计算机而言,编码比汉字好理解很多。又比如英文字母是复杂状态,二进制是简单状态,用 0 0 0 1 1 1两个简单数字可以表达所有的英文字母。
在这里插入图片描述
当然,这只是肤浅意义上的理解。在解决问题的过程中,尤其在动态规划中,对于没有十分明确的阶段顺序,状态可以表示为集合形式的问题,我们常常用 01 01 01串表示元素是否在集合中的状态,从而方便状态转移与书写。例如对于集合 { 3 , 1 , 5 } \{3,1,5\} { 3,1,5}可表示为 10101 10101 10101


状态运算与操作

位运算

  • 左移:左移1位相当于乘2,左移n为相当于乘 2 n 2^n 2n
    二进制中110左移1位即1100,十进制表示就是6变12。
    x << 1 ⇔ x × 2 \Leftrightarrow x\times2 x×2
    x << n ⇔ x × 2 n \Leftrightarrow x\times2^n x×2n
  • 右移:右移1位相当于除以2,右移n为相当于乘 2 n 2^n 2n
    二进制中110右移1位即11,十进制表示就是6变3。
    x >> 1 ⇔ x 2 \Leftrightarrow \frac{x}{2} 2x
    x >> n ⇔ x 2 n \Leftrightarrow \frac{x}{2^n} 2nx
  • 与运算:按位与运算,两位同时为1则结果为1,否则为0。101 & 110 == 100
  • 或运算:按位或运算,两位其中一位为1则结果为1,否则为0。101 | 110 == 111
  • 非运算:按位取反。~ 101 == 010

改变状态的操作

  1. 判断一个数字s二进制下第i位状态。
    s&(1<<(i-1))
  2. 将数字s二进制下第i为改为1。
    s=s|(1<<(i-1))s+=(1<<(i-1))
  3. 将数字s二进制下第i为改为0。
    s=s&(~(1<<(i-1)))s-=(1<<(i-1))
  4. 将数字s二进制下最靠右的第一个1去掉。
    x=x&(x-1)
  5. 获取数字s二进制最低位上的 1
    x&(-x)
  6. 枚举子集:for(int i = x;i;i = (i-1)&x)
  7. 统计s中1的个数
int cnt1(int x)
{
    
    
	int tmp = 0 ;
	for(;x;)
	{
    
    
		tmp ++;
		x=x&(x-1);
	}
	return tmp;
}

基本形式

S ′ ⊆ S , f ( S ) = f ( S ′ ) + v ( S − S ′ ) S'\sube S ,f(S) = f(S') + v(S-S') SS,f(S)=f(S)+v(SS)
意为:对于用集合 S S S表示的状态,可从它的其中一个子集 S ′ S' S通过某种方式1 v v v转移而来。

子集类问题:

子集中只含一个元素:

这一类题目通过一个一个元素加入集合而达到状态 S S S,这样的做法枚举量较小,运行速度快,从而形成基本转移方程中的一种特殊形态: { x } ⊆ S , f ( S ) = f ( S − { x } ) + v ( x ) \{ x\} \sube S, f(S) = f(S-\{ x\} )+v(x) { x}S,f(S)=f(S{ x})+v(x)
典例:
P3052 [USACO12MAR]Cows in a Skyscraper G
我们设集合 S S S中,第 i i i位为 0 0 0表示第 i i i个奶牛没有坐电梯下楼,第 i i i位为 1 1 1表示第 i i i个奶牛坐电梯下楼了,则函数 f ( S ) f(S) f(S)表示状态 S S S中标号为 1 1 1的奶牛下楼所需的最少坐电梯次数。对于当前状态 S , j ∉ S S,j\notin S Sj/S,第 j j j头奶牛要下楼了,显然分类讨论:1、第 j j j头奶牛挤上前面某次下楼的电梯;2、前面所有趟次的电梯中都不能容下第 j j j头奶牛了;于是就得到状态转移方程:
{ j } ⊆ S f ( S ) = { min ⁡ { f ( S ) , f ( S − { j } ) } , s u m ( S ) ≤ W × f ( S − { j } ) min ⁡ { f ( S ) , s u m ( S ) / W + 1 } , s u m ( S ) > W × f ( S − { j } ) \{j\}\sube S\\ f(S) = \begin{cases} \min \{ f(S),f(S-\{j\})\}, & sum(S) \leq W\times f(S-\{j\}) \\ \min \{ f(S),sum(S)/W+1\}, & sum(S) > W\times f(S-\{j\}) \\ \end{cases} { j}Sf(S)={ min{ f(S),f(S{ j})},min{ f(S),sum(S)/W+1},sum(S)W×f(S{ j})sum(S)>W×f(S{ j})
其中,函数 s u m ( S ) = ∑ i ∈ S w i sum(S)=\sum_{i\in S} w_i sum(S)=iSwi
参考程序:

#include<iostream>
#include<cstdio>
#include<string>
#include<cstring>

using namespace std;
const int inf = 2139062143;
int w,n,c[20],tot,map[263000];
int f[263000],sub[263000],s1,sum;

int main()
{
    
    
	memset(f,127,sizeof(f));
	scanf("%d%d",&n,&w);
	for(int i = 1;i <= n;i ++)
		scanf("%d",&c[i]);
			
	tot = (1<<n)-1;
	f[0] = 0;
	for(int s = 0;s <= tot;s ++)
	{
    
    
		sum = 0;//sum函数的体现
		for(int i = 1;i <= n;i ++)
			if((s&(1<<(i-1))) > 0)
				sum += c[i];
		for(int i = 1;i <= n;i ++)
			if((s&(1<<(i-1))) > 0)//枚举单个元素构成的子集
			{
    
    
				s1 = s - (1<<(i-1));
				if(f[s1] != inf)
				{
    
    
					if(f[s1]*w >= sum) f[s] = min(f[s],f[s1]);//分类讨论
					else f[s] = min(f[s],(sum/w+1));
				}
			}
	}
	printf("%d",f[tot]);
	return 0;
}

真·子集:

这一类题目通过枚举状态的所有子集,将子集相并而达到状态 S S S,而非逐个元素枚举,这样的做法往往是受到题目的限制或为了操作的方便,枚举子集的枚举量往往比枚举元素的枚举量大,因为众所周知:对于 n n n个元素的集合,其子集有 2 n 2^n 2n个,而元素只有 n n n个。对于某个状态的子集,我们往往可以在动态规划之前就预处理出来,筛选合法有用的状态,摒弃多余无用的状态,从而提高程序的运行速度。其通式就是上文所列的基本形式。
典例1:
P5911 [POI2004]PRZ
既然要分组,那么不妨把过一次桥可能出现的所有组合算出来,然后套用状态转移方程: f ( S ) = min ⁡ S ′ ⊆ S { f ( S ′ ) + v ( S − S ′ ) } f(S) = \min_{S'\sube S}\{f(S')+v(S-S')\} f(S)=SSmin{ f(S)+v(SS)}
其中,函数 v ( S ) = max ⁡ i ∈ S { t i } v(S)=\max_{i\in S}\{t_i\} v(S)=maxiS{ ti}
参考代码:

//Classic:f(S) = f(S') + v(S-S')
#include<iostream>
#include<cstdio>
#include<string>
#include<cstring>

using namespace std;
const int inf = 2139062143;
int w,u,a[25],t[25];
int tot,f[66000],sub[66000];

void check(int x)
{
    
    
	int tmp = 0,ti = 0;
	for(int i = 1;i <= u;i ++)
		if((x&(1<<(i-1))) > 0)
		{
    
    
			tmp += a[i];
			ti = max(ti,t[i]);
		}
	if(tmp > w)//桥塌了
		sub[x] = -1;
	else//子集合法,即这种组合桥不会塌
		sub[x] = ti;
	return ;
}

int main()
{
    
    
	memset(f,127,sizeof(f));
	scanf("%d%d",&w,&u);
	tot = ((1<<u) - 1);
	for(int i = 1;i <= u;i ++)
		scanf("%d%d",&t[i],&a[i]);
	
	for(int s1 = tot;;s1 = ((s1-1)&tot))//枚举子集
	{
    
    
		check(s1);
		if(s1 == 0) break;
	}
	f[0] = 0;
	for(int s = 0;s <= tot;s ++)
	{
    
    
		for(int s1 = s;;s1 = ((s1-1)&s))//枚举子集
		{
    
    
			int s2 = s - s1;
			if(sub[s2]!=-1) f[s] = min(f[s],f[s1]+sub[s2]);//状态转移
			if(s1 == 0) break;
		}
	}
	
	printf("%d",f[tot]);
	return 0;
}

典例2:
P2831 愤怒的小鸟
看到题目时,自然一脸茫然,从数据入手发现猪个数很少,固然想到状压dp,然而怎么处理状态的确花费不少心思。看到一条抛物线可以一连打掉几只猪,所以想到将子集和抛物线联系在一起:对于某一抛物线 y = a x 2 + b x y=ax^2+bx y=ax2+bx,设集合 g g g中的猪全部在这条抛物线上;而对于 y = a x 2 + b x y=ax^2+bx y=ax2+bx,我们知道,只需要知道两个确切坐标就能算出 a , b a,b a,b,所以我们暴力枚举任意两只猪 i , j i,j i,j,就直接算出抛物线 y i j = a x 2 + b x y_{ij}=ax^2+bx yij=ax2+bx,并且在枚举一遍其它猪,将在这条抛物线上的猪加入集合 g i j g_{ij} gij,这样就预处理出子集 g g g了。最后在dp的时候,没有必要枚举太多子集,因为两猪确定一条抛物线,所以只需枚举状态中的两只猪,然后就可以得到子集。所以有方程:
f ( S ) = min ⁡ i ∈ S , j ∈ S , i ≠ j { f ( S ) , f ( S − ( S ∩ g i j ) ) + 1 } f(S)=\min_{i\in S,j\in S,i\neq j}\{f(S),f(S-(S\cap g_{ij})) + 1\} f(S)=iS,jS,i=jmin{ f(S),f(S(Sgij))+1}
关于 S − ( S ∩ g i j ) S-(S\cap g_{ij}) S(Sgij)这里表示的是二进制数 S & ( ! g i j ) S \&(!g_{ij}) S&(!gij),含义为集合 S S S去掉 S S S g i j g_{ij} gij中共有的元素,因为有时 g i j g_{ij} gij并非恰好是 S S S的一个子集,但 i , j ∈ S i,j\in S i,jS,为了使用 g i j g_{ij} gij这条抛物线且保证构造出 S S S的子集,我们假设 k ∈ g i j 且 k ∉ S k\in g_{ij}\text 且 k\notin S kgijk/S没有被打到,于是子集就为 S − ( S ∩ g i j ) S-(S\cap g_{ij}) S(Sgij)
最后再注意一下只打一只猪的情况就可以了。
参考代码:

#include<iostream>
#include<cstdio>
#include<cmath>

using namespace std;
const int inf = 0xfffffff;
int T,n,m;
double x[25],y[25],a[25],b[25],a1,b1;
int f[1048576],g[25][25];
/*
f[S]:表示战场状况为S时最少需要的小鸟个数;在S中,1表示这只猪被打了,
0表示这只猪没有打
g[i][j]:表示经过i和j两点形成的抛物线可以打掉猪的状况;
如g[2][4]=1011表示经过第1只猪和第4只猪的抛物线不仅可以打掉这两头猪,
还可以打掉第3头猪
f[S] = min(f[S],f[S'] + 1);其中S' = S - g[i][j] 
*/ 

void calc_para()//处理抛物线 
{
    
    
	for(int i = 1;i <= n;i ++)//初始化很重要!!
		for(int j = 1;j <= n;j ++)
		{
    
    
			if(i == j) g[i][i] = g[i][i] | (1<<(i-1));
			else g[i][j] = 0;
		}
	for(int i = 1;i < n;i ++)
		for(int j = i + 1;j <= n;j ++)
			if(x[i]!=x[j])
				{
    
    
					a1 = (y[i]*x[j]-y[j]*x[i])/(x[i]*x[i]*x[j]-x[j]*x[j]*x[i]);//暴力计算求抛物线
					b1 = (y[i]*x[j]*x[j]-y[j]*x[i]*x[i])/(x[i]*x[j]*x[j]-x[j]*x[i]*x[i]);
					
					if(a1 < 0)
						for(int k = 1;k <= n;k ++)
							if(a1*x[k]*x[k]+b1*x[k] + 0.000001 >= y[k] && a1*x[k]*x[k]+b1*x[k] - 0.000001 <= y[k])//误差计算
								g[i][j] = g[i][j] | (1<<(k-1));
				}
}


int main()
{
    
    
	scanf("%d",&T);
	for(int i = 1;i <= T;i ++)
	{
    
    
		scanf("%d%d",&n,&m);
		for(int i = 0;i <= (1<<n);i ++) f[i] = inf;//初始化
		for(int i = 1;i <= n;i ++)
			scanf("%lf%lf",&x[i],&y[i]);
		calc_para();//预处理
		f[0] = 0;int tmp = 1;
		for(int i = 1;i <= n;i ++)//初始化很重要!!!
		{
    
    
			f[tmp] = 1;
			tmp = tmp * 2;
		}
		for(int i = 1;i <= (1<<n)-1;i ++)
			for(int p = 1;p <= n;p ++)//枚举子集
				if(i&(1<<(p-1)))
				{
    
    
					bool flag = 0;
					for(int q = p+1;q <= n;q ++)//枚举子集
						if(i&(1<<(q-1)))
						{
    
    
							f[i] = min(f[i],f[(i&(~g[p][q]))] + 1);
							flag = 1;
						}
					if(flag) f[i] = min(f[i],f[(i&(~(1<<(p-1))))] + 1);//只打一头猪的情况
				}
		printf("%d\n",f[(1<<n)-1]);
	}
	return 0;
}

网格类问题

行列式做法:

对于一个网格,可以将一行中的所有格子压缩成一个二进制数,然后枚举行数与一行中的状态从而进行状态压缩dp,此时一维的式子不再适用,所以可得到变形通式:
f ( i , S ) = f ( i − 1 , S ′ ) + v ( ( i − 1 , S ′ ) , ( i , S ) ) f(i,S) = f(i-1,S') +v((i-1,S'),(i,S)) f(i,S)=f(i1,S)+v((i1,S),(i,S))
其中,函数 v ( ( i − 1 , S ′ ) , ( i , S ) ) v((i-1,S'),(i,S)) v((i1,S),(i,S))表示从状态 ( i − 1 , S ′ ) (i-1,S') (i1,S)转移到状态 ( i , S ) (i,S) (i,S)的方式。对于这一类问题, S S S S ′ S' S不一定是包含与被包含的关系,往往要求二者之间满足题目中一些特殊条件,需要用位运算判断一下二者是否符合条件,也可根据条件预处理出合法的状态。
典例:
P1896 [SCOI2005]互不侵犯
类似于八皇后的问题,但是国王比皇后攻击范围更短,可放置的数量更多。
看到N很小,考虑用状压dp,可预处理出同一行中所有合法状态,然后在dp时上下两行之间比对,合法则转移。设 f ( i , k , S ) f(i,k,S) f(i,k,S)表示第 i i i行放置 k k k个国王,放置状态为 S S S的方案数,则:
f ( i , k , S ) = ∑ S ′ 与 S 不 冲 突 f ( i − 1 , k ′ , S ′ ) f(i,k,S) = \sum_{S'\text 与S\text不冲突}f(i-1,k',S') f(i,k,S)=SSf(i1,k,S)
参考代码:

#include<iostream>
#include<cstdio>

using namespace std;
int n,k,cnt,s[520],num[520];
long long f[15][520][105],ans;

void pre()
{
    
    
	bool flag=0;
	for(int i=0;i<(1<<n);i++)
	{
    
    
		if((i&(i<<1))) continue;
		/*
		5		5<<1		5&(5<<1)		101		result=0
		101		1010		101&1010	 & 1010		不冲突的摆放 
		*/
		int tmp=0;
		for(int j=0;j<n;j++)
			if(i&(1<<j))
				tmp++;//统计当中1的个数
		cnt++;
		s[cnt]=i;//登记可用的状态 
		num[cnt]=tmp;//登记这个状态放置的国王数 
	}
}

void DP()
{
    
    
	f[0][1][0]=1;//初始化,边界条件 
	for(int i=1;i<=n;i++)//枚举每一行 
		for(int j=1;j<=cnt;j++)//枚举第i行可能出现的状态 
			for(int p=0;p<=k;p++)//枚举前i行放置的国王数 
				if(p>=num[j])
					for(int t=1;t<=cnt;t++)//枚举i-1行放置国王的状态 
						if( !(s[t]&s[j])/*这一行没有任何国王在上一行的某个国王正下方*/ && !(s[t]&(s[j]<<1))/*这一行没有任何国王在上一行的某个国王左下方*/ && !(s[t]&(s[j]>>1))/*这一行没有任何国王在上一行的某个国王右下方*/ )
							f[i][j][p]+=f[i-1][t][p-num[j]];//累加上一行的方案数 
	for(int i=1;i<=cnt;i++)
		ans+=f[n][i][k];//累加不同状态结尾的方案数 
}

int main()
{
    
    
	scanf("%d%d",&n,&k);
	pre();
	DP();
	printf("%lld\n",ans);
	return 0;
}

轮廓线做法:

对于行列式做法,往往要枚举 S S S的所有情况,然而我们能否减少状态的枚举量,加快运算速度呢?想到逐个元素枚举快于枚举子集,我们可以枚举具体的格子,同时记录格子周边一些必要的状态,为了讲述方便,接下来我们看一道例题:
P1879 [USACO06NOV]Corn Fields G
看到两变量范围都在12以内,首先想到用状压dp;
当我们不考虑题目附加的限制,即把所有格子都看作是肥沃的。然后考虑具体的一个格子,不难发现:如果在这个格子上种植,其充要条件是上下左右4个格子都不种植。

如果以一个格子作为决策阶段,按普通的思维,从上至下,从左至右地动态规划,我们发现:这个格子能否种植取决于其上格子和左格子有没有种植,即当前格子种植状态由上格子不种植和左格子不种植这一状态转移而来,所以上格子和左格子的状态是必须记录的。
a

但在dp的同时,又要考虑为后面决策记录状态,所以只记录上、左格子是不够的。我们仔细观察,发现当前格子左边的所有格子是下一行一些格子的上格子状态,格子右边是未决策的格子,需要记录上一行此处右边的所有格子状态作为右半部分的上格子状态,而上一行此处左半部分格子已经没有用处了。
在这里插入图片描述
所以我们将左半部分格子的状态和右半部分上一行的状态合并在一起,就达到了更新和记录状态的效果
在这里插入图片描述

决策的时候,可用上格子的状态,也能用左格子状态,处理完后,上格子已经无用,记录当前决策点状态,抛弃上格子状态,为下一行提供上格子状态,为下一个格子提供左格子状态。概括来说,状态记录的就是当前一行决策过的格子的状态和上一行决策过的且对后面处理有用的格子的状态,到这里,状态的压缩体现得淋漓尽致。
最后为了操作的方便,我们采取dfs的遍历方式,最后就搞成了搜索记忆化。最后再加上题目本身所带的限制,用位运算处理一下即可。

参考代码:

#include<cstdio>
#include<iostream>
#include<cmath>

using namespace std;
const int modnum = 100000000;
int n,m,map[20][20],f[20][20][32780];
int st[20];
bool vis[20][20][32780];

int dfs(int i,int j,int s)
{
    
    
	if(i>n) return 1;//全部放置完毕,算作一种方案 
	if(j>m) return dfs(i+1,1,s&st[i+1])%modnum;//this row has been counted,go to the next grid
	if(vis[i][j][s]) return f[i][j][s]%modnum;//we've known the answer and return it
	vis[i][j][s] = 1;//
	int now = 1<<(j-1);//the status of (i,j)
	if((s&now)==0)//we can't place a plant here
	{
    
    
		int s1 = (s|now);//we can't place a plant on (i,j) , but we can place a plant on (i+1,j)
		f[i][j][s] = dfs(i,j+1,s1)%modnum;//go to the next grid
		return f[i][j][s]%modnum;
	}
	int tmp = dfs(i,j+1,s)%modnum;//we don't place a plant here
	int s2 = s - now;int next = 1<<j;//if we place a plant here,we can't place a plant on the next grid
	if(((next&s2)>0)&&(j<m)) s2 -= next;//figure out the status of placing a plant here
	int tmp2 = dfs(i,j+1,s2)%modnum;//go to the next grid
	f[i][j][s] = (tmp+tmp2)%modnum;//sum up the answer
	return f[i][j][s];
}

int main()
{
    
    
	scanf("%d%d",&n,&m);
	for(int i=1;i<=n;i++) st[i]=0;
	for(int i=1;i<=n;i++)
	{
    
    
		for(int j=1;j<=m;j++)
		{
    
    
			scanf("%d",&map[i][j]);
			if(map[i][j])
				st[i]+=pow(map[i][j]*2,j-1);
		}
	}
		
	printf("%d",dfs(1,1,st[1])%modnum);
	
	return 0;
}

许多行列式做法的题目往往可以用轮廓线做法代替,从而提高程序运行效率。


相关思路提醒

  • 看到数据范围较小如20以内,考虑状态压缩
  • 考虑预处理所有基础变化对应的子集
  • 由局部推出整体

  1. 在本文中,“方式”一词表示在状态转移时所需的花费,或收益,或方案计数,或取最大值等能达到预期目的、符合题意或得到答案的操作 ↩︎

猜你喜欢

转载自blog.csdn.net/bell041030/article/details/108056144
今日推荐