并查集
并查集的基本原理、路径压缩、按秩合并与典型应用场景
基础连通性问题
-
并查集可以解决的问题
- 判断是否连通
- 连通块个数
- 每个集合的大小
- 合并连通图
-
典型题目
- 朋友圈问题(多少个朋友圈)
- 岛屿数量(二维并查集)
- 网络连接判断
-
可以通过在并查集的 root 上挂载信息,来存储连通块的信息,例如
size[root] // 集合大小 max_val[root] // 最大值 sum[root] // 权值和 -
并在
unite时维护:size[a] += size[b]; max_val[a] = max(max_val[a], max_val[b]); -
DSU 模板
struct DSU { vector<int> parent, sz; int cnt; // 连通块个数 // 初始化:元素个数为 n DSU(int n) { parent.resize(n + 1); // 1-based 编号 sz.resize(n + 1, 1); for (int i = 1; i <= n; ++i) parent[i] = i; } // 查找根 + 路径压缩 int find(int x) { if (parent[x] != x) parent[x] = find(parent[x]); return parent[x]; } // 合并 x 和 y bool unite(int x, int y) { x = find(x), y = find(y); if (x == y) return false; // 已经在同一集合 // 小集合合并到大集合(按大小合并) if (sz[x] < sz[y]) swap(x, y); parent[y] = x; sz[x] += sz[y]; cnt--; // 合并成功则连通块个数就减一 return true; } // 获取当前连通块数量 int getCount() { return cnt; } // 判断是否连通 bool same(int x, int y) { return find(x) == find(y); } // 获取 x 所在集合的大小 int size(int x) { return sz[find(x)]; } };
冗余连接
-
直接利用并查集,当添加某条边时,如果二者已经在一个连通图中,说明该边冗余
class DSU { public: vector<int> parent; vector<int> sz; DSU(int n) { parent.resize(n + 1); sz.resize(n + 1, 1); for (int i = 1; i <= n; ++i) parent[i] = i; } int find(int i) { if (parent[i] != i) { parent[i] = find(parent[i]); } return parent[i]; } bool same(int x, int y) { return find(x) == find(y); } bool unite(int x, int y) { x = find(x), y = find(y); if (x == y) return false; if (sz[x] < sz[y]) swap(x, y); parent[y] = x; sz[x] += sz[y]; return true; } }; class Solution { public: vector<int> findRedundantConnection(vector<vector<int>>& edges) { int n = edges.size(); DSU dsu(n); for (auto edge : edges) { bool flag = dsu.unite(edge[0], edge[1]); if (!flag) return edge; } return {}; } };
带权并查集(维护到根的距离 / 关系)
-
代码模板
#include <iostream> #include <vector> #include <algorithm> using namespace std; // 带权并查集(维护节点到根的距离/权值) // T 可为 int / long long template<typename T> struct UnionFind { vector<int> fa; // 父节点 vector<T> dis; // dis[x] = x 到 fa[x] 的权值(路径压缩后直接到根) // 初始化 n 个节点(0 ~ n-1) UnionFind(int n) { fa.resize(n); dis.resize(n, 0); for (int i = 0; i < n; ++i) fa[i] = i; } // 查找根 + 路径压缩 + 更新权值 int find(int x) { if (fa[x] != x) { int root = find(fa[x]); dis[x] += dis[fa[x]]; fa[x] = root; } return fa[x]; } // 判断是否在同一集合 bool same(int x, int y) { return find(x) == find(y); } // 获取 x 到 y 的相对距离:x 到根 - y 到根 // 必须保证 same(x,y) 才有效 T getDist(int x, int y) { find(x); find(y); return dis[x] - dis[y]; } // 合并 x 和 y,规定 x 到 y 的权值为 val // 已连通:返回是否与已知条件冲突 // 未连通:合并,返回 true bool unite(int x, int y, T val) { int rx = find(x); int ry = find(y); if (rx == ry) { // 检查是否满足 x 到 y = val return dis[x] - dis[y] == val; } // 合并两个集合,更新权值 fa[rx] = ry; dis[rx] = dis[y] - dis[x] + val; return true; } };
食物链
-
有 N 个动物,分别属于 A、B、C 三类:
- A 吃 B,B 吃 C,C 吃 A
- 给定 K 句话,两种描述:
1 x y:x 和 y 是同类2 x y:x 吃 y
- 问:有多少句话是假话?
-
用
dis[x]表示 x 与根节点的关系:dis[x] = 0:同类dis[x] = 1:x 被根吃dis[x] = 2:x 吃根
-
代码实现
#include <iostream> #include <vector> using namespace std; template<typename T> struct UnionFind { vector<int> fa; vector<T> dis; UnionFind(int n) { fa.resize(n + 1); dis.resize(n + 1, 0); for (int i = 1; i <= n; ++i) fa[i] = i; } int find(int x) { if (fa[x] != x) { int root = find(fa[x]); dis[x] = (dis[x] + dis[fa[x]]) % 3; fa[x] = root; } return fa[x]; } bool merge(int x, int y, int op) { int rx = find(x), ry = find(y); if (rx == ry) { if (op == 1) return dis[x] == dis[y]; else return (dis[x] + 1) % 3 == dis[y]; } fa[rx] = ry; if (op == 1) dis[rx] = (dis[y] - dis[x] + 3) % 3; else dis[rx] = (dis[y] - dis[x] - 1 + 3) % 3; return true; } }; int main() { ios::sync_with_stdio(0); cin.tie(0); int n, k, ans = 0; cin >> n >> k; UnionFind<int> uf(n); while (k--) { int op, x, y; cin >> op >> x >> y; if (x > n || y > n) { ans++; continue; } if (!uf.merge(x, y, op)) ans++; } cout << ans << endl; return 0; }
等式 / 不等式推断
-
给定一组变量
a1,a2...an,M 个约束:a == ba != ba - b = ca - b >= c
-
问:这些约束是否全部自洽?
-
核心思路
- 相等 / 差值约束 → 直接用带权并查集维护
- 不等约束 → 检查是否在同一集合且差值矛盾
-
代码实现
#include <iostream> #include <vector> using namespace std; template<typename T> struct UnionFind { vector<int> fa; vector<T> dis; UnionFind(int n) { fa.resize(n + 1); dis.resize(n + 1, 0); for (int i = 1; i <= n; ++i) fa[i] = i; } int find(int x) { if (fa[x] != x) { int root = find(fa[x]); dis[x] += dis[fa[x]]; fa[x] = root; } return fa[x]; } bool merge(int x, int y, T val) { int rx = find(x), ry = find(y); if (rx == ry) return dis[x] - dis[y] == val; fa[rx] = ry; dis[rx] = dis[y] - dis[x] + val; return true; } bool same(int x, int y) { return find(x) == find(y); } T getDist(int x, int y) { return dis[x] - dis[y]; } }; int main() { int n, m; cin >> n >> m; UnionFind<long long> uf(n); bool ok = 1; while (m--) { int op, a, b, c; cin >> op >> a >> b; if (op == 1) ok &= uf.merge(a, b, 0); if (op == 2) { cin >> c; ok &= uf.merge(a, b, c); } if (op == 3) { if (uf.same(a,b) && uf.getDist(a,b) == 0) ok = 0; } } cout << (ok ? "YES" : "NO") << endl; return 0; }
奇偶游戏
-
一个 01 序列,多次询问:
-
l r odd:[l,r] 内 1 的个数是奇数 -
l r even:[l,r] 内 1 的个数是偶数 -
问:最多有多少句话是真的?
-
-
核心思路:前缀和
s[0],s[1]...s[n]-
even→s[r] - s[l-1] = 0 -
odd→s[r] - s[l-1] = 1 -
用
dis维护奇偶性(模 2)
-
-
代码实现
#include <iostream> #include <vector> using namespace std; template<typename T> struct UnionFind { vector<int> fa; vector<T> dis; UnionFind(int n) { fa.resize(n + 1); dis.resize(n + 1, 0); for (int i = 0; i <= n; ++i) fa[i] = i; } int find(int x) { if (fa[x] != x) { int root = find(fa[x]); dis[x] ^= dis[fa[x]]; fa[x] = root; } return fa[x]; } bool merge(int x, int y, int val) { int rx = find(x), ry = find(y); if (rx == ry) return (dis[x] ^ dis[y]) == val; fa[rx] = ry; dis[rx] = dis[x] ^ dis[y] ^ val; return true; } }; int main() { int n, m, ans = 0; cin >> n >> m; UnionFind<int> uf(n); while (m--) { int l, r; string s; cin >> l >> r >> s; int val = (s == "odd"); if (uf.merge(l-1, r, val)) ans++; else break; } cout << ans << endl; return 0; }
节点间距离维护
-
一棵树 / 图,动态加边,动态查询:
u v w:u 到 v 的距离为 w? u v:查询 u 到 v 的距离
-
最标准的带权并查集用法:
dis[x]= x 到根的距离u 到 v 的距离 = dis[u] - dis[v]
-
代码实现
#include <iostream> #include <vector> using namespace std; template<typename T> struct UnionFind { vector<int> fa; vector<T> dis; UnionFind(int n) { fa.resize(n + 1); dis.resize(n + 1, 0); for (int i = 1; i <= n; ++i) fa[i] = i; } int find(int x) { if (fa[x] != x) { int root = find(fa[x]); dis[x] += dis[fa[x]]; fa[x] = root; } return fa[x]; } void merge(int x, int y, T val) { int rx = find(x), ry = find(y); if (rx == ry) return; fa[rx] = ry; dis[rx] = dis[y] - dis[x] + val; } T getDist(int x, int y) { find(x); find(y); return dis[x] - dis[y]; } }; int main() { int n, m; cin >> n >> m; UnionFind<long long> uf(n); while (m--) { char op; int u, v, w; cin >> op; if (op == '+') { cin >> u >> v >> w; uf.merge(u, v, w); } else { cin >> u >> v; cout << uf.getDist(u, v) << '\n'; } } return 0; }
并查集+懒删除
美团 2027 届实习第二轮笔试
-
给定一个由
n个编号为1~n的节点和m条编号为1~m的边组成的无向图- 节点权值定义:节点编号 + 当前度数(度数是执行完之前所有操作后的边数)
- 共
q次操作,分为两类:- 操作一:断开编号为
x的边(保证每条边至多被删除一次,删除时一定存在) - 操作二:查询编号为
x的节点所在连通块中,所有节点的最大权值
- 操作一:断开编号为
-
输入输出
- 输入:
- 第一行:
n, m, q(节点数、边数、操作数) - 接下来
m行:每行两个整数u, v,表示第i条边连接节点u和v - 接下来
q行:每行描述一个操作:1 x:删除第x条边2 x:查询节点x所在连通块的最大权值
- 第一行:
- 输出:对每个操作二,输出一行表示该连通块的最大权值
- 输入:
-
数据范围
- 节点数
n、边数m、操作数q均在1e5级别,需要高效离线算法
- 节点数
-
并查集不支持高效删除边,但支持高效合并,因此采用离线逆序处理:
- 先把所有要删除的边标记出来,初始图只保留未被删除的边
- 逆序执行操作:
- 原操作一(删边)→ 逆序变为加边(用并查集合并)
- 原操作二(查询)→ 逆序时记录答案,最后再反转输出
- 并查集需要额外维护每个连通块的最大权值,合并时更新最大值
-
额外细节
- 节点初始权值:
w[i] = i + 初始度数(初始度数是该节点在未被删除边中的连接数) - 并查集结构:每个根节点维护当前连通块的最大权值,合并两个连通块时,新的最大值为两个子块最大值的较大者
- 节点初始权值:
-
代码实现
#include <bits/stdc++.h> using namespace std; const int MAXN = 1e5 + 5; const int MAXM = 1e5 + 5; const int MAXQ = 1e5 + 5; struct DSU { vector<int> parent; vector<int> max_val; // 维护每个连通块的最大权值 DSU(int n, const vector<int>& w) { parent.resize(n + 1); max_val.resize(n + 1); for (int i = 1; i <= n; ++i) { parent[i] = i; max_val[i] = w[i]; } } int find(int x) { if (parent[x] != x) { parent[x] = find(parent[x]); } return parent[x]; } void unite(int x, int y) { x = find(x); y = find(y); if (x == y) return; // 小的合并到大的,同时更新最大值 parent[y] = x; max_val[x] = max(max_val[x], max_val[y]); } int get_max(int x) { return max_val[find(x)]; } }; int main() { ios::sync_with_stdio(false); cin.tie(nullptr); int n, m, q; cin >> n >> m >> q; vector<pair<int, int>> edges(m + 1); // 边从1开始编号 vector<int> deg(n + 1, 0); vector<bool> deleted(m + 1, false); for (int i = 1; i <= m; ++i) { int u, v; cin >> u >> v; edges[i] = {u, v}; } vector<tuple<int, int>> ops(q); for (int i = 0; i < q; ++i) { int op, x; cin >> op >> x; ops[i] = {op, x}; if (op == 1) { deleted[x] = true; // 标记要删除的边 } } // 计算初始度数(只算未被删除的边) fill(deg.begin(), deg.end(), 0); for (int i = 1; i <= m; ++i) { if (!deleted[i]) { auto [u, v] = edges[i]; deg[u]++; deg[v]++; } } // 初始权值 w[i] = i + deg[i] vector<int> w(n + 1); for (int i = 1; i <= n; ++i) { w[i] = i + deg[i]; } DSU dsu(n, w); // 先把所有未被删除的边合并 for (int i = 1; i <= m; ++i) { if (!deleted[i]) { auto [u, v] = edges[i]; dsu.unite(u, v); } } vector<int> ans; // 逆序处理操作 for (int i = q - 1; i >= 0; --i) { auto [op, x] = ops[i]; if (op == 1) { // 原操作是删边x,逆序就是加边x auto [u, v] = edges[x]; dsu.unite(u, v); } else { // 原操作是查询x,记录当前连通块最大值 ans.push_back(dsu.get_max(x)); } } // 答案逆序输出 reverse(ans.begin(), ans.end()); for (int x : ans) { cout << x << '\n'; } return 0; }
区间连通查询问题
阿里 2027 届实习 3.25 笔试
-
有编号为 1 ~ n 的 n 个城市,初始时所有城市互相独立、互不连通
-
共有 m 次操作,每次操作包含三个参数
a, b, x:- 操作:将编号在区间
[a, b]内的所有城市两两连通(即把这个区间变成一个完整的连通块); - 查询:输出从城市 x 出发,能够到达的最大编号城市
- 操作:将编号在区间
-
要求对每次查询,快速输出正确结果
-
输入格式
- 第一行:两个整数
n, m,分别表示城市总数、操作次数 - 接下来 m 行:每行三个整数
a, b, x,表示一次操作与查询
- 第一行:两个整数
-
输出格式
- 共 m 行,每行一个整数,表示对应查询的答案
-
数据范围
- 城市数量 n 可以极大(可达 1e9 级别)
- 操作次数 m 为常规范围(1e6 以内)
- 1 ≤ a ≤ b ≤ n,1 ≤ x ≤ n
-
输入示例
5 3 1 3 2 2 5 1 1 5 4 -
输出示例
3 5 5 -
样例解释
- 第一次操作:连通 [1,3],从 2 出发能到的最大城市是 3
- 第二次操作:连通 [2,5],从 1 出发能到的最大城市是 5
- 第三次操作:连通 [1,5](已连通),从 4 出发能到的最大城市是 5
-
最简单地,考虑并查集,对于每个区间,遍历区间内任意两个城市,加入并查集中
-
显然,这种做法会超时,那么如何用 DSU 来解决区间连通查询问题?
-
需要用并查集 + 路径跳跃来高效处理区间合并:
- 维护
parent[i]:表示从i出发,下一个需要合并的位置 - 每次合并
[l, r]时,从l开始,不断合并i和i+1,并将i的父节点设为i+1,直到i >= r - 这样可以保证每个位置最多被合并一次,整体时间复杂度为
O(n α(n) + m α(n)),可以通过题目数据范围
- 维护
-
代码实现
#include <iostream> #include <vector> using namespace std; const int MAXN = 1e6 + 10; // 按题目n上限调整 vector<int> parent; int find(int x) { if (parent[x] != x) { parent[x] = find(parent[x]); } return parent[x]; } int main() { ios::sync_with_stdio(false); cin.tie(nullptr); int n, m; cin >> n >> m; parent.resize(n + 2); // 1-based 编号,多开一位防止越界 for (int i = 1; i <= n + 1; i++) { parent[i] = i; } while (m--) { int l, r, x; cin >> l >> r >> x; // 合并 [l, r] 区间 for (int i = find(l); i < r; i = find(i)) { parent[i] = i + 1; } // 查询 x 能到达的最大城市编号 cout << find(x) << '\n'; } return 0; } -
把问题转化为区间删除问题,用并查集维护每个点的 next 指针吗,而 find(x) 返回当前未被删除的最小 ≥x 的位置
-
每个点最多被删除一次,因此整体复杂度是近似线性的