把棋盘按行列奇偶黑白染色,骨牌必然跨色,于是每块骨牌对应二分图的一条匹配边,答案即最大匹配数,用 Hopcroft-Karp 在 O(E√V) 内求出。
OJ: roj
题目 ID: 3200
难度:提高
标签:图论二分图最大匹配Hopcroft-Karp网格
创建: 2026-10-02 01:20
更新: 2026-10-10 13:00
形式化题目
给定一张
在
在网格中上下或左右相邻; - 所有被选中的格子两两不同(每对内部两个格子也不同)。
求这样的格子对的最大数量。
把可放格看成点、相邻关系看成边,得到网格图
样例 8 0(
B W B W B W B W
W B W B W B W B
B W B W B W B W
W B W B W B W B
B W B W B W B W
W B W B W B W B
B W B W B W B W
W B W B W B W B按
正解
思路
第一步:贪心不行。
最自然的想法是逐格扫描,能横放就横放,否则竖放。这个做法在
# . .
. . .
. # #行优先的贪心会先把
# b a
c b a
c # #问题的本质是:局部的最优搭配可能挡死全局,需要一个能撤销已有决策的模型。
第二步:骨牌就是边。
一块骨牌压住两个相邻格子,正好是网格图上的一条边;“骨牌互不重叠"就是"边两两不共享端点”。 所以
合法摆放
匹配,最多骨牌数 最大匹配。
第三步:网格图是二分图,按奇偶染色。
相邻两格
下面画出
graph LR
subgraph left["左部: 黑格 (r+c 为偶)"]
b11["(1,1)"]
b13["(1,3)"]
b22["(2,2)"]
b31["(3,1)"]
b33["(3,3)"]
end
subgraph right["右部: 白格 (r+c 为奇)"]
w12["(1,2)"]
w21["(2,1)"]
w23["(2,3)"]
w32["(3,2)"]
end
b11 --- w12
b11 --- w21
b13 --- w12
b13 --- w23
b22 --- w12
b22 --- w21
b22 --- w23
b22 --- w32
b31 --- w21
b31 --- w32
b33 --- w23
b33 --- w32
图中每条边就是一种可以横放或竖放的骨牌位置;选出的骨牌必须让每个格子最多只被用一次,
这正是匹配的定义。左部 5 个点、右部 4 个点意味着这个棋盘最多只能放 4 块骨牌(
第四步:为什么答案要靠"增广路"而不是靠贪心。
匹配理论的核心结论是 Berge 增广路定理:
匹配
是最大匹配 图中不存在 的增广路(两端都是未匹配点的交错路)。
所以算法不需要枚举方案,只要反复做一件事:找一条增广路,把路上的匹配边与非匹配边 整体互换,匹配数就 +1;找不到增广路时,当前匹配就是最大的。这正好解释了贪心为什么错—— 贪心只看一条边,而增广路允许撤销一串已有匹配,是一种"有结构的后悔"。
以
| 步骤 | 找到的交错路 | 增广后的匹配 |
|---|---|---|
| 1 | ||
| 2 | 再加上 |
|
| 3 | 再加上 |
|
| 4 | 前三条边翻转, |
第 4 步的路径长度是 3(三条边),
第五步:用 Hopcroft–Karp 批量增广。
匈牙利算法每个左部点依次找一个增广路,复杂度
- BFS 分层:从所有未匹配左部点同时出发,交替走"未匹配边 → 匹配边"。走到右部点
后下一步只能走 的匹配边到 match_r[y],没有分支,所以层号只需记在左部点上。 第一次碰到空闲右部点时的层号就是最短增广路长度(BFS 保证最短)。 - DFS 批量增广:只沿
的边往下走,并且只接受长度恰好等于 的路。长度相同的增广路彼此不共享端点,所以一轮里能一次增广多条。 - 每轮结束后
严格增大,轮数不超过 ,总复杂度 。
HK 的复杂度上界严格优于匈牙利(
第六步:实现细节。
- 棋盘四周补一圈禁止格,编号
。这样判断上下左右时可以无条件取 ,不必写四组边界条件;重复出现的禁止格由 banned[v] = 1幂等处理。 - DFS 深度最坏可达
,改写成显式栈,避免递归开销与 setrecursionlimit。 - 迭代 DFS 的翻转顺序:命中空闲右部点
时先写 match_r[y] = x、match_l[x] = y, 再沿路径把每对(px, py)写成match_r[py] = px、match_l[px] = py。路径里保存的正是 “上一层的左部点与它连出去的右部点”,所以整条路同时翻转,不需要额外判断奇偶。
a b b c
a # e c
d f e #
d f g g其中竖放的是
代码
/**
* 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 13:00
* update_at: 2026-10-10 13:00
*/
// 棋盘覆盖:黑白染色建二分图,最大匹配数就是最多骨牌数(Hopcroft-Karp)
#include <cstdio>
#include <vector>
using namespace std;
const int INF = 1 << 30;
int main() {
int n, t;
if (scanf("%d %d", &n, &t) != 2) {
return 0;
}
int width = n + 2;
vector<char> banned((long long)width * width, 0);
for (int i = 0; i < width; i++) { // 四周边界也标成禁止格,越界判断直接省掉
banned[i] = 1;
banned[i * width] = 1;
banned[i * width + width - 1] = 1;
banned[width * (width - 1) + i] = 1;
}
for (int i = 0; i < t; i++) {
int x, y;
scanf("%d %d", &x, &y);
banned[x * width + y] = 1;
}
int size = width * width;
vector<vector<int> > adj(size);
for (int r = 1; r <= n; r++) {
for (int c = 1; c <= n; c++) {
int v = r * width + c;
if (banned[v] || ((r + c) & 1)) {
continue;
}
int nb[4] = {v - width, v + width, v - 1, v + 1};
for (int s = 0; s < 4; s++) {
if (!banned[nb[s]]) {
adj[v].push_back(nb[s]);
}
}
}
}
vector<int> match_l(size, -1), match_r(size, -1), dist(size, INF);
int total = 0;
while (true) {
// 分层:未匹配左部点入队,沿「未匹配边 → 匹配边」交替走
vector<int> q;
for (int u = 0; u < size; u++) {
bool free_l = !banned[u] && !((u / width + u % width) & 1) &&
(match_l[u] < 0);
dist[u] = free_l ? 0 : INF;
if (free_l) {
q.push_back(u);
}
}
int shortest = INF;
for (int h = 0; h < (int)q.size(); h++) {
int u = q[h];
if (dist[u] >= shortest) {
continue;
}
for (int i = 0; i < (int)adj[u].size(); i++) {
int v = adj[u][i];
int nx = match_r[v];
if (nx < 0) {
shortest = dist[u] + 1;
} else if (dist[nx] == INF) {
dist[nx] = dist[u] + 1;
q.push_back(nx);
}
}
}
if (shortest == INF) {
break; // 不存在增广路 ⇒ 已是最大匹配
}
// 沿长度恰为 shortest 的增广路逐条增广(用显式栈避免深递归)
for (int root = 0; root < size; root++) {
if (banned[root] || ((root / width + root % width) & 1) || match_l[root] >= 0) {
continue;
}
if (dist[root] >= shortest) {
continue;
}
vector<int> su, si, sv;
su.push_back(root);
si.push_back(0);
sv.push_back(-1);
bool found = false;
while (!su.empty()) {
int u = su.back(), i = si.back();
if (i >= (int)adj[u].size()) {
dist[u] = INF;
su.pop_back();
si.pop_back();
sv.pop_back();
continue;
}
si.back() = i + 1;
int v = adj[u][i];
int nx = match_r[v];
if (nx < 0) {
if (dist[u] + 1 != shortest) {
continue;
}
match_r[v] = u;
match_l[u] = v;
for (int z = (int)su.size() - 1; z > 0; z--) { // 交替路整体翻转
match_r[sv[z]] = su[z - 1];
match_l[su[z - 1]] = sv[z];
}
total++;
found = true;
break;
}
if (dist[nx] == dist[u] + 1) { // 只能沿分层方向往下走
su.push_back(nx);
si.push_back(0);
sv.push_back(v);
}
}
if (found) {
continue;
}
}
}
printf("%d\n", total);
return 0;
}#!/usr/bin/env python3
# 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-02 01:20
# update_at: 2026-10-02 01:37
import sys
from collections import deque
from collections.abc import Iterator
INF = 1 << 30 # 层号哨兵:比任何合法层号都大,表示"这一轮从这个点出发没有增广路"
def bfs_layers(lefts: list[int], adj: list[tuple[int, ...]], match_l: list[int],
match_r: list[int], dist: list[int]) -> int:
"""给左部点分层,返回最短增广路的边数;一条增广路都没有时返回 INF。
从每个未匹配左部点出发,沿"未匹配边 → 匹配边"交替走:走到右部点 y 后,
下一步只能走 y 的匹配边到 match_r[y],所以层号只需记在左部点上。
第一次碰到未匹配右部点时得到的层号就是最短增广路长度,BFS 保证它最小。
"""
queue = deque()
for u in lefts:
free = match_l[u] < 0 # 未匹配的左部点才是增广路起点
dist[u] = 0 if free else INF
if free:
queue.append(u)
shortest = INF
while queue:
x = queue.popleft()
if dist[x] >= shortest: # 同层及更深的点只会给出不更短的路,剪掉
continue
for y in adj[x]:
z = match_r[y]
if z < 0: # 右部点空闲:找到一条增广路
shortest = dist[x] + 1
elif dist[z] == INF: # 已匹配右部点:顺着它的匹配边走到左部点 z
dist[z] = dist[x] + 1
queue.append(z)
return shortest
def augment_all(lefts: list[int], adj: list[tuple[int, ...]], match_l: list[int],
match_r: list[int], dist: list[int], shortest: int) -> int:
"""沿长度恰为 shortest 的增广路逐条增广,返回本轮增广条数。
每个点在一轮里只允许被成功增广一次:匹配边一翻转,另一端就不再是自由点,
搜索自然失败,因此一轮里这些增广路点不相交。失败时把该左部点层号置 INF,
同一轮里不再重复搜索这棵失败的子树。
"""
found = 0
for root in lefts:
if match_l[root] >= 0 or dist[root] >= shortest:
continue # 已匹配或起点太深,做不了最短增广路
stack = [(root, 0)] # (当前左部点, 下一个待试邻居的下标)
path: list[tuple[int, int]] = [] # 交替路:(来的左部点, 指向它的右部点)
while stack:
x, i = stack[-1]
neighbours = adj[x]
descended = False
while i < len(neighbours):
y = neighbours[i]
i += 1
z = match_r[y]
if z < 0:
if dist[x] + 1 != shortest:
continue # 只有最短的增广路才在本轮使用
match_r[y] = x
match_l[x] = y
for px, py in path: # 交替路整体翻转
match_r[py] = px
match_l[px] = py
found += 1
stack = [] # 这条路已经用掉,换下一个起点
descended = True
break
if dist[z] == dist[x] + 1: # 只能沿分层方向往下走
stack[-1] = (x, i)
path.append((x, y))
stack.append((z, 0))
descended = True
break
if not stack:
break
if descended:
continue
dist[x] = INF # 这个点往下走不到自由点,本轮放弃
stack.pop()
if path:
path.pop()
return found
def max_matching(size: int, lefts: list[int], adj: list[tuple[int, ...]]) -> int:
"""Hopcroft–Karp:反复"分层 + 沿最短增广路批量增广",直到不存在增广路。
每轮把这一层能用的增广路一次用完,所以最多 O(√V) 轮。
"""
match_l = [-1] * size # 左部点的匹配对象,-1 表示未匹配
match_r = [-1] * size # 右部点的匹配对象,-1 表示未匹配
dist = [INF] * size
total = 0
while True:
shortest = bfs_layers(lefts, adj, match_l, match_r, dist)
if shortest == INF: # 由增广路定理,不存在增广路即已是最大匹配
return total
total += augment_all(lefts, adj, match_l, match_r, dist, shortest)
def read_board(n: int, t: int, data: Iterator[int]) -> tuple[int, bytearray]:
"""返回 (棋盘边长, 禁止格标记);外圈也标记成禁止格,上下左右的越界判断直接省掉。"""
width = n + 2
banned = bytearray(width * width)
for i in range(width): # 四条边界
banned[i] = banned[i * width] = banned[i * width + width - 1] = 1
banned[width * (width - 1) + i] = 1
for _ in range(t):
x, y = next(data), next(data)
banned[x * width + y] = 1 # 重复出现的禁止格由幂等赋值自然吸收
return width, banned
def build_graph(n: int, banned: bytearray) -> tuple[list[tuple[int, ...]], list[int]]:
"""黑白染色建二分图,返回 (左部点邻接表, 左部点列表)。
骨牌必然压住一黑一白:把 (行号 + 列号) 为偶数的可放格当左部点,
每个左部点向上下左右四个非禁止格连边,最大匹配数就是最多骨牌数。
"""
width = n + 2
adj: list[tuple[int, ...]] = [()] * (width * width)
lefts: list[int] = []
for r in range(1, n + 1):
base = r * width
for c in range(1, n + 1):
v = base + c
if banned[v] or (r + c) & 1:
continue
lefts.append(v)
adj[v] = tuple(w for w in (v - width, v + width, v - 1, v + 1) if not banned[w])
return adj, lefts
def solve() -> None:
data = iter(map(int, sys.stdin.buffer.read().split()))
n = next(data)
t = next(data)
width, banned = read_board(n, t, data)
adj, lefts = build_graph(n, banned)
print(max_matching(width * width, lefts, adj))
if __name__ == "__main__":
solve()复杂度
- 时间复杂度:
,其中 是网格图顶点数、 是边数。 BFS 与 DFS 各 ,而每轮最短增广路长度至少 +1,轮数不超过 。 时约 量级,实测每组数据 0.03s 以内。 - 空间复杂度:
,主要是 adj、banned与match_l、match_r、dist三个长度的数组,实测峰值约 16MB,限制 128MB。
总结
这道题的关键是换模型:不要在网格上直接搜摆放方案,而是看出"骨牌 = 边、不重叠 = 不共端点",
把问题化为网格图的最大匹配。之后按
实现上选择 Hopcroft–Karp 而不是匈牙利算法:它的
图示解析
从输入到答案的主线:
输入: N x N 棋盘 + t 个禁止格
`- 可放格建图: 相邻(上下左右)连边
|- 骨牌 = 一条边; 不重叠 = 边不共享端点
| `- 合法摆放 <=> 匹配 => 答案 = 最大匹配
`- (r+c) 奇偶染色: 每条边跨色 => 网格图是二分图
|- 左部 = 黑格, 右部 = 白格, 邻居 = 边
`- Hopcroft-Karp
|- BFS 分层: 求最短增广路长度 shortest
|- DFS 批量增广: 只走分层方向、只取长度 = shortest
`- shortest = INF 时停机 => 由增广路定理即最大匹配其中最后两个分支对应代码里的 bfs_layers 与 augment_all,两者被 max_matching
的 while True 反复调用,直到 shortest == INF 为止。
顺着读:先确认"骨牌就是边"这一步把组合摆放大问题变成匹配问题;再用奇偶染色说明为什么 可以用二分图算法(这是能套 HK 的前提);最后 BFS 分层 + DFS 批量增广就是具体算法, 停机条件由 Berge 增广路定理保证正确。整条链上没有任何一步依赖"局部最优", 所以贪心的反例在这里不会出现。