介绍
本质上是状压dp,所以是用在一类数据范围很小的题中,一般是求方案数之类的。
正题
这个东西大家有时候也管他叫插头dp,个人认为插头dp只是轮廓线dp的一种,只是把轮廓线上的状态设计成插头而已,事实上也可以不是插头,确实也有这样的题。
不过插头dp可能更广为人知,这里就以一道经典题作为例题:
给出一张网格图,每个位置是
.
或*
,*
表示这个位置不可用,你要在可用的位置上设计一条哈密顿回路,经过所有可用点。链接
不知道这道题的话还是看一眼原题比较好qwq。
先说轮廓线是什么东西,说清楚也很简单,就是下面的红线部分:
其中,绿色的两段称为关键段,转移了一次后就变成了右图的样子。
不难发现,上面的部分是已经dp过的部分,而下面的部分是仍未dp的。每一段上如果有一条边从已处理部分延伸到未处理部分,则称这条边为插头,插头分两类:如果这条边是他所在路径的起点,那么插头类型为 1 1 1,如果是终点,则为 2 2 2。没有插头,就是 0 0 0。
注意到由于这个哈密顿回路不可能有边相交,所以 1 , 2 1,2 1,2 之间是类似括号匹配的,即在轮廓线上,一个 1 1 1 往右找第一个没有 1 1 1 和他匹配的 2 2 2 一定是和自己匹配的,也就是和自己是同一条路径。
然后就可以开始转移了,设左边的绿色段的状态为 b 1 b_1 b1,上面的绿色短的状态为 b 2 b_2 b2。
先上一张图,这样下面好讲,也许你看了就知道怎么转移了:
接下来就是分类讨论,顺序和上图对应:
当 b 1 = 0 , b 2 = 0 b_1=0,b_2=0 b1=0,b2=0 时,由于每个位置都要被覆盖,所以肯定要新建一条路径,变成 b 1 = 1 , b 2 = 2 b_1=1,b_2=2 b1=1,b2=2。
当 b 1 , b 2 b_1,b_2 b1,b2 其中一个是 0 0 0 时,也就是有一条边伸到了这个格子里,那么可能从下面伸出去,也可能从右边伸出去,讨论一下即可。
当 b 1 = 2 , b 2 = 1 b_1=2,b_2=1 b1=2,b2=1 时,这两条路径就直接连起来。
当 b 1 = 1 , b 2 = 1 b_1=1,b_2=1 b1=1,b2=1 时,这两条路径也要连起来,但是后面就会有一组 2 , 2 2,2 2,2 的插头,需要将 b 2 b_2 b2 对应的路径结尾的插头类型改成 1 1 1,这个暴力 O ( n ) O(n) O(n) 往后找即可。
b 1 = 2 , b 2 = 2 b_1=2,b_2=2 b1=2,b2=2 时类似,将 b 1 b_1 b1 对应的路径起点的插头类型改成 2 2 2 即可。
最后一种情况, b 1 = 1 , b 2 = 2 b_1=1,b_2=2 b1=1,b2=2,这时这个哈密顿回路就封闭起来了,只能在最后下面最右边的格子连。
然后这状态三进制不好存,直接四进制即可,状态数很多是没用的,再用哈希压缩一下即可。
代码如下(磕代码的过程是少不了的qwq,不要放弃呀):
#include <cstdio>
#include <cstring>
#include <algorithm>
using namespace std;
#define ll long long
int n,m,mp[20][20],ex,ey,bin[20];
int sta[2][300010],tot[2],cur=0;//滚动数组
int last[300010],ne[300010];ll f[2][300010],ans=0;
const int hash_val=299997;
void add(int x,ll val){
int hv=x%hash_val;
for(int i=last[hv];i;i=ne[i])
if(sta[cur][i]==x){
f[cur][i]+=val;return;}
ne[++tot[cur]]=last[hv];last[hv]=tot[cur];
sta[cur][tot[cur]]=x;f[cur][tot[cur]]=val;
}
void solve(){
bin[0]=1;for(int i=1;i<=12;i++)bin[i]=bin[i-1]<<2;
sta[0][tot[0]=1]=0;f[cur][1]=1;
for(int i=1;i<=n;i++){
for(int j=1;j<=tot[cur];j++)sta[cur][j]<<=2;//换到下一行时,状态要左移,玩一玩就懂了
for(int j=1;j<=m;j++){
cur^=1;memset(last,0,sizeof(last));tot[cur]=0;
for(int p=1;p<=tot[cur^1];p++){
int now=sta[cur^1][p],x=(now>>((j-1)<<1))%4,y=(now>>(j<<1))%4;
ll &F=f[cur^1][p];
if(!mp[i][j]){
//如果这个位置不能放,那么就不能有插头指向这个格子,否则这个状态就不合法
if(!x&&!y)add(now,F);
}else if(!x&&!y){
if(mp[i][j+1]&&mp[i+1][j])add(now+bin[j-1]+(bin[j]<<1),F);
}else if(!x){
if(mp[i][j+1])add(now,F);
if(mp[i+1][j])add(now-bin[j]*y+bin[j-1]*y,F);
}else if(!y){
if(mp[i+1][j])add(now,F);
if(mp[i][j+1])add(now-bin[j-1]*x+bin[j]*x,F);
}else if(x==2&&y==1){
add(now-(bin[j-1]<<1)-bin[j],F);
}else if(x==1&&y==1){
int c=1;for(int k=j+1;k<=m;k++){
if((now>>(k<<1))%4==1)c++;
if((now>>(k<<1))%4==2)c--;
if(!c){
add(now-bin[j-1]-bin[j]-bin[k],F);break;}
}
}else if(x==2&&y==2){
int c=1;for(int k=j-2;k>=0;k--){
if((now>>(k<<1))%4==2)c++;
if((now>>(k<<1))%4==1)c--;
if(!c){
add(now-(bin[j]<<1)-(bin[j-1]<<1)+bin[k],F);break;}
}
}else if(i==ex&&j==ey)ans+=F;
}
}
}
}
int main()
{
scanf("%d %d",&n,&m);
static char s[20];
for(int i=1;i<=n;i++){
scanf("%s",s+1);
for(int j=1;j<=m;j++){
mp[i][j]=(s[j]=='.');
if(s[j]=='.')ex=i,ey=j;
}
}
solve();
printf("%lld\n",ans);
}
然而还有一个简单一些的题,区别在于可以有多个回路而不一定是一个。
这样就不需要记录插头的类型,只在乎插头的有无,这样只需要记录 0 , 1 0,1 0,1,二进制即可。
转移时, 00 → 11 , 10 → 01 , 10 , 01 → 01 , 10 , 11 → 00 00\to 11,10\to01,10,01\to01,10,11\to 00 00→11,10→01,10,01→01,10,11→00。
膜一下神兔,就可以得到一个很简洁的代码,思想是这个思想,但可以巧妙利用位运算:
#include <cstdio>
#define maxn 15
#define FOR(i,a,b) for(int i=(a);i<=(b);i++)
int T,n,m,a[maxn][maxn];
long long f[2][1<<maxn];
int main()
{
scanf("%d",&T);while(T--)
{
scanf("%d %d",&n,&m);int s=1<<m+1;
FOR(i,1,n)FOR(j,1,m)scanf("%d",&a[i][j]);
FOR(i,0,s)f[1][i]=0;f[1][0]=1;
FOR(i,1,n)FOR(j,1,m){
FOR(k,0,s)f[0][k]=j>1?f[1][k]:k&1?0:f[1][k>>1];
FOR(k,0,s)if(!a[i][j])f[1][k]=k>>(j-1)&3?0:f[0][k];
else f[1][k]=f[0][k^3<<(j-1)]+((k>>(j-1)&1)^(k>>j&1)?f[0][k]:0);
}
printf("%lld\n",f[1][0]);
}
}
还有一道好题,不是插头dp,但是是轮廓线dp:这里。