插头 dp
1 引入
插头 dp 是一种基于连通性状态压缩的动态规划,这一类问题要求我们记录元素的联通状况,例如在棋盘上走出一条回路等。此时朴素的状压 dp 难以处理,所以需要引入插头 dp 帮助我们求解。
开始前需要了解两个基本概念:
- 轮廓线:已决策状态和未决策状态的分界线。
- 插头:对于四连通的网格来讲,每一个格子有上下左右四个插头,一个插头存在表示这个格子在这个方向与相邻格子相连。
2 多条回路问题
在了解上述基本概念之后,实际上我们就可以直接来看一下多条回路的问题了。尽管实际操作中并没有利用连通性维护信息,但是有助于我们先一步了解插头 dp 的基本转移模式。
2.1 状态转移
首先轮廓线的长度一定是
- 若
,则这里不能有插头。判断之后转移即可。 - 若
,则当前格子没有上插头和左插头,为了使这个格子被覆盖,它必然要有下插头和右插头。 - 若
,则当前格子只有左插头,那么它可以有右插头或者有下插头。 - 若
,则当前格子只有上插头,那么它可以有右插头或者有下插头。 - 若
,则当前格子有上插头和左插头,此时它们已经构成了路径,所以它不会再有下插头或右插头了。
接下来考虑行间转移,显然结束状态是纵向分界在最右侧边界上,于是要保证它没有插头。删掉这个分界后加入下一行最开头的分界线即可。最后答案为
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 最小表示法
最小表示法可以用于表示插头构成的联通块信息。具体来讲,我们对每一个联通块标号,每一个联通块的标号就是当前已有联通块个数加一,并对所有属于该联通块的点均标记为该编号。
例如对于某个轮廓线,其上插头所属联通块序列为
3.1.2 括号表示法
括号表示法可以表示插头构成的回路或路径信息。对于回路而言,轮廓线上的部分一定构成了若干个互不相交的路径。那么我们就可以得出,对于任意联通块,恰有两个插头属于这个联通块(因为路径的两个端点都是插头);并且由于路径互不相交,所以如果我们将每一条路径对应的两个插头视作一个区间,那么所有路径构成的区间之间只可能相离或包含,不可能相交。
根据上面两个性质,我们不难想到维护方式了——括号序列。对于一个联通块的两个端点,分别将其看作左括号和右括号,分别记为
注意只有回路或路径才有上面的性质,可以使用括号表示法;而联通块没有上述性质,只能采用最小表示法。
3.2 状态转移
此题的状态转移与 2.1 中的转移讨论方式基本一致,但是我们需要额外讨论
这里我们重新强调,原先的插头状态是
-
不允许铺线,那么只有 时可以转移,此时 。 -
时,为了满足经过每一个点的要求,必须新建两个插头。于是 。 -
时,只能有下插头或右插头,并且这个插头应该作为原先 状态的延伸,所以 或者 。 -
时,同上。 -
时,我们只能合并这两个插头,所以 。但是此时我们还要根据 具体的值做分类讨论。-
时,这两个插头合并会形成回路。对于一条回路的题目来讲,这种转移只能在 处进行,标志着形成了回路。 -
时,两个联通块被合并成了一个联通块,除了改动 以外没有改动。 -
时,由于我们将两个联通块的左端点合并在了一起,那么原先 对应的右端点会变成新联通块的左端点,所以我们需要找到 对应的右端点,将其修改为一个左端点。至于如何找到这个位置,直接暴力向右枚举并记录左端点和右端点个数,相等时即找到匹配端点。
-
时,同上。
-
于是我们按此进行转移即可。
3.3 状态存储
采用括号表示法的好处在于它的状态好表示,发现所有状态只有
但是发现采用四进制后我们的状态总数会来到
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 型意味着必须要有转折,于是设计三个插头状态:
:无插头。 :当前插头还没有转折。 :当前插头已经转折。
接下来分类讨论转移即可。转移并不困难,留给读者自行完成。
例 2 [CQOI2015] 标识设计
发现此题与上一题很像,不过此时的 L 型图案不能旋转,所以转移的时候要注意对方向的讨论。
但是这道题的重点不在这里,我们发现此题中
如此可以大大减少枚举量,可以通过。
例 3 [BZOJ2310] ParkII
题意: 从方格中选出一条路径,使路径上数字之和最大。
此时我们选的不是回路,而是路径,因此必然会有两个单独的端点。并且会发现与端点联通的插头是不会和任何另外的插头构成联通块的。所以除了回路中设计的
然后分类讨论数量就从
例 4 [WC2008] 游览计划
这道题就不是路径或者回路,而是联通块了,所以只能用最小表示法。
关于最小表示法有这样一件事:由于一行最多
然后就是转移了,我们依然对
- 当
时,我们可能会新建一个联通块。此时我们直接将这两个位置赋成 ,表示它们位于一个联通块,也不会和其他编号重复。接下来我们需要实现一个函数,对当前的序列重构最小表示,然后重构出的序列就是当前状态了。 - 当
时,我们可能会将两个联通块合并起来。这个时候我们遍历当前序列,将所有的 改成 ,就可以将两者合并。然后再跑一边上面的重构函数就可以得到当前状态的最小表示了。
剩下的部分就比较简单了,留给读者自行完成。和例 3 一样的,我们也需要进行剪枝,如果当前状态中联通块数量小于记录的联通块数量且后者不为
由于本题还需要输出最优方案,所以我们还需要另开一个哈希表存储当前状态的最优解是从上一个位置的哪个状态转移而来,以及最优解情况下这个点需不需要放,然后反推一遍就可以得到最优方案了。
【推荐】国内首个AI IDE,深度理解中文开发场景,立即下载体验Trae
【推荐】编程新体验,更懂你的AI,立即体验豆包MarsCode编程助手
【推荐】抖音旗下AI助手豆包,你的智能百科全书,全免费不限次数
【推荐】轻量又高性能的 SSH 工具 IShell:AI 加持,快人一步
· 震惊!C++程序真的从main开始吗?99%的程序员都答错了
· 【硬核科普】Trae如何「偷看」你的代码?零基础破解AI编程运行原理
· 单元测试从入门到精通
· 上周热点回顾(3.3-3.9)
· winform 绘制太阳,地球,月球 运作规律