把「两两不可达的最多点数」翻译成 DAG 最小路径点覆盖,先求可达闭包再拆点跑二分图最大匹配,答案 = N - 最大匹配。
OJ: roj
题目 ID: 3206
难度:提高
标签:图论二分图匹配匈牙利算法DAG最小路径点覆盖
创建: 2026-10-02 01:44
更新: 2026-10-10 13:00
形式化题目
给定一张
等价的说法:在
正解
思路
第一步:看清“两两不可达”是什么
“任意两个藏身点之间都没有路径相连”就是:选出的集合里不存在
直接枚举子集需要
第二步:用 Dilworth 定理换成链覆盖
Dilworth 定理:有限偏序集中,最大反链的大小等于覆盖全集的链的最少条数。
至少有一个方向的直觉是清楚的:一条链上任意两点都可比较,所以一条链里最多放一个反链元素;
用
于是问题变成:用最少的链覆盖全部点,这就是 DAG 最小路径点覆盖。
这里有一个容易被忽略的点:题面说“从
第三步:最小路径点覆盖 $= N - $ 最大匹配
把每个点
理由:把匹配边理解成链内的“箭头”。一条链
为什么要排除
第四步:两个方向夹出答案
记
这一步用二分图的 König 定理(二分图中最小点覆盖
:每个点 最多只有两个副本, 总共才 个副本, 所以“两个副本都不在 里”的点至少 个。 是反链:若 且 能到达 ,则拆点图里有边 , 而它的两个端点都不在 里,与 是点覆盖矛盾。
于是我们真的找到了不少于
这也顺便把 Dilworth 定理的反方向补齐了,整个过程没有黑箱。
注意
样例对照
先把样例的原始边画出来(
flowchart LR
n1((1)) --> n2((2))
n3((3)) --> n2
n2 --> n4((4))
n4 --> n5((5))
n4 --> n6((6))
n7((7))
图中有两条带分支的路径
把这张图“补全成可达关系”,得到下表(均不含自身)。BFS 的闭包结果与原始出边的差别, 就是本题最容易做错的地方:
| 原始出边 | 能到达的点 | |
|---|---|---|
| 1 | 2 | 2, 4, 5, 6 |
| 2 | 4 | 4, 5, 6 |
| 3 | 2 | 2, 4, 5, 6 |
| 4 | 5, 6 | 5, 6 |
| 5 | — | — |
| 6 | — | — |
| 7 | — | — |
对照第 1 行:原始出边只有
在闭包二分图上取最大匹配
| 链 | 点数 | 链内箭头 |
|---|---|---|
| 4 | ||
| 2 | ||
| 1 | 无 |
一共 3 条链,箭头总数
算法流程
- 读入
与 条边(允许重边, 可以超过 ,必须按 计数读入)。 - 对每个起点做一次 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
*/
// 最小路径覆盖:先预传递成闭包,再求二分图最大匹配,答案是 n - 最大匹配
#include <cstdio>
#include <vector>
using namespace std;
struct Frame { // 匈牙利算法的显式栈帧
int u; // 左部点
int i; // 下一个待试邻居下标
int v; // 选中的右部点
};
int main() {
int n, m;
if (scanf("%d %d", &n, &m) != 2) {
return 0;
}
vector<vector<int> > adj(n + 1);
for (int i = 0; i < m; i++) {
int x, y;
scanf("%d %d", &x, &y);
adj[x].push_back(y);
}
// 每个点沿有向边能到达的点集(不含自己)
vector<vector<int> > reach(n + 1);
for (int src = 1; src <= n; src++) {
vector<char> seen(n + 1, 0);
vector<int> q = adj[src];
for (int i = 0; i < (int)q.size(); i++) {
seen[q[i]] = 1; // 先整层标记,避免多边把同一个点重复入队
}
for (int h = 0; h < (int)q.size(); h++) {
int u = q[h];
for (int i = 0; i < (int)adj[u].size(); i++) {
int v = adj[u][i];
if (!seen[v]) {
seen[v] = 1;
q.push_back(v);
}
}
}
reach[src] = q;
}
vector<int> match_l(n + 1, 0), match_r(n + 1, 0); // 0 表示未配对
int pairs = 0;
for (int root = 1; root <= n; root++) {
if (match_l[root]) {
continue;
}
vector<char> used(n + 1, 0); // 本次搜索走过的右部点
vector<Frame> stk;
Frame first = {root, 0, 0};
stk.push_back(first);
while (!stk.empty()) {
Frame& fr = stk.back();
vector<int>& nbr = reach[fr.u];
int i = fr.i;
while (i < (int)nbr.size() && used[nbr[i]]) {
i++;
}
fr.i = i + 1;
if (i == (int)nbr.size()) { // 没有别的出路,回溯
stk.pop_back();
continue;
}
int v = nbr[i];
used[v] = 1;
fr.v = v;
if (match_r[v] == 0) { // 右部点空闲,增广路打通
break;
}
Frame nf = {match_r[v], 0, 0}; // 让 v 的原配点另寻出路
stk.push_back(nf);
}
if (!stk.empty()) { // 栈非空说明增广成功
for (int t = 0; t < (int)stk.size(); t++) {
match_l[stk[t].u] = stk[t].v;
match_r[stk[t].v] = stk[t].u;
}
pairs++;
}
}
printf("%d\n", n - pairs);
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:44
# update_at: 2026-10-02 02:12
import sys
def reachable_sets(adj: list[list[int]], n: int) -> list[list[int]]:
"""每个点沿有向边能到达的点集(不含自己),即把图预传递成闭包。"""
reach: list[list[int]] = [[] for _ in range(n + 1)]
for src in range(1, n + 1):
seen = [False] * (n + 1)
queue = adj[src][:]
for v in queue:
seen[v] = True # 先整层标记,避免多边把同一个点重复入队
head = 0
while head < len(queue):
u = queue[head]
head += 1
for v in adj[u]:
if not seen[v]:
seen[v] = True
queue.append(v)
reach[src] = queue
return reach
def max_matching(reach: list[list[int]], n: int) -> int:
"""二分图最大匹配(匈牙利)。左部点 x 连向右部点 y,当且仅当 x 能到达 y。
用迭代版 DFS 代替递归:栈帧是 [左部点, 下一个待试邻居下标, 选中的右部点],
找到空闲右部点时整条交替路一起换匹配边,就完成一次增广。
每个左部点只需搜一次:处理完前 i 个左部点后,当前匹配已是它们诱导子图上的最大匹配。
"""
match_l = [0] * (n + 1) # 左部点 x 配到的右部点,0 表示未配对
match_r = [0] * (n + 1) # 右部点 y 配到的左部点,0 表示空闲
pairs = 0
for root in range(1, n + 1):
if match_l[root]:
continue
used = [False] * (n + 1) # 本次搜索走过的右部点,防止来回绕圈
stack = [[root, 0, 0]]
while stack:
frame = stack[-1]
nbr = reach[frame[0]]
i = frame[1]
while i < len(nbr) and used[nbr[i]]:
i += 1
frame[1] = i + 1
if i == len(nbr): # 该左部点没有别的出路,回溯上一层
stack.pop()
continue
v = nbr[i]
used[v] = True
frame[2] = v
if match_r[v] == 0: # 右部点空闲,增广路打通
break
stack.append([match_r[v], 0, 0]) # 让 v 的原配点另寻出路
if stack: # 栈非空说明增广成功,栈里就是从 root 出发的交替路
for u, _i, v in stack: # 交替路上每条边换成匹配边
match_l[u] = v
match_r[v] = u
pairs += 1
return pairs
def solve() -> None:
data = iter(map(int, sys.stdin.buffer.read().split()))
n, m = next(data), next(data)
adj: list[list[int]] = [[] for _ in range(n + 1)]
for _ in range(m):
x, y = next(data), next(data)
adj[x].append(y)
reach = reachable_sets(adj, n)
# 最小路径点覆盖 = n - 最大匹配;答案正是这个最小值
print(n - max_matching(reach, n))
if __name__ == "__main__":
solve()复杂度
设
- 传递闭包:对每个起点 BFS 一次,
。 时约 次操作。 - 最大匹配:匈牙利算法,每个左部点增广一次、每次搜索最多扫过
条闭包边, 合计 ,即最坏约 次操作。20 组官方数据实测最慢 0.022s。 - 空间:邻接表
;闭包 个整数(最坏 39800 个); 匹配数组 。实测峰值内存约 15.7MB,远低于 128MB。
与标准 C++ 正解(传递闭包 + 匈牙利)同阶,没有降级成暴力。
总结
- 「两两不可达的最多点数」就是可达偏序下的最大反链;
- Dilworth 定理把它换成 DAG 最小路径点覆盖,而链的“连续”是指可达连续, 所以先做传递闭包,而不是直接用原图边;
- 拆点建二分图后,最小路径点覆盖
最大匹配,用匈牙利算法求解即可; 再用 König 定理就能把“答案 最小链覆盖”的另一个方向补齐,公式不靠猜; - 实现上两处关键:闭包时必须排除自身(否则产生假的匹配边), 以及链是“可达连续”而非“边连续”。
图示解析
从题面到答案只有一条主线,四步都是等价的改写:
题面:最多选几个两两不能互望的点
│ 互望 = 存在有向路径
▼
DAG 可达偏序下的最大反链
│ Dilworth 定理
▼
DAG 最小路径点覆盖(链 = 可达连续,需传递闭包)
│ 拆点建二分图:可达 u≺v ⇒ 边 uL→vR
▼
N - 二分图最大匹配(匈牙利算法)读图要点:每一步都是等价改写而不是近似,所以最终输出的