1 引入
插头 dp 是一种基于连通性状态压缩的动态规划,这一类问题要求我们记录元素的联通状况,例如在棋盘上走出一条回路等。此时朴素的状压 dp 难以处理,所以需要引入插头 dp 帮助我们求解。
开始前需要了解两个基本概念:
- 轮廓线:已决策状态和未决策状态的分界线。
- 插头:对于四连通的网格来讲,每一个格子有上下左右四个插头,一个插头存在表示这个格子在这个方向与相邻格子相连。
2 多条回路问题
在了解上述基本概念之后,实际上我们就可以直接来看一下多条回路的问题了。尽管实际操作中并没有利用连通性维护信息,但是有助于我们先一步了解插头 dp 的基本转移模式。
2.1 状态转移
首先轮廓线的长度一定是 \(m+1\),包含 \(m\) 个横向分界和 \(1\) 个纵向分界。对于这 \(m+1\) 条分界线,我们只需要维护它们上面有无插头即可。令 \(dp(i,j,S)\) 表示考虑完格子 \((i,j)\),轮廓线状态为 \(S\) 的方案数。不难发现,与当前格子有关的两个插头分别位于第 \(j,j+1\) 位上,记其值分别为 \(x,y\)。然后进行分类讨论:
- 若 \(a_{i,j}=0\),则这里不能有插头。判断之后转移即可。
- 若 \(x=y=0\),则当前格子没有上插头和左插头,为了使这个格子被覆盖,它必然要有下插头和右插头。
- 若 \(x=1,y=0\),则当前格子只有左插头,那么它可以有右插头或者有下插头。
- 若 \(x=0,y=1\),则当前格子只有上插头,那么它可以有右插头或者有下插头。
- 若 \(x=y=1\),则当前格子有上插头和左插头,此时它们已经构成了路径,所以它不会再有下插头或右插头了。
接下来考虑行间转移,显然结束状态是纵向分界在最右侧边界上,于是要保证它没有插头。删掉这个分界后加入下一行最开头的分界线即可。最后答案为 \(f(n,m,0)\)。
2.2 代码
#include <bits/stdc++.h>
#define il inline
#define int long long
using namespace std;
const int Maxn = 2e5 + 5;
const int Inf = 2e9;
int T;
int n, m, a[15][15];
int dp[15][15][1 << 12];
void solve() {
cin >> n >> m;
for(int i = 1; i <= n; i++) {
for(int j = 1; j <= m; j++) {
cin >> a[i][j];
}
}
int M = (1 << (m + 1)) - 1;
for(int i = 1; i <= n; i++) {
for(int j = 0; j <= m; j++) {
for(int k = 0; k <= M; k++) dp[i][j][k] = 0;
}
}
dp[1][0][0] = 1;
for(int i = 1; i <= n; i++) {
for(int j = 1; j <= m; j++) {
for(int k = 0; k <= M; k++) {
int x = (k >> (j - 1)) & 1, y = (k >> j) & 1;
if(!a[i][j]) {
if(!x && !y) dp[i][j][k] += dp[i][j - 1][k];
}
else {
if(x + y == 1) {
//可以通过位运算简化分类讨论
dp[i][j][k ^ (3 << (j - 1))] += dp[i][j - 1][k];
dp[i][j][k] += dp[i][j - 1][k];
}
else {
dp[i][j][k ^ (3 << (j - 1))] += dp[i][j - 1][k];
}
}
}
}
for(int k = 0; k < (1 << m); k++) {
dp[i + 1][0][k << 1] += dp[i][m][k];//向下一行转移
}
}
cout << dp[n][m][0] << '\n';
}
signed main() {
ios::sync_with_stdio(0);
cin.tie(0), cout.tie(0);
cin >> T;
while(T--) solve();
return 0;
}
实际中还可以通过滚动数组优化掉前两维,此处不做赘述。
3 一条回路问题
上面的题目并没有涉及插头 dp 的维护连通性这个要点,接下来我们来看真正的模板题。
例题:【模板】插头 DP。
由于此时我们需要区分插头之间的连通性,所以仅仅记录轮廓线上是否存在插头已经不够用了。所以我们需要新的表示方法来记录轮廓线状态,然后再进行转移。
3.1 状态编码
3.1.1 最小表示法
最小表示法可以用于表示插头构成的联通块信息。具体来讲,我们对每一个联通块标号,每一个联通块的标号就是当前已有联通块个数加一,并对所有属于该联通块的点均标记为该编号。
例如对于某个轮廓线,其上插头所属联通块序列为 \((2,1,2,1,3,2,3)\),我们的最小表示为 \((1,2,1,2,3,1,3)\)。值得注意的是如果有插头没有所属联通块记为 \(0\)。
3.1.2 括号表示法
括号表示法可以表示插头构成的回路或路径信息。对于回路而言,轮廓线上的部分一定构成了若干个互不相交的路径。那么我们就可以得出,对于任意联通块,恰有两个插头属于这个联通块(因为路径的两个端点都是插头);并且由于路径互不相交,所以如果我们将每一条路径对应的两个插头视作一个区间,那么所有路径构成的区间之间只可能相离或包含,不可能相交。
根据上面两个性质,我们不难想到维护方式了——括号序列。对于一个联通块的两个端点,分别将其看作左括号和右括号,分别记为 \(1,2\),那么合法状态一定对应一个合法的括号序列。对于没有插头的点依然看作 \(0\)。
注意只有回路或路径才有上面的性质,可以使用括号表示法;而联通块没有上述性质,只能采用最小表示法。
3.2 状态转移
此题的状态转移与 2.1 中的转移讨论方式基本一致,但是我们需要额外讨论 \(x,y\) 具体的值以满足连通性要求。此处我们采用括号表示法来描述状态。
这里我们重新强调,原先的插头状态是 \(x,y\),假如我们转移后该节点下插头和右插头状态为 \(p,q\),那么实际上每次转移的时候就是将 \(x,y\) 改成 \(p,q\)。
-
\((i,j)\) 不允许铺线,那么只有 \(x=y=0\) 时可以转移,此时 \(p=q=0\)。
-
\(x=y=0\) 时,为了满足经过每一个点的要求,必须新建两个插头。于是 \(p=1,q=2\)。
-
\(x\ne 0,y=0\) 时,只能有下插头或右插头,并且这个插头应该作为原先 \(x\) 状态的延伸,所以 \(p=x,q=0\) 或者 \(p=0,q=x\)。
-
\(x=0,y\ne 0\) 时,同上。
-
\(x\ne 0,y\ne 0\) 时,我们只能合并这两个插头,所以 \(p=q=0\)。但是此时我们还要根据 \(x,y\) 具体的值做分类讨论。
-
\(x=1,y=2\) 时,这两个插头合并会形成回路。对于一条回路的题目来讲,这种转移只能在 \((n,m)\) 处进行,标志着形成了回路。
-
\(x=2,y=1\) 时,两个联通块被合并成了一个联通块,除了改动 \(x,y\) 以外没有改动。
-
\(x=1,y=1\) 时,由于我们将两个联通块的左端点合并在了一起,那么原先 \(y\) 对应的右端点会变成新联通块的左端点,所以我们需要找到 \(y\) 对应的右端点,将其修改为一个左端点。
至于如何找到这个位置,直接暴力向右枚举并记录左端点和右端点个数,相等时即找到匹配端点。
-
\(x=2,y=2\) 时,同上。
-
于是我们按此进行转移即可。
3.3 状态存储
采用括号表示法的好处在于它的状态好表示,发现所有状态只有 \(0,1,2\) 三种,所以可以用三进制数表示。不过实际中我们多采用四进制数来表示,这样的好处是我们可以用二进制数来表示(两位算一个数),然后就可以用位运算了。
但是发现采用四进制后我们的状态总数会来到 \(2^{24}\),直接枚举显然不可行。考虑到括号序列的特殊性,我们并不会有很多的合法状态,所以考虑利用哈希表来存储所有 dp 值。然后你就发现你必须要写滚动数组了:)
3.4 代码
#include <bits/stdc++.h>
#define il inline
#define int long long
using namespace std;
const int Maxn = 2e5 + 5;
const int Inf = 2e9;
const int P = 9973;
int n, m;
int a[15][15];
int ex, ey;
struct HashTable {
int head[P], nxt[Maxn], tot;
int st[Maxn], dp[Maxn];
il void clear() {
tot = 0;
for(int i = 0; i < P; i++) head[i] = 0;
}
il void ins(int sta, int val) {
int id = sta % P;
for(int i = head[id]; i; i = nxt[i]) {
if(st[i] == sta) {
dp[i] += val;
return ;
}
}
nxt[++tot] = head[id], st[tot] = sta, dp[tot] = val;
head[id] = tot;
}
}f[2];
il int getw(int sta, int i) {//找出状态中第 i 位
return (sta >> ((i - 1) << 1)) & 3;
}
il int setw(int sta, int i, int w) {//将状态中第 i 位改为 w
return sta ^ (getw(sta, i) << ((i - 1) << 1)) ^ (w << ((i - 1) << 1));
}
il int getr(int sta, int i) {//找到匹配右端点
int res = 0;
for(int p = i; p <= m + 1; p++) {
int ret = getw(sta, p);
if(ret == 1) res++;
else if(ret == 2) res--;
if(!res) return p;
}
return -1;
}
il int getl(int sta, int i) {//找到匹配左端点
int res = 0;
for(int p = i; p >= 1; p--) {
int ret = getw(sta, p);
if(ret == 2) res++;
else if(ret == 1) res--;
if(!res) return p;
}
return -1;
}
signed main() {
ios::sync_with_stdio(0);
cin.tie(0), cout.tie(0);
cin >> n >> m;
for(int i = 1; i <= n; i++) {
string s; cin >> s;
for(int j = 1; j <= m; j++) {
a[i][j] = (s[j - 1] == '.' ? 1 : 0);
if(a[i][j]) ex = i, ey = j;
}
}
int pre = 0, now = 1;
f[pre].ins(0, 1);
int ans = 0;
for(int i = 1; i <= n; i++) {
for(int j = 1; j <= m; j++) {
for(int k = 1; k <= f[pre].tot; k++) {
int sta = f[pre].st[k], val = f[pre].dp[k];
int x = getw(sta, j), y = getw(sta, j + 1);
if(!a[i][j]) {
if(!x && !y) f[now].ins(sta, val);
}
else {
if(!x && !y) {
int nst = setw(setw(sta, j, 1), j + 1, 2);
f[now].ins(nst, val);
}
else if(!x || !y) {
f[now].ins(sta, val);
int nst = setw(setw(sta, j, y), j + 1, x);
f[now].ins(nst, val);
}
else {
int nst = setw(setw(sta, j, 0), j + 1, 0);
if(x == 1 && y == 2) {
if(i == ex && j == ey && nst == 0) ans += val;
//注意统计答案的时候要在最后一个可以经过的格子统计
}
else if(x == 2 && y == 1) {
f[now].ins(nst, val);
}
else if(x == 1 && y == 1) {
int pos = getr(sta, j + 1);
nst = setw(nst, pos, 1);
f[now].ins(nst, val);
}
else if(x == 2 && y == 2) {
int pos = getl(sta, j);
nst = setw(nst, pos, 2);
f[now].ins(nst, val);
}
}
}
}
swap(pre, now);
f[now].clear();
}
for(int k = 1; k <= f[pre].tot; k++) {
int sta = f[pre].st[k], val = f[pre].dp[k];
if(getw(sta, m + 1) == 0) f[now].ins(sta << 2, val);
}
swap(pre, now);
f[now].clear();
}
cout << ans << '\n';
return 0;
}
4 例题
插头 dp 的关键点在于设计出插头状态,并且分类讨论出所有可能的转移。下面举几例说明如何设计插头状态和转移。
例 1 [SCOI2011] 地板
这道题要求的覆盖条件必须是 L 型。考虑到一个 L 型意味着必须要有转折,于是设计三个插头状态:
- \(0\):无插头。
- \(1\):当前插头还没有转折。
- \(2\):当前插头已经转折。
接下来分类讨论转移即可。转移并不困难,留给读者自行完成。
例 2 [CQOI2015] 标识设计
发现此题与上一题很像,不过此时的 L 型图案不能旋转,所以转移的时候要注意对方向的讨论。
但是这道题的重点不在这里,我们发现此题中 \(n,m\le 30\),如果暴力存储所有状态而不加剪枝的话是会爆炸的。所以我们需要利用当前新建的 L 型个数来进行判断(注意不是已经形成的个数),当其数量等于 \(3\) 的时候就不能再新建一个 L 型了。
如此可以大大减少枚举量,可以通过。
例 3 [BZOJ2310] ParkII
题意: 从方格中选出一条路径,使路径上数字之和最大。
此时我们选的不是回路,而是路径,因此必然会有两个单独的端点。并且会发现与端点联通的插头是不会和任何另外的插头构成联通块的。所以除了回路中设计的 \(0,1,2\) 三个插头状态之外,我们还需要一个插头状态 \(3\),用于表示该插头与路径端点联通。
然后分类讨论数量就从 \(3\times 3\) 变成了 \(4\times 4\) 种,所以讨论的时候一定要足够细致。另一个问题在于由于是一条路径,所以端点个数只能有两个,因此必须要记录当前的端点个数然后再转移。
例 4 [WC2008] 游览计划
这道题就不是路径或者回路,而是联通块了,所以只能用最小表示法。
关于最小表示法有这样一件事:由于一行最多 \(10\) 个格子,所以一行上最多会有 \(5\) 个不同的联通块,所以我们只需要用八进制来存储最小表示法的状态即可。并且由于此时求的是联通块,所以把轮廓线放在网格线上其实是浪费了一个位置的(就是纵向分界的那一条边,显然它一定等于前一个横向分界),于是可以将轮廓线放到网格上处理。不过我还是更习惯放到网格线上处理。
然后就是转移了,我们依然对 \(x,y\) 做分类讨论。这里着重说明新建联通块和合并联通块的过程。
- 当 \(x=y=0\) 时,我们可能会新建一个联通块。此时我们直接将这两个位置赋成 \(7\),表示它们位于一个联通块,也不会和其他编号重复。接下来我们需要实现一个函数,对当前的序列重构最小表示,然后重构出的序列就是当前状态了。
- 当 \(x\ne 0,y\ne 0,x\ne y\) 时,我们可能会将两个联通块合并起来。这个时候我们遍历当前序列,将所有的 \(x\) 改成 \(y\),就可以将两者合并。然后再跑一边上面的重构函数就可以得到当前状态的最小表示了。
剩下的部分就比较简单了,留给读者自行完成。和例 3 一样的,我们也需要进行剪枝,如果当前状态中联通块数量小于记录的联通块数量且后者不为 \(1\),那么说明上面一定有一个不和其他联通块联通的联通块,此时就不该转移。
由于本题还需要输出最优方案,所以我们还需要另开一个哈希表存储当前状态的最优解是从上一个位置的哪个状态转移而来,以及最优解情况下这个点需不需要放,然后反推一遍就可以得到最优方案了。
标签:插头,状态,联通,int,端点,dp From: https://www.cnblogs.com/UKE-Automation/p/18680816