问题引入 n n n 个元素,不断接收「x x x 与 y y y 同集合」的合并指令,并查询「x x x 与 y y y 是否同集合」「当前多少个独立集合」。图遍历每次查询 O ( n ) O(n) O ( n ) ,10 5 10^5 1 0 5 量级即超时。关键在于:只关心集合归属,不关心内部连接结构–并查集(Union-Find / DSU[Disjoint Set Union]) 即为此设计,配合两种优化后单次操作均摊近似 O ( 1 ) O(1) O ( 1 ) 。
核心思想:用森林表示集合 每个集合是一棵树,根为代表元 。每个节点保存 parent 指针,根的 parent 指向自身。
1 2 3 4 5 6 7 集合 { 1 , 2 , 3 , 5 , 8 } : 集合 { 4 , 6 , 7 } : 1 4 / \ / \ 2 3 6 7 / \ 5 8
三个核心操作:
makeSet(x) :parent[x] = x。find(x) :沿 parent 走到根。union(x, y) :find 出两根,不同则把一棵根挂到另一棵下。合并只需修改一个指针,查询沿父链走到根。但树形不加控制可退化为链,单次 find 最坏 O ( n ) O(n) O ( n ) 。
基本实现与两大优化 未优化实现:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 struct UnionFind { vector<int > parent; explicit UnionFind (int n) : parent(n) { iota (parent.begin (), parent.end (), 0 ); } int find (int x) { while (parent[x] != x) x = parent[x]; return x; } void unite (int x, int y) { int rx = find (x), ry = find (y); if (rx == ry) return ; parent[rx] = ry; } bool connected (int x, int y) { return find (x) == find (y); } };
依次 unite(1,2), unite(2,3), ..., unite(n-1,n) 可退化为长度 n n n 的链。两种优化单独使用均达 O ( log n ) O(\log n) O ( log n ) ,组合达 O ( α ( n ) ) O(\alpha(n)) O ( α ( n )) 。
路径压缩(Path Compression) find 时将路径上所有节点直接挂到根下:
1 2 3 4 5 6 find( 8 ) 前: find( 8 ) 后: 1 1 / \ / | \ 2 3 2 3 8 / \ | 5 8 5
递归实现(一行):
1 2 3 int find (int x) { return parent[x] == x ? x : parent[x] = find (parent[x]); }
两趟迭代实现(避免深递归):
1 2 3 4 5 6 7 8 9 10 11 int find (int x) { int r = x; while (parent[r] != r) r = parent[r]; while (parent[x] != r) { int nxt = parent[x]; parent[x] = r; x = nxt; } return r; }
变体路径减半 :沿父链上行时改指祖父,单趟完成,与按秩组合同样 O ( α ( n ) ) O(\alpha(n)) O ( α ( n )) :
1 2 3 4 5 6 7 int find (int x) { while (parent[x] != x) { parent[x] = parent[parent[x]]; x = parent[x]; } return x; }
按秩合并 / 按大小合并 始终将较小(矮)的树挂到较大(高)的树下 ,抑制树高增长。
按大小合并 (size 本身即常用信息,更实用):
1 2 3 4 5 6 7 8 9 10 11 vector<int > parent, sz; UnionFind (int n) : parent (n), sz (n, 1 ) { iota (parent.begin (), parent.end (), 0 ); }void unite (int x, int y) { int rx = find (x), ry = find (y); if (rx == ry) return ; if (sz[rx] < sz[ry]) swap (rx, ry); parent[ry] = rx; sz[rx] += sz[ry]; }
按秩合并 (rank 为树高上界,便于理论分析):
1 2 3 4 5 6 7 8 9 10 11 vector<int > parent, rank_; UnionFind (int n) : parent (n), rank_ (n, 0 ) { iota (parent.begin (), parent.end (), 0 ); }void unite (int x, int y) { int rx = find (x), ry = find (y); if (rx == ry) return ; if (rank_[rx] < rank_[ry]) swap (rx, ry); parent[ry] = rx; if (rank_[rx] == rank_[ry]) rank_[rx]++; }
复杂度:阿克曼反函数 两者组合均摊 O ( α ( n ) ) O(\alpha(n)) O ( α ( n )) 。α ( n ) \alpha(n) α ( n ) 增长极慢,任何实际规模下 α ( n ) ≤ 5 \alpha(n) \le 5 α ( n ) ≤ 5 ,可视为常数。
n n n α ( n ) \alpha(n) α ( n ) ≤ 16 \le 16 ≤ 16 ≤ 2 \le 2 ≤ 2 ≤ 10 80 \le 10^{80} ≤ 1 0 80 ≤ 4 \le 4 ≤ 4
注意:O ( α ( n ) ) O(\alpha(n)) O ( α ( n )) 为均摊 复杂度,单次最坏仍可达 O ( log n ) O(\log n) O ( log n ) 。需严格单次保证或可持久化时,只能用按秩合并不做路径压缩。
完整模板 同时启用路径压缩与按大小合并,附带连通分量计数与集合大小查询:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 struct UnionFind { vector<int > parent, sz; int components; explicit UnionFind (int n) : parent(n), sz(n, 1 ), components(n) { iota (parent.begin (), parent.end (), 0 ); } int find (int x) { if (parent[x] == x) return x; return parent[x] = find (parent[x]); } bool unite (int x, int y) { int rx = find (x), ry = find (y); if (rx == ry) return false ; if (sz[rx] < sz[ry]) swap (rx, ry); parent[ry] = rx; sz[rx] += sz[ry]; --components; return true ; } bool connected (int x, int y) { return find (x) == find (y); } int size (int x) { return sz[find (x)]; } };
要点:
下标 :模板 0-indexed;题目 1-indexed 时开 n + 1 n+1 n + 1 大小或调用时 -1。unite 返回值 :false 表示本就同集合——判环时有用。非整数元素 :字符串等先用哈希表映射为整数编号。连通分量计数 :初始 n n n ,每次成功 unite 减一。全局数组写法(竞赛常用) 固定上界 + 全局数组 + 自由函数,书写最短、规模再大也不爆栈,是竞赛最常见写法:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 const int N = 1e5 + 10 ; int fa[N], sz[N], cnt; void init (int n) { cnt = n; for (int i = 0 ; i < n; ++i) { fa[i] = i; sz[i] = 1 ; } }int find (int x) { return fa[x] == x ? x : fa[x] = find (fa[x]); }bool unite (int x, int y) { int rx = find (x), ry = find (y); if (rx == ry) return false ; if (sz[rx] < sz[ry]) swap (rx, ry); fa[ry] = rx; sz[rx] += sz[ry]; --cnt; return true ; }bool connected (int x, int y) { return find (x) == find (y); }
与类模板一一对应:fa↔parent、sz↔siz、cnt↔components,init 充当构造函数;集合大小直接读 sz[find(x)],无需再封装。注意 N 须大于数据规模上限,多组数据时每次重新 init,否则残留状态会污染结果。
经典示例 连通分量计数与无向图判环 逐条加边 unite,unite 返回 false 则成环,最终 components 即连通分量数。
1 2 3 4 5 6 7 8 9 10 11 12 13 int main () { int n, m; scanf ("%d%d" , &n, &m); UnionFind uf (n) ; bool hasCycle = false ; for (int i = 0 ; i < m; ++i) { int u, v; scanf ("%d%d" , &u, &v); if (!uf.unite (u, v)) hasCycle = true ; } printf ("%d\n" , uf.components); printf ("%s\n" , hasCycle ? "YES" : "NO" ); }
并查集判环仅适用于无向图 。有向图用 DFS 三色标记或拓扑排序。
冗余连接(LeetCode 684) 树多加一条边形成唯一环,按输入顺序逐条 unite,第一条返回 false 的边即冗余边。
1 2 3 4 5 6 7 vector<int > findRedundantConnection ( vector<vector<int >>& edges) { UnionFind uf (edges.size() + 1 ) ; for (auto & e : edges) if (!uf.unite (e[0 ], e[1 ])) return e; return {}; }
等式方程可满足性(LeetCode 990) 先处理等、再查不等 :第一趟合并所有 ==,第二趟检查 != 两端是否已连通。
1 2 3 4 5 6 7 8 9 10 bool equationsPossible (vector<string>& eqs) { UnionFind uf (26 ) ; for (auto & s : eqs) if (s[1 ] == '=' ) uf.unite (s[0 ]-'a' , s[3 ]-'a' ); for (auto & s : eqs) if (s[1 ] == '!' && uf.connected (s[0 ]-'a' , s[3 ]-'a' )) return false ; return true ; }
账户合并(LeetCode 721) 元素为邮箱(字符串),先哈希映射为整数编号,每个账户内首个邮箱与其余 unite,按根分组输出。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 vector<vector<string>> accountsMerge ( vector<vector<string>>& accounts) { unordered_map<string, int > emailId; unordered_map<int , string> idToEmail; vector<string> idName; int idx = 0 ; for (auto & acc : accounts) for (int i = 1 ; i < acc.size (); ++i) if (!emailId.count (acc[i])) { emailId[acc[i]] = idx; idToEmail[idx] = acc[i]; idName.push_back (acc[0 ]); idx++; } UnionFind uf (idx) ; for (auto & acc : accounts) { int first = emailId[acc[1 ]]; for (int i = 2 ; i < acc.size (); ++i) uf.unite (first, emailId[acc[i]]); } unordered_map<int , vector<string>> groups; for (int i = 0 ; i < idx; ++i) groups[uf.find (i)].push_back (idToEmail[i]); vector<vector<string>> ans; for (auto & [root, emails] : groups) { sort (emails.begin (), emails.end ()); vector<string> account; account.push_back (idName[root]); account.insert (account.end (), emails.begin (), emails.end ()); ans.push_back (move (account)); } return ans; }
Kruskal 最小生成树 边按权升序排序,逐条考察:unite 返回 true 则加入生成树,选够 n − 1 n-1 n − 1 条即得 MST。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 struct Edge { int u, v, w; }; int kruskal (int n, vector<Edge>& edges) { sort (edges.begin (), edges.end (), [](auto & a, auto & b){ return a.w < b.w; }); UnionFind uf (n) ; int total = 0 , cnt = 0 ; for (auto & e : edges) { if (uf.unite (e.u, e.v)) { total += e.w; if (++cnt == n - 1 ) break ; } } return total; }
复杂度 O ( m log m ) O(m\log m) O ( m log m ) 。并查集在此扮演「动态维护连通分量、快速判环」的角色。
岛屿数量(LeetCode 200) 每个陆地格子与左、上邻居若是陆地则 unite,二维坐标压成一维 i ⋅ n + j i\cdot n+j i ⋅ n + j 。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 int numIslands (vector<vector<char >>& grid) { int m = grid.size (), n = grid[0 ].size (); UnionFind uf (m * n) ; int land = 0 , merged = 0 ; for (int i = 0 ; i < m; ++i) for (int j = 0 ; j < n; ++j) if (grid[i][j] == '1' ) { ++land; int id = i * n + j; if (i > 0 && grid[i-1 ][j] == '1' && uf.unite (id, (i-1 )*n+j)) ++merged; if (j > 0 && grid[i][j-1 ] == '1' && uf.unite (id, i*n+(j-1 ))) ++merged; } return land - merged; }
带权并查集 元素间携带关系或权值 (差值、奇偶、倍数等)时,每个节点额外维护 w[x] 表示 x x x 到 parent[x] 的权值,在路径压缩与合并时同步更新。权值须可沿路径复合(加法、异或等)。
通用模板(加法权值) 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 struct WeightedUnionFind { vector<int > parent, w; explicit WeightedUnionFind (int n) : parent(n), w(n, 0 ) { iota (parent.begin (), parent.end (), 0 ); } int find (int x) { if (parent[x] == x) return x; int root = find (parent[x]); w[x] += w[parent[x]]; parent[x] = root; return root; } void unite (int x, int y, int val) { int rx = find (x), ry = find (y); if (rx == ry) return ; parent[rx] = ry; w[rx] = val - w[x] + w[y]; } };
关键:unite 中固定 parent[rx]=ry 后,由「x x x 到 y y y 的累计权值等于 val」反解 w[rx]。掌握该推导即可处理各类变体,无需死记公式。
校验合法性:find(x)==find(y) 时,x x x 到 y y y 的权值 = w [ x ] − w [ y ] =w[x]-w[y] = w [ x ] − w [ y ] ,与给定关系矛盾则不可满足。
食物链(NOI / POJ 1182) N N N 个动物构成 A→B→C→A 环形食物链。给定 K K K 句话(同类/吃),判断多少句是假话。
用模 3 权值 w [ x ] ∈ { 0 , 1 , 2 } w[x]\in\{0,1,2\} w [ x ] ∈ { 0 , 1 , 2 } :0 0 0 同类、1 1 1 x x x 吃 parent、2 2 2 x x x 被 parent 吃。复合关系是模 3 加法。x , y x,y x , y 同集合时,x x x 对 y y y 的关系 = ( w [ x ] − w [ y ] ) m o d 3 =(w[x]-w[y])\bmod 3 = ( w [ x ] − w [ y ]) mod 3 。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 int parent[MAXN], w[MAXN]; int find (int x) { if (parent[x] == x) return x; int root = find (parent[x]); w[x] = (w[x] + w[parent[x]]) % 3 ; parent[x] = root; return root; }int main () { int N, K; scanf ("%d%d" , &N, &K); iota (parent+1 , parent+N+1 , 1 ); fill (w+1 , w+N+1 , 0 ); int fake = 0 ; while (K--) { int d, x, y; scanf ("%d%d%d" , &d, &x, &y); if (x > N || y > N || (d == 2 && x == y)) { ++fake; continue ; } int rx = find (x), ry = find (y); if (rx == ry) { if ((w[x] - w[y] + 3 ) % 3 != d - 1 ) ++fake; } else { parent[rx] = ry; w[rx] = ((d - 1 ) - w[x] + w[y] + 3 ) % 3 ; } } printf ("%d\n" , fake); }
扩展域并查集(种类并查集) 另一条路线:将每个元素拆为 k k k 个虚拟节点,用基础并查集维护等价关系。以食物链为例,x x x 拆为 x x x (同类域)、x + N x+N x + N (猎物域)、x + 2 N x+2N x + 2 N (天敌域)。「x , y x,y x , y 同类」合并 x ↔ y x\leftrightarrow y x ↔ y 、x + N ↔ y + N x+N\leftrightarrow y+N x + N ↔ y + N 、x + 2 N ↔ y + 2 N x+2N\leftrightarrow y+2N x + 2 N ↔ y + 2 N ;「x x x 吃 y y y 」合并 x ↔ y + N x\leftrightarrow y+N x ↔ y + N 、x + N ↔ y + 2 N x+N\leftrightarrow y+2N x + N ↔ y + 2 N 、x + 2 N ↔ y x+2N\leftrightarrow y x + 2 N ↔ y 。
优势:仅需基础并查集,无需维护权值;代价是节点数扩大 k k k 倍。两种方案等价,按空间与习惯选择。
关押罪犯(NOIP 2010) N N N 个罪犯分押两座监狱,M M M 对冲突各有影响力,求合理分配使最大冲突影响力最小。
利用「敌人的敌人是朋友」:每个罪犯 x x x 拆为 x x x (自身域)与 x + N x+N x + N (敌人域)。冲突按影响力降序处理:若 find(a)==find(b) 则该冲突无法避免,输出其影响力;否则合并 a a a 与 b + N b+N b + N 、b b b 与 a + N a+N a + N 。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 int main () { int N, M; scanf ("%d%d" , &N, &M); vector<array<int ,3>> e (M); for (auto & [a,b,c] : e) scanf ("%d%d%d" , &a, &b, &c); sort (e.begin (), e.end (), [](auto & x, auto & y){ return x[2 ] > y[2 ]; }); UnionFind uf (2 * N + 1 ) ; int ans = 0 ; for (auto & [a,b,c] : e) { if (uf.find (a) == uf.find (b)) { ans = c; break ; } uf.unite (a, b + N); uf.unite (b, a + N); } printf ("%d\n" , ans); }
可撤销并查集 路径压缩破坏中间结构无法回退。放弃路径压缩、仅用按秩合并,则每次 unite 仅修改一个 parent 与至多一个 rank——记录修改即可按序回退,单次 O ( log n ) O(\log n) O ( log n ) 。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 struct RollbackUnionFind { vector<int > p, r; vector<pair<int ,int >> hist; RollbackUnionFind (int n) : p (n), r (n, 0 ) { iota (p.begin (), p.end (), 0 ); } int find (int x) { while (p[x] != x) x = p[x]; return x; } bool unite (int a, int b) { a = find (a); b = find (b); if (a == b) return false ; if (r[a] < r[b]) swap (a, b); hist.push_back ({b, p[b]}); p[b] = a; if (r[a] == r[b]) { hist.push_back ({a, ~r[a]}); r[a]++; } return true ; } int snapshot () { return hist.size (); } void rollback (int snap) { while (hist.size () > snap) { auto [i, old] = hist.back (); hist.pop_back (); if (old < 0 ) r[i] = ~old; else p[i] = old; } } };
核心应用:离线分治 。典型场景为动态连通性——边随时间加入与删除,在时间轴上构建线段树分治,递归时 unite、回溯时 rollback,O ( m log T ⋅ log n ) O(m\log T\cdot\log n) O ( m log T ⋅ log n ) 。
可撤销并查集不得与路径压缩混用 。
横向对比 场景 并查集 DFS/BFS 其他 静态图连通分量 O ( m α ) O(m\alpha) O ( m α ) O ( n + m ) O(n+m) O ( n + m ) 两者皆可,DFS 更直观 动态 加边、查连通O ( α ) O(\alpha) O ( α ) /次,最优 每次重遍历 O ( n + m ) O(n+m) O ( n + m ) LCT 常数极大 判无向图环 O ( m α ) O(m\alpha) O ( m α ) O ( n + m ) O(n+m) O ( n + m ) 拓扑排序仅适用有向图 动态连通性(含删边) 可撤销 + 离线分治 不适用 LCT O ( m log n ) O(m\log n) O ( m log n ) 在线MST Kruskal O ( m log m ) O(m\log m) O ( m log m ) Prim O ( m log n ) O(m\log n) O ( m log n ) 各有适用场景 带关系约束的分组 带权/扩展域 不自然 2-SAT 也可处理部分 路径还原 不能 可记前驱 用 BFS/Dijkstra
判别要点:
连通性 vs 路径 :并查集只判连通,无法给出具体路径。静态 vs 动态 :图完全给定时 DFS/BFS 一次即可;需逐条加边并随时查询时并查集几乎唯一选择。在线 vs 离线 :需删边/撤销,在线只能 LCT;允许离线则可撤销并查集 + 分治更轻量。常见陷阱 未初始化 parent[x]=x :vector 默认全 0,find 死循环。路径压缩与可撤销混用 :撤销无法正确恢复中间结构。按秩合并时 rank 失真 :路径压缩使实际树高小于 rank,但不影响正确性 ——rank 仅为上界,无需修正。0-indexed 与 1-indexed 混淆 :开 n + 1 n+1 n + 1 大小或调用时 -1。合并方向未约束 :必须配合按秩或按大小合并,否则可退化为链。unite 后忘记更新附加信息 :components、size 仅在 rx!=ry 时更新。带权并查集 find 顺序错误 :必须先递归更新 w[parent[x]],再更新 w[x]。关系编码方向不一致 :权值符号与域的选择必须全文统一。字符串元素直接用作数组下标 :必须先哈希映射为整数编号。判环只判无向图 :有向图用 DFS 三色标记或拓扑排序。以为并查集能求最短路 :只维护连通,不维护距离。小结 并查集以森林表示互不相交的集合族,路径压缩 (查询时扁平化)与按秩/按大小合并 (小树挂大树)将单次操作均摊至 O ( α ( n ) ) O(\alpha(n)) O ( α ( n )) 。变体覆盖「动态维护分组与关系」大类问题:
识别 :动态加边 + 连通查询、等价类划分、关系约束 → 优先考虑并查集。选型 :仅需连通性 → 基础并查集;带关系约束 → 带权或扩展域;需撤销 → 可撤销(禁用路径压缩)。推导 :带权并查集的合并权值由「x x x 到 y y y 累计权值等于 val」现场反解,掌握推导即可处理任意变体。