把「每个王子能娶谁」化为二分图完美匹配的可行边问题:以给定初配构造交替有向图,非匹配边可行当且仅当两端同属一个强连通分量。
OJ: roj
题目 ID: 3223
难度:提高+/省选-
标签:图论二分图匹配强连通分量Tarjan
创建: 2026-10-02 03:00
更新: 2026-10-10 11:30
形式化题目
有
对每个王子
; - 存在一个包含
的完美匹配——也就是把 配给 之后, 剩下的 个王子仍能各自配上自己喜欢的、互不重复的姑娘。
把它画成二分图
每条边
是否属于某个完美匹配?
这类边称为完美匹配的可行边。注意问的是"存在性",不是"在
以样例(
graph LR
subgraph boys["王子"]
b1((1))
b2((2))
b3((3))
b4((4))
end
subgraph girls["姑娘"]
g1((1))
g2((2))
g3((3))
g4((4))
end
b1 ==> g1
b2 ==> g2
b3 ==> g3
b4 ==> g4
b1 --> g2
b2 --> g1
b3 --> g2
b4 --> g3
粗箭头是初配
正解
思路
第一步:朴素做法为什么不行
最直接的做法是逐条边判定:把
这个做法完全正确,但代价太大。设
真正浪费的地方在于:我们把同一张图重跑了
第二步:新的完美匹配 = 把若干交替环翻面
设
- 每个点度数至多 2:完美匹配中每个点度数恰为 1,
用一次、 用一次。 于是 的连通分量只能是环或路径; 而路径的端点只能被 中的一个覆盖,可两者都覆盖了全部点,矛盾。 所以每个分量都是环,并且环上 的边与 的边严格交替。 - 交替环可以整体翻面:在交替环
上把 的边换成非 的边,得到的 仍然是完美匹配——环上每个点依然恰好关联一条匹配边,环外的点不受影响。
两件事合起来,就得到了可行边的刻画:
- “
”:翻面后得到的完美匹配含 。 - “
”:若存在含 的完美匹配 ,则 分解为交替环, 而 ,它所在的分量就是包含它的那个交替环。
于是问题从"
第三步:把交替环变成有向图的强连通
交替环在无向图里不好直接找,但它天生带方向:沿着环走,一定是
"非匹配边(王子→姑娘)"和"匹配边(姑娘→王子)"交替出现。那就照这个方向给边定向,
建一张有
- 对每条非匹配边
:连有向边 (王子 → 姑娘); - 对每条匹配边
:连有向边 (姑娘 → 王子)。
这样一来:
中任意一条有向路天然就是一条交替路(方向强制了交替); - 交替环
中的有向环 环上任意两点强连通。
所以对一条非匹配边
这一步的两边都站得住:
- 可行
同 SCC:若存在含 的完美匹配 ,则 所在的交替环 按上面的定向恰好是一条有向环,所以 互相可达。 - 同 SCC
可行: 本身就是一条边,再加上 的有向路, 得到一条以 开头的有向闭走;取其中最短的一条,它不会重复顶点 (否则重复点之间的那段可以删掉,得到更短的),因而是一个包含 的简单有向环, 也就是包含 的交替环,翻面后即得含 的完美匹配。
匹配边
把样例的交替有向图画出来(初配为
graph LR
b1((b1)) --> g2((g2))
g2 --> b2((b2))
b2 --> g1((g1))
g1 --> b1
b3((b3)) --> g2
b4((b4)) --> g3((g3))
g3 --> b3
g4((g4)) --> b4
互相可达的是
据此逐边判定,样例的每条边结果如下表。表中写出每条边两端各自所在的 SCC, 两端相同(或该边是匹配边)才可行。
| 边 | 类型 | 王子点所在 SCC | 姑娘点所在 SCC | 可行 |
|---|---|---|---|---|
| 匹配边 | 是(本来就在 |
|||
| 非匹配边 | 同左 | 是(交替环 |
||
| 非匹配边 | 同左 | 是(同一个交替环) | ||
| 匹配边 | 同左 | 是 | ||
| 非匹配边 | 否: |
|||
| 匹配边 | 是(匹配边无条件可行) | |||
| 非匹配边 | 否:两端不同 SCC | |||
| 匹配边 | 是 |
表里的第 5 行最值得注意:它是本题最容易写错的地方。
1 3。
反过来第 6、8 行说明匹配边绝不能去查 SCC:
把每行可行的边按王子整理,就得到样例输出:王子
2 1 2
2 1 2
1 3
1 4其中王子
算法流程
- 读入喜欢清单,把姑娘编号统一减一;
- 由初配
建反向映射 taken_by[姑娘] = 王子; - 按上面的规则建有向图
; - 跑一次 Tarjan 求 SCC,得到
comp[]; - 对每个王子,答案是"初配对象"加上"所有满足
comp[王子] == comp[姑娘]的喜欢对象",排序输出。
代码
/**
* 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 MAXN = 4005;
vector<int> graph[MAXN];
int order_[MAXN], low[MAXN], comp[MAXN];
char on_stack[MAXN];
int comp_cnt;
// Tarjan 迭代版求 SCC,返回每个点的分量编号
void scc_ids(int size) {
for (int i = 0; i < size; i++) { order_[i] = 0; low[i] = 0; comp[i] = -1; on_stack[i] = 0; }
vector<int> stack;
int clock_ = 0;
comp_cnt = 0;
for (int root = 0; root < size; root++) {
if (order_[root]) continue;
vector<pair<int, int> > work;
work.push_back(make_pair(root, 0));
while (!work.empty()) {
int v = work.back().first;
int edge_at = work.back().second;
if (!order_[v]) {
clock_++;
order_[v] = low[v] = clock_;
stack.push_back(v);
on_stack[v] = 1;
}
bool descended = false;
for (int i = edge_at; i < (int)graph[v].size(); i++) {
int w = graph[v][i];
if (!order_[w]) {
work.back().second = i + 1;
work.push_back(make_pair(w, 0));
descended = true;
break;
}
if (on_stack[w]) {
if (order_[w] < low[v]) low[v] = order_[w];
}
}
if (descended) continue;
if (low[v] == order_[v]) {
while (true) {
int w = stack.back(); stack.pop_back();
on_stack[w] = 0;
comp[w] = comp_cnt;
if (w == v) break;
}
comp_cnt++;
}
work.pop_back();
if (!work.empty()) {
int parent = work.back().first;
if (low[v] < low[parent]) low[parent] = low[v];
}
}
}
}
int main() {
ios::sync_with_stdio(false);
cin.tie(nullptr);
int n;
cin >> n;
// liked[boy]:喜欢的姑娘(0 基)
vector<vector<int> > liked(n);
for (int boy = 0; boy < n; boy++) {
int k;
cin >> k;
for (int j = 0; j < k; j++) {
int g; cin >> g;
liked[boy].push_back(g - 1);
}
}
int init_match[4005];
int taken_by[4005];
for (int boy = 0; boy < n; boy++) {
cin >> init_match[boy];
init_match[boy]--;
}
for (int i = 0; i < n; i++) taken_by[i] = 0;
for (int boy = 0; boy < n; boy++) taken_by[init_match[boy]] = boy;
// 有向图:非匹配边 王子->姑娘,匹配边 姑娘->王子
for (int i = 0; i < 2 * n; i++) graph[i].clear();
for (int boy = 0; boy < n; boy++) {
for (size_t t = 0; t < liked[boy].size(); t++) {
int g = liked[boy][t];
if (g != init_match[boy]) graph[boy].push_back(n + g);
}
}
for (int girl = 0; girl < n; girl++)
graph[n + girl].push_back(taken_by[girl]);
scc_ids(2 * n);
for (int boy = 0; boy < n; boy++) {
vector<int> girls;
for (size_t t = 0; t < liked[boy].size(); t++) {
int g = liked[boy][t];
if (g == init_match[boy] || comp[boy] == comp[n + g])
girls.push_back(g);
}
sort(girls.begin(), girls.end());
cout << girls.size();
for (size_t t = 0; t < girls.size(); t++)
cout << " " << girls[t] + 1;
cout << "\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 03:05
# update_at: 2026-10-02 03:05
import sys
def scc_ids(size: int, graph: list[list[int]]) -> list[int]:
"""Tarjan 迭代版:返回每个点的强连通分量编号(编号本身无意义,只用来判「是否相等」)。"""
order = [0] * size # 0 = 还没访问过,其它值是访问时间戳
low = [0] * size # low[v]:v 的子树能回溯到的最早时间戳
on_stack = bytearray(size) # 是否仍在 Tarjan 栈里
comp = [-1] * size
stack: list[int] = []
clock = 0 # 时间戳计数器
count = 0 # 已收拢的分量数
for root in range(size):
if order[root]:
continue
work = [(root, 0)] # (当前点, 下一条待检查的邻接边下标),代替递归调用的显式栈
while work:
v, edge_at = work[-1]
if not order[v]: # 首次进入 v:打时间戳,压入 Tarjan 栈
clock += 1
order[v] = low[v] = clock
stack.append(v)
on_stack[v] = 1
descended = False
for i in range(edge_at, len(graph[v])):
w = graph[v][i]
if not order[w]: # 树边:先深入 w,回来再继续扫
work[-1] = (v, i + 1)
work.append((w, 0))
descended = True
break
if on_stack[w]: # 回边/横叉边:用 w 的时间戳收缩 low
low[v] = min(low[v], order[w])
if descended:
continue
if low[v] == order[v]: # v 是所在分量的根,把整个分量弹出
while True:
w = stack.pop()
on_stack[w] = 0
comp[w] = count
if w == v:
break
count += 1
work.pop()
if work: # 回到父节点,孩子的 low 能向上传播
low[work[-1][0]] = min(low[work[-1][0]], low[v])
return comp
def solve() -> None:
data = iter(map(int, sys.stdin.buffer.read().split()))
n = next(data)
liked: list[list[int]] = []
for _ in range(n):
k = next(data)
liked.append([next(data) - 1 for _ in range(k)]) # 姑娘编号统一转成 0 起
init_match = [next(data) - 1 for _ in range(n)] # 初步配对:第 i 个王子的对象
taken_by = [0] * n # 逆映射:该姑娘由哪个王子占着
for boy, girl in enumerate(init_match):
taken_by[girl] = boy
# 建有向图:非匹配边 王子→姑娘,匹配边 姑娘→王子。
# 这样图里的有向环恰好是「关于初配的交替环」:沿环翻转匹配关系,就得到另一个完美匹配。
graph: list[list[int]] = [[] for _ in range(2 * n)]
for boy, girls in enumerate(liked):
graph[boy] = [n + g for g in girls if g != init_match[boy]]
for girl, boy in enumerate(taken_by):
graph[n + girl] = [boy]
comp = scc_ids(2 * n, graph)
out: list[str] = []
for boy in range(n):
# 非匹配边可行 ⟺ 两端同属一个 SCC(能绕成交替环);
# 匹配边本身就是初配,永远可行,无需任何条件。
girls = sorted(g for g in liked[boy] if g == init_match[boy] or comp[boy] == comp[n + g])
out.append(' '.join(map(str, [len(girls)] + [g + 1 for g in girls])))
sys.stdout.write('\n'.join(out) + '\n')
if __name__ == "__main__":
solve()复杂度
设
- 建图:每个王子贡献
条非匹配边,每个姑娘贡献 1 条匹配边,共 ; - 求 SCC:
有 个点、 条边,Tarjan 线性, ; - 枚举输出:每份清单扫一遍再排序,最坏
; - 合计
。
与朴素做法
空间上,邻接表与 Tarjan 的若干数组都是
实现上有一处与复杂度无关但影响正确性的细节:scc_ids 用显式栈迭代实现,不依赖递归深度,
也不需要调整 sys.setrecursionlimit。
总结
- 王子"能娶谁"= 二分图完美匹配的可行边:存在含这条边的完美匹配;
- 题面白送的初配
是解题的核心工具:任何另一个完美匹配 与 的对称差 分解为交替环,在交替环上翻面就得到新的完美匹配; - 于是
可行 ,或 落在某个交替环上; - 把非匹配边定向为王子→姑娘、匹配边定向为姑娘→王子,得到交替有向图
, 交替环就变成有向环,“落在同一个交替环上"就变成"强连通”; - 一次 Tarjan 求出全部 SCC,每条非匹配边
判定,总复杂度 ; - 两个易错点:匹配边无条件可行(不必两端同 SCC), 而非匹配边必须两端都在同一 SCC(只要求目标点在大 SCC 里是错的)。
图示解析
整道题的推理主线只有三次改写:
题面:每个王子能娶谁?
`- 任选一名喜欢的姑娘后,其余人仍能配齐 = 含该边的完美匹配存在
`- 二分图完美匹配的可行边问题
|- 已知初配方案 S,任何其他完美匹配 S'
| 与 S 的对称差 = 若干交替环,环上翻面即得新匹配
|- 结论:(u,v) 可行 ⇔ (u,v) ∈ S 或 (u,v) 在某个交替环上
| `- 环难找?给边定向:
| 非匹配边 王子→姑娘,匹配边 姑娘→王子
| `- 交替环 = 有向环;同环 = 强连通
`- 一次 Tarjan 求 SCC
`- 非匹配边看两端是否同 SCC;匹配边直接可行先看第一层改写:它把"逐个尝试配对"变成了"判断一条边的可行性",
这是后面能统一处理的前提。第二层是整题的枢纽——对称差分解为交替环,
它说明新方案只是在旧方案上"翻若干个环",于是完全不必重新匹配。
第三层把环的寻找翻译成强连通判定,让所有边共享同一次 SCC 计算;
最后一步用规则 (u,v) ∈ S 或 scc(u)=scc(v) 直接给出每行答案。