状压DP杂谈(+轮廓线,插头)
command_block
2020-03-09 09:32:06
**状压DP思想** : 用集合作为状态,采用特殊的寻址方法。
约定 $cnt(s)=s$ 的二进制中$1$的个数。
------------
- [P1879 [USACO06NOV]Corn Fields G](https://www.luogu.com.cn/problem/P1879)
这是一道最最最模板的题目了。
**题意** : 有一片 $n\times m$ 的土地,若干位置不能种菜,不能在相邻的土地种菜,求种菜的方案数。
$n,m\leq 12$ ,时限$\texttt{1s}$。
------------
- **朴素状态压缩**
能够观察到,每一行的决策只会直接影响前后两行。
可以设计出如下的状态 :
$f[i][s]$ 为考虑了前 $i$ 行,这一行选取的情况为$s$的最优解。
由于完全记录了上一行的决策,我们是能够转移的。
怎么表示这个 $s$ 呢?每一行有 $m$ 个位置,每个位置只有两种情况 : 选 / 不选
所以,可以使用长度为 $m$的 $01$ 串表示,也就是二进制数,总的状态量为 $2^m$ 。
转移可以考虑枚举目标状态 $s'$ 查看:
- $s'$ 是否包含了被钦定的点
- $s,s'$ 是否有交集,
- $s'$ 中是否有相邻的两个元素。(可以用`(s<<1)&s`判定)
如果合法则有贡献 :
$f[i+1][s']+=f[i][s]$
边界 : $f[1][s]=[s$合法$]$
答案 : $\sum\limits_{s}f[n][s]$
这样的复杂度是 $O(n4^m)$ 的,剪枝之后能过。
事实上,剪枝剪得好复杂度是 $O(n2^mF_m)$ 的,相关证明见下。
容易发现可以滚动数组,下面的代码都采用了此优化。
这份代码跑 `n=m=16` 无障碍矩阵,耗时 `1.7s`。
```cpp
#include<cstring>
#include<cstdio>
#define MaxS 4500
#define mod 100000000
using namespace std;
int n,m,lim,e[15],g[MaxS],f[MaxS],ans;
int main()
{
scanf("%d%d",&n,&m);
lim=(1<<m);
for (int i=1;i<=n;i++)
for (int j=1,c;j<=m;j++){
scanf("%d",&c);
e[i]=e[i]<<1|(!c);
}
for (int s=0;s<lim;s++)
if (!(s&e[1])&&!(s&(s<<1)))g[s]=1;
for (int i=2;i<=n;i++){
memset(f,0,sizeof(int)*(lim+5));
for (int u=0;u<lim;u++)if (g[u])
for (int v=0;v<lim;v++)
if (!(v&(u|(v<<1)|e[i])))
f[v]=(f[v]+g[u])%mod;
memcpy(g,f,sizeof(int)*(lim+5));
}for (int i=0;i<lim;i++)
ans=(ans+g[i])%mod;
printf("%d",ans);
return 0;
}
```
------------
- **缩小状态集合**
考虑到题目中要求**不能在相邻的土地种菜**,这是一个很强的约束。
只考虑对于单独一行的效果,我们发现,大多数的$s$都是不合法的。
可以枚举(搜索)得到合法的状态,总共仅约 $350$ 种。转移的时候照样转移即可。
为什么状态量这么少呢?
合法的 $s$ 一定是不存在 $11$ 串的,我们来计数一下:
$g[i][0/1]$ 表示长度为 $i$ 的串,末尾为?的方案数。
有$g[1][1]=g[1][0]=1$;
$g[i+1][1]=g[i][0];\ g[i+1][0]=g[i][0]+g[i][1]$
代换一下能得到这就是个斐波那契数列……
复杂度就是 $O(n(F_{m})^2)<O(n(1.618)^{2m})<O(n(2.6)^m)$
跑 `n=m=16` 无障碍矩阵,耗时 `115ms`。
```cpp
#include<cstring>
#include<cstdio>
#define MaxS 405
#define mod 100000000
using namespace std;
int n,m,tn,z[MaxS],e[15],g[MaxS],f[MaxS];
int main()
{
scanf("%d%d",&n,&m);
for (int i=1;i<=n;i++)
for (int j=1,c;j<=m;j++){
scanf("%d",&c);
e[i]=e[i]<<1|(!c);
}
for (int s=0;s<(1<<m);s++)
if (!(s&(s<<1)))z[++tn]=s;
for (int i=1;i<=tn;i++)
if (!(z[i]&e[1]))g[i]=1;
for (int i=2;i<=n;i++){
memset(f,0,sizeof(f));
for (int u=1;u<=tn;u++)
for (int v=1;v<=tn;v++)
if (!(z[v]&z[u])&&!(z[v]&e[i]))
f[v]=(f[v]+g[u])%mod;
memcpy(g,f,sizeof(f));
}int ans=0;
for (int i=1;i<=tn;i++)
ans=(ans+g[i])%mod;
printf("%d",ans);
return 0;
}
```
------------
- **枚举子集**
我们发现,从列的角度看 $s,s'$ 不交,同样是一个强有力的约束。
考虑对于每个 $s$ ,有多少个 $s'$ 是合法的。答案是 $2^{m-cnt(s)}$
枚举 $cnt(s)$ ,可得总枚举量为 $\sum\limits_{c=0}^m\dbinom{m}{c}2^{m-c}=3^m$! (二项式定理)
居然从 $4^m\rightarrow 3^m$ 了?
也就是说,我们如果能精准地枚举每个合法 $s'$ ,复杂度就降为 $O(n3^m)$。
容易发现, $s'$ 就是 $s$ 的补集的子集。我们要解决的问题就是不重不漏地枚举某个集合的子集。
有下列代码,可以令 `s2` 遍历 `s` 的**真子集** :
`for(int s2=s;s2;s2=(s2-1)&s)`
这是怎么工作的呢?考虑减法的退位:
`s2`是`s`的某个子集,`-1`之后,会把`s2`的`lowbit`清`0`,然后后面的都变为`1`。
然后对`s`取与,就把不合法的退位都消除了。效果就是在`s`的子集内做减法。
```cpp
s : 10101101
s2 : 00101000
s2-1 : 00100111
(s2-1)&s : 00100101
```
事实上,如果善用剪枝,复杂度会优于$O(n3^m)$.
跑`n=m=16`无障碍矩阵,耗时`170ms`
```cpp
#include<cstring>
#include<cstdio>
#define MaxS 4500
#define mod 100000000
using namespace std;
int n,m,lim,e[15],g[MaxS],f[MaxS],ans;
int main()
{
scanf("%d%d",&n,&m);
lim=(1<<m);
for (int i=1;i<=n;i++)
for (int j=1,c;j<=m;j++){
scanf("%d",&c);
e[i]=e[i]<<1|(!c);
}
for (int s=0;s<lim;s++)
if (!(s&e[1])&&!(s&(s<<1)))g[s]=1;
for (int i=2;i<=n;i++){
memset(f,0,sizeof(int)*(lim+5));
for (int u=0;u<lim;u++)if (g[u]){
int s=(lim-1)^u;
for (int v=s;v;v=(v-1)&s)
if (!(v&((v<<1)|e[i])))
f[v]=(f[v]+g[u])%mod;
f[0]=(f[0]+g[u])%mod;
}memcpy(g,f,sizeof(int)*(lim+5));
}for (int i=0;i<lim;i++)
ans=(ans+g[i])%mod;
printf("%d",ans);
return 0;
}
```
两个优化共用并不会带来很大的性能提升,读者可以自行尝试。
------------
- **轮廓线优化**
前面的想法是一行行转移,我们也可以尝试填表式转移。
```cpp
*****
***##
###..
.....
.....
```
状态可以这样记录 : $f[i][j][s]$表示填到$i$行$j$列,暴露出来的边缘状态为$s$的方案数。
暴露出来的部分即为图中`#`。
这样定义状态有什么好处呢?
前面我们是一次性转移一行,目标状态十分庞大,现在是一次填写一个,目标状态就很小了。
$s0=s$的$j$位变为$0$;
$s1=s$的$j$位变为$1$;
- $j=1$
只有$f[i-1][m][s0/s1]$能够向$f[i-1][m][s]$贡献。
- 填0 : $f[i][1][s0]+=f[i-1][m][s0]+f[i-1][m][s1]$
- 填1 : $f[i][1][s1]+=f[i-1][m][s0]$
- 当$j>1$时:
只有$f[i][j-1][s0/s1]$能够向$f[i][j][s]$贡献。
- 填0 : $f[i][j][s0]+=f[i][j-1][s0]+f[i][j-1][s1]$
- 填1 : $f[i][j-1][s1]+=f[i][j-1][s0]$ (要求$s0$不含第$j-1$个元素)
- 边界
- $f[0][m][0]=1$
容易发现,对于一个状态,只有$O(1)$种可能的转移,我们的复杂度就是状态总量,$O(nm2^m)$
跑 `n=m=18` 无障碍矩阵,耗时 `220ms` ; 跑 `n=m=20` 无障碍矩阵,耗时 `1.1s`。
```cpp
#include<cstring>
#include<cstdio>
#define MaxS 4500
#define mod 100000000
using namespace std;
int n,m,lim,e[15],g[MaxS],f[MaxS],ans;
int main()
{
scanf("%d%d",&n,&m);
lim=(1<<m);
for (int i=1;i<=n;i++)
for (int j=1,c;j<=m;j++){
scanf("%d",&c);
e[i]=e[i]<<1|(!c);
}
g[0]=1;
for (int i=1;i<=n;i++){
memset(f,0,sizeof(int)*(lim+3));
for (int u=0;u<lim;u++)
if (!(u&1))
f[u]=(f[u]+g[u&(u^1)]+g[u|1])%mod;
else if (!(e[i]&1))
f[u]=(f[u]+g[u&(u^1)])%mod;
memcpy(g,f,sizeof(int)*(lim+3));
for (int j=1;j<m;j++){
memset(f,0,sizeof(int)*(lim+3));
for (int u=0;u<lim;u++)
if (!(u&(1<<j)))
f[u]=(f[u]+g[u&(u^(1<<j))]+g[u|(1<<j)])%mod;
else if (!(u&(1<<(j-1)))&&!(e[i]&(1<<j)))
f[u]=(f[u]+g[u&(u^(1<<j))])%mod;
memcpy(g,f,sizeof(int)*(lim+3));
}
}
for (int i=0;i<lim;i++)
ans=(ans+g[i])%mod;
printf("%d",ans);
return 0;
}
```
------------
- **缩小状态集合** + **轮廓线优化**
容易发现,这两个优化可以一并使用。
考虑 $f[i][j][s]$ 有什么样的 $s$ 可以满足。
除了前文的 $O(F_m)$ 个串,还可以在断点 $j$ 处出现 $11$ 。
这样单个 $s$ 的总状态量仍然是 $O(F_m)$ 的。
怎么枚举呢?可以先不考虑断点处的 $11$ ,枚举一次。
然后再枚举一次,如果满足断点相邻两个是 $00$ 就换成 $11$ ,这样每个合法状态只会遍历到一次。
问题在于怎样寻址,也就是说如何找到 $s0$ 和 $s1$ 在上一层中是否合法。
我们可以直接开一个 $2^m$ 的表,带有时间戳,如果时间戳不是上一层的,则认为不合法。
然后滚动一下,避免不必要的内存开销。这样理论复杂度是 $O(nmF_m+2^m)$
实现的时候发现时间戳好难写……就写丑了一点,复杂度是 $O((nm^2+m^3)F_m+2^m)$
跑 `n=m=20` 无障碍矩阵,耗时 `220ms` ; 跑 `n=m=22` 无障碍矩阵,耗时 `670ms`。
```cpp
#include<algorithm>
#include<cstring>
#include<cstdio>
#define MaxS 220000
#define mod 100000000
using namespace std;
int n,m,tn,e[24],z[MaxS],
v0[24][MaxS],v1[24][MaxS],
g[MaxS],f[MaxS];
int main()
{
scanf("%d%d",&n,&m);
for (int s=0;s<(1<<m);s++){
int u=s&(s<<1);u^=u&-u;
if (!u)z[++tn]=s;
}
for (int i=1;i<=tn;i++){
for (int j=0,u,tp;j<m;j++){
u=z[i]&(z[i]^(1<<j));
tp=lower_bound(z+1,z+tn+1,u)-z;
if (z[tp]==u)v0[j][i]=tp;
u=z[i]|(1<<j);
tp=lower_bound(z+tp,z+tn+1,u)-z;
if (z[tp]==u)v1[j][i]=tp;
}
}
for (int i=1;i<=n;i++)
for (int j=1,c;j<=m;j++){
scanf("%d",&c);
e[i]=e[i]<<1|(!c);
}
g[1]=1;
for (int i=1;i<=n;i++){
for (int k=1;k<=tn;k++){
int u=z[k];
if (!(u&1))
f[k]=(g[v0[0][k]]+g[v1[0][k]])%mod;
else if (!(e[i]&1))
f[k]=g[v0[0][k]];
else f[k]=0;
}memcpy(g,f,sizeof(int)*(tn+3));
for (int j=1;j<m;j++){
for (int k=1;k<=tn;k++){
int u=z[k];
if (!(u&(1<<j)))
f[k]=(g[v0[j][k]]+g[v1[j][k]])%mod;
else if (!(u&(1<<(j-1)))&&!(e[i]&(1<<j)))
f[k]=g[v0[j][k]];
else f[k]=0;
}memcpy(g,f,sizeof(int)*(tn+3));
}
}int ans=0;
for (int i=1;i<=tn;i++)
ans=(ans+g[i])%mod;
printf("%d\n",ans);
return 0;
}
```
------------
- [P2704 [NOI2001]炮兵阵地](https://www.luogu.com.cn/problem/P2704)
和上一题很类似,只不过相邻距离增加为2,而且求的是最大值。
$n\leq 100;m\leq 10$
这时我们就要记录两层的状态。
设 $f[i][s1][s2]$ 为第 $i$ 行的状态为 $s1$ ,第 $i+1$ 行状态为 $s2$ 的最优解。
同样有`naive`的枚举目标状态暴力,复杂度 $O(n8^m)$ ,显然过不去。
考虑缩小状态集合,发现在 $m=10$ 的时候只有 $60$ 种状态。
实质上是递推式 $g[n]=g[n-1]+g[n-3]$ 的 $O(g_m)$。
仍然暴力枚举目标状态,复杂度就是 $O(n(g_m)^3)$ ,可过。
```cpp
#include<algorithm>
#include<cstring>
#include<cstdio>
#define MaxS 75
using namespace std;
int cnt(int s){
int ret=0;
while(s){ret++;s^=s&(-s);}
return ret;
}
int n,m,las[MaxS][MaxS],f[MaxS][MaxS],tn,z[MaxS];
void dfs(int l,int s,int sum)
{
if (l>m){z[++tn]=s;return ;}
dfs(l+1,s,sum);
if ((l>2&&s&(1<<l-3))||(l>1&&s&(1<<l-2)))return ;
dfs(l+1,s+(1<<l-1),sum+1);
}
char c[205];
int ms[205];
int main()
{
scanf("%d%d",&n,&m);
for (int i=1;i<=n;i++){
scanf("%s",c);
for (int j=0;j<m;j++)
if (c[j]=='H')
ms[i]|=1<<j;
}dfs(1,0,0);
for (int i=1;i<=n;i++){
memset(f,0,sizeof(f));
for (int j=1;j<=tn;j++)
if (!(z[j]&ms[i])){
int c=cnt(z[j]);
for (int k=1;k<=tn;k++)
if (!(z[k]&z[j])){
for (int p=1;p<=tn;p++)
if (!((z[j]&z[p])||(z[p]&z[k])))
f[k][j]=max(f[k][j],las[p][k]);
f[k][j]+=c;
}
}
memcpy(las,f,sizeof(f));
}int ans=0;
for (int i=1;i<=tn;i++)
for (int j=1;j<=tn;j++)
ans=max(ans,f[i][j]);
printf("%d\n",ans);
return 0;
}
```
这里因为 $g_m$ 较小,枚举子集并不香。
注意到 $s1,s2$ 同样不能有交,可以先预处理可行的 $(s1,s2)$ 对子,可惜并没有多大的优化……
同样可以轮廓线优化,但是优化效果并不显著(少了一个 $g_m$ 多了一个 $n$ ),而且增加了较多常数(理论上至少 $4$ 倍)。
------------
- [P2435 染色](https://www.luogu.com.cn/problem/P2435)
轮廓线DP的专业题目。
**题意** : 有一个$n\times m$的格子,给每个格子染上 $k$ 种颜色中的一种,要求相邻的格子颜色不同。
给定第一行与最后一行的染色,求总染色方案数。
$n\leq100;\ m\leq8;\ k\leq4$
------------
这题毒瘤之处在于,有一个点 $k=2$ 而且 $m\leq 10^6$ ……
对于 $k=2$ 的情况,显然只能每次`xor`,可以直接 $O(m)$ 判断是否合法。
否则就是正常状压DP。
设 $f[i][s]$ 表示考虑到前 $i$ 行,染色状态为 $s$ 的方案数。
这里 $s$ 可以使用 $m$ 位 $k$ 进制数表示。注意这里基础操作不能再使用位运算了,复杂度要乘上 $O(m)$。
暴力枚举目标状态转移,复杂度是 $O(nmk^{2m})$ ,肯定跑不过去。
考虑减少状态量,容易发现,只考虑行的约束时, $s$ 的总量为 $O((k-1)^m)$。
总复杂度变为 $O(nm(k-1)^{2m})$ ,虽然快了不少仍然无法通过。
考虑轮廓线优化,设 $f[i][j][s]$ 表示考虑到前 $i$ 行第 $j$ 列,暴露出来的染色状态为 $s$ 的方案数。
- $j=1$
$f[i-1][m][s']\rightarrow f[i-1][m][s]$,当且仅当$s',s$的第一位相同时不合法.
- 当$j>1$时:
$f[i][j-1][s']\rightarrow f[i][j][s]$
- 边界
- $f[1][m][$初始$]=1$
注意我们只会对单个位进行操作,所以复杂度可以不必额外乘$O(m)$.
复杂度就是$O(nmk^{m+1})$,看起来比较危险,事实上随便去掉一些无用状态(方案数为0)就能跑过去.
```cpp
```
------------
- [P3959 宝藏](https://www.luogu.com.cn/problem/P3959)
认真的子集DP,请自行忽略可以被随机化随便水过的事实……
**题意** : 给出一张带权无向图。
可以钦定一个点作为起点,然后生成一棵树,每条边的代价是到起点的**点数**$\times$**长度**。
问代价最小的生成树。$n\leq 12$ ,时限$\texttt{1s}$
------------
每条边的花费和到起点的点距离有关,我们可以按照`BFS`的方式来DP.
设$f[k][s]$为第$k$轮`BFS`中,已经扩展到的点为$s$的方案数。
每次转移的时候,向外扩展的边默认$k$倍花费,这种决策虽然不能保证每种情况都最优,但是一定不会漏掉最优解。
然后考虑一次性扩展点集$s'$,要计算$s'$到$s$中任意一点距离最小值的和,设为$G[s][s']$。
可以暴力枚举边集计算,复杂度$O(n^2)$,预处理的时候可以使用指针小技巧存储。
方程 : $f[k+1][s]=\max\limits_{s'∩s=\emptyset}f[k][s']+k*G[s][s']$
边界是$f[0][s]=0$ ($s$只含有1个元素)
`DP`的部分是$O(n3^n)$的,瓶颈在预处理的$O(n^23^n)$。
考虑对预处理进行优化,同样可以借用最优子结构。
$G[s][s'+u]=G[s][s']+\min\limits_{v∈s} dis(u,v)$
人话说就是目标集合新增了一个点,起始集合一起来干它。
这样的复杂度就是$O(n3^n)$的了,注意常数。
```cpp
```
另外赠送一个骗分方法。
随机生成一个点的排列,然后按顺序考虑,每次找到最优的边连上。这样的暴力跑一次是$O(n^2)$的。
这样子,总方案数是$n!$,能遇到正解的方案数相当于**遍历序计数**,设为$c$。
每次得到正解的概率是$O(\frac{c}{n!})$,随机$O(\frac{n!}{c})$次则有极大概率得到正解。
总的复杂度就是$O(\frac{n^2n!}{c})$。
对于一棵分叉很多的树,可以感知$c$是相当大的,事实上,$c$最小的就是链形结构。
如果正解是一条链,根在一端,这样$c=1$,我们就挂了。(本题中似乎也很难构造出来?)
事实上,CCF根本没给这种数据,这个骗分随机$50000$次居然直接`AC`了……
------------
- [P5074 Eat the Trees](https://www.luogu.com.cn/problem/P5074)
插头DP入门题。
考虑每个位置能铺成怎样的状态,容易发现只有如下 $6$ 种: (度数为 $2$ 即可)
```cpp
━ ┃ ┛ ┓ ┏ ┗
```
直接枚举一行的状态显然不可承受,考虑填表式轮廓线`DP`.
注意到我们可以只在乎插头,我们用 $2^n$ 记录朝向新区域的插头是否存在。
端点处还有一个侧向插头,需要单独记录。
$f[i][j][s][0/1]$表示填到$i$行$j$列,暴露的插头状态如$s$,侧向插头状态是$0/1$的方案数。
- ① : `━`
当左边**有**插头,上面**没**插头的时候可以填。产生一个向右的插头。
- ② : `┃`
当左边**没**插头,上面**有**插头的时候可以填。产生一个向下的插头。
- ③ : `┛`
当左边和上面都**有**插头的时候可以填。不会产生插头。
- ④ : `┓`
要求同①。产生一个向下的插头。
- ⑤ : `┏`
当左边和上面都**没**插头的时候可以填。会造成向下和向右的插头。
- ⑥ : `┗`
要求同②。会造成向右的插头。
最后一行和右侧是不能留有插头的。
复杂度$O(nm2^n)$。
```cpp
#include<algorithm>
#include<cstring>
#include<cstdio>
#define ll long long
#define MaxN 270500
using namespace std;
ll f[4100][2],g[4100][2];
#define clear(f,n) {\
for (int i=0;i<(1<<n);i++)\
f[i][0]=f[i][1]=0;\
}
int n,m;
void solve()
{
scanf("%d%d",&n,&m);
clear(f,m);clear(g,m);
f[0][0]=1;
int lim=(1<<m);
for (int i=0;i<n;i++)
for (int j=0,e;j<m;j++){
scanf("%d",&e);
if (e==1){
for (int s=0;s<lim;s++){
if (!(s&(1<<j))){
g[s][1]+=f[s][1];
g[s|(1<<j)][0]+=f[s][1];
g[s|(1<<j)][1]+=f[s][0];
}else {
g[s][0]+=f[s][0];
g[s^(1<<j)][1]+=f[s][0];
g[s^(1<<j)][0]+=f[s][1];
}
}
}else {
for (int s=0;s<lim;s++)
if (!(s&(1<<j)))
g[s][0]=f[s][0];
}
if (j+1==m)
for (int s=0;s<lim;s++)
g[s][1]=0;
swap(f,g);
clear(g,m);
}
printf("%lld\n",f[0][0]);
}
int main()
{
int T;scanf("%d",&T);
while(T--)solve();
return 0;
}
```
- [P3272 [SCOI2011]地板](https://www.luogu.com.cn/problem/P3272)
由于我们在从上到下,从左到右填表,对于某个连通块结构,第一次接触时必然在其最靠上靠左的位置。
这样,我们产生的插头方向也就可以确定了,如图。
![](https://cdn.luogu.com.cn/upload/image_hosting/gy8cslpu.png?x-oss-process=image/resize,m_lfit,w_300)
然后,根据这张图来分析,思路会清楚很多。
若只有一种插头,按照 ②,③ 的拐角,插头是可以拐弯的,但是这会使得一个连通块中有多个弯,显然是不兼容的。
我们只好把插头分成两类 : 未拐过的(红色)和拐过的(蓝色)。如图 :
![](https://cdn.luogu.com.cn/upload/image_hosting/xwvqu5dm.png?x-oss-process=image/resize,m_lfit,w_300)
这样,转移就一目了然了。
(注意,我们约定,凡是存在插头,必须将所在联通快向着所对的方向延伸一格)
- 当左边和上面都没插头
- 如①的拐角,产生两个蓝插头。
- 如②的上方起点,产生一个向下的红插头。
- 如③的右侧起点,产生一个向右的红插头。
- 当左边有蓝插头
- 如①横臂的中段,产生向右的蓝插头。
- 如①横臂的末尾,不产生插头。
- 当上面有蓝插头 : 类似
- 当左边有红插头
- 如③的横臂,产生向右的红插头。
- 如③的拐角,产生向下的蓝插头。
- 当上面有红插头 : 类似
- 当左边和上面都有红插头
- 如④的拐角,不产生插头。
设 $r<c$ ,复杂度为 $O(rc3^r)$。
实现中,为了取址方便,可以使用四进制来存储三进制的插头情况。在滚动数组之后,这部分不是空间瓶颈。
[评测记录](https://www.luogu.com.cn/record/36636721)
- [P5056 【模板】插头dp](https://www.luogu.com.cn/problem/P5056)
和 P5074 的主要区别是,本题要求铺成一整个闭合回路。
我们仅仅记录“是否存在插头”,这一信息足够吗?
对于我们记录的四个插头,可能有如下两种情况。
如果我们按照红色的方案来连接,那么第一种情况是合法的,第二种情况则连成两个圈,不合法。
![](https://cdn.luogu.com.cn/upload/image_hosting/av4rz9ah.png)
所以,我们需要记录插头的联通情况,即哪两个插头是相连的。
可以使用最小表示法来标号,然后使用 `hash` 表存储。
这样,由于每次需要将新的最小表示法再次计算并加密,复杂度会多一个 $n$ ,但由于不在常数瓶颈上,往往可以接受。
同时可以发现,插头不会交叉着相连,这样的匹配模式正是括号序列。
但是括号序列的变化就不像最小表示法那样显然了,如果需要追求理论较优的复杂度,可能需要进一步讨论。
当然,暴力重算括号序列也是一种方法。这可以视作以括号序列的方式来 `hash` 的最小表示法。
下面来讨论转移 :
显然,每个格子的度数必须是 $2$。
- 当左边或上面有插头 : 产生向右或向下的同类插头。
- 当左边和上面同时有**异类**插头 : 合并这两个类,不产生插头。
- 没插头 : 产生向右和向下的两个同类插头。
当没有插头时可以作为答案。
```cpp
```
- [P3886 [JLOI2009]神秘的生物](https://www.luogu.com.cn/problem/P3886)
**题意** : 最大权联通块。
由于最后一定要连成一整个联通块,同样需要记录插头的联通情况。
转移 : 每次都可以任意向下或向右产生同类插头。
若只有一个插头进入,合法。
若有两个插头进入,则必须是异类,且会合并这两个类。
注意,如果不产生插头,需要检查一下还有没有剩余其他的同类插头,如果没有,则表示这个联通块结束了。
此时,若还存在异类插头,则不合法。若不存在异类插头,可以向答案贡献,但是不能继续转移。
这里不能使用(经典的)括号序列,只能用最小表示法。
[评测记录] ()
- [P3190 [HNOI2007]神奇游乐园](https://www.luogu.com.cn/problem/P3190)
**题意** : 最大权回路。
显然,每个格子的度数必须是 $2$ 或者 $0$。
- 当左边或上面有插头 : 产生向右或向下的同类插头。
- 当左边和上面同时有异类插头 : 合并这两个类,不产生插头。
- 没插头 : 产生向右和向下的两个同类插头,也可以不产生。
当没有插头时可以作为答案。
[评测记录] ()
- [P4262 [Code+#3]白金元首与莫斯科](https://www.luogu.com.cn/problem/P4262)
**题意** : 给出一个有障碍的矩阵,分别对于每个非障碍点,求出将其变成障碍后,铺 $1\times 2$ 骨牌的方案数(不必铺满)。
先来思考对于一个明确的图,如何计算骨牌铺设方案数。
- 左边或上面有插头 : 不产生插头,骨牌到此为止。
- 无插头 : 可以产生一个向下或向右的插头。
单次 DP 的时间复杂度是 $O(nm2^n)$ 的。
$\rm Subploblem$ : [UVA11270 Tiling Dominoes](https://www.luogu.com.cn/problem/UVA11270)
若枚举障碍格,则复杂度升为 $O(n^2m^22^n)$ ,无法通过。
注意到障碍格只有一个,我们可以正着反着分别做一次 DP,然后合并两个对应的状态,如图:
![](https://cdn.luogu.com.cn/upload/image_hosting/nzn6arqb.png)
插头的位置必须是对应的,像对接一样。
这样,复杂度就是 $O(nm2^n)$ 的了,但是空间较大。
[评测记录] ()
------------
附送一些练习题:
- [P1896 [SCOI2005]互不侵犯](https://www.luogu.com.cn/problem/P1896)
- [P2473 [SCOI2008]奖励关](https://www.luogu.com.cn/problem/P2473)
- [P2150 [NOI2015]寿司晚宴](https://www.luogu.com.cn/problem/P2150)