轮廓线DP(Profile DP)是状态压缩动态规划的高级形式,专门用于解决棋盘类覆盖问题,如骨牌覆盖、铺砖方案计数等。与普通状压DP逐行处理不同,轮廓线DP以”轮廓线”(当前行已处理格子的边界状态)作为状态,通过插头(即格子的连接方向)进行状态转移,大幅减少状态空间。本文用Java实现轮廓线DP求解经典问题——用1×2骨牌覆盖M×N棋盘,计算覆盖方案总数。
一、问题建模
给定一个M行N列的棋盘,用1×2的骨牌(可横放或竖放)不重叠地覆盖所有格子,求覆盖方案总数。
当M,N≤11时,普通状压DP的状态数为2^N,而轮廓线DP通过插头表示可将状态压缩至M+1位的三进制/四进制数,效率更高。
二、插头与轮廓线状态表示
轮廓线是已处理区域与未处理区域的分界线。对于每个轮廓线上的位置,用一个”插头”表示该位置是否与下方/右方格子连接:
- 0:无插头(该位置未与任何格子连接)
- 1:有插头(该位置已向上/左连接,等待右/下补齐)
以逐格处理为例,状态用M+1位的整数表示轮廓线上每个位置的插头类型。
package com.algorithm.profiledp;
import java.util.*;
/**
* 轮廓线DP求解棋盘骨牌覆盖问题
*/
public class ProfileDPDomino {
private final int rows; // 棋盘行数
private final int cols; // 棋盘列数
private final long[][] dp; // dp[i][state] 表示处理到第i格、轮廓线状态为state的方案数
public ProfileDPDomino(int rows, int cols) {
this.rows = rows;
this.cols = cols;
// 状态数最多为 2^(cols+1),实际有效状态远少于此
this.dp = new long[rows * cols + 1][1 << (cols + 1)];
}
/**
* 获取状态s在第pos位的值(0或1)
*/
private int getBit(int state, int pos) {
return (state >> pos) & 1;
}
/**
* 设置状态s在第pos位的值为val(0或1)
*/
private int setBit(int state, int pos, int val) {
if (val == 1) {
return state | (1 << pos);
} else {
return state & ~(1 << pos);
}
}
/**
* 核心求解方法
* 逐格处理棋盘,每个格子(i,j)有两种选择:
* 1. 不放置骨牌(上方必须有插头补齐)
* 2. 竖放骨牌(向下生成插头)
* 3. 横放骨牌(向右生成插头)
*/
public long solve() {
int totalCells = rows * cols;
int stateSize = 1 << (cols + 1);
// 初始状态:没有任何插头
dp[0][0] = 1;
for (int cell = 0; cell < totalCells; cell++) {
int row = cell / cols;
int col = cell % cols;
for (int state = 0; state < stateSize; state++) {
if (dp[cell][state] == 0) continue;
// 提取当前格子上方的插头(从上方延伸下来的)
// 和左方的插头(从左方延伸过来的)
int up = getBit(state, col); // 上方插头(在轮廓线第col位)
int left = getBit(state, cols); // 左方插头(在轮廓线最后一位,即cols位)
// 情况分析:
// 当前格子(i,j)的处理方式取决于up和left
if (up == 1 && left == 1) {
// 上方和左方都有插头,当前格子被覆盖,不生成新插头
// 清除上方和左方插头
int newState = setBit(state, col, 0);
newState = setBit(newState, cols, 0);
dp[cell + 1][newState] += dp[cell][state];
} else if (up == 1 && left == 0) {
// 只有上方有插头,当前格子被上方骨牌覆盖
// 可以选择向右延伸(横放),或结束(不操作)
// 实际上上方有插头意味着上方骨牌占用了当前格子,当前格子已被覆盖
// 清除上方插头,左方插头保持0
int newState = setBit(state, col, 0);
newState = setBit(newState, cols, 0);
dp[cell + 1][newState] += dp[cell][state];
} else if (up == 0 && left == 1) {
// 只有左方有插头,当前格子被左方骨牌覆盖
// 清除左方插头
int newState = setBit(state, col, 0);
newState = setBit(newState, cols, 0);
dp[cell + 1][newState] += dp[cell][state];
} else {
// up == 0 && left == 0,当前格子空闲
// 必须放置一个骨牌覆盖它
// 选择1:竖放骨牌,向下生成插头(设置state的col位为1)
if (row + 1 < rows) {
int newState = setBit(state, col, 1);
newState = setBit(newState, cols, 0);
dp[cell + 1][newState] += dp[cell][state];
}
// 选择2:横放骨牌,向右生成插头(设置state的cols位为1)
if (col + 1 < cols) {
int newState = setBit(state, col, 0);
newState = setBit(newState, cols, 1);
dp[cell + 1][newState] += dp[cell][state];
}
}
}
}
// 最终状态应为全0(所有插头都被闭合)
return dp[totalCells][0];
}
}
三、优化版轮廓线DP(滚动数组)
package com.algorithm.profiledp;
import java.util.*;
/**
* 优化版轮廓线DP:使用滚动数组+哈希表压缩状态
* 适用于更大规模的棋盘
*/
public class OptimizedProfileDP {
private final int rows;
private final int cols;
public OptimizedProfileDP(int rows, int cols) {
this.rows = rows;
this.cols = cols;
}
private int getBit(int state, int pos) {
return (state >> pos) & 1;
}
private int setBit(int state, int pos, int val) {
return val == 1 ? (state | (1 << pos)) : (state & ~(1 << pos));
}
public long solve() {
// 使用两个Map交替作为当前层和下一层,只保存有效状态
Map<Integer, Long> current = new HashMap<>();
current.put(0, 1L);
for (int cell = 0; cell < rows * cols; cell++) {
int row = cell / cols;
int col = cell % cols;
Map<Integer, Long> next = new HashMap<>();
for (Map.Entry<Integer, Long> entry : current.entrySet()) {
int state = entry.getKey();
long count = entry.getValue();
int up = getBit(state, col);
int left = getBit(state, cols);
if (up == 1 && left == 1) {
// 被覆盖,清除两个插头
int ns = setBit(setBit(state, col, 0), cols, 0);
next.merge(ns, count, Long::sum);
} else if (up == 1 && left == 0) {
int ns = setBit(setBit(state, col, 0), cols, 0);
next.merge(ns, count, Long::sum);
} else if (up == 0 && left == 1) {
int ns = setBit(setBit(state, col, 0), cols, 0);
next.merge(ns, count, Long::sum);
} else {
// 竖放:向下生成插头
if (row + 1 < rows) {
int ns = setBit(setBit(state, col, 1), cols, 0);
next.merge(ns, count, Long::sum);
}
// 横放:向右生成插头
if (col + 1 < cols) {
int ns = setBit(setBit(state, col, 0), cols, 1);
next.merge(ns, count, Long::sum);
}
}
}
current = next;
}
return current.getOrDefault(0, 0L);
}
/**
* 主测试方法
*/
public static void main(String[] args) {
System.out.println("轮廓线DP求解骨牌覆盖问题\n");
int[][] testCases = {
{2, 3}, {2, 4}, {3, 4}, {4, 4}, {4, 5}, {5, 6}
};
for (int[] tc : testCases) {
int m = tc[0], n = tc[1];
long start1 = System.nanoTime();
ProfileDPDomino solver1 = new ProfileDPDomino(m, n);
long ans1 = solver1.solve();
long time1 = System.nanoTime() - start1;
long start2 = System.nanoTime();
OptimizedProfileDP solver2 = new OptimizedProfileDP(m, n);
long ans2 = solver2.solve();
long time2 = System.nanoTime() - start2;
System.out.printf("%d×%d 棋盘: 方案数 = %d%n", m, n, ans1);
System.out.printf(" 基础版耗时: %.3f ms | 优化版耗时: %.3f ms%n%n",
time1 / 1_000_000.0, time2 / 1_000_000.0);
}
}
}
四、扩展:插头DP求解多连方块覆盖
轮廓线DP的思想可以推广到更复杂的棋盘覆盖问题。以下展示如何用”括号表示法”的插头DP求解一般性连通块问题(如仓库管理员推箱子路径等)。
package com.algorithm.profiledp;
import java.util.*;
/**
* 插头DP进阶:使用三进制/四进制状态表示多种插头类型
* 求解带障碍物的棋盘覆盖问题
*/
public class AdvancedPlugDP {
private final int rows;
private final int cols;
private final boolean[][] blocked; // true表示障碍物
public AdvancedPlugDP(int rows, int cols, boolean[][] blocked) {
this.rows = rows;
this.cols = cols;
this.blocked = blocked;
}
/**
* 四进制状态表示:每个位置2位
* 0: 无插头 1: 左插头 2: 上插头 3: 右插头(或下插头)
*/
private int getQuad(int state, int pos) {
return (state >> (pos * 2)) & 3;
}
private int setQuad(int state, int pos, int val) {
int mask = ~(3 << (pos * 2));
return (state & mask) | (val << (pos * 2));
}
public long solve() {
Map<Integer, Long> current = new HashMap<>();
current.put(0, 1L);
for (int r = 0; r < rows; r++) {
for (int c = 0; c < cols; c++) {
Map<Integer, Long> next = new HashMap<>();
for (Map.Entry<Integer, Long> entry : current.entrySet()) {
int state = entry.getKey();
long count = entry.getValue();
if (blocked[r][c]) {
// 障碍物位置不能有插头
if (getQuad(state, c) == 0 && getQuad(state, c + 1) == 0) {
next.merge(state, count, Long::sum);
}
continue;
}
int up = getQuad(state, c); // 来自上方的插头
int left = getQuad(state, c + 1); // 来自左方的插头(存储在c+1位)
// 根据up和left的组合进行状态转移
// 此框架可扩展为L型骨牌、T型骨牌等复杂覆盖
// 这里演示标准的1x2骨牌(用四进制简化编码)
if (up == 0 && left == 0) {
// 必须放置骨牌覆盖当前格子
// 竖放:设置上方插头为"下插头"(用2表示)
if (r + 1 < rows && !blocked[r + 1][c]) {
int ns = setQuad(state, c, 2);
ns = setQuad(ns, c + 1, 0);
next.merge(ns, count, Long::sum);
}
// 横放:设置左方插头为"右插头"(用3表示)
if (c + 1 < cols && !blocked[r][c + 1]) {
int ns = setQuad(state, c, 0);
ns = setQuad(ns, c + 1, 3);
next.merge(ns, count, Long::sum);
}
} else if (up != 0 && left == 0) {
// 上方有插头延伸下来,当前格子被覆盖
int ns = setQuad(setQuad(state, c, 0), c + 1, 0);
next.merge(ns, count, Long::sum);
} else if (up == 0 && left != 0) {
// 左方有插头延伸过来,当前格子被覆盖
int ns = setQuad(setQuad(state, c, 0), c + 1, 0);
next.merge(ns, count, Long::sum);
} else {
// 上方和左方都有插头,非法状态(格子被重复覆盖)
// 但在正确的1x2骨牌覆盖中不应出现
}
}
current = next;
}
// 行末:左方插头位(cols)应归零,此处简化处理
// 实际实现需要更精细的状态管理
}
return current.getOrDefault(0, 0L);
}
}
五、复杂度分析
| 算法版本 | 时间复杂度 | 空间复杂度 | 适用规模 |
|---|---|---|---|
| 基础轮廓线DP | O(R×C×2^(C+1)) | O(R×C×2^(C+1)) | C ≤ 10 |
| 滚动数组优化 | O(R×C×S) | O(S) | C ≤ 12 |
| 哈希表压缩 | O(R×C×S_valid) | O(S_valid) | C ≤ 15 |
其中S为有效状态数,实际远小于2^(C+1)。轮廓线DP相比普通状压DP的优势在于状态更紧凑,且天然支持逐格处理障碍物。
六、应用场景
轮廓线DP及插头DP的思想广泛应用于:
– 棋盘覆盖方案计数(骨牌、L型骨牌、多连方块)
– 网格路径问题(哈密顿回路、欧拉路径)
– 电路板布线(连通性约束下的最优路径)
– 推箱子等状态空间搜索问题的解数统计
掌握轮廓线DP的核心在于理解”轮廓线”作为已处理与未处理区域分界面的抽象,以及插头如何编码跨越该分界面的连接关系。