并查集入门详解(Union-Find 原理与应用场景)

200 万条社交关系里要反复判断”两个人是否属于同一个人脉圈”,挨个遍历肯定来不及——并查集(Union-Find)就是为这类动态连通性问题准备的。它只做两件事:查询两个元素是否同属一个集合(Find),以及把两个集合合并成一个(Union),配合路径压缩和按秩合并两项优化后,单次操作的均摊复杂度接近常数级。

并查集的核心操作流程

用数组就能实现的树形结构

并查集的存储结构简单到只有一维数组:parent[i] 表示元素 i 的父节点。每个集合组织成一棵树,树的根节点就是这个集合的”代表元”——判断两个元素是否同属一个集合,等价于判断它们向上追溯到的根是否相同。

初始化时每个元素自成一个集合,父节点指向自己:

parent = list(range(n))   # parent[i] = i,各自为根

def find(x):
    while parent[x] != x:
        parent[x] = parent[parent[x]]  # 路径压缩:跳过中间节点直连祖先
        x = parent[x]
    return x

def union(a, b):
    ra, rb = find(a), find(b)
    if ra == rb:
        return False        # 已在同一集合
    parent[rb] = ra         # 把一棵树挂到另一棵下面
    return True

find 里的 parent[x] = parent[parent[x]] 是路径压缩的精髓:每次查询顺路把链上的节点直接挂到祖父节点上,多次查询后整棵树会越来越扁,后续查询越来越快。这种写法叫”路径减半”,比递归版压缩更省栈空间。

没有优化会慢成什么样

只有数组的朴素并查集,极端情况下会退化成一条长链——所有元素首尾相接,find 一次要爬完整条链,复杂度退化到 O(n)。两项优化就是用来堵住这个漏洞的:

优化手段做了什么效果
路径压缩查询时把沿途节点直接挂到根附近压扁树高,查询越勤树越平
按秩合并/按大小合并合并时把矮树挂到高树下避免树越摞越高
两者结合—单次操作均摊 O(α(n)),近似常数

α(n) 是阿克曼反函数,对现实中的任何数据规模它都不会超过 4——可以认为做了双优化的并查集,单次操作就是常数时间。这也是它在竞赛和工程里都能放心大规模使用的原因。

按秩合并需要额外维护秩数组,实现时多几行;按大小合并更直观,维护每个集合的元素个数即可。二者选哪个效果差异可以忽略,顺手用哪个都行。

给工程实现加双优化的完整步骤只有三步:

  1. 初始化时除了 parent 数组,再建一个 rank 或 size 数组,全部置 0 或 1;
  2. union 时先比较两棵树的秩,把矮树挂到高树下;秩相同则任挂其一并把被挂方秩加一;
  3. 只有当两棵树秩相等时才增加秩,其余情况秩不变,防止树高失控。

写完后用极端数据自测一遍:把 10 万个元素按 1-2、2-3、3-4 串成一条链再查询末尾元素,响应应是瞬间完成——如果出现可感知的卡顿,说明压缩逻辑没生效。

它在真实工程里出现在哪

并查集的应用场景有一个共同模式:动态地合并分组,并随时回答”谁和谁是一伙的”。凡是能套进这个模式的问题,它都有一席之地:

场景问题形态并查集承担的角色
Kruskal 最小生成树加边是否成环合并前查根,同根即跳过
社交网络朋友圈动态加好友求分组数每次有效合并,分量计数减一
节点上下线连通性剩余节点是否连通离线倒序加边,天然匹配并查集
图像连通区域相邻同色像素归块逐行扫描合并相邻块

同样解决连通问题,并查集和它的替代方案各有领地,选错工具会付出数量级的性能代价:

方案单次查询动态合并适用边界
并查集近似常数支持动态加边、只需判连通
BFS/DFS 遍历O(n+m)不支持,每次重建静态图、需要具体路径
LCA/重链剖分O(log n)不支持需要查询树上任意两点关系
  • 图论算法:Kruskal 求最小生成树时,用并查集判断加一条边会不会成环;
  • 社交网络:好友关系的传递闭包,快速求出”朋友圈”个数;
  • 网络运维:机房节点动态上下线时,判断剩余节点是否仍然连通;
  • 图像处理:像素连通区域标记,把相邻同色像素合并成同一块;
  • 编译器与虚拟机:等价类合并,比如类型推断里的变量等价关系。

一个典型的工程案例是离线删边问题。图上不断删边、每次删完问还剩几个连通分量——删边本身没有高效算法,但把时间倒过来变成”不断加边”,就用上了并查集:每成功合并一次,连通分量个数减一。

def count_components(n, edges):
    parent = list(range(n))
    count = n
    for a, b in edges:
        ra, rb = find(a), find(b)
        if ra != rb:
            parent[rb] = ra
            count -= 1          # 每次有效合并,分量数减一
    return count

这类”正着做很难、反着做很简单”的转化,是并查集很见功力的用法,也是面试和竞赛里的高频考点。

常见问题(FAQ)

Q1:为什么并查集比图遍历快?

遍历每次都要 O(n+m),并查集合并与查询均摊近似常数,适合频繁动态合并的场景。

Q2:单次操作怎么做到接近常数时间?

路径压缩加按秩合并使树高被压到近乎扁平,均摊复杂度是阿克曼反函数级。

Q3:并查集能不能删除一条边?

原生不支持删边,需离线倒序处理或改用可持久化等变体来实现。

版权声明:本文内容由互联网用户自发贡献,该文观点仅代表作者本人。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如发现本站有涉嫌抄袭侵权/违法违规的内容, 请发送邮件至 qiqicto@qq.com 举报,一经查实,本站将立刻删除。
赞 (0)
小码农的头像小码农认证作者

相关推荐

返回顶部