插头DP

前言

学习完来自陈丹琦的《基于连通性状态压缩的动态规划问题》后来做个总结。

算法简介

插头 DP 就是一种基于连通性状态压缩的动态规划。

也就是说,在状态中需要记录若干个元素的联通情况。

插头 DP 有两个比较明显的特征:

  1. 以方格表为背景

  2. 状态总数为指数级,故数据范围在 (10) 左右。

详细的内容会结合着下面的例题来讲。

例题选讲

下面提供几道经典的题目:

例题1:BZOJ1814: Formula 1

(Large{ ext{Description:}})

给定一个 (m imes n) 的棋盘,有的格子中存在障碍,求经过所有非障碍格子的哈密顿回路个数。

数据范围:(2leq m,nleq 12)

(Large{ ext{Solution:}})

这题怎么做呢?

首先想到搜索,但是时间复杂度是 (O((mn)!)) 的,显然不太行……

于是考虑状态压缩!

下面先介绍两个基本概念:轮廓线、插头。

  • 轮廓线:已决策格子与未决策格子的分界线;(下图中的红线)
  • 插头:当前格子在插头方向与相邻格子相连。(下图中的箭头)

我们来从上到下,从左到右逐格转移。

考虑如下图所示的红色轮廓线向蓝色轮廓线转移时该怎么做。

(f(i,j,S)) 表示刚刚转移完 ((i,j)),插头状态为 (S) 的方案总数。

那么想一下 (S) 应该如何表示?

最小表示法!

我们用一个 (n+1) 个数来表示 (S),具体如下:

  • 轮廓线上的这一个格子如果无插头,该位为 (0)
  • 轮廓线上的这一个格子如果有插头,该位与和这个格子联通的格子标记相同数字。

其中的非零数字从 (1) 开始取,每次取未取的最小数字。

但这么做在这题中依旧不够优秀,还有没有办法进行优化呢?

括号匹配序列!

为什么会这么想呢?

由于插头不会两两交叉且是两两匹配的,我们很自然地想到括号匹配问题。

接下来我们考虑用括号匹配序列来表示这个 (S),具体如下:

用一个三进制数来设置状态:

  • (0):无插头状态
  • (1):左括号插头
  • (2):右括号插头

就下图而言,它表示出来的 (S) 是这样子的:((##)() o 100212)

直到这里,我们把插头 DP 的基本概念学清楚了!

现在我们考虑怎么转移 (f(i,j,S))

其实每次转移就是把当前决策格子的左插头改成下插头,上插头改成右插头。

于是我们把 ((i,j)) 的插头状态分类讨论一下。

1. 没有上插头和左插头,那么变成有下插头和右插头。

这种情况相当于构成一个新的连通块。

以下图为例,(S={1,0,0,0,0,2})

由于有一条直线会穿过当前格,那么它是怎么被穿过的呢?

一定是这个格子多了下插头和右插头对吧。

此时的 (S'={1,0,0,1,2,2})

2. 上插头和左插头中恰好只有 1 个。

此时相当于延续原来的连通分量。

什么意思呢?

我们同样举下图为例。

转移前,(S={1,0,0,0,2,0})

那么当前格有两种决策:继续向下,向右拐。

而两种决策后得到的 (S') 分别为:

  • 向下:(S'={1,0,0,2,0,0})
  • 向右:(S'={1,0,0,0,2,0})

我们发现其实这并不改变原来的括号匹配情况。

简单来讲,直接不改变 (S) 就行了。

3. 同时有上插头和左插头。

那么此时就相当于在这个格子结束,合并两个联通分量。

但实际上这个非常麻烦,我们再分成 4 类进行讨论。

3.1. 上插头和左插头均为 (

我们发现它影响的不只是这两个插头了,那怎么办呢?

考虑枚举这一位后的插头情况,直到遇见第一个不匹配成功的 ())

此时把它变成 (() 即可。

这么做为什么是对的呢?我们看一下下面的图。

原先,(S={0,1,1,2,0,2})

当我们把当前格合并后,如果只是把 (1 o 0),那么 (S'={0,0,0,2,0,2})

这样是不匹配的!

所以我们要找到右边第一个未匹配的 ()),并使 () o ()

在这里,我们让 (S'={0,0,0,1,0,2}) 即可。

3.2. 上插头和左插头均为 )

同上,读者自行推导不难。

3.3. 上插头为 (,左插头为 )

我们考虑由于这两个必定会合起来,变成两个 (#)

直接这么做有没有问题呢?

显然没有。

如上图所示,(S={1,0,0,2,1,2},S'={1,0,0,0,0,2})

3.4. 上插头为 ),左插头为 (

这种情况只有在下面都是障碍方格时才可能会出现,特判一下即可。

对于刚刚提出来的例题,我们像上面这样转移即可。

时间复杂度:(O(3^{m+1}nm)),相比于搜索还是优化了非常多!

但其实它还有一点点的小常数可以优化。

在我们存 (S) 的时候其实是用 (3) 进制存的,这样没有用位运算,显然不够快。

于是我们干脆把它变成用 (4) 进制存。

这样虽然多了一个无用的数字,但可以用位运算实现,实测效率会高很多!

(Large{ ext{Code:}})

#include<bits/stdc++.h>
#define Re register
using namespace std;

typedef long long LL;

const int N=15;
const LL Hash=299987;

LL n,m,X,Y,inc[N],ans;

int mp[N][N];

LL now,lst;

LL hd[Hash+5],nxt[1<<25],que[2][1<<25],val[2][1<<25],cnt[2];

inline void insert(LL bit,LL num)
{
	LL u=bit%Hash+1;
	for(Re LL i=hd[u];i;i=nxt[i])
	{
		if(que[now][i]==bit)
		{
			val[now][i]+=num;
			return;
		}
	}
	cnt[now]++; 
	nxt[cnt[now]]=hd[u];
	hd[u]=cnt[now];
	que[now][cnt[now]]=bit;
	val[now][cnt[now]]=num;
}

int main()
{
	scanf("%d%d",&n,&m);
	for(Re LL i=1;i<=n;i++)
	{
		char s[21];
		scanf("%s",s+1);
		for(Re LL j=1;j<=m;j++)
		{
			if(s[j]=='.')
			{
				mp[i][j]=1;
				X=i,Y=j;
			}
		}
	}
	inc[0]=1;
	for(Re LL i=1;i<=13;i++)
	{
		inc[i]=inc[i-1]<<2;
	}
	cnt[0]=1;
	val[0][1]=1;
	que[0][1]=0;
	for(Re LL i=1;i<=n;i++)
	{
		for(Re LL j=1;j<=cnt[now];j++)
		{
			que[now][j]<<=2;
		}
		for(Re LL j=1;j<=m;j++)
		{
			memset(hd,0,sizeof hd);
			lst=now;
			now^=1;
			cnt[now]=0;
			for(Re LL k=1;k<=cnt[lst];k++)
			{
				LL bit,num,b1,b2;
				bit=que[lst][k];
				num=val[lst][k];
				b1=(bit>>((j-1)<<1))%4;
				b2=(bit>>(j<<1))%4;
				if(!mp[i][j])
				{
					if(!b1&&!b2)
					{
						insert(bit,num);
					}
				}
				else 
				{
					if(!b1&&!b2)
					{
						if(mp[i+1][j]&&mp[i][j+1])
						{
							insert(bit+inc[j-1]+(inc[j]<<1),num);
						}
					}
					if(!b1&&b2)
					{
						if(mp[i][j+1])
						{
							insert(bit,num);
						}
						if(mp[i+1][j])
						{
							insert(bit-inc[j]*b2+inc[j-1]*b2,num);
						}
					}
					if(b1&&!b2)
					{
						if(mp[i+1][j])
						{
							insert(bit,num);
						}
						if(mp[i][j+1])
						{
							insert(bit-inc[j-1]*b1+inc[j]*b1,num);
						}
					}
					if(b1==1&&b2==1)
					{
						LL K=1;
						for(Re LL l=j+1;l<=m;l++)
						{
							if((bit>>(l<<1))%4==1)
							{
								K++;
							}
							if((bit>>(l<<1))%4==2)
							{
								K--;
							}
							if(!K)
							{
								insert(bit-inc[j]-inc[j-1]-inc[l],num);
								break;
							}
						}
					}
					if(b1==2&&b2==2)
					{
						LL K=1;
						for(Re LL l=j-2;l>=0;l--)
						{
							if((bit>>(l<<1))%4==1)
							{
								K--;
							}
							if((bit>>(l<<1))%4==2)
							{
								K++;
							}
							if(!K)
							{
								insert(bit-(inc[j]<<1)-(inc[j-1]<<1)+inc[l],num);
								break;
							}
						}
					}
					if(b1==2&&b2==1)
					{
						insert(bit-(inc[j-1]<<1)-inc[j],num);
					}
					if(i==X&&j==Y)
					{
						ans+=num;
					}
				}
			}
		}
	}
	printf("%lld",ans);
	return 0;
}

例题2:Eat the Trees

(Large{ ext{Description:}})

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

数据范围:(2leq nleq 12)

(Large{ ext{Solution:}})

这题与之前的例题类似,只是在这题中,它可以形成多条回路。

正因为如此,所以我们没必要用括号序列。

为什么呢?

因为括号序列是为了防止回路提前闭合才使用的,而这题不需要。

那就直接存轮廓线上有的插头即可。

现在我们来考虑如何转移状态。

  1. 左 0 上 0 ( o) 右 1 下 1,即加了个转角。

  2. 左 1 上 1 ( o) 右 0 下 0,即闭合了一段回路。

  3. 左 0 上 1 ( o) 右 1 下 0 或 右 0 下 1,即向右转弯或继续向下。

  4. 左 1 上 0 ( o) 右 1 下 0 或 右 0 下 1,即继续向下或向右转弯。

上述的情况 (3,4) 本质相同,可以归为一类。

这样就完成了本题。

#include<bits/stdc++.h>
#define Re register
using namespace std;

typedef long long LL;

const int N=15;

int T,n,m,mp[N][N];

LL dp[2][1<<20];

inline int rd()
{
	char ch=getchar();
	int x=0,f=1;
	while(ch<'0'||ch>'9'){if(ch=='-')f=-1;ch=getchar();}
	while(ch>='0'&&ch<='9'){x=x*10+ch-'0';ch=getchar();}
	return x*f;
}

inline void change(int& cur,int pos,int val)
{
	if(val==1) cur=cur|(1<<(pos-1));
	else cur=cur&(~(1<<(pos-1)));
}

int main()
{
	scanf("%d",&T);
	while(T--)
	{
		memset(dp,0,sizeof dp);
		n=rd();
		m=rd();
		for(Re int i=1;i<=n;i++)
		{
			for(Re int j=1;j<=m;j++)
			{
				mp[i][j]=rd();
			}
		}
		int lst=0,now=1;
		dp[0][0]=1;
		for(Re int i=1;i<=n;i++)
		{
			for(Re int j=1;j<=m;j++)
			{
				for(Re int k=0;k<(1<<(m+1));k++)
				{
					int cur=k;
					int L=k&(1<<(j-1));
					int U=k&(1<<j);
					if(!mp[i][j])
					{
						if(!L&&!U)
						{
							dp[now][k]+=dp[lst][k];
						}
					}
					else 
					{
						if(!L&&!U)
						{
							change(cur,j,1);
							change(cur,j+1,1);
							dp[now][cur]+=dp[lst][k];
						}
						if(L&&U)
						{
							change(cur,j,0);
							change(cur,j+1,0);
							dp[now][cur]+=dp[lst][k];
						}
						if((L&&!U)||(!L&&U))
						{
							change(cur,j,1);
							change(cur,j+1,0);
							dp[now][cur]+=dp[lst][k];
							change(cur,j,0);
							change(cur,j+1,1);
							dp[now][cur]+=dp[lst][k];
						}
					}
				}
				memset(dp[lst],0,sizeof dp[lst]);
				lst^=1,now^=1;
			}
			for(Re int j=0;j<(1<<m);j++)
			{
				dp[now][j<<1]=dp[lst][j];
			}
			memset(dp[lst],0,sizeof dp[lst]);
			lst^=1,now^=1;
		}
		printf("%lld
",dp[lst][0]);
	}
	return 0;
}

练习题

  1. [JLOI2009]神秘的生物

  2. [HNOI2007]神奇游乐园

  3. [ZJOI2009]多米诺骨牌

  4. [SCOI2011]地板

原文地址:https://www.cnblogs.com/kebingyi/p/14351391.html