插头DP 从入门到精通
aaaaaaaawsl · · 个人记录
0x10 插头 DP 是什么
插头 DP 就是状压 DP。不过是处理连通性问题的状压 DP。主要处理基于棋盘模型的,操作带有联通性的,方案计数或者价值最大问题。
回顾在棋盘模型上进行的那些普通状压 DP,如 炮兵阵地
可以发现他们的操作其实也是具有联通性的,但是这个连通性又什么特点?
-
规模小
-
固定形式
这就导致了我们可以直接讨论出所有状态转移的方案。同时由于每个操作在一定程度上是独立的(我们无需考虑到每个攻击的格子的交叉情况,每个交叉的格子是否联通等),以至于可以用一个数来表示一行的状态,并且上下行之间的可变化小。所以这种 DP 可以做到逐行递推。
而插头 DP 的常见形式是这样的:
【模板】插头DP
以通常的思路来讲,我们用一个二进制数表示这一行格子上铺没铺线,但是考虑不下去了,因为无法在状态转移的时候确定和否合法,并且对于每一条线都可以有多种下一个铺法。并且,它是基于全局的联通,要求闭合回路。这种问题就要用到插头 DP 了。
0x20 普通棋盘形插头DP的思路
棋盘形插头 DP 通常有两种形式,本质差别不大,不过由于这是一篇面向没接触过插头 DP 的人的博客,我们分开讲一下。
0x21 路径形
典型的便是上文提到的模板,要求合法的一条路径。
插头 DP 题解或博客到这里大多是告诉你要设轮廓线,轮廓线的定义。我们来从本质讲讲为什么要设轮廓线。
这类联通性问题,单单考虑其中已经决策到的某一条路径(可能不是完整的),它的下一步应该怎么走的时候,就有多种走法,虽然这个是可以手讨出来的,但是在一整行上考虑的时候,由于不是最终状态,会导致有多条分裂的路径,如。
(上图借用于cdp论文。)
单单看蓝线上方那一段,他们就是多条路径,而我们按照普通状压枚举状态枚举的不是蓝线而是黄线。
在这行(状态)上存在的部分路径,既可以往下走,也可以往右走,也可以往左走,也可以终止。结合一行来看,转移是不可能的。所以这就是这类插头 DP 的第一个特点:逐格递推。
接下来考虑我们正在考虑某一格,一定是考虑完这一格继续考虑这一行下一格,这一格如何转移既能影响到下一行,又能影响到下一格(向下走,向右走),所以我们要维护一行的状态,一格一格推,这一行推过的格子的状态,这一格的状态,还没推过的格子的状态,三个组合起来形成了 轮廓线 这就是轮廓线。
看下面一个轮廓线:
手画了一个小时累死我了
黄色格子是我们正在决策的格子,你会发现由于它是一个格子,所以轮廓线要先向上走再向右走,所以轮廓线是要比列数多一位的, 并且在决策到第 i 位的时候,其实是
接下来怎么设计状态呢?
先不管最后形成一条回路,这道题的要求明显是路径不能交叉。我们要用轮廓线正确记录下合法的状态,有两种方法,最小表示法和括号表示法。
这里我们把每个格和其他格的联通情况看作插头,例如第四行第一列的格子有一个下插头和一个上插头,第一行第四列的格子有一个右插头和一个左插头。第七行第二列的格子有一个下插头和一个右插头。
- 最小表示法
最小表示法状态数大,不常用,所以在这里简单介绍下(才不是我没用过根本不会呢)
我自己理解的就是,将每个连通块当前表现在轮廓线上的插头编号为这个连通块最左边的那个插头是第几个出现的。
例如当前轮廓线:
先不讲转移了,有兴趣请自学(没用过)。
这种方法显然可以保证在转移的过程中保证不相交。原因见下面括号表示法。
- 括号表示法
最小表示法不优的原因在于它一个联通块要一个数来表示,当这个所以状态数会很大,括号表示法则用两个数
为什么括号表示法是对的呢?
考虑从左到右
(上图借用于cdp论文。)
证:在只考虑到轮廓线以上时,若
即插头之间有一个性质:两两匹配,不会交叉。正是括号匹配的性质,我们用
接下来状态怎么转移呢?考虑下面这张图:
这是可能的一种转移后的状态,考虑完黄色格子,绿线是转移完的轮廓线。为什么要把轮廓线这样变呢?再想想轮廓线的定义 (自创) :
轮廓线是已决策完的格子和还未决策的格子之间的插头状态。
其实就是那条分界线上的状态,既然黄色格子决策完了,自然分界线就会变成这样。
由于我们只决策了一个格子,我们只需考虑有关这个格子的状态的变化,拿模板题分讨一下:
前情提要: 由于下面对初学者来说可能弄混,我提前声明,左插头是第
由于本题有障碍,预处理下,在转移的时候判断下一格是否是状态即可。
请忽略我的 Zip 函数。
我们判断完一格其实是结束一次操作,把判断完的状态存起来去递推下一个。
-
这一格是障碍
不能有左插头和下插头,状态没变化,直接存。
if(!a[i][j]){ // 当前不可走 if(!b1 && !b2) Zip(bit, num); //有障碍,不增加括号序列 } -
这一格没有插头
新增下插头,和右插头。
else if(!b1 && !b2){ // 没有插头,新增个连通块。 if(a[i + 1][j] && a[i][j + 1]) Zip(bit + tw[j - 1] + tw[j] * 2, num); //四进制下储存一个左括号一个右括号 } -
这一格只有左插头(默认周围没障碍
-
延续这个方向往右走
删除
i 位插头 加上i + 1 位插头。else if(b1 && !b2){ if(a[i][j + 1]) Zip(bit - tw[j - 1] * b1 + tw[j] * b1, num); } -
向下走 删除第
i 位,加上第i 位;else if(b1 && !b2){ if(a[i + 1][j]) Zip(bit, num); if(a[i][j + 1]) Zip(bit - tw[j - 1] * b1 + tw[j] * b1, num); }
-
-
这一格只有上插头
同上不述。
-
这一格有两个
1 插头观察这个图,我们只能将两个连通块连到一起,此时我们需要删除这两个插头,向右找到第一个右插头把他变成左插头,在这个图中就是轮廓线第4位的插头。
else if(b1 == 1 && b2 == 1){ // 两个左括号 int flag = 1; for(int l = j + 1; l <= m; ++ l){ if((bit >> (l * 2)) % 4 == 1) flag ++; if((bit >> (l * 2)) % 4 == 2) flag --; if(!flag){ // 最左の右括号 Zip(bit - tw[j] - tw[j - 1] - tw[l], num); break; } } } -
这一格有两个
2 插头同上不述。
-
这一格左上都有插头且不同
这表示我们要把这里作为两个连通块合并的结束之处。
如果左是
2 插头,上为1 插头,判断是否是最后一格。如果是,统计答案。else{ if(b1 == 2 && b2 == 1) Zip(bit - tw[j - 1] * 2 - tw[j], num); else if(i == ei && j == ej) ans += num; }
至此模板的主体部分以实现,接下来说一些其他部分。
-
我们要存储
0, 1, 2 三种插头,至少要用三进制,但是考虑到 c++ 里位运算的便利,我们用 两位二进制表示四进制来存储至少要三进制存储的0,1,2 这三个数。tw数组就是预处理这个的。 -
由于这类题的要求严格,会导致出现大量的无用状态,对空间很不友好,所以采用链表 hash,也就是代码中的
Zip函数。 -
考虑这一行的状态怎么用于下一行
蓝线是上一行结束状态,绿线是这一行起始状态。
注意!这里的结束状态实际是某一格决策完后存下来的状态,而不是推到最后一个的状态
可以观察到,上一行的状态最后一定没有插头,这一行开头一定没有插头(不能插到图外面),其他地方完全相同,即上一行的状态是 0……,这一行的状态是 ……0。所以我们只需要将上一行的状态左移 2 (见第一条)位即可。
这里也是易混的一点,图中第一位在左边,但是在数中第一位在右边,不要理所当然的把图带入数。并且注意要取出数的第
i 位是向左移i - 1 位。for(int j = 1; j <= cnt[now]; ++ j) que[now][j] <<= 2;
下面是代码,本题主要滚动数组,注释很详细,可能有重复的地方。 (因为是我以前打的)
#include<iostream>
#include<cstdio>
#include<cstring>
#include<algorithm>
#define int long long
using namespace std;
const int Mod = 299987;
const int N = 14;
const int M = 2 << 24;
int n, m, ans;
int a[N][N];
char s;
int que[2][M], val[2][M]; // que是当前阶段hash对可行状态的映射数组, val是当前阶段hash映射数组所存的值, 即“f”
int tw[N], cnt[2]; // tw是预处理の四进制数字,cnt为当前阶段可行の状态数(理解可以idx
int ei, ej;
int now;
int last, num;
int Next[M], head[300000];
void Zip(int bit, int num){ // hash 压缩和连边 bit为当前状态, num 为这个状态的方案数
long long u = bit % Mod + 1; // 取head
for(int i = head[u]; i; i = Next[i]){
if(que[now][i] == bit){
val[now][i] += num;
return;
}
}// 没有return就说明是新的点,建新边并且赋值
Next[++ cnt[now]] = head[u];
head[u] = cnt[now];
que[now][cnt[now]] = bit;
val[now][cnt[now]] = num;
}
void dp(){
cnt[now] = 1;
val[now][1] = 1;
que[now][1] = 0;
for(int i = 1; i <= n; ++ i){ // 逐行推
for(int j = 1; j <= cnt[now]; ++ j) que[now][j] <<= 2; /*
que是个队列,装的是上一步转移后的合法状态,由于我们是>>取插头,同时
由上一条轮廓线的末状态(每个状态是进行一次转移的来的,可以看作剩下的部分不转移而直接到末状态)转移到这一条轮廓线的初状态,画图
得知,上一个状态的最后一个插头一定不会有(不可能指向格外),这一个状态的第一个插头肯定不会存在(不可能由格外指进来),即上一个状态
一定是 00(最后一格储存在最高位) …… (两个二进制表示一个三进制数) 下一个状态还没开始转移的时候一定是 ……00 (第一格储存在第一位
这两个省略号表达的是相同的 ,因为还没发生转移。所以我们发现,由上一个转移的结束状态转为这个转移的初始状态,只需要 << 2
*/
for(int j = 1; j <= m; ++ j){ // 逐格枚举
memset(head, 0, sizeof head); // 初始化hash链表
last = now; now ^= 1; // 滚动数组
cnt[now] = 0; // 滚动数组
for(int k = 1; k <= cnt[last]; ++ k){ //
int bit = que[last][k], num = val[last][k]; // last阶段第k个状态,要求的是方案数,所以要继承last阶段第k个状态对应的方案数
int b1 = (bit >> ((j - 1) * 2)) % 4, b2 = (bit >> (j * 2)) % 4; // bit 为轮廓线状态,取出两个插头
if(!a[i][j]){ // 当前不可走
if(!b1 && !b2) Zip(bit, num); //有障碍,不增加括号序列
}
else if(!b1 && !b2){ // 没有插头,新增个连通块。
if(a[i + 1][j] && a[i][j + 1]) Zip(bit + tw[j - 1] + tw[j] * 2, num); //四进制下储存一个左括号一个右括号
}
else if(!b1 && b2) {
if(a[i][j + 1]) Zip(bit, num);
if(a[i + 1][j]) Zip(bit - tw[j] * b2 + tw[j - 1] * b2, num); // b2 是右括号の四进制编号
}
else if(b1 && !b2){
if(a[i + 1][j]) Zip(bit, num);
if(a[i][j + 1]) Zip(bit - tw[j - 1] * b1 + tw[j] * b1, num);
}
else if(b1 == 1 && b2 == 1){ // 两个左括号
int flag = 1;
for(int l = j + 1; l <= m; ++ l){
if((bit >> (l * 2)) % 4 == 1) flag ++;
if((bit >> (l * 2)) % 4 == 2) flag --;
if(!flag){ // 最左の右括号
Zip(bit - tw[j] - tw[j - 1] - tw[l], num);
break;
}
}
}
else if(b1 == 2 && b2 == 2){ //两个右括号
int flag = 1;
for(int l = j - 2; l >= 0; -- l){
if((bit >> (l * 2)) % 4 == 1) flag --;
if((bit >> (l * 2)) % 4 == 2) flag ++;
if(!flag){ // 最右の左括号
Zip(bit - tw[j] * 2 - tw[j - 1] * 2 + tw[l], num);
break;
}
}
}
else{
if(b1 == 2 && b2 == 1) Zip(bit - tw[j - 1] * 2 - tw[j], num);
else if(i == ei && j == ej) ans += num;
}
}
}
}
}
signed main(){
scanf("%lld%lld", &n, &m);
for(int i = 1; i <= n; ++ i)
for(int j = 1; j <= m; ++ j){
char ch = getchar();
while(ch != '*' && ch != '.') ch = getchar();
if(ch == '.'){
a[i][j] = 1;
ei = i; ej = j; // 结束节点
}
else a[i][j] = 0;
}
tw[0] = 1;
for(int i = 1; i <= 13; ++ i) tw[i] = tw[i - 1] << 2; //每个下标是个四进制状态。
dp();
printf("%lld\n", ans);
return 0;
}
掌握了这道题,百分之
-
P3190 神奇游乐园
这道题几乎和模板一样。
我写的题解
0x21 图形形
这种和上方差不多,但是介于我做的时候还是思考了段时间,这里简单说一下。
这种不同的地方在于它给你满足某些条件的图形, 用这个图形在网格上做操作。不同的地方是我们在分讨的时候某些条件的变化不同了,有时候插头的定义变化了,典型的是
P3272 [SCOI2011]地板
这道题要求 L 形砖来铺,那我们的转移就变化了,直走,或者转一下。
由于只能转一下,这里把插头设置为是否转过即可。
我写的题解
给出一道练习题
-
P4262 白金元首与莫斯科
这道题要求计算以所有格都做一遍障碍的数,直接算会暴力,要用到预处理的技巧。
我写的题解
还有一道困难点的留在下面。
0x30 棋盘染色形插头DP的思路
见 cdp 论文所给出的题:
连通性这一条好整,按照插头 DP 的套路来就行,看向「不会有一个
所以我们要记录两个状态,和一个格子的颜色。
由于我没手打过(也不知道在哪打)
下面给出 cdp 的状态转移解释:
动态规划的状态为: 表示转移完
状态转移: 枚举当前格子
下面一道结合了上面两种形式,可谓是毒瘤。
P2337 喵星人的入侵
本身打算写的但是这里都说完了也就剩个分类讨论不写了你们自己看其他题解吧(才不是我懒