横向/纵向极大泥段各作二分图一侧,每个泥格在两段间连边,由 König 定理最小木板数 = 最小点覆盖 = 最大匹配,匈牙利算法求解。
OJ: roj
题目 ID: 3204
难度:提高+/省选-
标签:图论二分图最小点覆盖二分图最大匹配匈牙利算法python
创建: 2026-10-02 01:57
更新: 2026-10-10 13:00
形式化题目
给定 * 是泥泞格、. 是干净格。用若干宽度为
把网格做两次切分:
- 横向极大泥段:同一行里一段连续的
*,左右两侧顶住边界或.; - 纵向极大泥段:同一列里一段连续的
*,上下两侧顶住边界或.。
样例
| 横向段 | 位置 | 纵向段 | 位置 | |
|---|---|---|---|---|
| R0 | V0 | |||
| R1 | V1 | |||
| R2 | V2 | |||
| R3 | V3 | |||
| R4 | V4 |
行段
正解
思路
第一步:朴素做法卡在哪里。
直接搜索「每一块木板从哪开始、往哪个方向摆」是指数级的;换成枚举「哪些段放板」
也有
第二步:每个泥格都是一次「横板还是纵板」的二选一。
一个泥格
把横向段、纵向段看成顶点,泥格
这正是最小点覆盖(minimum vertex cover)的定义。所以
样例的二分图长这样(左部横向段、右部纵向段):
graph LR
subgraph L["左部:横向段"]
R0["R0"]
R1["R1"]
R2["R2"]
R3["R3"]
R4["R4"]
end
subgraph R["右部:纵向段"]
V0["V0"]
V1["V1"]
V2["V2"]
V3["V3"]
V4["V4"]
end
V0 --- R0
V1 --- R3
V2 --- R2
V2 --- R3
V3 --- R1
V3 --- R2
V3 --- R3
V3 --- R4
V4 --- R2
样例共
第三步:König 定理把点覆盖换成匹配。
最小点覆盖在一般图上是 NP-hard 的,但二分图有 König 定理:
最小点覆盖的大小
一半是显然的:匹配的
要提醒的是:数量相等,对象不等。一组最大匹配的边本身不一定是点覆盖
(样例的最大匹配
样例上验证:最大匹配是
第四步:匈牙利算法求最大匹配。
从右部(纵向段)逐点找增广路:沿未匹配边摸到一个横向段;若它还空着,直接配上;
若已被某个纵向段占用,就顺着那条匹配边下潜到那个纵向段,替它另找新横段……
这样走出一条「未匹配边、匹配边」交替的路,撞到空闲横段时把整条路翻转,
匹配数加一。seen 集合保证每轮里每个横段只被访问一次。若某个纵向段找不到
增广路,由 Berge 定理,当前匹配已是最大,直接跳过即可。
实现细节。
- 按行读整段字符串;行段编号扫描时用
seg记录「正在走的段」, 遇.归、遇 *且无段时开新段; - 列段不需要编号表(它只当匹配的发起方),扫到段首时把整段压住的行段号 收进邻接表即可;
match挂在横段上:被抢的是横段,下潜一步从横段直接跳到占用它的纵段;- 增广用显式栈(帧
纵向段、下一个待试邻居、最近想抢的横段), 避免深递归触发 Python 递归上限。
代码
/**
* 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
*/
// 木板:横木板与竖木板建二分图,König 定理下最小点覆盖 = 最大匹配
#include <cstdio>
#include <vector>
using namespace std;
struct Frame { // 匈牙利算法的显式栈帧
int v; // 当前纵向木板
int i; // 下一个待试邻居下标
int x; // 最近想抢的横向木板
};
int main() {
int n, m;
if (scanf("%d %d", &n, &m) != 2) {
return 0;
}
vector<vector<char> > grid(n, vector<char>(m + 1));
for (int i = 0; i < n; i++) {
scanf("%s", &grid[i][0]);
}
// 横向切分:每格所属横向木板编号(干净格为 -1)
vector<vector<int> > rows(n, vector<int>(m, -1));
int row_cnt = 0;
for (int i = 0; i < n; i++) {
int seg = -1;
for (int j = 0; j < m; j++) {
if (grid[i][j] == '.') {
seg = -1;
} else {
if (seg < 0) {
seg = row_cnt++;
}
}
rows[i][j] = seg;
}
}
// 纵向木板 → 它压住的横向木板编号列表
vector<vector<int> > groups;
for (int c = 0; c < m; c++) {
int r = 0;
while (r < n) {
if (grid[r][c] == '.') {
r++;
continue;
}
int start = r;
while (r < n && grid[r][c] == '*') {
r++;
}
vector<int> cur;
for (int i = start; i < r; i++) {
cur.push_back(rows[i][c]);
}
groups.push_back(cur);
}
}
// 匈牙利:每个纵向段沿「横向段已配的纵向段」下潜找增广路
vector<int> match(row_cnt, -1);
vector<char> seen(row_cnt, 0);
int total = 0;
for (int v0 = 0; v0 < (int)groups.size(); v0++) {
for (int i = 0; i < row_cnt; i++) {
seen[i] = 0;
}
vector<Frame> stk;
Frame first = {v0, 0, -1};
stk.push_back(first);
while (!stk.empty()) {
Frame& fr = stk.back();
if (fr.i == (int)groups[fr.v].size()) { // 所有出路都试过了,回溯
stk.pop_back();
continue;
}
int x = groups[fr.v][fr.i];
fr.i++;
fr.x = x;
if (seen[x]) {
continue;
}
seen[x] = 1;
if (match[x] < 0) { // 撞上空闲横向木板:交替路上每个纵向段各拿一段
for (int t = (int)stk.size() - 1; t >= 0; t--) {
match[stk[t].x] = stk[t].v;
}
total++;
stk.clear();
break;
}
Frame nf = {match[x], 0, x};
stk.push_back(nf);
}
}
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 02:00
# update_at: 2026-10-02 02:00
import sys
def row_ids(grid: list[str]) -> tuple[list[list[int]], int]:
"""横向切分:每格所属横向木板编号(干净格为 -1),返回编号表与横向木板总数。"""
table: list[list[int]] = []
total = 0
for row in grid:
ids: list[int] = []
seg = -1 # 正在走的横向段编号,-1 表示还没进入任何段
for ch in row:
if ch == '.':
seg = -1
elif seg < 0:
seg = total # 一块新的横向木板从这里开始
total += 1
ids.append(seg)
table.append(ids)
return table, total
def column_groups(grid: list[str], rows: list[list[int]]) -> list[list[int]]:
"""纵向木板 → 它压住的横向木板编号列表:每列扫到纵向段首时登记整段。"""
n, m, groups = len(grid), len(grid[0]), []
for c in range(m):
r = 0
while r < n:
if grid[r][c] == '.':
r += 1
continue
start = r
while r < n and grid[r][c] == '*':
r += 1
groups.append([rows[i][c] for i in range(start, r)])
return groups
def min_boards(groups: list[list[int]], row_cnt: int) -> int:
"""匈牙利算法:每个纵向段沿「横向段已配的纵向段」下潜找增广路,返回最大匹配。"""
match = [-1] * row_cnt # 横向木板 -> 配到的纵向木板,-1 表示还空着
total = 0
for v0 in range(len(groups)):
seen: set[int] = set()
# 显式栈代替递归:帧 = [纵向木板, 下一个待试邻居下标, 最近想抢的横向木板]
stack = [[v0, 0, -1]]
while stack:
frame = stack[-1]
u, i = frame[0], frame[1]
if i == len(groups[u]): # 这个纵向木板的所有出路都试过了,回溯
stack.pop()
continue
x = groups[u][i]
frame[1], frame[2] = i + 1, x
if x in seen: # 本轮已访问过的横向木板,再走一次没有新信息
continue
seen.add(x)
if match[x] < 0: # 撞上空闲横向木板:整条交替路上每个纵向段各拿一段
for f in reversed(stack):
match[f[2]] = f[0]
total += 1
break
stack.append([match[x], 0, x]) # 该横向木板已被占用,顺着它的匹配边走到 match[x]
return total
def solve() -> None:
data = iter(sys.stdin.buffer.read().split())
n = int(next(data))
m = int(next(data))
grid = [next(data).decode() for _ in range(n)]
rows, row_cnt = row_ids(grid) # 左部顶点:横向木板
groups = column_groups(grid, rows) # 右部顶点:纵向木板,邻接 = 压住的横向木板
# König 定理:二分图最小点覆盖 = 最大匹配,点覆盖里的每个「段」就是一块木板
print(min_boards(groups, row_cnt))
if __name__ == "__main__":
solve()复杂度
- 建图:行段扫描
,列段扫描并登记邻接 (每个泥格登记一条边)。 - 匈牙利:右部每个纵段做一轮增广 DFS,
seen保证一轮内每个横段至多被访问一次, 一轮,总复杂度 。行段、列段各 、 边数 ,上界 ;真实数据(10 组、约一半泥格)单组实测约 s,远低于限制。 - 空间:
row_ids、groups、match、seen均为。
总结
- 关键建模:横向/纵向极大泥段作二分图两侧顶点,每个泥格在两段之间连边;
「盖住每个泥格」恰是「每条边至少一端被选」,答案就是最小点覆盖
。 允许木板重叠与点覆盖「两端都选也合法」完全吻合。 - 关键定理:二分图上 König 定理
,答案直接就是最大匹配数。 注意「数量相等、对象不等」——匹配的边本身不必是点覆盖,本题只要数。 - 实现要点:列段无需编号表、
match挂在横段上、显式栈增广、seen用当轮局部集合。
图示解析
从题面到答案的主线:
网格中的每个泥格 (r,c)
│ 它同时属于一个横向极大泥段和一个纵向极大泥段
▼
行段 R = 左部 ──边=泥格── 右部 = 列段 V (按构造就是二分图)
│ 盖住泥格 ⟺ 每条边至少一端被选中(板可重叠 ⟺ 两端都选也合法)
▼
答案 = 最小点覆盖 τ(G)
│ König 定理:二分图 τ = ν
▼
答案 = 最大匹配 ν(G)
│ 匈牙利:每个纵段找一条增广路,找不到就停
▼
输出 ν(G)样例
| 最小点覆盖( |
对应的木板方案 | 盖住的泥格 |
|---|---|---|
| R0 | 横板 |
|
| R2 | 横板 |
|
| R3 | 横板 |
|
| V3 | 纵板 |
最大匹配(
读表时注意两点:点覆盖里