引言
状态压缩动态规划(State Compression DP)是处理棋盘类组合问题的经典方法,但当问题涉及连通性约束时,普通的状态压缩往往力不从心。例如:棋盘骨牌覆盖要求骨牌之间不重叠且完全覆盖;哈密顿回路要求路径经过每个格子恰好一次且形成闭合回路。这类问题需要维护轮廓线上的连通性信息,而插头DP(Plug DP / Transfer-Matrix Method with Connectivity)正是解决这类问题的利器。本文将用Java完整实现插头DP框架,并以”m x n棋盘用1×2骨牌完全覆盖的方案数”为例,深入浅出地讲解这一高级技巧。
核心概念
轮廓线
插头DP采用逐格递推的方式处理棋盘。在任意时刻,轮廓线是指已处理区域与未处理区域之间的边界。对于逐行从左到右的递推顺序,轮廓线就是当前格子上方和左方的边界。
插头
插头表示轮廓线上某个位置的连通状态。对于骨牌覆盖问题,一个插头表示该位置有一条骨牌的”半边”伸出到未处理区域。每个轮廓线位置可能有0个或1个插头。
状态编码
使用三进制或四进制对轮廓线状态进行编码:
– 0:该位置无插头
– 1/2/3…:该位置有插头,数字表示连通分量编号
对于骨牌覆盖这种只有”有无”两种状态的问题,可以用二进制压缩;对于需要维护连通性的问题,则需要更高进制的编码。
算法原理
以m x n棋盘骨牌覆盖为例,逐格处理每个位置(i, j):
状态转移
对于当前格子,根据上方和左方是否有插头,共有四种情况:
- 上方有插头,左方无插头:当前格必须与下方格子配对,向下放置骨牌
- 上方无插头,左方有插头:当前格必须与右方格子配对,向右放置骨牌
- 上方和左方都有插头:当前格是骨牌的拐角,连接上方和左方的骨牌(仅适用于L型骨牌)
- 上方和左方都无插头:当前格作为新骨牌的起点,可以选择向右或向下放置
对于1×2直线骨牌,情况3不适用。状态转移简化为:
- 若上方有插头:必须向下延伸,在当前格创建向下插头
- 若左方有插头:必须向右延伸,在当前格创建向右插头
- 若都没有:可以选择向右或向下放置新骨牌
- 若都有:非法状态(1×2骨牌无法形成拐角)
Java完整实现
import java.util.*;
/**
* 插头DP求解m x n棋盘1x2骨牌完全覆盖方案数
* 使用状态压缩,逐格递推
*/
public class PlugDP {
// 骨牌覆盖:每个状态用位掩码表示轮廓线上的插头分布
// 对于逐行递推,轮廓线长度为n(当前行的列数)
// 状态中的第j位表示第j列位置是否有向下的插头
private final int rows;
private final int cols;
public PlugDP(int rows, int cols) {
this.rows = rows;
this.cols = cols;
}
/**
* 计算骨牌完全覆盖方案数
* 使用逐行状态压缩DP
*/
public long countTilings() {
// 如果总格子数为奇数,不可能完全覆盖
if ((rows * cols) % 2 == 1) {
return 0;
}
// dp[state] = 当前轮廓线状态为state时的方案数
// 状态用位掩码表示,第j位为1表示第j列有一个向下的插头
Map<Integer, Long> dp = new HashMap<>();
dp.put(0, 1L); // 初始状态:没有任何插头
for (int i = 0; i < rows; i++) {
for (int j = 0; j < cols; j++) {
Map<Integer, Long> newDp = new HashMap<>();
for (Map.Entry<Integer, Long> entry : dp.entrySet()) {
int state = entry.getKey();
long count = entry.getValue();
// 提取当前位置上方和左方的插头信息
boolean up = ((state >> j) & 1) == 1; // 上方是否有插头
boolean left = (j > 0) && ((state >> (j - 1)) & 1) == 1; // 左方是否有插头
// 根据上方和左方的插头情况进行状态转移
if (up && left) {
// 上方和左方都有插头:对于1x2骨牌,这是非法状态
continue;
} else if (up) {
// 上方有插头,左方无插头:必须向下延伸骨牌
int newState = state & ~(1 << j);
newDp.merge(newState, count, Long::sum);
} else if (left) {
// 左方有插头,上方无插头:必须向右延伸骨牌
int newState = state & ~(1 << (j - 1));
newDp.merge(newState, count, Long::sum);
} else {
// 上方和左方都没有插头:可以选择放置新骨牌
// 选项1:放置竖直骨牌(向下延伸)
int newState1 = state | (1 << j);
newDp.merge(newState1, count, Long::sum);
// 选项2:放置水平骨牌(向右延伸)
if (j < cols - 1) {
int newState2 = state | (1 << j);
newDp.merge(newState2, count, Long::sum);
}
}
}
dp = newDp;
}
}
// 最终状态应该没有任何未闭合的插头
return dp.getOrDefault(0, 0L);
}
public static void main(String[] args) {
// 测试案例1:2x3棋盘的骨牌覆盖
PlugDP dp1 = new PlugDP(2, 3);
System.out.println("2x3棋盘骨牌覆盖方案数: " + dp1.countTilings()); // 期望: 3
// 测试案例2:2x4棋盘的骨牌覆盖
PlugDP dp2 = new PlugDP(2, 4);
System.out.println("2x4棋盘骨牌覆盖方案数: " + dp2.countTilings()); // 期望: 5
// 测试案例3:3x4棋盘的骨牌覆盖
PlugDP dp3 = new PlugDP(3, 4);
System.out.println("3x4棋盘骨牌覆盖方案数: " + dp3.countTilings()); // 期望: 11
}
}
算法说明
上述基础版实现中,骨牌覆盖的状态转移逻辑基于以下原则:
对于1×2直线骨牌,逐格处理时每个格子只能是以下四种情况之一:
- 被上方骨牌覆盖:上方有向下插头,当前格承接该插头
- 被左方骨牌覆盖:左方有向右插头,当前格承接该插头
- 作为竖直骨牌起点:当前格开始一个向下延伸的骨牌
- 作为水平骨牌起点:当前格开始一个向右延伸的骨牌
状态用二进制表示,第j位表示第j列是否有”未闭合的向下插头”。
复杂度分析
| 指标 | 基础版(骨牌覆盖) | 高级版(连通性追踪) |
|---|---|---|
| 状态数 | O(2^n) | O(3^n x n) |
| 时间复杂度 | O(m x n x 2^n) | O(m x n x 3^n x n) |
| 空间复杂度 | O(2^n) | O(3^n x n) |
| 适用n | n <= 20 | n <= 12 |
对于骨牌覆盖问题,由于只需记录”有无插头”,状态空间为2^n;对于哈密顿回路等需要连通性信息的问题,状态空间膨胀到3^n。
应用场景
插头DP可以解决多种棋盘组合问题:
- 骨牌/多格骨牌覆盖:计算完全覆盖方案数
- 哈密顿路径/回路:经过每个格子恰好一次的路径计数
- 连通块计数:特定形状的连通块数量统计
- 管道连接:所有格子通过管道连通且形成特定拓扑的方案数
- 电路板布线:满足约束的布线方案计数
与其他算法的对比
| 方法 | 适用问题 | 时间复杂度 | 空间限制 |
|---|---|---|---|
| 普通状态压缩DP | 无连通性约束 | O(m x n x 2^n) | n <= 20 |
| 插头DP | 有连通性约束 | O(m x n x 3^n) | n <= 12 |
| 矩阵快速幂 | 递推关系明确 | O(2^(3n) log m) | n <= 8 |
| 容斥原理 | 小规模精确计数 | O(2^n x poly) | n <= 20 |
总结
插头DP是状态压缩动态规划的高级形态,其核心思想是在逐格递推的过程中维护轮廓线上的连通性信息。虽然状态空间较大,但对于许多棋盘类组合问题,它是已知的最优解法。掌握插头DP后,读者可以进一步探索:
- 轮廓线DP的优化:使用哈希表压缩状态空间,或利用矩阵快速幂加速行间转移
- 连通性最小表示:使用括号序列代替数字标签,进一步压缩状态
- 广义插头DP:处理更复杂的连通性约束,如双连通、k连通等
插头DP的学习曲线较陡,但一旦掌握,它将成为解决棋盘组合问题的利器。