按怨气值从大到小逐条加入异监约束,带权并查集传递同监/异监关系,第一个被强制同监的边就是最小的最大冲突,O(M log M + M α(N))。
OJ: roj
题目 ID: 3581
难度:普及+/提高-
标签:图论并查集带权并查集贪心python
创建: 2026-10-02 08:48
更新: 2026-10-10 11:30
形式化题目
给定无向带权图
样例是一张 4 个点、6 条边的图,边上的数字为怨气值:
graph G {
layout=neato;
node [shape=circle, fontname="Helvetica"];
1 -- 4 [label="2534"];
2 -- 3 [label="3512"];
1 -- 2 [label="28351"];
1 -- 3 [label="6618"];
2 -- 4 [label="1805"];
3 -- 4 [label="12884"];
}
读图时注意两点。第一,边权差距悬殊(
正解
思路
朴素做法:枚举所有分法。 暴力枚举
瓶颈与关键观察。 瓶颈在"枚举方案",而不是"计算某个方案的代价"。顺着答案的结构走:
- 答案只有
个候选值: 或某条边的权值——因为目标函数就是生效边权值的最大值。 - 单调性:固定一个阈值
,问"能不能染色让权值 的边全部异色"。 越大需要异色的边越少,判定只会更容易。判定本身就是"边权 的子图上做二分图判定", ,于是得到二分答案写法 ——它已经与正解同阶。 - 把二分压成一次降序扫描:既然
越大越"松",就不必二分,直接把所有边按权值 从大到小处理,逐条加入"两端必须异色"的约束。约束系统一旦装不下当前这条边 (它的两端被之前的约束强制同色),当前权值就是答案;全部边装得下则输出 。 这等价于在权值轴上从大到小试 ,"首次矛盾"就是最小可行的 。
约束怎么表达:带权(种类)并查集。 每条约束都是"两端异监",状态只有同监/异监两种,
用二进制位表示即可。并查集里 FA[x] 是父节点,REL[x] 记录 find 路径压缩时沿链把 REL 异或累计,使 REL[x] 始终表示
、 异根 → 合并两棵约束树,并令 REL[ra] = REL[a] ^ REL[b] ^ 1, 这个取值恰好使,即强制 、 异监; 、 同根 → 它们的相对关系已确定,等于 REL[a] ^ REL[b]:结果为说明约束早已满足,跳过; 结果为 说明 、 已被强制同监,当前边 躲不掉,返回 。
用样例走一遍降序扫描(≡ 表示被约束为同监):
| 序号 | 处理边(怨气值) | 新增约束 / 判定 | 当前推出的阵营关系(≡ 同监) |
|---|---|---|---|
| 1 | (1, 2) 28351 | 新增:1 ≠ 2 | (尚无被迫同监的罪犯) |
| 2 | (3, 4) 12884 | 新增:3 ≠ 4 | (尚无被迫同监的罪犯) |
| 3 | (1, 3) 6618 | 新增:1 ≠ 3 | 1 ≡ 4、2 ≡ 3 |
| 4 | (2, 3) 3512 | 2、3 已被强制同监 → 返回 3512 | 1 ≡ 4、2 ≡ 3 |
| — | 后续 2 条边 | 不再处理 | 答案锁定为 3512 |
第 3 步是全题的缩影:
为什么返回的
- 下界:任取一种染色。若
中有边生效,它的权值 ,代价 ; 否则 的约束全部成立,而 已把 、 传递成必须同色,于是 生效,代价 。 两种情况都得到代价 ,因此最小代价不可能低于 。 - 上界:并查集只在两端异根时合并、同根且已异监时跳过,因此除返回那一刻外,
约束系统始终可满足——
存在一个合法二染色,在它之下 中的边全部异监, 只有权值 的边可能生效,代价至多 。
上下界夹逼,
实现上的三个要点。
- 惰性读入:逐行
line.split()转整数,不一次性生成几十万个 token 字符串。 - 边打包成一个整数再排序:
(c << 40) | (a << 20) | b()。 降序排序等价于"先按怨气值降序",而单个整数比三元组省下十余 MB——为的是守住 config 的 32MB 内存限制。 - 两趟式路径压缩:先沿父链收集
path到根,再reversed(path)从根往回累计异或并直接挂到根。 每个节点只在被访问时读一次自己的旧REL,读完立刻改写,不会读到被覆盖后的脏值。
代码
/**
* 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;
int FA[20005];
int REL[20005]; // 节点与父节点的仇敌关系:0 同监狱,1 不同监狱
int find(int x) {
if (FA[x] == x) return x;
int root = find(FA[x]);
REL[x] ^= REL[FA[x]];
FA[x] = root;
return root;
}
struct Edge { ll c; int a, b; };
Edge edges[100005];
int main() {
ios::sync_with_stdio(false);
cin.tie(nullptr);
int n, m;
cin >> n >> m;
for (int i = 0; i < m; i++) {
int a, b;
ll c;
cin >> a >> b >> c;
edges[i].a = a; edges[i].b = b; edges[i].c = c;
}
sort(edges, edges + m, [](const Edge& x, const Edge& y) { return x.c > y.c; });
for (int i = 1; i <= n; i++) { FA[i] = i; REL[i] = 0; }
for (int i = 0; i < m; i++) {
int a = edges[i].a, b = edges[i].b;
ll c = edges[i].c;
int ra = find(a), rb = find(b);
if (ra == rb) {
if ((REL[a] ^ REL[b]) == 0) {
cout << c << "\n";
return 0;
}
} else {
FA[ra] = rb;
REL[ra] = REL[a] ^ REL[b] ^ 1;
}
}
cout << "0\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 08:50
# update_at: 2026-10-02 09:05
import sys
V_BITS = 20 # 顶点编号占 20 位:N ≤ 20000 < 2^20,怨气值放在高 40 位
V_MASK = (1 << V_BITS) - 1
FA: list[int] = [] # 并查集父节点,下标 0 不用
REL: list[int] = [] # 节点与父节点的仇敌关系:0 同监狱,1 不同监狱
def find(x: int) -> int:
"""找根并路径压缩;返回时 REL[x] 更新为 x 与根的仇敌关系(0 同监,1 异监)。"""
path: list[int] = []
while FA[x] != x:
path.append(x)
x = FA[x]
# 从根往回累计异或:每个节点只读一次自己的旧关系,读完立即改挂到根
acc = 0
for node in reversed(path):
acc ^= REL[node]
REL[node] = acc
FA[node] = x
return x
def worst_conflict(edges: list[int]) -> int:
"""按怨气值从大到小加“异监”约束,返回第一条被迫同监的怨气值;全程无冲突为 0。"""
for e in edges:
b = e & V_MASK
a = e >> V_BITS & V_MASK
c = e >> V_BITS * 2
ra, rb = find(a), find(b)
if ra == rb:
# 两人已被约束串起来:仇敌关系为 0 才会真的关进同一监狱
if REL[a] ^ REL[b] == 0:
return c
else:
# 合并两棵约束树,强制 a、b 分属两监:REL[ra] 取值让异或结果为 1
FA[ra] = rb
REL[ra] = REL[a] ^ REL[b] ^ 1
return 0
def solve() -> None:
# 逐行惰性切分:不一次性生成几十万个 token 字符串,给 32MB 内存限制让路
data = (int(tok) for line in sys.stdin.buffer for tok in line.split())
n, m = next(data), next(data)
edges: list[int] = []
for _ in range(m):
a, b, c = next(data), next(data), next(data)
# 打包成一个整数再排序:高位 c 优先,降序等价于按怨气值降序,且省内存
edges.append((c << V_BITS * 2) | (a << V_BITS) | b)
edges.sort(reverse=True)
FA[:] = list(range(n + 1)) # 并查集初始化
REL[:] = [0] * (n + 1)
print(worst_conflict(edges))
if __name__ == "__main__":
solve()复杂度
- 时间复杂度:排序
;每条边常数次 find,路径压缩下并查集操作均摊, 扫描合计 。总计 ,与 C++ 正解(排序 + 并查集)同阶, 也与二分答案写法 同阶。实测最大测例( , ) 本地 0.065s,远低于 OJ 的 1000ms。 - 空间复杂度:并查集
、边表 ,合计 。实测最大测例峰值 19.7MB, 低于 config 的 32MB(打包排序与惰性读入就是为内存服务的)。
总结
- 模型:二染色 + 只关心生效边的最大权值 ⇒ “按权值从大到小加异色约束,首个矛盾即答案”。
- 单调性给了二分答案;把扫描线放在权值轴上从大到小走,二分就被一次降序遍历替代。
- 带权并查集用一个二进制位(
REL异或)表达"同监/异监",路径压缩时沿链异或即可维护到根的关系。 - 正确性来自两头夹逼:矛盾边给出下界,约束系统的可满足性给出上界。
- 边打包排序、逐行惰性读入都是为 32MB 内存限制服务的工程细节,不改变算法复杂度。
图示解析
这张图串起本题从建模到得到答案的主线:
带权图 G,目标 = 生效(同监)边的最大权值最小;答案 ∈ {0} ∪ {边权}
`- 单调性:X 越大,"权值 > X 全异色"越容易
`- 扫描代替二分:边按权值从大到小加"异监"约束
`- 带权并查集用 REL 异或传递同监/异监关系
`- 两端同根且异或 = 0 -> 首个躲不开的冲突即答案;全部装得下 -> 输出 0先确认第一行“目标 = 同监边最大权、候选只有边权和 0”,再沿分支看单调性如何把二分简化成一次降序扫描;
最后一层是算法的判定核心:并查集把一串“异监”约束压成到根的关系,
两个点的异或结果为
