状压 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_S=\max\limits_{T\subseteq S,w_T\le W}\{dp_T+1\} \]

边界:\(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_{S,u}=\min\limits_{v\in S}\{dp_{S-\{u\},v}+d_{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\)。综上可得:

\[dp_{i,S}=\sum\limits_{S\cap T=\varnothing,S\cup T\in L}\{dp_{i-1,T}\} \]

不过本题的边界不好设置,可以特变 \(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\)

其实这里和上一题一模一样。直接写出转移方程(默认状态是合法的)

\[dp_{i,S,T}=\max\limits_{S\cap G=\varnothing}\{dp_{i-1,T,G}+|G|\} \]

如果暴力存储状态的话,会空间爆炸。我们发现很多状态都是无用的,预处理出所有相邻 \(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}\),我们枚举上一步扔掉了那个卡片。

可以写出转移方程:

\[dp_{S}=\begin{cases} 0&w_S=b_1\text{ or }w_S=b_2\\ \sum\limits_{u\in S,T=S-\{u\}}dp_T&\text{other} \end{cases}\]

这样转移为 TLE。常数太大,可以利用 \(\text{lowbit}\) 优化转移的写法

然后就过了,不是很会做卡常题。

posted @ 2024-08-19 11:38  zuoqingyuan111  阅读(8)  评论(0编辑  收藏  举报