把任务看成 A、B 模式之间的边,最小重启次数等于二分图最小点覆盖,由 König 定理化为最大匹配。
OJ: roj
题目 ID: 3203
难度:提高
标签:图论二分图匹配最小点覆盖匈牙利算法
创建: 2026-10-02 01:34
更新: 2026-10-10 13:00
形式化题目
有两台机器
任务可以按任意顺序执行。一台机器每切换一次模式记一次重启,求完成全部任务所需的最少重启次数。
约束:
正解
思路
朴素做法:枚举每个任务分给
关键观察:顺序其实是无关变量。 对一台机器来说,重要的是"它最终要经过哪些模式"。
若分配给
坑点:模式
化为最小点覆盖。 删掉免费任务后,把所有"要付钱的模式"建成点:
- 左部点:
的模式 ; - 右部点:
的模式 ; - 剩下每个任务
连一条无向边 。
选哪些模式就是"选图中的哪些点",代价是选中点数;而"每个任务至少被一台机器覆盖"
就是"每条边至少有一个端点被选中"——这正是点覆盖的定义。
度为
König 定理。 图是二分图(左侧只有
举个例子:题面样例
graph LR
subgraph LA["A 的模式(左部)"]
direction TB
A1["1"]
A2["2"]
A3["3"]
A4["4"]
end
subgraph RB["B 的模式(右部)"]
direction TB
B1["1"]
B2["2"]
B3["3"]
B4["4"]
end
A1 --- B1
A1 --- B2
A1 --- B3
A1 --- B4
A2 --- B1
A2 --- B2
A2 --- B3
A2 --- B4
A3 --- B3
A4 --- B3
任务的分配对应"每条边挑一个端点",重启次数对应"被挑中的点数"。
最小的挑法是选
最大匹配用 Hopcroft–Karp。 每一轮先 BFS 给左部点分层并求出最短增广路的长度,
再沿分层方向一次性把所有这么长的增广路都增广掉。每轮至少消掉一层,
最多
代码
/**
* 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 二分图最大匹配(多组数据,读到 0 结束)
#include <cstdio>
#include <vector>
using namespace std;
const int INF = 1 << 30;
int n, right_size;
vector<vector<int> > adj;
vector<int> match_l, match_r, dist;
// 给左部点分层,返回最短增广路的边数;没有增广路时返回 INF
int shortest_augmenting_length() {
vector<int> q;
for (int u = 0; u < n; u++) {
bool free_l = 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);
}
}
}
return shortest;
}
// 从已分层的左部点 u 出发找长度恰为 shortest 的增广路,成功就翻转匹配
bool try_augment(int u, int shortest) {
for (int i = 0; i < (int)adj[u].size(); i++) {
int v = adj[u][i];
int nx = match_r[v];
bool grown;
if (nx < 0) {
grown = (dist[u] + 1 == shortest);
} else {
grown = (dist[nx] == dist[u] + 1) && try_augment(nx, shortest);
}
if (grown) {
match_l[u] = v;
match_r[v] = u;
return true;
}
}
dist[u] = INF; // 这个点往下到不了自由右部点,本轮放弃它
return false;
}
int max_matching() {
int total = 0;
while (true) {
int shortest = shortest_augmenting_length();
if (shortest == INF) {
return total;
}
for (int u = 0; u < n; u++) {
if (match_l[u] < 0 && try_augment(u, shortest)) {
total++;
}
}
}
}
int main() {
int printed = 0;
while (true) {
if (scanf("%d", &n) != 1) {
break;
}
if (n == 0) {
break;
}
int m, k;
scanf("%d %d", &m, &k);
adj.assign(n, vector<int>());
for (int i = 0; i < k; i++) {
int task, a, b;
scanf("%d %d %d", &task, &a, &b);
if (a && b) { // 用初始模式就能做的任务不产生约束
adj[a].push_back(b);
}
}
right_size = m;
match_l.assign(n, -1);
match_r.assign(m, -1);
dist.assign(n, INF);
printf("%d\n", max_matching());
printed++;
}
if (printed == 0) {
printf("\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 01:34
# update_at: 2026-10-02 01:42
import sys
from collections import deque
INF = 1 << 30 # 层号哨兵:比任何合法层号都大,表示"这个点还没被 BFS 访问到"
def shortest_augmenting_length(adj: list[list[int]], match_l: list[int],
match_r: list[int], dist: list[int]) -> int:
"""给左部点分层,返回最短增广路的边数;一条增广路都没有时返回 INF。
从每个未匹配左部点出发,沿"未匹配边 → 匹配边"交替走:走到右部点 v 后,
下一步只能沿 v 的匹配边回到 match_r[v],所以层号只需记在左部点上。
BFS 第一次碰到未匹配右部点得到的层数就是最短增广路长度。
"""
queue = deque()
for u in range(len(adj)):
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]:
nxt = match_r[v]
if nxt < 0: # 右部点空闲:找到一条增广路
shortest = dist[u] + 1
elif dist[nxt] == INF: # 已匹配右部点:顺着它的匹配边走到左部点 nxt
dist[nxt] = dist[u] + 1
queue.append(nxt)
return shortest
def try_augment(u: int, adj: list[list[int]], match_l: list[int], match_r: list[int],
dist: list[int], shortest: int) -> bool:
"""从已分层的左部点 u 出发找一条长度恰为 shortest 的增广路,成功就翻转匹配。
只沿分层方向 dist + 1 往下走,所以递归深度不超过层数(< N),无需放开递归上限。
"""
for v in adj[u]:
nxt = match_r[v]
if nxt < 0:
grown = dist[u] + 1 == shortest # 只有最短增广路才在本轮使用
else:
grown = dist[nxt] == dist[u] + 1 and try_augment(
nxt, adj, match_l, match_r, dist, shortest)
if grown:
match_l[u], match_r[v] = v, u
return True
dist[u] = INF # 这个点往下到不了自由右部点,本轮放弃它
return False
def max_matching(adj: list[list[int]], right_size: int) -> int:
"""Hopcroft–Karp:反复"分层 + 沿最短增广路批量增广",直到不存在增广路。
每轮把这一层能用的增广路一次用完,所以最多 O(√V) 轮;由增广路定理,
不存在增广路时的匹配就是最大匹配。
"""
match_l = [-1] * len(adj) # 左部点的匹配对象,-1 表示未匹配
match_r = [-1] * right_size # 右部点的匹配对象,-1 表示未匹配
dist = [INF] * len(adj)
total = 0
while True:
shortest = shortest_augmenting_length(adj, match_l, match_r, dist)
if shortest == INF:
return total
total += sum(try_augment(u, adj, match_l, match_r, dist, shortest)
for u in range(len(adj)) if match_l[u] < 0)
def solve() -> None:
data = iter(map(int, sys.stdin.buffer.read().split()))
out: list[str] = []
for n in data: # 终止行只有一个 0,它落在 N 的位置上
if n == 0:
break
m, k = next(data), next(data)
# 模式 0 是初始状态:用它执行的任务不需要重启,不产生任何约束,
# 因此只把 a[i] != 0 且 b[i] != 0 的任务建成二分图的边。
adj: list[list[int]] = [[] for _ in range(n)]
for _ in range(k):
_i, a, b = next(data), next(data), next(data) # 任务编号只作标识,不参与计算
if a and b:
adj[a].append(b)
out.append(str(max_matching(adj, m)))
print('\n'.join(out))
if __name__ == "__main__":
solve()复杂度
- 时间复杂度:Hopcroft–Karp 为
,其中 、 ,单组数据在 量级,多组数据也能在 1000ms 内轻松跑完。 - 空间复杂度:
,即邻接表与匹配、层号数组。
总结
本题的推导是一条直线:先证明执行顺序无关(一台机器的代价只等于它需要的非
图示解析
样例的最优分配
下表逐任务展示上图中一组最优选点;"说明"列写出该任务是被哪台机器的哪个模式覆盖的。 (这些数字是把样例输入跑一遍最大匹配 + König 构造得到的,不是手写的。)
| 任务 |
分配给谁 | 说明 | ||
|---|---|---|---|---|
| 0 | 1 | 1 | A | A 选模式 1 |
| 1 | 1 | 2 | A | A 选模式 1 |
| 2 | 1 | 3 | A | A 选模式 1 |
| 3 | 1 | 4 | A | A 选模式 1 |
| 4 | 2 | 1 | A | A 选模式 2 |
| 5 | 2 | 2 | A | A 选模式 2 |
| 6 | 2 | 3 | A | A 选模式 2 |
| 7 | 2 | 4 | A | A 选模式 2 |
| 8 | 3 | 3 | B | B 选模式 3 |
| 9 | 4 | 3 | B | B 选模式 3 |
选中的非
为什么模式 0 必须排除
把这个坑拆开看:下面两个输入仅在"是否把 0 当普通模式建边"这一点上有差别。
| 输入 | 正确做法(删除 0 任务) | 错误做法(把 0 当普通模式建边) |
|---|---|---|
3 3 3 / 0 0 1 / 1 1 0 / 2 2 2 |
1 | 3 |
| 题面样例(无 0 任务) | 3 | 3(无 0 边,两者相同) |
第一行说明偏大量并不固定为
根源是模式
这个坑在本题的评测数据上会真实扣分:10 个数据文件共 19 个测试组,
其中 15 组会因为把 0 当普通模式而偏大(各偏大