插头 dp 学习笔记

插头 dp 学习笔记

前置芝士:状态压缩 dp,轮廓线 dp

引入

存在一个 n×m 的棋盘,若使用多米诺骨牌进行覆盖,有多少种方式能不重叠不遗漏的覆盖整个棋盘?

对于上面的问题,使用状压 dp 求解是简单的。考虑逐行转移,并设 f(i,s) 表示第 i 行时当前行棋盘的覆盖状态为 s 的方案数。这里的 s 代表了每一格是否需要被下一行覆盖(0 为需要,1 为不需要)。不难看出转移方程为

f(i,s)+[check(s,t)]f(i1,t)

这里的 check(s,t) 表示 ts 是否是一种合法的转移,需要满足 t 中为 0 的位置 s 中为 1,且排除这些 1s 中没有连续的 1 是奇数个。我们可以看出这个判断条件相较而言较难满足。

因此我们可以采用另一种 dp 方式——逐格 dp,也叫轮廓线 dp,是维护所有对确定后续格信息有用的已转移格子信息的 dp。令 f(i,j,s) 表示当前转移到格子 (i,j) 时轮廓线的状态为 s 的方案数。这里的 s 表示每一格是否已经被覆盖,不难看出,需要维护的格子信息只有 (i1,jm)(i,1j1),从左到右依次编码为二进制的低位到高位即可,那么有转移方程

f(i,j,s)+[check(s,t)]f(i,j1,t)

这里的 check(s,t) 需要满足当 t 中的第 j 位为 0 时,s 中的第 j 位为 1,其余时候随意,并且除第 j 位以外其他格子的信息不变。

简介

CDQ 在论文题目中提到的 《基于连通性状态压缩的动态规划问题》,插头 dp 是用于处理一类连通性状态压缩问题的 dp。首先我们需要了解基础定义:

插头:在插头DP中,插头表示一种联通的状态,以棋盘为例,一个格子有一个向某方向的插头,就意味着这个格子在这个方向可以与外面相连,即与插头那边的格子联通。值得注意的一点是,插头不是表示将要去某处的虚拟状态,而是表示已经到达某处的现实状态。也就是说,如果有一个插头指向某个格子,那么这个格子已经和插头来源联通了,我们接下来要考虑的是从这个插头往哪里走。并且插头是相互的,比如上方的格子有一个下插头,那么下方的格子就一定会有一个上插头。

但需要具体注意的是,在某一些情景下,插头 dp 中插头的定义与最基础的定义有所偏差,或根本不存在插头,这种情况一定要特殊分析。

应用

下面将会是一些插头 dp 应用的场景。

多条回路

严格来讲,这类题型不能算作插头 dp,只是在转移中运用了插头的概念。但类似的转移和分类讨论的思想依然需要学习。

对于一个 n×m 的棋盘,存在一些格子不能经过,求解用多条不重叠的回路不遗漏的覆盖整个棋盘的方案数。

首先考虑状态设计:什么样的轮廓对后续格的转移是有用的?我们考虑格与格之间的分界线,我们发现 (i,1j1)(i+1,1j1) 之间的分界线对下一行的转移有用,(i1,jm)(i,jm) 之间的分界线对当前行后续的转移有用,而 (i,j1)(i,j) 之间的分界线对当前格的转移有效,因此共 m+1 条分界线是有用的,对于压缩的每一位,我们只需要考虑对应位置的分界线上是否有插头即可。

接着考虑转移,对于当前格来说,我们需要知道这个格子里有哪些插头,因为每一个格子都会被回路覆盖,因此,除了不能经过的格子里,每个格子都应该恰有两个插头,我们考虑这个格子和它相邻两个格子之间的分界线的状态,它们是第 j,j+1 位,下面称这两个位置的状态分别为 l,r,并进行分类讨论:

  1. ai,j=0,表示这个格子不能经过,那么一个插头都不能放,判断 l,r 是否均为 0 之后直接转移,即 f(i,j,s)+f(i,j1,s)
  2. l=r=0,表示当前格子没有左插头和上插头,所以它一定有右插头和下插头,说明转移过后 l,r 一定均为 1,即 f(i,j,s+vj+vj+1)+f(i,j1,s),其中 vj 表示第 j1 时表示的压缩值。
  3. l=1,r=0,说明当前格子一定有左插头而没有上插头,所以它要么有下插头,要么有右插头。
    1. 如果有下插头,那么转移完后 l,r 的值不变,即 f(i,j,s)+f(i,j1,s)
    2. 如果有右插头,那么转移完后 l=0,r=1,即 f(i,j,svj+vj+1)+f(i,j1,s)
  4. l=0,r=1,说明当前格子一定没有左插头而有上插头,所以它要么有下插头,要么有右插头。
    1. 如果有下插头,那么转移完后 l=1,r=0 ,即 f(i,j,s+vjvj+1)+f(i,j1,s)
    2. 如果有右插头,那么转移完后 l,r 的值不变,即 f(i,j,s)+f(i,j1,s)
  5. l=r=1,说明当前格子一定有左插头和上插头,没有其他插头,说明转移后 l,r 一定均为 0,即 f(i,j,svjvj+1)+f(i,j,s)

上述 5 种讨论,概括了所有可能的情况,接下来我们需要考虑行间转移,即 (i,m)(i+1,1) 的转移,显然最后一个分界线上的插头无用的,因此将最后一个插头出去后在最前方添加一个空分界线即可,这个过程需要保证最后一个分界线上没有插头。显然答案的最后一行不会有插头,因此答案为 f(n,m,0)

上述做法给出了一个 O(nm2m) 的做法,但我们显然可以加入一些优化:

滚动数组。优化空间的做法,具体做法是将第一位压成 0,1,表示当前进行的是第奇数次转移还是第偶数转移。

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;
}

一条回路

对于一个 n×m 的棋盘,存在一些格子不能经过,求解用一条不重叠的回路不遗漏的覆盖整个棋盘的方案数。

可以考虑如何通过上面的方法转化过来,我们发现,在正常转移的时候,每一条回路都一定至少有一个位置满足 l=r=1,因为我们可能进行了多次这样的操作,从而导致了我们的选择出现了不止一条的回路。然而,我们也不能限制只进行一次这样的操作,因为有些回路可能存在多处这样的操作。我们不妨找出一些唯一的性质。

注意到一条回路中被我们遍历到的最后一个格子一定满足它的左分界线和上分界线上的两个插头联通,而其他的格子一定不满足,于是我们通过维护连通性一定可以限制一条回路的条件。现在问题来到怎么限制连通性,下面是常见的两种方法:

  1. 最小表示法。注意到我们只关注两个插头是否联通,而并不关心它们属于哪一个联通块,因此 [0,1,1,3,3,0] 的表示和 [0,1,1,2,2,0] 是本质相同的(0 表示没有插头),因此我们可以从低位向高位枚举,对遇到的数字离散化,从而得到一个最小的解,优化空间复杂度。同时应当考虑当前题目最多出现的联通块个数,从而选择对应的进制压缩方法。
  2. 括号表示法。事实上这个表示法的适用性并没有最小表示法广,但胜在常数小。考虑对于棋盘问题来说,其轮廓线上的四个位置为 a,b,c,d 的插头,不可能同时满足 a,c 联通,b,d 联通,并且属于同一个联通块的插头肯定有两个。通过这个性质,我们可以简单联想到括号序列,因此我们可以将第一个在某联通块里出现的插头全部记作 1,其他的全部视作 2,即最小表示法 [0,1,1,2,2,0] 在括号表示法中等价于 [0,1,2,1,2,0]

均不难证明以上两种方法所表示出来的状态与轮廓线上插头的状态是一一对应的。因为括号表示法的分讨和代码均较为简易,因此在无特殊说明的前提下,本文均采用括号表示法讲解。(因为位运算的常数小,因此我们常用四进制、八进制代替其他进制)

然而,事情开始变得奇怪了,因为我们不管采用哪种方法,都绝对不可能用二进制实现,最少也需要使用三进制,因此我们 n,m 的范围将被限制得很小。然而,考虑到括号序列的特殊性,即合法的状态数量较少,因此可以采用手写哈希表的形式实现,从而压缩空间复杂度和时间复杂度。

对于手写哈希表的实现,我们采用挂表法,即选取一个质数 P,将状态 s 存储到 smodP 对应的链表中。

同时我们还能进一步压榨空间,考虑到在之前的分类讨论中有一些状态无法继续向后转移,因此我们可以尝试将这些无用状态特判掉,具体的,我们不在 1,5 中特判,而在 2,3,4 中特判所选插头对应方向的格子是否可以经过,从而达到实现删除无用状态的目的。

状态设计不变,下面直接考虑转移。

  1. ai,j=0,表示这个格子不能经过,那么一个插头都不能放,即 f(i,j,s)+f(i,j1,s)
  2. l=r=0,表示当前格子没有左插头和上插头,所以它一定有右插头和下插头,同时可以发现这两个插头联通,说明转移过后 l=1,r=2,即 f(i,j,s+vj+2vj+1)+f(i,j1,s)
  3. l0,r=0,说明当前格子一定有左插头而没有上插头,所以它要么有下插头,要么有右插头。
    1. 如果有下插头,那么转移完后 l,r 的值不变,即 f(i,j,s)+f(i,j1,s)
    2. 如果有右插头,那么转移完后 rl,l0,即 f(i,j,slvj+lvj+1)+f(i,j1,s)
  4. l=0,r0,说明当前格子一定没有左插头而有上插头,所以它要么有下插头,要么有右插头。
    1. 如果有下插头,那么转移完后 lr,r0 ,即 f(i,j,s+rvjrvj+1)+f(i,j1,s)
    2. 如果有右插头,那么转移完后 l,r 的值不变,即 f(i,j,s)+f(i,j1,s)
  5. 否则,说明当前格子一定有左插头和上插头,没有其他插头。
    1. l=r=1,此时连接 l,r 会对与 r 相连的插头产生影响,即 [1,1,2,2][0,0,1,2],因此暴力枚举找到 r 的对应插头 p,有 f(i,j,svjvj+1vp)+f(i,j1,s)
    2. l=r=2,此时连接 l,r 会对与 r 相连的插头产生影响,即 [1,1,2,2][1,2,0,0],因此暴力枚举找到 r 的对应插头 p,有 f(i,j,s2vj2vj+1+vp)+f(i,j1,s)
    3. l=2,r=1,此时连接 l,r 不会对其他插头产生影响,即 [1,2,1,2][1,0,0,2],有 f(i,j,svjvj+1)+f(i,j1,s)
    4. l=1,r=2,此时应当作为回路的最后一个格子,即若能遍历到的最后一个可以经过的格子为 (x,y),则应满足 i=x,j=y 后直接转移到答案中。

于是我们结束了我们的分类讨论,手写哈希表的实现建议类似我代码中的部分,因为可以将转移同时封装进去。另提一嘴,对于质数 P 的选择,我一般在 t10t 之间选择,同时最好不要超过 104。其中 t 为最大状态个数。

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;
}

一条路径

对于一个 n×m 的棋盘,存在一些格子不能经过,求解用一条不重叠的路径不遗漏的覆盖整个棋盘的方案数。

考虑新的问题:路径和回路有什么区别?不难想象,路径的两端的格子只有一个插头。考虑这会对我们的状态设计带来什么影响,发现与两端直接连通的部分不会在轮廓线上有对应的插头,因此不能通过括号序列来实现。但我们考虑加入一个新定义,即 3。我们令 3 表示和某一端直接相连的插头,那么就可以继续进行讨论了。

到这里,我们实现的是路径的维护(如果题目是多条路径的话就可以直接开始分讨了)。然而,我们要怎么维护一条路径呢?考虑一条路径显然只会有两个端头,所以我们只会添加 2 格端头,因此对 dp 状态多加一维表示当前插入了多少格端头即可。同时注意,向答案转移的时候在合并两个端头的时候,一定会在最后一个可经过的位置进行。

不难想象状态改动之后的转移,请读者自主实现。其实就是分类讨论太多了自己不想写

注意事项

对插头的定义不要太死板,对于不同的题目分析特点,找到特点之后重新设计状态,找到合适的表示法之后进行分类讨论。

注意题面中的细节,全面的分析所有情况,包括可转移的情况和每种转移可以转到哪里。

posted @   DycIsMyName  阅读(27)  评论(0编辑  收藏  举报
相关博文:
阅读排行:
· 震惊!C++程序真的从main开始吗?99%的程序员都答错了
· 【硬核科普】Trae如何「偷看」你的代码?零基础破解AI编程运行原理
· 单元测试从入门到精通
· 上周热点回顾(3.3-3.9)
· winform 绘制太阳,地球,月球 运作规律
点击右上角即可分享
微信分享提示