每行每列被墙壁切出的极大段当作二分图两侧顶点、空地格连边,机器人互不射击等价于匹配边不共享端点,答案即最大匹配,用 Hopcroft-Karp 求。
OJ: roj
题目 ID: 3219
难度:提高+/省选-
标签:图论二分图最大匹配Hopcroft-Karp网格python
创建: 2026-10-02 02:35
更新: 2026-10-10 11:30
形式化题目
给定 #、草地 * 或空地 o 之一。机器人只能放在空地上,
每个机器人沿上、下、左、右四个方向发射激光;激光被墙壁挡住,不被草地挡住,
也被别的机器人挡住。
两个机器人
要求选出尽可能多的空地格放机器人,使选出的格子两两不冲突。答案即最大数量。
样例 1 的网格(
o * * *
* # # #
o o # o
* * * o其中 * 不挡光这一条最容易看错。样例 1 里第 0 行第 0 列是 o、第 2 行第 0 列也是 o,
两格在同一列且中间只有草地
正解
思路
第一步:冲突是"二维"的,但可以拆成两类。
机器人只沿四个正方向发射激光,所以它能打到的格子必然与自己在同一行或同一列。 于是"两个机器人互相打到"只可能是两件事之一:同行且中间无墙,同列且中间无墙。
固定一行来看:把这一行用墙壁 # 切开,得到若干横向极大段:一段内任意两格之间
没有墙壁,段与段之间隔着至少一面墙。草地 * 不算切割点,因为它不挡光,
所以 o*o 的两个空地格必须落在同一个横向段里。用同样的方法对每列切出纵向极大段。
两个机器人冲突
它们落在同一个横向段,或落在同一个纵向段。
第二步:贪心是不可行的。
最自然的想法是逐格扫描、能放就放(放了就把所在的行段与列段标记为已占用)。
这个做法在下面的
o o o
o o o
o o *行优先贪心先放
第三步:换个角度——把段当成资源。
不去数"选哪些格子",而是数"占用了哪些段"。每放一个机器人,它就占用了自己所在的那一个 横向段和那一个纵向段;两个机器人不冲突,等价于它们占用的横向段互不相同、纵向段也互不相同。
这句话正好是一个二分图匹配。建图
- 左部顶点:每个横向极大段一个;
- 右部顶点:每个纵向极大段一个;
- 每个空地格
连一条边 (它恰好属于唯一一个横向段和唯一一个纵向段)。
因为机器人只能站在空地上,所以只有空地格会产生边。现在:
一条边对应一个机器人、两个端点对应它占用的两个段,“不共享端点"就是"不冲突”。
样例 1 切分后的段与二分图如下(左部 H 是横向段,右部 V 是纵向段)。
先看横向切分(箭头前是格子内容,箭头后是该格所属的 H):
| 行 \ 列 | 0 | 1 | 2 | 3 |
|---|---|---|---|---|
| 0 | o → H0 |
* → H0 |
* → H0 |
* → H0 |
| 1 | * → H1 |
# |
# |
# |
| 2 | o → H2 |
o → H2 |
# |
o → H3 |
| 3 | * → H4 |
* → H4 |
* → H4 |
o → H4 |
再看纵向切分:
| 行 \ 列 | 0 | 1 | 2 | 3 |
|---|---|---|---|---|
| 0 | o → V0 |
* → V1 |
* → V3 |
* → V5 |
| 1 | * → V0 |
# |
# |
# |
| 2 | o → V0 |
o → V2 |
# |
o → V6 |
| 3 | * → V0 |
* → V2 |
* → V4 |
o → V6 |
两张表对照着看:V1、V3、V4、V5 覆盖的全是草地(例如 V4 只有 *),
它们不产生任何边;只有 5 个空地格分别落在 5 组段里。这 5 条边列在下表,
也正是二分图的全部边:
| 空地格 | 横向段 | 纵向段 |
|---|---|---|
| H0 | V0 | |
| H2 | V0 | |
| H2 | V2 | |
| H3 | V6 | |
| H4 | V6 |
由此得到的二分图(
graph LR
H0["H0: (0,0)...(0,3)"]
H1["H1: (1,0)"]
H2["H2: (2,0)(2,1)"]
H3["H3: (2,3)"]
H4["H4: (3,0)...(3,3)"]
V0["V0: (0,0)...(3,0)"]
V1["V1: (0,1)"]
V2["V2: (2,1)(3,1)"]
V3["V3: (0,2)"]
V4["V4: (3,2)"]
V5["V5: (0,3)"]
V6["V6: (2,3)(3,3)"]
H0 --- V0
H2 --- V0
H2 --- V2
H3 --- V6
H4 --- V6
H1、V1、V3、V4、V5 是没有边的孤立点,放不放机器人都不影响别人。注意 V0 被两条边
(
第四步:用增广路求最大匹配。
有了模型,剩下的就是二分图最大匹配。匹配理论的基本结论是 Berge 增广路定理:
匹配
最大 图中不存在 的增广路(两端点都未匹配的交错路)。
所以算法反复做一件事:找一条增广路,把路上的匹配边与非匹配边整体互换,匹配数就
本题用 Hopcroft–Karp:
- BFS 分层:从所有未匹配左部点同时出发,交替走"未匹配边 → 匹配边"。走到右部点
后, 下一步只能沿 的匹配边到 match_r[v],没有分支,所以层号只需记在左部点上。 第一次碰到空闲右部点时的层号就是最短增广路的长度。 - 批量增广:只沿
的分层方向往下走,并且只接受长度恰好最短的路。 长度相同的增广路彼此不共享端点,所以一轮能一次增广多条,轮数不超过 。
因为每轮 BFS 与增广都是
第五步:实现要点。
- 段编号只由墙壁
#重置:逐行扫描时遇#把段号设回,遇 *和o都延续段号。 - 草地不出现在二分图里:纵向段扫描时只对
o格append一条边。 - 增广路的深度最坏可达
,所以用显式栈加 path列表代替递归, 不必设递归深度。 - 输出格式是
Case :id(冒号后紧跟数字,与题面样例一致),每组两行。
代码
/**
* 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
*/
#include <bits/stdc++.h>
using namespace std;
typedef long long ll;
const int INF = 1 << 30;
// 左部:横向极大非墙壁段;右部:纵向极大非墙壁段
vector<int> adj[10005]; // 左部点的邻接表(右部点编号)
int match_l[10005]; // 横向段 -> 纵向段,-1 未配
int match_r[10005]; // 纵向段 -> 横向段
int distl[10005]; // 左部点层号
// BFS 分层,返回最短增广路长度,没有则返回 INF
int bfs_layers(int lefts) {
queue<int> q;
for (int u = 0; u < lefts; u++) {
if (match_l[u] < 0) { distl[u] = 0; q.push(u); }
else distl[u] = INF;
}
int shortest = INF;
while (!q.empty()) {
int u = q.front(); q.pop();
if (distl[u] >= shortest) continue;
for (size_t i = 0; i < adj[u].size(); i++) {
int v = adj[u][i];
int w = match_r[v];
if (w < 0) shortest = distl[u] + 1;
else if (distl[w] == INF) {
distl[w] = distl[u] + 1;
q.push(w);
}
}
}
return shortest;
}
// 沿分层方向找一条长度恰为 shortest 的增广路并翻转
bool augment(int root, int shortest) {
// 显式栈:帧 [当前横向段, 下一个待试邻居下标]
vector<pair<int, int> > stack;
stack.push_back(make_pair(root, 0));
vector<pair<int, int> > path; // 交替路上的未匹配边 (横向段, 纵向段)
while (!stack.empty()) {
int u = stack.back().first;
int& fi = stack.back().second;
if (fi == (int)adj[u].size()) {
distl[u] = INF;
stack.pop_back();
if (!path.empty()) path.pop_back();
continue;
}
int v = adj[u][fi];
fi++;
int w = match_r[v];
if (w < 0) {
if (distl[u] + 1 != shortest) continue;
match_l[u] = v;
match_r[v] = u;
for (size_t i = 0; i < path.size(); i++) {
match_l[path[i].first] = path[i].second;
match_r[path[i].second] = path[i].first;
}
return true;
}
if (distl[w] == distl[u] + 1) {
path.push_back(make_pair(u, v));
stack.push_back(make_pair(w, 0));
}
}
return false;
}
int hopcroft_karp(int lefts, int rights) {
for (int i = 0; i < lefts; i++) match_l[i] = -1;
for (int i = 0; i < rights; i++) match_r[i] = -1;
int matched = 0;
while (true) {
int shortest = bfs_layers(lefts);
if (shortest == INF) return matched;
for (int root = 0; root < lefts; root++) {
if (match_l[root] < 0 && augment(root, shortest)) matched++;
}
}
}
int main() {
ios::sync_with_stdio(false);
cin.tie(nullptr);
int T;
cin >> T;
for (int case_no = 1; case_no <= T; case_no++) {
int rows, cols;
cin >> rows >> cols;
vector<string> grid(rows);
for (int i = 0; i < rows; i++) cin >> grid[i];
// 每格所属的横向极大非墙段编号(墙 = -1)
vector<vector<int> > table(rows, vector<int>(cols));
int lefts = 0;
for (int r = 0; r < rows; r++) {
int seg = -1;
for (int c = 0; c < cols; c++) {
if (grid[r][c] == '#') seg = -1;
else if (seg < 0) { seg = lefts; lefts++; }
table[r][c] = seg;
}
}
for (int i = 0; i < lefts; i++) adj[i].clear();
// 纵向段:每段内每个空地格所在的横向段就是它的邻居
int vertical_cnt = 0;
for (int c = 0; c < cols; c++) {
int r = 0;
while (r < rows) {
if (grid[r][c] == '#') { r++; continue; }
int start = r;
while (r < rows && grid[r][c] != '#') r++;
for (int i = start; i < r; i++) {
if (grid[i][c] == 'o')
adj[table[i][c]].push_back(vertical_cnt);
}
vertical_cnt++;
}
}
int ans = hopcroft_karp(lefts, vertical_cnt);
cout << "Case :" << case_no << "\n" << ans << "\n";
}
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 02:35
# update_at: 2026-10-02 02:42
import sys
from collections import deque
WALL = ord('#') # 只有墙壁挡光;草地 '*' 透光,所以「极大段」只被墙壁切断
EMPTY = ord('o') # 只有空地能放机器人与被激光打到,也只有空地格产生一条边
INF = 1 << 30 # 层号哨兵:比任何合法层号都大,表示该横向段在本轮分层里到不了
def row_segments(grid: list[bytes], rows: int, cols: int) -> tuple[list[list[int]], int]:
"""每格所属的横向极大非墙壁段编号(墙壁记 -1),返回编号表与横向段总数。
段内任意两格之间没有墙壁,机器人沿行方向必能互相打到;
草地不重置段号,否则 `o*o` 这种「隔着草地互射」的冲突会被漏掉。
"""
table: list[list[int]] = []
total = 0
for r in range(rows):
row = grid[r]
ids: list[int] = []
seg = -1 # 上一个格子是墙壁(或行首)时置 -1,表示还没进段
for c in range(cols):
if row[c] == WALL:
seg = -1
elif seg < 0:
seg = total # 一段新的横向极大段从这里开始
total += 1
ids.append(seg)
table.append(ids)
return table, total
def build_adjacency(
grid: list[bytes], table: list[list[int]], lefts: int, rows: int, cols: int
) -> tuple[list[list[int]], int]:
"""建二分图:左部是横向段、右部是纵向段,每个空地格连一条边。
返回 (左部点的邻接表, 右部点个数)。纵向段沿列扫描得到,
段内每个空地格所在的横向段编号就是它的一个邻居。
"""
adj: list[list[int]] = [[] for _ in range(lefts)]
vertical_cnt = 0
for c in range(cols):
r = 0
while r < rows:
if grid[r][c] == WALL:
r += 1
continue
start = r
while r < rows and grid[r][c] != WALL:
r += 1
# 这一段上下两端顶住墙壁或边界,段内每个空地格都向它连一条边
for i in range(start, r):
if grid[i][c] == EMPTY:
adj[table[i][c]].append(vertical_cnt)
vertical_cnt += 1
return adj, vertical_cnt
def bfs_layers(
adj: list[list[int]], lefts: int, match_l: list[int], match_r: list[int], dist: list[int]
) -> int:
"""给左部点分层,返回最短增广路的边数;一条增广路都没有时返回 INF。
从每个未匹配横向段出发,沿「未匹配边 → 匹配边」交替走:走到纵向段 v 后,
下一步只能走 v 的匹配边到 match_r[v],没有分支,所以层号只需记在左部点上。
第一次碰到空闲纵向段时的层号就是最短增广路长度,BFS 保证它最小。
"""
queue: deque[int] = deque()
for u in range(lefts):
free = match_l[u] < 0 # 未匹配的左部点才是增广路起点
dist[u] = 0 if free else INF
if free:
queue.append(u)
shortest = INF
while queue:
u = queue.popleft()
if dist[u] >= shortest: # 同层及更深的点只会给出不更短的路,剪掉
continue
for v in adj[u]:
w = match_r[v]
if w < 0: # 纵向段空闲:找到一条增广路
shortest = dist[u] + 1
elif dist[w] == INF: # 已匹配的纵向段:顺着匹配边走到左部点 w
dist[w] = dist[u] + 1
queue.append(w)
return shortest
def augment(
root: int, adj: list[list[int]], match_l: list[int], match_r: list[int],
dist: list[int], shortest: int
) -> bool:
"""只沿分层方向找一条长度恰为 shortest 的增广路并翻转,返回是否成功。
显式栈代替递归:帧是 [当前横向段, 下一个待试邻居下标],
path 记录已走过的未匹配边,命中空闲纵向段时整条交替路一起翻转。
"""
stack = [[root, 0]]
path: list[tuple[int, int]] = [] # 交替路上的未匹配边 (横向段, 纵向段)
while stack:
frame = stack[-1]
u = frame[0]
if frame[1] == len(adj[u]): # 这个横向段的所有出路都试过,回溯
dist[u] = INF # 本轮再也走不到空闲纵向段,剪掉
stack.pop()
if path:
path.pop()
continue
v = adj[u][frame[1]]
frame[1] += 1
w = match_r[v]
if w < 0:
if dist[u] + 1 != shortest:
continue # 只有最短的增广路才在本轮使用
match_l[u], match_r[v] = v, u
for pu, pv in path: # 交替路整体翻转,未匹配边全部变成匹配边
match_l[pu], match_r[pv] = pv, pu
return True
if dist[w] == dist[u] + 1: # 只能沿分层方向往下走
path.append((u, v))
stack.append([w, 0])
return False
def hopcroft_karp(adj: list[list[int]], lefts: int, rights: int) -> int:
"""Hopcroft–Karp:反复「分层 + 沿最短增广路批量增广」,返回最大匹配数。
每次成功的增广让匹配数 +1,所以最大匹配数 = 最多能同时放下的机器人个数;
长度相同的增广路互不相交,一轮能一次增广多条,轮数不超过 O(sqrt(V))。
"""
match_l = [-1] * lefts # 横向段 -> 配到的纵向段,-1 表示还没配
match_r = [-1] * rights # 纵向段 -> 配到的横向段
dist = [INF] * lefts
matched = 0
while True:
shortest = bfs_layers(adj, lefts, match_l, match_r, dist)
if shortest == INF: # 由增广路定理,找不到增广路即已是最大匹配
return matched
for root in range(lefts):
free = match_l[root] < 0 # 只有仍未匹配的横向段才能当增广路起点
if free and augment(root, adj, match_l, match_r, dist, shortest):
matched += 1
def solve() -> None:
data = iter(sys.stdin.buffer.read().split())
out: list[str] = []
T = int(next(data))
for case in range(1, T + 1):
rows, cols = int(next(data)), int(next(data))
grid = [next(data) for _ in range(rows)]
table, lefts = row_segments(grid, rows, cols) # 左部顶点:横向极大段
adj, rights = build_adjacency(grid, table, lefts, rows, cols) # 右部顶点:纵向极大段
out += [f"Case :{case}", str(hopcroft_karp(adj, lefts, rights))]
print('\n'.join(out))
if __name__ == "__main__":
solve()复杂度
- 时间复杂度:
,其中 是空地格数( )、 是段数之和( )。构建段表与邻接表各 ; Hopcroft–Karp 每轮 、轮数 。 时最坏约 量级, 实测 11 组数据共 0.03s(限制 1000ms)。 - 空间复杂度:
。段编号表、邻接表、 match_l/match_r/dist都是网格规模, 实测峰值约 16MB(限制 128MB)。
总结
这道题的核心是换一个决策单位。直接决定"哪些格子放机器人"会得到一个 NP-hard 的 最大独立集问题;改成决定"占用哪些行段、哪些列段",冲突就变成了"端点不能重复", 问题立刻落进二分图最大匹配。
推导链条是三步:先看清冲突必然是"同行同段"或"同列同段"(草地不切断段);
再把每个空地格表示成一条连接其行段与列段的边,合法摆放就是匹配;最后用 Hopcroft–Karp
求最大匹配。两条容易踩的坑是:把草地误当成墙壁(会导致 o*o 少算冲突),
以及以为逐格贪心可行(上面的
图示解析
从输入到答案的主线:
输入: m x n 网格 (# 墙 / * 草 / o 空地)
`- 冲突刻画: 激光只走四个正方向, 只有墙挡光
|- 同行: 用 # 切出「横向极大段」H <- 草地不切断
`- 同列: 用 # 切出「纵向极大段」V
`- 两个机器人冲突 <=> 同一 H 或同一 V
`- 建二分图: 左部 = H, 右部 = V, 每个 o 格连一条边
`- 机器人 <-> 边; 不冲突 <-> 边不共享端点 <-> 匹配
`- 答案 = 最大匹配
`- Hopcroft-Karp
|- BFS 分层: 记 dist 于左侧, 找最短增广路长度
`- 沿分层方向批量增广; 无增广路则停机(增广路定理)顺着读:第一步把"激光"翻译成"段"(墙是唯一的切割者),第二步把"段"翻译成"二分图的顶点", 第三步把"机器人"翻译成"边"。三种翻译做完,题目就只剩下一个标准的最大匹配, 后面的 BFS 分层与批量增广都是匹配算法的常规部分。整条链上没有任何一步依赖"局部最优", 所以贪心的反例在这里不会出现。