【数据结构与算法】并查集
代码路径: 并查集 · zengjx/algorithm - 码云 - 开源中国
牛客网: 题解 | 【模板】最小生成树_牛客网
一、原始无向加权图(6 个节点:0-5)
1. 节点位置建议(便于画图)
为了清晰展示边的连接关系,建议按以下位置排列节点(类似 “两层分布”):
- 上层(从左到右):节点 0、节点 1、节点 2
- 下层(从左到右):节点 3、节点 4、节点 5
2. 边与权重标注(按原始数据)
用 “线段连接节点”,并在线段旁标注权重,所有边如下(无方向,双向连通):
plaintext
(上层)[0] —4— [1]| | \3 8 2| | /[2] —1— [3] —2— [5]| | |7 | 6| | |[4] ————(无直接边,需通过5连接)(下层)
3. 完整边列表(对应图形)
节点对 | 权重 | 图形中的连接关系 |
---|---|---|
0-1 | 4 | 上层左 1(0)连上层左 2(1) |
0-2 | 3 | 上层左 1(0)连上层左 3(2) |
0-3 | 5 | 上层左 1(0)连下层左 1(3) |
1-2 | 2 | 上层左 2(1)连上层左 3(2) |
1-3 | 8 | 上层左 2(1)连下层左 1(3) |
2-3 | 1 | 上层左 3(2)连下层左 1(3) |
2-4 | 7 | 上层左 3(2)连下层左 2(4) |
3-5 | 2 | 下层左 1(3)连下层左 3(5) |
4-5 | 6 | 下层左 2(4)连下层左 3(5) |
二、最小生成树(MST)结构
MST 仅保留 5 条边(6 个节点需 n-1=5 条边),总权重 14,边为:(2-3,1)、(1-2,2)、(3-5,2)、(0-2,3)、(4-5,6)。
1. MST 图形描述(保留关键边,去掉冗余边)
plaintext
(上层)[0] —3— [2] —2— [1]/ |1 |/ |[3] —2— [5] —6— [4](下层)
2. MST 边的标注(加粗为保留边)
- 核心连接:[2] 是 “枢纽节点”,连接 [0](权重 3)、[1](权重 2)、[3](权重 1);
- 下层连接:[3] 连 [5](权重 2),[5] 连 [4](权重 6);
- 去掉的边:0-1(4)、0-3(5)、1-3(8)、2-4(7)(这些边会形成环或权重更大)。
并查集
并查集介绍
并查集(Union-Find)是一种用于处理集合合并和元素查询的数据结构,主要支持两种操作:
- 查找(Find):确定某个元素属于哪个集合
- 合并(Union):将两个集合合并成一个集合
它在处理连通性问题时非常高效,比如判断图中两点是否连通、网络连接问题等场景。
并查集的基本实现
并查集通常用数组实现,每个元素有一个父节点,当父节点是自身时候,表示该元素是集合的根节点。
python代码
class UnionFind:def __init__(self, size):# 初始化父节点数组,每个元素的父节点是自己self.parent = list(range(size))# 初始化秩(用于优化合并操作)self.rank = [0] * sizedef find(self, x):"""查找元素x所在集合的根节点"""# 路径压缩:将x的父节点直接指向根节点,加快后续查询if self.parent[x] != x:self.parent[x] = self.find(self.parent[x])return self.parent[x]def union(self, x, y):"""合并元素x和y所在的集合"""# 找到两个元素的根节点root_x = self.find(x)root_y = self.find(y)# 如果已经在同一个集合中,则不需要合并if root_x == root_y:return# 按秩合并:将秩较小的树合并到秩较大的树下面if self.rank[root_x] < self.rank[root_y]:self.parent[root_x] = root_yelse:self.parent[root_y] = root_x# 如果秩相等,合并后秩加1if self.rank[root_x] == self.rank[root_y]:self.rank[root_x] += 1def is_connected(self, x, y):"""判断元素x和y是否在同一个集合中"""return self.find(x) == self.find(y)
初始化:
parent数组:存储每个元素的父节点
rank数组:存储每个集合的秩(可以理解为树的高度)
查找操作:
使用递归实现路径压缩,将查询路径上的所有节点直接指向根节点
路径压缩大大加快了后续的查询速度
合并操作:
先找到两个元素的根节点
使用按秩合并策略,将较矮的树合并到较高的树下面
这样可以保持树的平衡性,避免出现极端的长链结构
连通性判断:
两个元素在同一个集合中当且仅当它们的根节点相同
时间复杂度
- 带路径压缩和按秩合并的并查集,每次操作的时间复杂度接近 O (1)
- 严格来说是 O (α(n)),其中 α 是阿克曼函数的反函数,增长极其缓慢,在实际应用中可视为常数
并查集是一种非常高效的数据结构,在处理动态连通性问题时表现出色,广泛应用于图论、网络分析、 Kruskal 算法等领域。
例 1:判断社交网络中的朋友关系
假设有 5 个人(编号 0-4),我们想通过并查集来管理他们之间的朋友关系,并判断任意两人是否是朋友(直接或间接):
python
运行
# 创建包含5个人的并查集
uf = UnionFind(5)# 建立朋友关系(合并集合)
uf.union(0, 1) # 0和1成为朋友
uf.union(1, 2) # 1和2成为朋友(0、1、2现在是一个朋友圈)
uf.union(3, 4) # 3和4成为朋友# 判断关系
print(uf.is_connected(0, 2)) # True(0和2通过1间接是朋友)
print(uf.is_connected(0, 3)) # False(0和3不在同一个朋友圈)# 建立更多关系
uf.union(2, 3) # 2和3成为朋友(现在所有人都在一个朋友圈)
print(uf.is_connected(0, 4)) # True(0和4现在间接是朋友)
例 2:网格中的连通分量计数
假设有一个 3x3 的网格,我们想计算其中连通的 1 的块数(上下左右相邻视为连通):
python
运行
grid = [[1, 1, 0],[0, 1, 0],[0, 0, 1]
]# 初始化并查集,每个1初始为独立集合
uf = UnionFind(9) # 3x3网格共9个位置
count = sum(row.count(1) for row in grid) # 初始连通分量数等于1的个数# 遍历网格,合并相邻的1
for i in range(3):for j in range(3):if grid[i][j] == 1:# 检查右侧元素if j < 2 and grid[i][j+1] == 1:uf.union(i*3+j, i*3+(j+1))count -= 1# 检查下方元素if i < 2 and grid[i+1][j] == 1:uf.union(i*3+j, (i+1)*3+j)count -= 1print(f"连通分量数: {count}") # 输出: 2
count = sum(row.count(1) for row in grid) # 初始连通分量数等于1的个数 的解释
这行代码的作用是计算网格中值为1的元素总数,作为连通分量的初始计数。
让我们逐步解析:
row.count(1) for row in grid:
这是一个生成器表达式,遍历网格中的每一行(row in grid)
对于每一行,row.count(1) 计算当前行中值为1的元素个数
sum(...):
对生成器表达式的结果求和,得到整个网格中所有值为1的元素总数
为什么初始连通分量数等于 1 的个数?
在开始处理前,每个1都被视为一个独立的连通分量(因为还没有检查它们之间的相邻关系)
例如,如果网格中有 5 个1且它们互不相邻,那么就有 5 个连通分量
当我们通过并查集合并相邻的1时,每合并一次,连通分量数就减 1
举个例子:
如果网格是:
python
运行
grid = [[1, 1, 0],[0, 1, 0],[0, 0, 1]
]第一行有 2 个1
第二行有 1 个1
第三行有 1 个1
总和是 4,所以初始count = 4
随着后续合并相邻的1,这个计数会不断减少,最终得到实际的连通分量数量。
例 3:Kruskal 算法求最小生成树
并查集常用于 Kruskal 算法中,判断添加边是否会形成环:
一、先明确基础概念
-
最小生成树(MST):在包含
n
个节点的无向连通图中,选择n-1
条边,使得:- 所有节点通过这些边连通(无孤立节点);
- 所有边的权重之和 最小;
- 无环(否则边数会超过
n-1
,权重和必然不是最小)。
- Kruskal 算法核心思想:
- 按 边的权重从小到大排序;
- 依次选择权重最小的边,若该边连接的两个节点 不在同一个连通分量(用并查集判断,避免形成环),则将该边加入 MST,并合并两个节点的连通分量;
- 重复步骤 2,直到 MST 包含
n-1
条边(此时所有节点已连通)。
python
运行
def kruskal(n, edges):"""n: 节点数edges: 边的列表,每个元素为(权重, u, v)"""uf = UnionFind(n)edges.sort() # 按权重排序total_weight = 0mst = []for weight, u, v in edges:# 如果两个节点不在同一集合,添加这条边不会形成环if not uf.is_connected(u, v):uf.union(u, v)total_weight += weightmst.append((u, v, weight))# 当添加了n-1条边时,最小生成树完成if len(mst) == n - 1:breakreturn mst, total_weight# 示例
n = 4 # 4个节点
edges = [(10, 0, 1),(6, 0, 2),(5, 0, 3),(15, 1, 3),(4, 2, 3)
]mst, weight = kruskal(n, edges)
print(f"最小生成树总权重: {weight}") # 输出: 19
二、示例图与数据准备
1. 示例图结构
假设有一个 6 个节点(编号 0-5)的无向加权图,边的权重如下表所示(边的顺序无关,后续会排序):
边(起点 - 终点) | 权重 | 边(起点 - 终点) | 权重 | 边(起点 - 终点) | 权重 |
---|---|---|---|---|---|
0-1 | 4 | 1-3 | 8 | 3-5 | 2 |
0-2 | 3 | 2-3 | 1 | 4-5 | 6 |
0-3 | 5 | 2-4 | 7 | 1-2 | 2 |
2. 初始状态
- 节点数
n=6
,因此 MST 需要n-1=5
条边; - 初始时,每个节点都是独立的连通分量(用并查集表示:
parent[0]=0, parent[1]=1, ..., parent[5]=5
); - MST 为空,已选边数
count=0
,总权重total_weight=0
。
三、Kruskal 算法分步执行
步骤 1:按边的权重从小到大排序
首先将所有边按权重升序排列(权重相同的边顺序不影响最终结果):
- 2-3(权重 1)→ 2. 1-2(权重 2)→ 3. 3-5(权重 2)→ 4. 0-2(权重 3)→ 5. 0-1(权重 4)→ 6. 0-3(权重 5)→ 7. 4-5(权重 6)→ 8. 2-4(权重 7)→ 9. 1-3(权重 8)
步骤 2:依次选边,用并查集判断是否形成环
我们逐条处理排序后的边,核心逻辑是:选边不形成环则加入 MST,否则跳过。
步骤 | 待选边(u-v) | 权重 | 并查集判断(u 和 v 是否同属一个连通分量) | 操作 | MST 当前边数 | MST 总权重 | 连通分量变化(并查集合并后) |
---|---|---|---|---|---|---|---|
1 | 2-3 | 1 | u=2(根为 2),v=3(根为 3)→ 不同 | 加入 MST,合并 2 和 3 | 1 | 1 | {0}, {1}, {2,3}, {4}, {5} |
2 | 1-2 | 2 | u=1(根为 1),v=2(根为 2)→ 不同 | 加入 MST,合并 1 和 2 | 2 | 1+2=3 | {0}, {1,2,3}, {4}, {5} |
3 | 3-5 | 2 | u=3(根为 2),v=5(根为 5)→ 不同 | 加入 MST,合并 3 和 5 | 3 | 3+2=5 | {0}, {1,2,3,5}, {4} |
4 | 0-2 | 3 | u=0(根为 0),v=2(根为 2)→ 不同 | 加入 MST,合并 0 和 2 | 4 | 5+3=8 | {0,1,2,3,5}, {4} |
5 | 0-1 | 4 | u=0(根为 2),v=1(根为 2)→ 相同 | 跳过(形成环) | 4 | 8 | 无变化 |
6 | 0-3 | 5 | u=0(根为 2),v=3(根为 2)→ 相同 | 跳过(形成环) | 4 | 8 | 无变化 |
7 | 4-5 | 6 | u=4(根为 4),v=5(根为 2)→ 不同 | 加入 MST,合并 4 和 5 | 5 | 8+6=14 | {0,1,2,3,4,5}(所有节点连通) |
- | 后续边(2-4、1-3) | - | 已选够 5 条边(n-1),算法终止 | 停止处理 | - | - | - |
四、最终结果
- 最小生成树(MST)的边:(2-3,1)、(1-2,2)、(3-5,2)、(0-2,3)、(4-5,6)
- MST 总权重:1+2+2+3+6 = 14
- MST 结构:所有 6 个节点通过 5 条边连通,且无环、权重和最小。
五、关键总结:并查集的作用
在 Kruskal 算法中,并查集是判断 “是否形成环” 的核心工具:
- 每次选边前,通过
find(u)
和find(v)
查看两个节点的根是否相同:- 若根相同:说明两个节点已在同一个连通分量中,加边会形成环,跳过;
- 若根不同:说明加边后不会形成环,将边加入 MST,并通过
union(u,v)
合并两个连通分量。
通过 “按权重排序 + 并查集判环”,Kruskal 算法高效地找到了最小生成树,时间复杂度主要由 “边排序”(O (E log E),E 为边数)和 “并查集操作”(接近 O (1))决定,适用于边数较少的稀疏图。
"""并查集常用于Krushkal 算法中,判断添加边是够会形成环
"""
from 并查集 import UnionFinddef kruskal(n, edges):""":param n: 节点数:param edges: 边的列表,每个元素为(权重,u,v):return:"""uf = UnionFind(n)#edges.sort(edges) #按照权重重排序# 按照权重排序sorted_edges = sorted(edges, key=lambda x: (x[2]))print("按照权重排序:",sorted_edges)total_weight = 0mst = []for u, v,weight in sorted_edges:# 如果两个节点不在同一个集合,添加这条边不会形成环print(weight)if not uf.is_connected(u,v):uf.union(u, v)total_weight += weightmst.append((u, v, weight))# 当添加的n-1 条边时,最小生成树形成if len(mst) == n - 1:breakreturn mst, total_weightdef demo1():n = 4edges = [( 0, 1,10), (0,2,6), (0, 3,5), ( 1, 3,15), ( 2, 3,4)]mst, weight = kruskal(n, edges)print(mst, weight)def demo2():n = 6edges = [(0, 1, 4), (0, 2, 3), (0, 3, 5), (1, 3, 8), (2, 3, 1), (2, 4, 7), (3, 5, 2), (4, 5, 6), (1, 2, 2)]mst, weight = kruskal(n, edges)print("最小生成树(起点,终点,权重)",mst)print("最小生成树的权重",weight)if __name__ == '__main__':demo1()demo2()