插头 dp 学习笔记
插头 学习笔记
前置芝士:状态压缩
引入
存在一个
的棋盘,若使用多米诺骨牌进行覆盖,有多少种方式能不重叠不遗漏的覆盖整个棋盘?
对于上面的问题,使用状压
这里的
因此我们可以采用另一种
这里的
简介
如
插头:在插头DP中,插头表示一种联通的状态,以棋盘为例,一个格子有一个向某方向的插头,就意味着这个格子在这个方向可以与外面相连,即与插头那边的格子联通。值得注意的一点是,插头不是表示将要去某处的虚拟状态,而是表示已经到达某处的现实状态。也就是说,如果有一个插头指向某个格子,那么这个格子已经和插头来源联通了,我们接下来要考虑的是从这个插头往哪里走。并且插头是相互的,比如上方的格子有一个下插头,那么下方的格子就一定会有一个上插头。
但需要具体注意的是,在某一些情景下,插头
应用
下面将会是一些插头
多条回路
严格来讲,这类题型不能算作插头
对于一个
的棋盘,存在一些格子不能经过,求解用多条不重叠的回路不遗漏的覆盖整个棋盘的方案数。
首先考虑状态设计:什么样的轮廓对后续格的转移是有用的?我们考虑格与格之间的分界线,我们发现
接着考虑转移,对于当前格来说,我们需要知道这个格子里有哪些插头,因为每一个格子都会被回路覆盖,因此,除了不能经过的格子里,每个格子都应该恰有两个插头,我们考虑这个格子和它相邻两个格子之间的分界线的状态,它们是第
,表示这个格子不能经过,那么一个插头都不能放,判断 是否均为 之后直接转移,即 。 ,表示当前格子没有左插头和上插头,所以它一定有右插头和下插头,说明转移过后 一定均为 ,即 ,其中 表示第 为 时表示的压缩值。 ,说明当前格子一定有左插头而没有上插头,所以它要么有下插头,要么有右插头。- 如果有下插头,那么转移完后
的值不变,即 。 - 如果有右插头,那么转移完后
,即 。
- 如果有下插头,那么转移完后
,说明当前格子一定没有左插头而有上插头,所以它要么有下插头,要么有右插头。- 如果有下插头,那么转移完后
,即 。 - 如果有右插头,那么转移完后
的值不变,即 。
- 如果有下插头,那么转移完后
,说明当前格子一定有左插头和上插头,没有其他插头,说明转移后 一定均为 ,即
上述
上述做法给出了一个
滚动数组。优化空间的做法,具体做法是将第一位压成
,表示当前进行的是第奇数次转移还是第偶数转移。
code
//P5074 Eat the Trees
#include <bits/stdc++.h>
using namespace std;
#define ll long long
const int N=1<<13;
int T,n,m;
ll f[2][N],*f0,*f1;
int main(){
ios::sync_with_stdio(0);
cin.tie(0),cout.tie(0);
cin>>T;
while(T--){
cin>>n>>m;
int H=1<<(m+1);
f0=f[0],f1=f[1],fill(f1,f1+H,0);
f1[0]=1;
for(int i=0;i<n;i++){
for(int j=0;j<m;j++){
swap(f0,f1),fill(f1,f1+H,0);
int opt;cin>>opt;
for(int S=0;S<H;S++){
ll v=f0[S];
if(v){
int l=(S>>j)&1,u=(S>>(j+1))&1;
if(opt){
if(l==u)f1[S^(3<<j)]+=v;
else f1[S^(3<<j)]+=v,f1[S]+=v;
}else{
if(!l&&!u)f1[S]+=v;
}
}
}
}
swap(f0,f1),fill(f1,f1+H,0);
for(int s=0;s<(1<<m);s++)f1[s<<1]=f0[s];
}
cout<<f1[0]<<"\n";
}
return 0;
}
一条回路
对于一个
的棋盘,存在一些格子不能经过,求解用一条不重叠的回路不遗漏的覆盖整个棋盘的方案数。
可以考虑如何通过上面的方法转化过来,我们发现,在正常转移的时候,每一条回路都一定至少有一个位置满足
注意到一条回路中被我们遍历到的最后一个格子一定满足它的左分界线和上分界线上的两个插头联通,而其他的格子一定不满足,于是我们通过维护连通性一定可以限制一条回路的条件。现在问题来到怎么限制连通性,下面是常见的两种方法:
- 最小表示法。注意到我们只关注两个插头是否联通,而并不关心它们属于哪一个联通块,因此
的表示和 是本质相同的( 表示没有插头),因此我们可以从低位向高位枚举,对遇到的数字离散化,从而得到一个最小的解,优化空间复杂度。同时应当考虑当前题目最多出现的联通块个数,从而选择对应的进制压缩方法。 - 括号表示法。事实上这个表示法的适用性并没有最小表示法广,但胜在常数小。考虑对于棋盘问题来说,其轮廓线上的四个位置为
的插头,不可能同时满足 联通, 联通,并且属于同一个联通块的插头肯定有两个。通过这个性质,我们可以简单联想到括号序列,因此我们可以将第一个在某联通块里出现的插头全部记作 ,其他的全部视作 ,即最小表示法 在括号表示法中等价于 。
均不难证明以上两种方法所表示出来的状态与轮廓线上插头的状态是一一对应的。因为括号表示法的分讨和代码均较为简易,因此在无特殊说明的前提下,本文均采用括号表示法讲解。(因为位运算的常数小,因此我们常用四进制、八进制代替其他进制)
然而,事情开始变得奇怪了,因为我们不管采用哪种方法,都绝对不可能用二进制实现,最少也需要使用三进制,因此我们
对于手写哈希表的实现,我们采用挂表法,即选取一个质数
,将状态 存储到 对应的链表中。
同时我们还能进一步压榨空间,考虑到在之前的分类讨论中有一些状态无法继续向后转移,因此我们可以尝试将这些无用状态特判掉,具体的,我们不在
状态设计不变,下面直接考虑转移。
,表示这个格子不能经过,那么一个插头都不能放,即 。 ,表示当前格子没有左插头和上插头,所以它一定有右插头和下插头,同时可以发现这两个插头联通,说明转移过后 ,即 。 ,说明当前格子一定有左插头而没有上插头,所以它要么有下插头,要么有右插头。- 如果有下插头,那么转移完后
的值不变,即 。 - 如果有右插头,那么转移完后
,即 。
- 如果有下插头,那么转移完后
,说明当前格子一定没有左插头而有上插头,所以它要么有下插头,要么有右插头。- 如果有下插头,那么转移完后
,即 。 - 如果有右插头,那么转移完后
的值不变,即 。
- 如果有下插头,那么转移完后
- 否则,说明当前格子一定有左插头和上插头,没有其他插头。
,此时连接 会对与 相连的插头产生影响,即 ,因此暴力枚举找到 的对应插头 ,有 。 ,此时连接 会对与 相连的插头产生影响,即 ,因此暴力枚举找到 的对应插头 ,有 。 ,此时连接 不会对其他插头产生影响,即 ,有 。 ,此时应当作为回路的最后一个格子,即若能遍历到的最后一个可以经过的格子为 ,则应满足 后直接转移到答案中。
于是我们结束了我们的分类讨论,手写哈希表的实现建议类似我代码中的部分,因为可以将转移同时封装进去。另提一嘴,对于质数
code
//P5056 【模板】插头 DP
#include <bits/stdc++.h>
using namespace std;
#define ll long long
const int N=15;
int n,m,ex,ey;
int a[N][N],bas[N];
ll ans;
char s[N][N];
struct HashTable{
static const int M=9973,P=14005;
int tot;
int head[M],nxt[P],st[P];
ll dp[P];
void Clear(){
fill(head,head+M,0);
tot=0;
return ;
}
void Insert(int x,ll s){
int id=x%M;
for(int i=head[id];i;i=nxt[i]){
if(st[i]==x){
dp[i]+=s;
return ;
}
}
nxt[++tot]=head[id],head[id]=tot;
st[tot]=x,dp[tot]=s;
return ;
}
}f0,f1;
bool IsPrime(int x){
for(int i=2;i<=x/i;i++){
if(x%i==0)return false;
}
return true;
}
int decode(int opt,int x){return (opt>>(x<<1))&3;}
int main(){
ios::sync_with_stdio(0);
cin.tie(0),cout.tie(0);
cin>>n>>m;
for(int i=0;i<n;i++){
cin>>s[i];
for(int j=0;j<m;j++){
a[i][j]=s[i][j]=='.';
if(a[i][j])ex=i,ey=j;
}
}
bas[0]=1;
for(int i=1;i<=m;i++)bas[i]=bas[i-1]<<2;
f0.Clear(),f1.Clear();
f1.Insert(0,1);
for(int i=0;i<n;i++){
swap(f0,f1),f1.Clear();
for(int k=1;k<=f0.tot;k++){
int opt=f0.st[k];ll v=f0.dp[k];
f1.Insert(opt<<2,v);
}
for(int j=0;j<m;j++){
swap(f0,f1),f1.Clear();
for(int k=1;k<=f0.tot;k++){
int opt=f0.st[k];ll v=f0.dp[k];
int l=decode(opt,j),r=decode(opt,j+1);
if(!a[i][j]){
if(!l&&!r)f1.Insert(opt,v);
}else if(!l&&!r){
if(a[i][j+1]&&a[i+1][j])f1.Insert(opt+bas[j]+bas[j+1]*2,v);
}else if(!l&&r){
if(a[i][j+1])f1.Insert(opt,v);
if(a[i+1][j])f1.Insert(opt+bas[j]*r-bas[j+1]*r,v);
}else if(l&&!r){
if(a[i][j+1])f1.Insert(opt-bas[j]*l+bas[j+1]*l,v);
if(a[i+1][j])f1.Insert(opt,v);
}else if(l==1&&r==1){
int tmp=1;
for(int p=j+2;p<=m;p++){
int ch=decode(opt,p);
if(ch==1)tmp++;
if(ch==2)tmp--;
if(!tmp){
f1.Insert(opt-bas[j]-bas[j+1]-bas[p],v);
break;
}
}
}else if(l==2&&r==2){
int tmp=1;
for(int p=j-1;j>=0;p--){
int ch=decode(opt,p);
if(ch==1)tmp--;
if(ch==2)tmp++;
if(!tmp){
f1.Insert(opt-bas[j]*2-bas[j+1]*2+bas[p],v);
break;
}
}
}else if(l==2&&r==1)f1.Insert(opt-bas[j]*2-bas[j+1],v);
else if(i==ex&&j==ey)ans+=v;
}
}
}
cout<<ans;
return 0;
}
一条路径
对于一个
的棋盘,存在一些格子不能经过,求解用一条不重叠的路径不遗漏的覆盖整个棋盘的方案数。
考虑新的问题:路径和回路有什么区别?不难想象,路径的两端的格子只有一个插头。考虑这会对我们的状态设计带来什么影响,发现与两端直接连通的部分不会在轮廓线上有对应的插头,因此不能通过括号序列来实现。但我们考虑加入一个新定义,即
到这里,我们实现的是路径的维护(如果题目是多条路径的话就可以直接开始分讨了)。然而,我们要怎么维护一条路径呢?考虑一条路径显然只会有两个端头,所以我们只会添加
不难想象状态改动之后的转移,请读者自主实现。其实就是分类讨论太多了自己不想写。
注意事项
对插头的定义不要太死板,对于不同的题目分析特点,找到特点之后重新设计状态,找到合适的表示法之后进行分类讨论。
注意题面中的细节,全面的分析所有情况,包括可转移的情况和每种转移可以转到哪里。
【推荐】国内首个AI IDE,深度理解中文开发场景,立即下载体验Trae
【推荐】编程新体验,更懂你的AI,立即体验豆包MarsCode编程助手
【推荐】抖音旗下AI助手豆包,你的智能百科全书,全免费不限次数
【推荐】轻量又高性能的 SSH 工具 IShell:AI 加持,快人一步
· 震惊!C++程序真的从main开始吗?99%的程序员都答错了
· 【硬核科普】Trae如何「偷看」你的代码?零基础破解AI编程运行原理
· 单元测试从入门到精通
· 上周热点回顾(3.3-3.9)
· winform 绘制太阳,地球,月球 运作规律