逐行扫描的轮廓线状压 DP:外层按行、内层逐格 dfs 填本行,竖牌记录向下伸出的列集合,横牌要求右侧格未被占,答案为填完 M 行且伸出集合为 0。
OJ: roj
题目 ID: 3152
难度:提高
标签:动态规划状态压缩DP轮廓线DP
创建: 2026-10-01 21:07
更新: 2026-10-10 11:30
形式化题目
给定 0 0 结束。
思路
朴素做法及其困境
最直接的想法是给每个格子依次决定放什么牌,但骨牌是横跨两个格子的:放一张横牌会影响右邻格,放一张竖牌会影响下一行。如果只记录"扫到哪个格子",无法知道哪些格子已经被之前的牌占住了,决策无从做起。所以状态里必须携带"当前格之前的格子被谁占了"的完整信息。
逐行划分 + 列压缩状态
把棋盘按行划分阶段。逐行填放,先注意一个关键性质:横牌一旦越过行边界就不存在——横牌只占同一行的相邻两格。因此行与行之间的"相互影响"只有一种来源:竖牌。一张竖牌占第
于是定义:
其中
初始 cur 记录向下伸出的列集合,填完后累加到
行内逐格 dfs 的三种情况
设进入本行时被占列集合为 s,dfs(pos, cur) 表示正在决定第 pos 列(cur 为本行向下伸出的列集合):
| 情况 | 条件 | 动作 |
|---|---|---|
| 已被上竖牌占据 | s 的第 pos 位为 1 |
直接跳到 pos+1,不改 cur |
| 放竖牌 | 总可放 | 记 cur 第 pos 位为 1,走 pos+1 |
| 放横牌 | pos+1 < N 且第 pos+1 位也不在 s 中 |
同时占 pos, pos+1 两格,cur 不变,走 pos+2 |
注意两点:横牌合法的前提是右邻格没有被上一行的竖牌占住;本行内每张竖牌都登记到 cur,传给下一行处理,本行自身不会出现"填一半"的状态。
为什么这样是对的
按行从上往下扫,决策只依赖"上一行传下来的伸出列集合",不依赖更早的行(更早的行已全部填满,无法再伸出)。每个合法铺法按行的顺序、每行内按列的顺序被唯一地展开成一条决策链;反之每条以 s=0 结尾的决策链也唯一对应一个合法铺法,所以方案数被不重不漏地统计。
状态规模与记忆化
有效状态只有 functools.cache 记忆化后每个
代码
/**
* Author by Rainboy blog: https://rainboylv.com github: https://github.com/rainboylvx
* rbook: -> https://rbook.roj.ac.cn https://rbook2.roj.ac.cn
* rainboy的学习导航网站: https://idx.roj.ac.cn
* create_at: 2026-10-10 11:30
* update_at: 2026-10-10 11:30
*/
// 铺地砖:轮廓线 DP,逐行推进,状态是本行被上方横牌占住的列集合。
#include <cstdio>
#include <cstring>
typedef long long ll;
const ll MAXN = 13;
const ll MAXS = 1 << MAXN;
ll n, m;
ll memo[MAXN][MAXS]; // g[j][s] 的记忆化
char vis[MAXN][MAXS];
ll dfs_row(ll j, ll pos, ll cur, ll s);
// 处理第 j+1 行:进入时该行已被占列为 s,返回本行填完后传给下一行的伸出列集合的方案数
ll g(ll j, ll s) {
if (j == m) return (s == 0) ? 1 : 0;
if (vis[j][s]) return memo[j][s];
ll res = dfs_row(j, 0, 0, s);
vis[j][s] = 1;
memo[j][s] = res;
return res;
}
// 逐格填第 j+1 行,cur 为本行向下伸出的列集合
ll dfs_row(ll j, ll pos, ll cur, ll s) {
if (pos == n) return g(j + 1, cur);
ll b = 1LL << pos;
if (s & b) return dfs_row(j, pos + 1, cur, s); // 该格已被上方横牌占据
ll res = dfs_row(j, pos + 1, cur | b, s); // 竖牌:向下一行伸出
if (pos + 1 < n && !(s & (b << 1))) { // 横牌:占 (pos,pos+1),不伸出
res += dfs_row(j, pos + 2, cur, s);
}
return res;
}
int main() {
ll a, b;
while (scanf("%lld %lld", &a, &b) == 2) {
if (a == 0 && b == 0) break;
n = a;
m = b;
memset(vis, 0, sizeof(vis)); // 记忆化缓存与 m 有关,每个用例必须清空
printf("%lld\n", g(0, 0));
}
return 0;
}import sys
from functools import lru_cache
def solve(n: int, m: int) -> int:
"""N*M 棋盘用 1*2 骨牌铺满的方案数(轮廊线 DP)。"""
# 按行转移:f[j][s] = 已填满前 j 行时,第 j+1 行被上方横牌占住的列集合 s 的方案数
# 初始 f[0][0]=1,答案 f[m][0](全部行填完且无伸出)。
@lru_cache(maxsize=None)
def g(j: int, s: int) -> int:
# 处理第 j+1 行:进入时该行已被占列为 s,返回本行填完后传给下一行的伸出列集合
if j == m:
return 1 if s == 0 else 0
# dfs(pos, cur):逐格填第 j+1 行,cur 为本行向下伸出的列集合
def dfs(pos: int, cur: int) -> int:
if pos == n:
return g(j + 1, cur)
b = 1 << pos
if s & b: # 该格已被上方横牌占据,跳过
return dfs(pos + 1, cur)
res = dfs(pos + 1, cur | b) # 竖牌:向下一行伸出
if pos + 1 < n and not (s & b << 1):
res += dfs(pos + 2, cur) # 横牌:占 (pos,pos+1),不伸出
return res
return dfs(0, 0)
return g(0, 0)
def main() -> None:
out = []
for line in sys.stdin:
parts = line.split()
if len(parts) < 2:
continue
n, m = int(parts[0]), int(parts[1])
if n == 0 and m == 0:
break
out.append(str(solve(n, m)))
print('\n'.join(out))
main()复杂度
- 状态数
,每个状态行内转移 量级(逐格 dfs 的每层只有常数分支),总复杂度 。 时约 次基本操作,即使多组询问也可以先离线预处理所有 的答案再回答。 - 空间
,本题用 lru_cache隐式存储。
总结
- 核心是轮廓线 DP 的建模:阶段是行、附加信息是"下一行被竖牌占住的列集合",一个
位二进制足以概括行间全部影响。 - 行内用递归逐格决策,天然处理了"横牌需要右邻空、竖牌登记到下一行"的分支逻辑,比手写按列循环的写法更短。
- 这套"逐行扫描 + 列状态压缩"是棋盘覆盖类问题的通用骨架(插头 DP 的简化版),
的范围正是对 状态数的暗示。
图示解析
逐行扫描与伸出列集合
以
flowchart LR
A["j=0 进入<br/>s=00"] -->|"两格都可竖放/横放"| B["j=1<br/>s ∈ {01, 10, 11}"]
B --> C["j=2<br/>s ∈ {00, 01, 10, 11}"]
C -->|"最后一行后 s=0 才计数"| D["答案 = 3"]
| 进入第 2 行的 s | 第 2 行的填法 | 传给第 3 行的 cur | 对答案的贡献 |
|---|---|---|---|
00 |
一张横牌 | 00 |
1 |
01 |
第 0 列竖放补齐,第 1 列竖放 | 01 |
0(最末行伸出非法) |
10 |
第 1 列竖放补齐,第 0 列竖放 | 10 |
0 |
11 |
两格均已被占 | 11 |
0 |
中间行的情况对称展开,最终只有
一张牌如何影响轮廓线
flowchart TB
subgraph row1["第 j 行 → 第 j+1 行"]
direction LR
V1["竖牌<br/>cur 该位记 1"] --- V2["下一行该格开局即被占"]
H1["横牌<br/>要求右邻不在 s 中"] --- H2["两格同 row 内消化<br/>cur 不变"]
end
绿色标记的伸出列就是状态
