LG5056 【模板】插头dp

题意

题目背景

ural 1519

陈丹琦《基于连通性状态压缩的动态规划问题》中的例题

题目描述

给出n*m的方格,有些格子不能铺线,其它格子必须铺,形成一个闭合回路。问有多少种铺法?

输入输出格式

输入格式:

第1行,n,m(2<=n,m<=12)

从第2行到第n+1行,每行一段字符串(m个字符),"*"表不能铺线,"."表必须铺

输出格式:

输出一个整数,表示总方案数

输入输出样例

输入样例#1: 复制
4 4
**..
....
....
....
输出样例#1: 复制
2
输入样例#2: 复制
4 4
....
....
....
....
输出样例#2: 复制
6

GNAQ的题解

1. 什么是插头DP

插头DP (PlugDP),也就是一类基于连通性的状态压缩动态规划,是用状压DP处理联通问题的强劲武器。

常见的类型有棋盘插头DP和CDQ论文里的两个非棋盘上的模型。

常见的联通问题:多回路问题、路径问题、简单回路问题、广义路径问题、生成树问题

2. 插头DP的大致思路

2.1 划分阶段

大家都知道了这是基于联通性的状压 DP ,所以本质上还是状压 DP 。

一般设 \(\text{dp}[i][j][\mathrm{state}]\)\((i,j)\) 位置,状态为 $\mathrm{state} $ 的方案数(或者代价,等等让你求的东西……)

所以我们状压什么呢?轮廓线。

DP求解棋盘问题是逐格转移的。所以已经转移过的格子和没转移过的格子被一个折线分成了两半儿。这个折线就是轮廓线。

(@远航之曲 的简洁解释:已决策格子和未决策格子的分界线)

就这个蓝线(现在转移的格子是第3行第3个)。

插头又是什么呢?一个格子有四个插头,一个存在的插头表示在它代表的方向上能与相邻的格子连接(联通)。不存在就不能。

为什么要引入插头?要求一个回路,也就意味着最后所有的非障碍格子通过插头连接成了一个连通块,因此转移时需要记录格子的连通情况。

我们递推的时候就是依据轮廓线上的插头存在性,求出所有能转移到的合法状态。

显然回路问题中一个格子恰好有两个插头,一个是 “进来” 的一个是 “出去” 的。

2.2 记录状态

最小表示法:

所有的障碍格子标记为 \(0\) ,第一个非障碍格子以及与它连通的所有格子标记为 \(1\) ,然后再找第一个未标记的非障碍格子以及与它连通的格子标记为 \(2\)

……重复这个过程,直到所有的格子都标记完毕。

比如连通信息 \(((1,2,5),(3,6),(4))\) 表示为 \(\{ 1,1,2,3,1,2 \}\)

(还有一种极不常用的最小表示法,即一个连通块内所有的格子都标记成该连通块最左边格子的列编号。)

括号表示法:

【性质】轮廓线上从左到右 \(4\) 个插头 \(a, b, c, d\) ,如果 \(a, c\) 连通,并且与 \(b\) 不连通,那么 \(b, d\) 一定不连通。这个性质对所有的棋盘模型的问题都适用。 (证明详见CDQ论文。)

“两两匹配”,“不会交叉”这样的性质,我们很容易联想到括号匹配。

将轮廓线上每一个连通分量中左边那个插头标记为左括号,右边那个插头标记为右括号,由于插头之间不会交叉,那么左括号一定可以与右括号一一对应,这样我们就可以使用 \(3\) 进制, \(0\) 表示无插头, \(1\) 表示左括号插头, \(2\) 表示右括号插头,记录下所有的轮廓线信息。不妨用 \(\#\) 表示无插头,那么上面的三幅图分别对应的是 \((())\#(),(()\#)(),(()\#\#\#)\) ,即 \((1122012),(1120212),(1120002)\) ,这种表示方法称为括号表示法。

状态的编码:

对于最小表示法,编码最简单的方法就是表示成一个 \(n+1\) 位的 \(p\) 进制数, \(p\) 可以取能够达到的最大的连通块标号 \(+1\) 。在不会超过数据类型的范围的前提下,建议将 \(p\) 改成 \(2\) 的幂,因为位运算比普通的运算要快很多。

如需大范围修改连通块标号,最好将状态 \(O(n)\) 解码到一个数组中,修改后再 \(O(n)\) 计算出新的 \(p\) 进制数,而对于只需要局部修改几个标号的情况下,可以直接用 \((x \;\mathrm{div}\; p^{i-1} ) \;\mathrm{mod}\; p\) 来获取第 \(i\) 位的状态,然后 \(+k \times p^{i-1}\) 直接对第 \(i\) 位进行修改。

2.3 转移状态

首先,因为逐行递推要讨论的情况还是比较难处理的,除非要求解的问题特别适合这样做,要不然我们一般使用逐格递推,这样可以方便讨论情况。

一般来说轮廓线上有不少状态都是无用的,而且一般来说头插插头 DP 的状态数很多,所以我们使用一个技巧来加速,那就是,我们用一个线性数据结构(我愿意开一个/模拟一个 vector )来存储状态,每次读取上一格的所有合法状态扩展,而不是xjb枚举状态来扩展。

然后,我们来研究一下怎么转移。我们看一种题型吧。

(其实就是这道题)

给你一个 \(m \times n\) 的棋盘,有的格子是障碍,问共有多少条回路使得经过每个非障碍格子恰好一次。

\(m, n \leqslant 12\)

1. 新建一个连通分量

这个情况只出现在转移位置的轮廓线上没有下插头和右插头。

如图。然后我们只有一种转移方式就是当前格做右插头和下插头。

括号表示法就是新建一对紧挨着的左右括号。最小表示法就直接解码重编一下。

2. 合并两个连通分量

如果当前轮廓线上既有右插又有下插,那你只能当前格做左插和上插,要不然就不是回路了。

然后如果右插和下插联通,那这种情况只能是在最后一个非障碍格是合法的。

不连通的话,当然这样做会把他们变联通,看图:

括号表示法里就直接删一对括号,最小表示法还是解码重编。

3. 保持原来的连通分量

当轮廓线上只有下插或者右插,就只能当前格做一个左插/上插来满足回路性质,剩下一个插头是随便安排的。

图:

括号表示法的话就把下插/右插对应的括号移到你加的插头上就行了。

最小表示法也类似,把下插/右插位置的记号移到你加的插头对应位置就行(因为是延续了一个连通分量)。

注意当从一行的最后一个格子转移到下一行的第一个格子的时候,轮廓线需要特殊处理。这个看代码最好解释了。

(还要多啰嗦一句,一般状态编码的左右和轮廓线的左右是反着对应的……也就是编码最右面一位是对应轮廓线最左面格子)( 这样大概比较好写? )

一个小优化

有时候你转移的时候有可能从两个不同状态转移出同一个状态,这个时候我们用 hash 优化它就好了。 hash 表的实现用挂表法就行。

还有提取每个插头状态的时候我们可以预处理一个对应 bit 的数组,然后用上文提到的解码方式去解码。

然后我们就讨论完了插头DP的第一道入门题该怎么做。上代码。这里由于洛谷排版的原因,解释我压在代码下面了,一定要好好看。后面一堆图,我就不信你看不明白是怎么转移的(不过最好的方法是去博客看原文)

  • state 是表示状态,dp 表示方案数。这里用了滚动数组来优化空间( pre , cnt 来滚动)

  • bits 是一个方便提取插头的数组。比如 bits[3]=6,那提取第三个格子上面和左面的插头(分别叫做 is_d 和 is_r )就是 state>>bits[3-1] 和 state>>bits[3]

  • 我们存储状态是四进制,括号表示法。 \(1\) 表示 \((\)\(2\) 表示 \()\)

  • hash 表的内部实现你可以看到就是一个链表。( struct hash_table )

  • 因为可能最后一格也有障碍,所以要记录一下最后一个无障碍格子( endx , endy )

代码

要考虑的8种情况:
0. 有障碍。

  1. (要改插头)

  2. 形成一个回路。只有最后一个格子才能形成一个回路。

#include<bits/stdc++.h>
#define rg register
#define il inline
#define co const
template<class T>il T read(){
    rg T data=0,w=1;rg char ch=getchar();
    while(!isdigit(ch)) {if(ch=='-') w=-1;ch=getchar();}
    while(isdigit(ch)) data=data*10+ch-'0',ch=getchar();
    return data*w;
}
template<class T>il T read(rg T&x) {return x=read<T>();}
typedef long long ll;

co int N=6e5;
int n,m,mapx[14][14],endx,endy,bits[13]; // edit 1: mapx 14*14=AC 13*13=WA
// bits是一个方便提取插头的数组。比如bits[3]=6,那提取第三个格子上面和左面的插头
//(分别叫做is_d和is_r)就是state>>bits[3-1]和state>>bits[3]
int state[2][N],tots[2];
ll all_ans=0,dp[2][N];
int pre=1,cnt=0;
// state是表示状态,dp表示方案数。这里用了滚动数组来优化空间(pre,cnt来滚动)
// 我们存储状态是四进制,括号表示法。1表示(,2表示) 。
co int mo=590027;
struct hash_table{
	int pre,to;
}idx[N];
int ptr[N],at=0;
// hash表的内部实现你可以看到就是一个链表。(struct hash_table)
void hah(int sta,ll val){ // 添加状态压进hah()函数里了。
	int key=sta%mo;
	for(int prex=ptr[key];prex;prex=idx[prex].pre)
		if(state[cnt][idx[prex].to]==sta)
			return dp[cnt][idx[prex].to]+=val,void();
	++tots[cnt],state[cnt][tots[cnt]]=sta,dp[cnt][tots[cnt]]=val;
	idx[++at].pre=ptr[key],idx[at].to=tots[cnt],ptr[key]=at;
}

int main(){
	// init
	read(n),read(m);
	char cht=0;
	for(int i=1;i<=n;++i)for(int j=1;j<=m;++j,cht=0){
		while(cht!='.'&&cht!='*') cht=getchar();
		if(cht=='.') mapx[i][j]=1,endx=i,endy=j;
	}
// 因为可能最后一格也有障碍,所以要记录一下最后一个无障碍格子(endx,endy)
	for(int i=1;i<=12;++i) bits[i]=i<<1;
// DP
	tots[cnt]=1,dp[cnt][1]=1,state[cnt][1]=0;
// 一开始要初始化dp(0,0)=1
	for(int i=1;i<=n;++i){
		for(int j=1;j<=tots[cnt];++j) state[cnt][j]<<=2;
// 这是为了转移到下一行的时候处理第一个格子的is_r插头(建议在纸上模拟一下)
		for(int j=1;j<=m;++j){
			at=0,memset(ptr,0,sizeof ptr); //clear hash_table
			std::swap(pre,cnt),tots[cnt]=0; //rolling, clear state counter
			int nowsta,is_d,is_r;
			ll nowans;
			for(int k=1;k<=tots[pre];++k){
				nowsta=state[pre][k],nowans=dp[pre][k]; //get previous state&answer
				is_r=(nowsta>>bits[j-1])%4,is_d=(nowsta>>bits[j])%4; //get current plugs
				if(!mapx[i][j]){ //case 0 有障碍
					if(!is_r&&!is_d) hah(nowsta,nowans);
				}
				else if(!is_r&&!is_d){ //case 1
					if(mapx[i+1][j]&&mapx[i][j+1])
						hah(nowsta+(1<<bits[j-1])+(2<<bits[j]),nowans);
				}
				else if(is_r&&!is_d){ //case 2
					if(mapx[i+1][j]) hah(nowsta,nowans); //go down
					if(mapx[i][j+1]) //go right
						hah(nowsta-(is_r<<bits[j-1])+(is_r<<bits[j]),nowans);
				}
				else if(!is_r&&is_d){ //case 3
					if(mapx[i][j+1]) hah(nowsta,nowans); // go right
					if(mapx[i+1][j]) // go down
						hah(nowsta-(is_d<<bits[j])+(is_d<<bits[j-1]),nowans);
				}
				else if(is_r==1&&is_d==1){ // case 4
					int count=1;
					for(int l=j+1;l<=m;++l){
						if((nowsta>>bits[l])%4==1) ++count;
						else if((nowsta>>bits[l])%4==2) --count;
						if(!count){
							hah(nowsta-(1<<bits[j-1])-(1<<bits[j])-(1<<bits[l]),nowans);
							break;
						}
					}
				}
				else if(is_r==2&&is_d==2){ // case 5
					int count=1;
					for(int l=j-2;l>=0;--l){
						if((nowsta>>bits[l])%4==1) --count;
						else if((nowsta>>bits[l])%4==2) ++count;
						if(!count){
							hah(nowsta-(2<<bits[j-1])-(2<<bits[j])+(1<<bits[l]),nowans);
							break;
						}
					}
				}
				else if(is_r==2&&is_d==1) // case 6
					hah(nowsta-(2<<bits[j-1])-(1<<bits[j]),nowans);
				else if(is_r==1&&is_d==2) // case 7
					if(i==endx&&j==endy) all_ans+=nowans;
			}
		}	
	}
	printf("%lld\n",all_ans);
	return 0;
}

posted on 2019-04-11 20:58  autoint  阅读(332)  评论(0编辑  收藏  举报

导航