并查集算法详解:从Quick-Find到Quick-Union的实战与优化
2026/8/20 8:05:42 网站建设 项目流程

这次我们来看一个数据结构中的经典问题:并查集。很多人在学习算法时,觉得并查集的概念很“玄学”,抽象难懂,代码实现更是无从下手。这篇文章的目标很直接:帮你彻底搞懂并查集,从抽象概念到两种核心实现(Quick-Find 和 Quick-Union)的源码实战,让你能自己动手写出来,并理解其性能差异。

并查集(Union-Find)是解决动态连通性问题的高效数据结构。它不关心节点之间具体的连接路径,只关心它们是否属于同一个集合。这个特性让它在处理“朋友圈划分”、“网络连接检查”、“岛屿数量”等问题时,效率远超其他数据结构。

对于开发者来说,学习并查集的核心价值在于:

  1. 面试高频:大厂算法面试必考知识点。
  2. 竞赛利器:在算法竞赛中,是解决连通性问题的标准工具。
  3. 工程基础:理解其优化思想(路径压缩、按秩合并),对设计高性能系统有帮助。

本文将带你完成一次深度实战。我们会先理解并查集到底在解决什么问题,然后手把手实现最直观的 Quick-Find 算法,分析其性能瓶颈;接着实现更高效的 Quick-Union 算法,并引入路径压缩优化;最后,通过LeetCode经典题目来验证我们的实现。你将看到清晰的代码、每一步的操作演示以及时间复杂度对比。

1. 核心能力速览:并查集是什么,能做什么?

在深入代码之前,我们先快速了解并查集的核心特性。它不是一个库或者一个需要安装的工具,而是一种数据结构的思想和实现模式。

能力项说明
核心问题动态连通性问题。支持快速合并两个集合,以及查询两个元素是否属于同一集合。
主要操作find(p): 查找元素p的根节点(集合代表)。union(p, q): 合并元素p和q所在的集合。
时间复杂度未经优化的朴素实现最坏可达O(N)。经路径压缩和按秩优化后,近似O(1)。
空间复杂度O(N),N为元素数量。通常使用一个长度为N的数组来存储父节点信息。
适用场景网络节点连通性、社交网络好友关系、图像像素连通区域、变量名等价性(编译器)等。
不适合场景需要知道具体连通路径的场景。并查集只回答“是否连通”,不记录“如何连通”。

并查集的“玄学”感往往来自于其利用数组存储树形结构的巧妙设计,以及“代表元”的思想。接下来,我们通过两种最经典的实现方式来破除这种抽象。

2. 理解并查集:从实际问题出发

假设我们有一个社交网络,有10个人,编号0-9。最初,每个人都是一个独立的圈子(单身)。

  • 查询:我们想知道5号和9号是不是朋友(属于同一个圈子)。
  • 合并:如果5号和9号成了朋友,那么他们所在的整个圈子就需要合并。

随着关系的建立,圈子会越来越大。并查集就是为了高效处理这种“合并集合”“查询归属”操作而生的。

关键抽象

  • 每个集合用一棵树来表示。
  • 树的根节点是这个集合的“代表”或“老大”。
  • find(x)操作就是找到x的根节点(老大)。
  • union(x, y)操作就是将x所在树的根,连接到y所在树的根上(或者反之),从而让两棵树变成一棵树,两个集合合并为一个。

理解了这个模型,我们来看第一种实现。

3. 环境准备与代码框架

我们的“环境”很简单,就是任何支持你熟悉编程语言的开发环境。本文将使用Python进行演示,因其语法清晰,易于理解。你可以轻松地将其转化为Java、C++或Go。

前置条件

  1. 一台能写代码的电脑。
  2. 一个文本编辑器或IDE(如VSCode、PyCharm)。
  3. Python 3.x 环境(如果你用Python)。

我们首先定义一个并查集的基类,明确接口:

class UnionFind: """并查集基类,定义通用接口""" def __init__(self, n: int): """ 初始化并查集,包含 n 个元素 (0 到 n-1) :param n: 元素总数 """ self.count = n # 连通分量的数量 # 具体数据结构由子类实现 pass def find(self, p: int) -> int: """ 查找元素 p 的根节点(集合标识) :param p: 元素索引 :return: 根节点的索引 """ raise NotImplementedError def union(self, p: int, q: int) -> None: """ 连接元素 p 和元素 q :param p: 元素 p 的索引 :param q: 元素 q 的索引 """ raise NotImplementedError def connected(self, p: int, q: int) -> bool: """ 判断元素 p 和元素 q 是否相连(属于同一集合) :param p: 元素 p 的索引 :param q: 元素 q 的索引 :return: 相连返回 True,否则 False """ return self.find(p) == self.find(q) def get_count(self) -> int: """ 返回当前连通分量的数量 :return: 连通分量数量 """ return self.count

接下来,我们实现两种具体的子类。

4. 实现一:Quick-Find 算法

核心思想:让同一个连通分量中的所有元素,其id[i]的值完全相同,这个值就是该分量的“根”。find(p)操作极快,直接返回id[p],但union(p, q)操作较慢,需要遍历整个数组来修改分量ID。

4.1 数据结构与初始化

class UnionFindQuickFind(UnionFind): """Quick-Find 版本并查集""" def __init__(self, n: int): super().__init__(n) # 初始化:每个元素的组号就是自己 self.id = [i for i in range(n)] def find(self, p: int) -> int: # 查找操作非常快,O(1)时间复杂度 return self.id[p]

初始化后,数组id的状态为[0, 1, 2, 3, 4, 5, 6, 7, 8, 9],表示10个独立的集合。

4.2 Union 操作实现与性能分析

合并操作是Quick-Find的瓶颈所在。

def union(self, p: int, q: int) -> None: p_id = self.find(p) q_id = self.find(q) # 如果 p 和 q 已经在同一个集合中,则无需操作 if p_id == q_id: return # 将 p 所在集合的所有元素的 id 都改为 q 的集合 id for i in range(len(self.id)): if self.id[i] == p_id: self.id[i] = q_id # 连通分量减少一个 self.count -= 1

操作演示:执行union(4, 3)

  1. find(4)= 4,find(3)= 3。
  2. 遍历数组,将所有值为4的元素(只有id[4])的值改为3。
  3. 数组变为[0, 1, 2, 3, 3, 5, 6, 7, 8, 9]。分量数由10变为9。

再执行union(3, 8)

  1. find(3)= 3,find(8)= 8。
  2. 遍历数组,将所有值为3的元素(id[3], id[4])的值改为8。
  3. 数组变为[0, 1, 2, 8, 8, 5, 6, 7, 8, 9]。分量数变为8。

性能分析

  • find():O(1),极快。
  • union():O(N),每次合并都需要遍历整个数组。
  • 对于N个元素,进行N次合并操作,时间复杂度是O(N²)。这在数据量大时是无法接受的。

5. 实现二:Quick-Union 算法

核心思想:使用树形结构。id[i]存储的是元素i的父节点。根节点的父节点指向自己。find(p)需要向上追溯找到根节点,union(p, q)只需要将一棵树的根连接到另一棵树的根上。

5.1 数据结构与 Find 操作

class UnionFindQuickUnion(UnionFind): """Quick-Union 版本并查集""" def __init__(self, n: int): super().__init__(n) # 初始化:每个元素的父节点都是自己,即自己是自己的根 self.parent = [i for i in range(n)] def find(self, p: int) -> int: # 不断向上查找父节点,直到找到根节点(parent[x] == x) while p != self.parent[p]: p = self.parent[p] return p

初始化后,数组parent状态为[0, 1, 2, 3, 4, 5, 6, 7, 8, 9]

5.2 Union 操作实现

def union(self, p: int, q: int) -> None: p_root = self.find(p) q_root = self.find(q) if p_root == q_root: return # 将 p 的根节点连接到 q 的根节点上 self.parent[p_root] = q_root # 连通分量减少一个 self.count -= 1

操作演示:执行union(4, 3)

  1. find(4)= 4,find(3)= 3。
  2. parent[4]设为 3。
  3. 数组变为[0, 1, 2, 3, 3, 5, 6, 7, 8, 9]。此时树的结构是:3是4的父节点。

再执行union(3, 8)

  1. find(3)= 3,find(8)= 8。
  2. parent[3]设为 8。
  3. 数组变为[0, 1, 2, 8, 3, 5, 6, 7, 8, 9]。注意,id[4]仍然是3,但3的父节点现在是8。树的结构是:8 <- 3 <- 4。

性能分析

  • find():O(h),h为树的高度。最坏情况下(树退化成链表),h = N,复杂度为O(N)。
  • union():O(h),因为主要开销在find根节点上。
  • 虽然union操作不再需要遍历整个数组,但树可能变得很高,导致find操作变慢。在最坏情况下(按顺序union形成长链),进行N次操作的时间复杂度仍是O(N²)

6. 优化:路径压缩与按秩合并

Quick-Union 的主要问题是树可能变得不平衡。有两种经典优化可以极大改善性能,使每次操作的均摊时间复杂度接近O(1)

6.1 路径压缩 (Path Compression)

find操作时,顺便将查找路径上的所有节点都直接指向根节点,使树变得更扁平。

class UnionFindPC(UnionFindQuickUnion): """带路径压缩的 Quick-Union""" def find(self, p: int) -> int: # 方法一:循环式路径压缩(推荐,易理解) root = p # 先找到根节点 root while root != self.parent[root]: root = self.parent[root] # 再次遍历,将路径上的所有节点直接指向根 while p != self.parent[p]: next_node = self.parent[p] self.parent[p] = root p = next_node return root # 方法二:递归式路径压缩(代码简洁,但可能有递归深度限制) # if p != self.parent[p]: # self.parent[p] = self.find(self.parent[p]) # 递归查找并压缩 # return self.parent[p]

6.2 按秩合并 (Union by Rank)

union操作时,总是将“矮”的树接到“高”的树下,避免树的高度增长过快。这里的“秩”(rank)可以近似理解为树的高度。

class UnionFindOptimized(UnionFind): """同时使用路径压缩和按秩合并的优化版本(最常用)""" def __init__(self, n: int): super().__init__(n) self.parent = [i for i in range(n)] self.rank = [1] * n # 初始化每个树的秩为1 def find(self, p: int) -> int: # 递归式路径压缩 if p != self.parent[p]: self.parent[p] = self.find(self.parent[p]) return self.parent[p] def union(self, p: int, q: int) -> None: p_root = self.find(p) q_root = self.find(q) if p_root == q_root: return # 按秩合并:将秩小的树根连接到秩大的树根下 if self.rank[p_root] > self.rank[q_root]: self.parent[q_root] = p_root elif self.rank[p_root] < self.rank[q_root]: self.parent[p_root] = q_root else: # 两棵树秩相等,任意连接,但被连接的树根秩需要加1 self.parent[q_root] = p_root self.rank[p_root] += 1 self.count -= 1

经过这两种优化,并查集的效率已经非常高,足以应对绝大多数算法问题。

7. 功能测试与效果验证:LeetCode 实战

理论讲完了,我们通过一道经典的LeetCode题目来验证并查集的威力。

题目: LeetCode 547. 省份数量问题描述:有 n 个城市,其中一些彼此相连,另一些没有相连。如果城市 a 与城市 b 直接相连,且城市 b 与城市 c 直接相连,那么城市 a 与城市 c 间接相连。省份是一组直接或间接相连的城市,组内不含其他没有相连的城市。给你一个 n x n 的矩阵 isConnected ,其中isConnected[i][j] = 1表示第 i 个城市和第 j 个城市直接相连,而isConnected[i][j] = 0表示二者不直接相连。返回矩阵中省份的数量。

解题思路:这正是并查集的典型应用——动态连通性问题。每个城市是一个元素。遍历矩阵,如果isConnected[i][j] == 1,就执行union(i, j)。最后,统计并查集中连通分量的数量即可。

代码实现(使用优化版并查集)

class Solution: def findCircleNum(self, isConnected: List[List[int]]) -> int: n = len(isConnected) uf = UnionFindOptimized(n) for i in range(n): # 矩阵是对称的,可以只遍历一半,但遍历全部也没问题 for j in range(n): if isConnected[i][j] == 1: uf.union(i, j) return uf.get_count() # 假设 UnionFindOptimized 类已定义如上

测试用例

# 输入:isConnected = [[1,1,0],[1,1,0],[0,0,1]] # 解释:城市0和1相连,城市2独立。 # 预期输出:2 solution = Solution() print(solution.findCircleNum([[1,1,0],[1,1,0],[0,0,1]])) # 输出:2

通过这个例子,你可以清晰地看到并查集如何将问题抽象化,并用极简的代码高效解决。你可以尝试用 Quick-Find 和未优化的 Quick-Union 实现同样的功能,并对比运行时间,直观感受性能差异。

8. 性能对比与适用场景总结

我们来对比一下几种实现的性能:

实现方式find时间复杂度union时间复杂度N次操作时间复杂度特点
Quick-FindO(1)O(N)O(N²)查找极快,合并极慢,易理解。
Quick-UnionO(h)O(h)O(N²) (最坏)合并变快,但树可能退化,查找变慢。
Quick-Union + 路径压缩接近 O(1)O(h)O(N log N)查找路径被压缩,效率提升。
Quick-Union + 按秩合并O(h)O(log N)O(N log N)树高度得到控制,合并更平衡。
优化版 (路径压缩+按秩合并)近似 O(1)近似 O(1)近似 O(N)工程实践标准,效率最高。

如何选择?

  • 学习理解:从 Quick-Find 和 Quick-Union 开始,明白基本思想。
  • 面试手撕:必须掌握优化版(路径压缩+按秩合并),这是标准答案。
  • 算法竞赛:直接使用优化版模板。
  • 生产环境:根据语言选择高效库(如C++的std::union_find),或使用优化版实现。

9. 常见问题与排查方法

在实现和使用并查集时,你可能会遇到以下问题:

问题现象可能原因排查方式解决方案
数组越界错误find(p)union(p, q)中的 p, q 超出了初始化大小 n。检查输入的元素索引是否在 [0, n-1] 范围内。在函数入口添加边界检查,或确保外部调用合法。
死循环(递归版)递归实现find时,父指针设置错误,形成了环。使用小数据测试,或打印parent数组观察。检查union操作,确保是将根节点相连。使用循环版find更安全。
结果不正确1.union前未正确找到根节点。
2. 连通分量计数count逻辑错误。
在每次union后打印parent数组和count,与手动推导对比。仔细核对findunion的逻辑,确保count只在成功合并时减1。
性能低下(大数据超时)使用了未优化的 Quick-Find 或 Quick-Union。分析算法时间复杂度,替换为优化版本。务必使用路径压缩按秩合并
“按秩合并”中“秩”的理解错误误将“秩”等同于树的精确高度。理解“秩”是树高的上界,在路径压缩后可能不精确,但用于比较是有效的。记住按秩合并的目的是平衡,不要求高度绝对精确。

10. 最佳实践与使用建议

  1. 模板化:将优化版的并查集代码(UnionFindOptimized)作为模板保存,遇到连通性问题直接套用。
  2. 初始化大小:在构造函数中一次性分配足够大的数组,避免动态扩容。
  3. 路径压缩选择:递归写法代码简洁,但可能存在递归深度限制(Python默认约1000层)。对于超大N(>10^5),建议使用循环写法。
  4. 计数维护count变量非常有用,可以直接获取当前集合数量,如在“岛屿数量”问题中。
  5. 灵活变通:并查集存储的不一定是整数,可以是任何有唯一标识的对象。通常做法是先用哈希表给对象分配一个整数ID。
  6. 理解本质:并查集的优势在于忽略连接细节,只维护连通关系。如果问题需要知道具体的连接路径,则需要使用DFS/BFS或图算法。

并查集从看似“玄学”的抽象,到清晰的两类实现(Quick-Find, Quick-Union),再到近乎O(1)的优化,体现了算法设计中“空间换时间”和“扁平化组织”的核心思想。掌握它,不仅能解决一大类算法题,更能提升你对数据组织方式的认知深度。下次遇到“是否连通”、“有多少个组”这类问题,你的第一反应就应该是它。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询