Harris
发布于 2026-03-24 / 12 阅读
0
0

轮廓线 DP:网格图上的状态压缩艺术

轮廓线 DP (Broken Profile DP):网格图上的状态压缩艺术


在处理棋盘、网格图的覆盖问题时(例如骨牌铺满问题、哈密顿路径计数),我们经常会想到状态压缩 DP。传统的状压 DP 通常是“逐行转移”的:用一个二进制数表示第 $i$ 行的状态,推导第 $i+1$ 行。但逐行转移的痛点在于:两行状态之间的兼容性判定往往需要极其复杂的嵌套循环或 DFS 预处理,时间复杂度通常高达 $O(4^M N)$。


轮廓线 DP (Broken Profile DP) 提供了一种更优雅的降维视角:我们不再“逐行”扫描,而是“逐格”扫描。我们维护一条分隔“已处理区域”和“未处理区域”的折线(轮廓线),极大地简化了状态转移逻辑。

一、 核心概念:什么是“轮廓线”?


想象我们正在逐行逐列(从左到右,从上到下)扫描一个 $N \times M$ 的网格。假设当前我们准备处理坐标为 $(i, j)$ 的格子。那么整个网格被分成了两部分:


1. 已确定的过去:第 $0$ 到 $i-1$ 行,以及第 $i$ 行的第 $0$ 到 $j-1$ 列。


2. 待确定的未来:当前的 $(i, j)$,以及它后面的所有格子。


这两部分之间,有一条长度正好为 $M$ 的边界线。这条线像阶梯一样折断了,因此得名 Broken Profile(轮廓线 / 折线)


状态定义


在这个轮廓线上,我们只需要关心 $M$ 个格子的状态:


第 $i$ 行的前 $j$ 个格子:它们是否向下一行(第 $i+1$ 行)凸出
第 $i+1$ 行的后 $M-j$ 个格子:它们是否向当前行(第 $i$ 行)凸出

这刚好可以用一个长度为 $M$ 的二进制整数 $S$ 来表示!$S$ 的第 $k$ 位为 1,表示轮廓线在该列有一个向下的“插头”(或者说该列已被上方的骨牌占据)。


二、 经典模型:骨牌覆盖问题 (Domino Tiling)


问题描述:用 $1 \times 2$ 的骨牌(可横放或竖放)铺满一个 $N \times M$ 的棋盘,求一共有多少种不同的铺法?(假设 $N \times M$ 为偶数)

极简转移法则 (Cell by Cell)


我们站在格子 $(i, j)$,看着状态为 $S$ 的轮廓线。$S$ 的第 $j$ 位,完美代表了格子 $(i, j)$ 当前是否已经被上方或左方的骨牌占据了


#### 情况 1:第 $j$ 位是 1 (当前格子已被占据)


说明 $(i-1, j)$ 放置了一个竖向的骨牌插了进来,或者 $(i, j-1)$ 放置了一个横向骨牌插了进来。我们现在什么都不能做,只能跳过它。转移:这个骨牌的末端就在 $(i, j)$,它绝不可能再继续向下凸出到 $(i+1, j)$。因此,在新的轮廓线状态中,我们将第 $j$ 位清零 (0)


#### 情况 2:第 $j$ 位是 0 (当前格子是空的)


既然是空的,我们必须在 $(i, j)$ 放置一个骨牌的起点,否则这里将永远留下空洞(因为我们是逐格不可逆推进的)。我们有两种放法:


选择 A:竖着放 这块骨牌会占据 $(i, j)$ 和 $(i+1, j)$。这意味着它突破了当前的轮廓线,向下方凸出转移:在新的轮廓线状态中,我们将第 $j$ 位置一 (1)
选择 B:横着放 这块骨牌会占据 $(i, j)$ 和 $(i, j+1)$。_条件_:必须保证右边还有格子($j+1 < M$),且右边的格子目前也是空的($S$ 的第 $j+1$ 位必须是 0)。转移神来之笔:我们如何告诉系统 $(i, j+1)$ 被占用了?很简单,我们直接强行把状态 $S$ 的第 $j+1$ 位置为 1!这样,当下一轮循环推进到 $(i, j+1)$ 时,系统一查第 $j+1$ 位是 1,就会触发【情况 1】,自动将其清零并跳过。这完美模拟了横向骨牌的覆盖!并且,横向骨牌不会向下方 $(i+1, j)$ 凸出,所以第 $j$ 位保持为 0

三、 C++ 模板代码:滚动数组优化


根据上述极其优雅的逻辑,我们可以写出没有任何复杂特判、只有短短几行核心逻辑的极致代码。


#include <iostream>
#include <vector>
#include <cstring>

using namespace std;

// 求解用 1x2 骨牌铺满 N x M 棋盘的方案数
long long dominoTiling(int N, int M) {
    if ((N * M) % 2 != 0) return 0; // 奇数个格子绝对铺不满
    
    // 如果 M 比较大,可以交换 N 和 M,保证状压的位数最小
    if (N < M) swap(N, M);

    int max_S = 1 << M;
    // 使用滚动数组,只有当前格和上一格的状态
    long long dp[2][1 << 20]; 
    memset(dp, 0, sizeof(dp));

    int old = 0, now = 1;
    dp[old][0] = 1; // 初始状态:空棋盘,轮廓线没有任何凸出,方案数为 1

    for (int i = 0; i < N; ++i) {
        for (int j = 0; j < M; ++j) {
            swap(now, old); // 滚动
            memset(dp[now], 0, sizeof(long long) * max_S); // 清空当前状态

            for (int S = 0; S < max_S; ++S) {
                if (!dp[old][S]) continue;

                if (S & (1 << j)) {
                    // 情况 1:当前格已被占用,直接跳过,将该位清零
                    dp[now][S ^ (1 << j)] += dp[old][S];
                } else {
                    // 情况 2:当前格为空,必须放置骨牌
                    
                    // 选项 A:竖放 (向下一行凸出,当前位变 1)
                    dp[now][S | (1 << j)] += dp[old][S];
                    
                    // 选项 B:横放 (占用右边一格,右边一位变 1,当前位保持 0)
                    if (j + 1 < M && !(S & (1 << (j + 1)))) {
                        dp[now][S | (1 << (j + 1))] += dp[old][S];
                    }
                }
            }
        }
    }

    // 最终状态:铺满且最后一行没有任何向下的凸出,即 S = 0
    return dp[now][0];
}

int main() {
    int N, M;
    while (cin >> N >> M && (N || M)) {
        cout << dominoTiling(N, M) << "\n";
    }
    return 0;
}

四、 复杂度与进阶展望


1. 复杂度碾压


逐行状压 DP 的复杂度通常是 $O(N \cdot 4^M)$ 或更高。
轮廓线 DP 的复杂度严格为 $O(NM \cdot 2^M)$。在 $M \leq 12$ 时,内部计算仅需 $2^{12} = 4096$ 次操作,效率极高。

2. 进化:插头 DP (Plug DP)


在“一双木棋”那道题中,我们的轮廓线区分的是 Alice 和 Bob 的地盘;在这里,轮廓线区分的是“已铺满”和“未铺”的格子。


如果我们要在一个网格里寻找一条回路 (哈密顿环),轮廓线 DP 就会进化成由 陈丹琦 (CDQ) 引入的 插头 DP (Plug DP)。在插头 DP 中,二进制的 1 不仅代表有骨牌凸出,还代表有一条“线头(插头)”穿过了轮廓线。为了保证线头最终能闭合成一个环,我们需要把二进制升级为三进制 (或四进制 / 括号匹配序列),来区分左括号插头和右括号插头,以保证路径不交叉、不提前闭合。


掌握了骨牌覆盖的逐格转移思想,你就已经半只脚踏入了插头 DP 的殿堂!


评论