插头DP(轮廓线DP)——拼图游戏中的智慧
困难2插头DP:一格一格铺砖的智慧
你听说过“多米诺骨牌”吗?用1×2的小砖块铺满一个长方形地板,有多少种铺法?这个问题看起来简单,但格子一多,数起来就头疼。这时候,电脑就需要一种聪明的算法——插头DP(也叫轮廓线DP)。它把铺砖过程想象成“一格一格地拼图”,每拼一格,都记录边界上伸出来的“插头”,就像玩接力棒游戏一样,最后所有插头都消失,就成功了。
插头DP是一种用状态压缩(把很多0/1信息打包成一个二进制数)来记录网格边界状态,然后逐格转移的动态规划方法。它不仅能铺砖,还能解决回路、涂色、棋盘覆盖等很多难题。
铺砖接力:生活中的比喻
想象你和朋友一起拼一幅巨大的拼图,拼图是长方形,只有1×2的碎片。你们约定从左到右、从上到下一行一行地拼。每拼一个格子,你不仅要放好当前的碎片,还要记住“手里还抓着什么”——如果竖着放砖块,上半块已经放好,下半块要留给下一行同一列;如果横着放,左边半块已经放好,右边半块要留给右边的格子。这些“伸出去等别人接”的部分,就是插头。所有插头都用0(没伸出去)和1(伸出去)表示,排列起来就是轮廓线。当你拼完最后一个格子,手里什么都没有(所有插头都是0),就表示铺满了。
核心概念:轮廓线、插头、状态压缩
1. 轮廓线是什么?
在处理到某个格子时,我们关心的是:当前格子左边和上边的边界。比如你在拼第2行第3列,你左边是第2行第2列,上边是第1行第3列。这些边界上可能有“伸出的插头”——左边格子可能横放了一个砖块,伸了一个“向右”的插头过来;上边格子可能竖放了一个砖块,伸了一个“向下”的插头过来。所有边界上的插头合在一起,就是轮廓线。因为每一行只有有限列(比如m列),所以轮廓线可以用一串0和1表示,每个位置对应一列。
2. 插头是什么?
每个插头就是一个位:1表示有伸出的砖块(需要和后面的格子连接),0表示没有。例如,轮廓线 0110 表示:第0列没有插头,第1列有插头,第2列有插头,第3列没有插头。
3. 状态压缩是什么?
把轮廓线用二进制数表示,比如 0110 就是二进制数 0110,对应十进制6。这样我们就可以用一个整数 state 来代表整个轮廓线。因为m很小(通常≤10),所以可能的轮廓线数量最多是2^m,我们可以用数组 dp[state] 来存储每种轮廓线对应的方案数。
状态转移:怎么放砖块?
我们一格一格地处理,假设当前格子是第i行第j列(行和列从0开始)。轮廓线 state 的二进制位中,第j位(从低到高)表示当前格子上方是否有向下插头(即上一行同一列是否竖放了下半块)。注意:处理到格子(i,j)时,左边的插头实际上已经被下一列处理掉了?标准写法中,轮廓线通常只记录“向下的插头”,而“向右的插头”会在处理下一个格子时变成左边的插头。为了避免混淆,我们用一个常见的写法:轮廓线存储所有列是否有向下的插头。当处理当前格子时,我们还需要知道左边是否有插头?实际上,左边插头已经在上一个格子处理时变成了当前格子左边一列的向下插头?不对。更简单的做法是:在逐格递推中,我们用一个二维数组 dp[cur][state] 表示当前行处理到第j列时的状态,其中 state 的二进制位表示当前行已经处理过的格子中,有哪些列有向下伸出的插头。但这样解释起来复杂。
对于铺砖问题,有一个经典实现:每处理一个格子,我们看它的上方和左边是否有插头。上方插头直接来自 state 的第j位,左边插头需要额外记录?其实常见写法是:轮廓线中第j位表示上一行同一列向下插头,而左边一列的插头则隐含在状态的其他位中?不,有一个更简洁的方法:用state表示当前行处理到当前列时,所有列(包括当前列左边的列)的向下插头,而当前格子左边的插头就是状态中第j-1位?但j-1位是下一个格子的上方插头,不是当前格子的左方插头。为了便于理解,我们采用另一种更直观的“逐格递推”实现:把轮廓线定义为当前边界上所有向下的插头(即下一行要处理的插头)。当处理格子(i,j)时,我们查看当前格子是否被上方插头覆盖(state 的第j位为1),如果被覆盖,则这个格子已经填好了,我们直接去掉这个插头,转移到新状态;否则,我们可以选择横放或竖放。
这个逻辑对应原完整代码中的转移。我们来详细拆解。
转移规则(假设必须铺满,每个格子都不能空)
-
如果当前格子有上方插头(即
(state>>j)&1为1):说明这个格子已经被上一行竖放的砖块下半块覆盖了,我们不能再放砖块。此时,我们要把这个插头“消掉”,即新状态中第j位变成0(因为已经用掉了)。同时,左边没有插头(因为横放不会从左边伸入?实际上左边插头已经被前一个格子处理了)。所以转移为:ndp[state ^ (1<<j)] += dp[state]。 -
如果当前格子没有上方插头:我们可以选择横放或竖放。
- 横放:需要条件:j+1 < m(右边有格子),并且右边格子当前没有上方插头(因为右边格子还没处理,它的上方插头就是当前state的第j+1位,必须为0,否则横放会冲突)。放完后,会产生一个向右的插头,这个向右插头会变成右边格子的左边插头?但在我们的状态表示中,向右插头实际上会变成下一行同一列的向下插头吗?不是。实际上横放意味着当前格子被覆盖,并且右边格子也会被覆盖(由左边砖块覆盖)。所以当前格子处理完后,新状态中第j位还是0(没有向下插头),但第j+1位需要置1(表示右边格子被这个横放砖块占用了?等等,这里的“插头”是向下插头,横放不会产生向下插头。但为了标记右边格子已经被覆盖,我们需要保证后面处理右边格子时不会再放砖块。实际上,横放砖块直接覆盖了当前格子和右边格子,当处理到右边格子时,它已经被覆盖了,所以我们需要提前“标记”右边格子有一个向左的插头?但常见实现中,横放会产生一个向右的插头,这个向右插头会体现在轮廓线的第j+1位为1?不对,因为轮廓线只记录向下的插头。有很多种实现方式。原代码中,横放的做法是:如果右边没有上方插头,则把新状态的第j+1位设为1。这个1代表什么?其实是代表“右边格子已经被当前格子横放的砖块覆盖了,所以后面处理右边格子时,这个1会作为‘上方有插头’来识别”?不对,因为右边格子的上方插头是来自上一行向下的插头,而不是来自本行。这里的实现实际上是把“向右插头”转换成了“下一行同列的向下插头”?这样做会导致混乱。
其实,原代码中的转移是常见的“逐格递推”写法,但它的状态定义比较特殊:
state的每一位表示当前行当前列之前(包括当前列?)的格子中,哪些列有向下的插头。具体到每个格子,我们不仅考虑上方,还考虑左边。但为了不让学生困惑,我们回到最经典、最清晰的实现:使用“轮廓线DP”的通用方法——逐格递推,状态存储当前轮廓线(即边界上的插头)。边界上的插头包括:当前行已经处理过的格子右边界向下的插头,以及下一行还未处理的格子向上的插头。但解释起来复杂。为了忠于原文,我们保留原代码并给出详细注释,用生活例子辅助理解。原完整代码解决的是
n=4,m=4的铺砖,输出方案数。我们可以在注释中说明状态的含义:state的二进制位中,第j位表示第j列是否有向下伸出的插头(即当前格子这一行是否有一个竖放砖块的下半部分要留给下一行)。当处理到格子(i,j)时:- 如果
state的第j位为1,说明这个格子已经被上一行的竖放砖块覆盖(即上插头),所以只能“接过砖块”,把这一位清零。 - 否则,可以尝试横放:需要j+1<m且
state的第j+1位为0,然后设置第j+1位为1(表示右边格子被横放砖块覆盖了?其实这个1会作为一个“向右插头”,但后面处理右边格子时,会作为“左边有插头”来处理?实际上,这个1会在之后的状态中变成“上方有插头”?唔,这解释不通。
经过思考,原代码中的实现其实是另一种常见的“铺砖问题”的写法:它把轮廓线定义为当前行中哪些列已经放了一个砖块的一半(即向下或向右伸出的插头)。具体地,在逐格处理时,
state表示当前行还没放砖块的那些列中,有哪些列已经有“向下”的插头(来自上一行)。当我们在当前列放了一个横砖时,我们实际上把下一列“标记”为已经放了一半(即向下的插头),这样下一列处理时就会看到有插头,从而跳过。但这样表述不太准确。为了让学生能理解,我们可以放弃纠结于具体实现细节,而是用比喻:想象你手里拿着一根“接线”代表轮廓线。每个格子有四个方向:上、下、左、右。处理格子时,检查上、左是否有线;根据是否放砖,修改上、下、左、右的线。但最终代码里只用了向下插头。
鉴于原文已经给出了完整代码,我们只需补充更多注释和例子即可。下面我会在原代码基础上增加大量中文注释,并解释每一步。
完整代码示例(铺满4×4的地板)
#include <bits/stdc++.h>
using namespace std;
int n = 4, m = 4; // 行数n,列数m
long long dp[2][1<<4]; // dp[滚动][状态],状态表示当前轮廓线(向下插头)
int main() {
int cur = 0; // 当前滚动数组下标
dp[cur][0] = 1; // 开始前没有插头,1种方法
for (int i = 0; i < n; i++) { // 行
for (int j = 0; j < m; j++) { // 列
cur ^= 1; // 切换到下一个数组(滚动)
memset(dp[cur], 0, sizeof(dp[cur])); // 清空
for (int state = 0; state < (1<<m); state++) {
// 遍历所有可能的轮廓线状态
if (dp[cur^1][state] == 0) continue; // 没有方案则跳过
// 检查当前格子是否被上方插头覆盖
if ((state >> j) & 1) {
// 当前格子已经有砖块(从上边竖放而来),直接消掉这个插头
// 新状态:把第j位从1变成0
int new_state = state ^ (1<<j);
dp[cur][new_state] += dp[cur^1][state];
} else {
// 当前格子没有被覆盖,需要放砖块
// 尝试横放:需要右边还有格子,且右边格子当前没有上方插头
if (j + 1 < m && !((state >> (j+1)) & 1)) {
// 横放后,当前格子被覆盖,右边格子被标记(通过设置第j+1位为1)
int new_state = state | (1<<(j+1));
dp[cur][new_state] += dp[cur^1][state];
}
// 尝试竖放:需要下方还有格子(即不是最后一行)
if (i + 1 < n) {
// 竖放后,当前格子被覆盖,下方格子会产生一个向上的插头
// 在我们的状态表示中,向下插头就是第j位
int new_state = state | (1<<j);
dp[cur][new_state] += dp[cur^1][state];
}
}
}
}
// 行结束:换行时不需要额外操作,因为状态中的向下插头会自然成为下一行的上方插头
}
// 最终,所有格子铺满,且没有向下插头,即状态为0
cout << dp[cur][0] << endl; // 输出方案数
return 0;
}
运行结果:对于4×4的地板,输出36。你可以自己试试不同尺寸。
代码解释:
-
dp数组用滚动模式,cur交替表示当前和前一个格子处理完后的结果。 -
状态
state的二进制位中,第0位表示最左边一列,第m-1位表示最右边一列。每一位为1表示该列有一个向下伸出的插头(需要下一行同一列接住)。 -
处理格子(i,j)时,如果
state的第j位为1,说明这个格子已经被上一行竖放的砖块下半部分覆盖了,我们只需要消掉这个插头(变成0),然后直接进入下一格。 -
否则,可以尝试竖放:在当前位置放一个竖砖,它会占用当前格子(i,j)和下一行格子(i+1,j),所以会产生一个向下插头(第j位设为1),等待下一行处理。
-
也可以尝试横放:需要右边格子存在且当前没有来自上方的插头(否则横放会跟上方插头冲突)。横放后,当前格子被覆盖,右边格子也会被覆盖(由这个横砖的右半部分),所以需要设置第j+1位为1,表示右边格子已经被“占用”。注意:这个1实际上并不是向下插头,而是代表“右边格子已经被覆盖”,但当我们处理下一个格子(同一行下一列)时,它的左边已经被覆盖,我们不需要再处理它。在我们的状态表示中,这个1会在下一个格子处理时被视为“上方插头”?不,下一个格子是(j+1),对应的是state的第j+1位,如果这个位是1,那么它会被当作“上方插头”处理,从而跳过。所以横放实际上是把右边格子标记为“已被覆盖”,通过设置一个假的“上方插头”来阻止后续再放砖。这是一种巧妙的技巧,但初学者容易混淆。
因此,理解这个代码的关键是:状态中的每一位,在每一列的处理中,代表的是“这个格子是否已经被上一行或左边横放的砖块覆盖了”。如果被覆盖,我们就不放砖,直接消掉。如果没被覆盖,我们就要放砖(横或竖)。竖放产生向下的覆盖,标记给下一行同一列;横放产生向右的覆盖,标记给同一行下一列(通过设置状态位,下一列处理时会看到这个位而跳过)。
-
换行时不需要额外操作,因为状态中的向下插头自然成为下一行的上方插头。注意:当一行结束时,最后一列的状态位可能还有1(表示最后一列向右的覆盖?不,最后一列不会产生向右的插头,因为右边没格子。所以状态中只会有向下的插头,这些就是下一行的上方插头。
常见错误与调试技巧
-
数组大小不够:m最大为10时,状态数为2^10=1024,数组大小至少
1<<m。如果m更大(如15),状态数会急剧增加,可能内存溢出。插头DP通常适用于m≤10-12。 -
忘记清空滚动数组:每处理一个新格子,必须用
memset清空当前dp[cur],否则会保留上一次的数据。 -
边界判断错误:横放时要确保
j+1 < m,竖放时要确保i+1 < n。否则会越界或产生错误方案。 -
状态位搞混:注意二进制位的顺序,通常第0位对应最左侧列。如果搞反,代码也会运行但结果错误。建议在调试时输出状态值核对。
-
不处理“必须覆盖所有格子”的约束:铺砖问题中每个格子都必须被覆盖,所以不能有“不放砖”的情况。上述代码中,当格子没有被覆盖时,必须选择横放或竖放。如果两个条件都不满足(比如最后一列且最后一行),那么该状态就没有转移,方案数归零,这是合理的。
-
换行时的状态处理:有些实现中,换行时需要将状态左移一位(因为轮廓线移动到下一行)。但上述代码中,由于状态的定义方式,换行后状态不变(向下的插头直接成为下一行的上方插头)。注意不要重复左移。
更多应用与拓展
插头DP不仅能铺1×2的砖块,还能解决:
- 铺L形砖块:修改转移规则即可。
- 哈密顿回路:在一个网格图中找一条经过每个格子一次且回到起点的路径(如“骑士周游”的变种)。需要用括号匹配法表示插头的连接关系。
- 涂色问题:给网格涂色,相邻不能同色等。
- 植物大战僵尸:某些游戏中的路径规划也可以用插头DP。
如果你想进一步学习,可以搜索“插头DP 轮廓线DP 入门”,或者阅读《算法竞赛进阶指南》中的相关章节。理解了基础铺砖问题后,试着写一个解决m=3, n=3的铺砖问题(输出方案数),看看是不是3种?动手试试吧!
小结
插头DP就像一场精密的拼图接力赛。我们用一串0/1的“插头”记录边界,一格一格地决策,最终所有插头都消失时,就得到了一个完整的铺法。虽然代码看起来有点复杂,但只要理解轮廓线的含义和转移规则,你就能解决很多有趣的棋盘覆盖问题。现在,拿起键盘,尝试写一个自己的铺砖计数器吧!
例题精讲
在插头DP的括号表示法中,通常使用三进制编码(0表示无插头,1表示左括号,2表示右括号)来表示轮廓线上的每个插头状态。请问每个插头状态需要用几个比特位编码?
在插头DP处理回路计数问题时,当所有格子处理完毕后,轮廓线状态必须为0(即所有位置无插头)才表示形成了一条合法回路。
在插头DP中,状态压缩使用每2位表示一个插头。当前处理列号为j(从0开始),轮廓线上左插头位于第j个位置,上插头位于第j+1个位置。已知提取左插头的代码为:int left = (state >> (j*2)) & 3; 请补全提取上插头的代码:int up = ___;