状压 dp
定义
在动态规划中,可能存在以“集合”为状态的动态规划,应为集合不易表示,所以通常用一个二进制数来表示集合。具体的,二进制数的第 \(i\) 位即表示当前集合是否包含第 \(i\) 个元素。
技巧
因为许多位运算的运算优先级很迷,所以搞不明白就尽量用括号。
二进制操作
-
将 \(n\) 位二进制数全部赋值为 \(1\):
s=(1<<n)-1;
-
位 \(i\) 赋值 \(1\):
s=(s|(1<<(i-1)));
-
位置 \(i\) 赋值 \(0\):
s=(s&(~(1<<(i-1))));
-
查询位置 \(i\) 为 \(1/0\):
op=((s>>(i-1))&1;
-
判断 \(T\) 是否属于 \(S\) 的子集:
op=((s|t)==s);
-
取出 \(S\) 后 \(k\) 位:
t=(s&((1<<k)-1));
当然还有一些基础的,就不说了。
子集枚举
有两种方法,第一种是枚举集合,再判断是否属于子集。时间复杂度取决于二进制串的长度 \(m\),为 \(2^m\)。
实际上有一种更快捷的方式:
for(int s=now;;s=((s-1)&now)){
if(!pre)break;// 不会枚举到空子集
work...
//if(!pre)break; 会枚举到空子集
}
时间会更快,具体快多少不知道。
元素枚举
如果要枚举集合中的元素,可以枚举每一位在判断是否为 \(1\),时间复杂度取决于串长,已经十分优秀。但为了应对 sb 卡常题目。可以使用 \(\text{lowbit}\) 运算优化这个过程。
for(int now=0;now<(1<<n);now++){
for(int s=now,u=(s&-s);s;s^=u,u=(s&-s)){//log2(u) 为 now 中的一个 1 的位置
...
}
}
对于 \(\log_2(u)\),通常可以预处理出一个数组,可以直接id[1<<i]=i+1
。不过比较浪费空间,可以选取一个模数,预处理时 id[(1<<i)%mod]=i+1
,访问时 id[u%mod]
。模数 \(m\) 可以一个 \(100\sim 200\) 的质数,只要能使 \(2^{0\sim n}\bmod m\) 各不相同即可,可以多试试,例如 \(77,177\)。
好像存在完美算法,可以直接算模数,不过不会。
变量
为了避免混淆,一般用 now,pre,s,t
这样的变量名,而不是 i,j,k
这样的
例题
1.[USACO12MAR] Cows in a Skyscraper G
状压模板。
定义 \(dp_S\) 表示表示当前集合 \(S\) 中所有物品均分组时,最小的分组数量,枚举上一组分出物品的集合 \(T\),显然 \(T\) 为 \(S\) 子集。定义 \(w_S\) 表示集合 \(S\) 中所有物品总重,如果集合 \(T\) 中物品可以放到一组,则必须满足 \(w_T\le W\),综上可得:
边界:\(dp_{0}=0\)。
可以用到子集枚举的优化,就可以通过了。
双倍经验。
2.最短 Hamilton 路径
令 \(dp_{S,u}\) 表示目前已经经过了 \(S\) 中的所有点,且目前位于点 \(u\) 上。显然,一个状态如果合法,必然满足 \(u\in S\)。
枚举上一步走到是哪一个点,记为点 \(v\),合法的 \(v\) 也必须满足 \(v\in S\)。如果上一步走到了 \(v\),那么经过的点集不包含 \(u\),即 \(S-\{u\}\)。定义 \(d_{u,v}\) 表示边 \((u,v)\) 的长。可得
边界:\(dp_{\{1\},1}=0\),然后没了。
3.蒙德里安的梦想
注意到骨牌有两种摆放方式,分别是竖着放和横着放。如果横着放,那么状态只和这一行有关,如果竖着放,则和上一行有关,我们着重注意这一点设计状态。
我们记 \(dp_{i,S}\) 为当前第 \(i\) 行中,骨牌上半部分所在下标构成集合 \(S\) 时,铺满的方案数。我们考虑第 \(i\) 行和第 \(i-1\) 行的关系。
如果第 \(i,i-1\) 行的状态合法,必须满足第 \(i-1\) 行所记录的骨牌的上半部分与第 \(i\) 行的下半部分一一对应,即必须满足:
-
第 \(i\) 行和 \(i-1\) 不能有同一个位置均为骨牌的上半部分。
-
竖着放的骨牌外必须能够用横着放的骨牌填满。
对于第 \(1\) 点,我们将压缩的二进制串相与,如果存在某个位置为 \(1\),则必然不是合法的。对于第 \(2\) 点,将压缩的二进制串相或,则此时任何一个 \(1\) 的位置均被某个竖着放的骨牌覆盖,只需检查相邻 \(1\) 之间的 \(0\) 的个数是否为偶数个。
可以预处理出第二点中的合法集合的集合,记为 \(L\)。综上可得:
不过本题的边界不好设置,可以特变 \(i=1\),更简单的做法是将边界设为 \(dp_{0,0}=1\)。
4.[SCOI2005] 互不侵犯
和上一题类似,甚至更简单一点。
注意掉 \(i,i-1\) 行的国王不能处在同一列,且中间至少相隔一列,预处理出相邻 \(1\) 之间不少于 \(1\) 个 \(0\) 的二进制串,记为 \(L\),然后就转换为上一题了,连转移方程都一模一样。
统计答案就是 \(\sum\limits_{S\in L}dp_{n,S}\)。
这题与上题的区别是,联通范围从八个方向变成了四个方向,且增加了“地形”限制,只需在输入时将“地形”压缩起来。在动态规划枚举状态时,只需判断是否属于对应行压缩串的子串即可。
当然,也可以子集枚举,具体看技巧。
5.[NOI2001] 炮兵阵地
其实还是和上面是同一道题。
不过略有不同,这道题中当前阶段不止依赖于上一阶段,还依赖于上上个阶段。这样的话我们就要同时记录两个阶段的状态。
设 \(dp_{i,S,T}\) 表示第 \(i\) 行放置的炮兵构成 \(S\),第 \(i-1\) 行构成 \(T\)。这样的话就可以从 \(dp_{i-1,T,G}\) 进行转移。无论是 \(S\) 或 \(T\),都必须满足题目中给出的“地形条件”。且不能有同一个位置都放置了炮兵。具体的,记 \(V_i\) 表示第 \(i\) 行可防止炮兵位置。合法的状态必须满足 \(S\subseteq V_i,T\subseteq V_{i-1},S\cap T=\varnothing\)。
其实这里和上一题一模一样。直接写出转移方程(默认状态是合法的)
如果暴力存储状态的话,会空间爆炸。我们发现很多状态都是无用的,预处理出所有相邻 \(1\) 之间不少于 \(2\) 个 \(0\) 的二进制串,发现及时当 \(n=10\) 时,满足条件的二进制数不超过 \(75\) 个。我们记录这 \(75\) 个数就可以。
点击查看代码
#include <iostream>
#include <cstdio>
using namespace std;
const int N=105,M=(1<<10),inf=1e9+10;
int n,m,val[N],p,mk[N],dp[N][N][N],num[M],ans;
char ch;
inline bool check(int i,int s){
return (mk[i]|s)==mk[i]?0:1;
}
int main(){
scanf("%d %d",&n,&m);
for(int i=1;i<=n;i++){
for(int j=1;j<=m;j++){
cin>>ch;
if(ch=='P')mk[i]=(mk[i]<<1)+1;
else mk[i]=(mk[i]<<1);
}
}
for(int now=0;now<(1<<m);now++){
bool f=1;int cnt=0,tmp=2;
for(int i=0;i<m;i++){
if((now>>i)&1){
if(tmp<2){f=0;break;}
else tmp=0;
}
else tmp++;
}
for(int s=now;s>0;s-=(s&-s))cnt++;
if(f)val[++p]=now;
num[now]=cnt;
}
dp[0][0][0]=0;
for(int i=1;i<=n;i++){
for(int j=1;j<=p;j++){
if(check(i,val[j]))continue;
for(int k=1;k<=p;k++){
dp[i][j][k]=-inf;
if(val[j]&val[k])continue;
if(check(i-1,val[k]))continue;
for(int l=1;l<=p;l++){
if(check(i-2,val[l]))continue;
if((val[j]&val[l])||(val[k]&val[l]))continue;
dp[i][j][k]=max(dp[i][j][k],dp[i-1][k][l]+num[val[j]]);
}
if(i==n)ans=max(ans,dp[i][j][k]);
}
}
}
cout<<ans<<endl;
return 0;
}
6.yyy loves Maths VII
很恶心的一道题。
我们记当前扔掉的卡片构成的集合为 \(S\),扔掉 \(S\) 中所有卡片成功的方案数为 \(dp_S\),记集合 \(S\) 能走的路程为 \(w_{S}\),我们枚举上一步扔掉了那个卡片。
可以写出转移方程:
这样转移为 TLE。常数太大,可以利用 \(\text{lowbit}\) 优化转移的写法
然后就过了,不是很会做卡常题。