参考资料:
- B站视频(104min):杭电ACM-LCY算法入门培训丨3-并查集
文章目录
导引问题
在某个城市里住着
n
n
n 个人,现在给定这
n
n
n 个人的
m
m
m 条信息(每条信息格式【A B】,表示A和B认识)。
假设所有认识的人一定属于同一个单位,请计算该城市最多有多少单位?
什么是并查集?
Disjoint Set,即“不相交的集合”。
-
问题描述:将编号分别为 1... N 1...N 1...N 的 N N N 个对象划分为不相交的集合,在每个集合中,选择其中某个元素代表所在集合。
-
常见两种操作:
-
合并两个集合
-
查找某元素属于哪个集合
-
实现方法1:数组
- 用编号最小的元素标记所在集合;
- 定义一个数组
Set[1, ..., n]
,其中Set[i]
表示元素i
所在的集合。
即:数组的下标 i
表示元素(学生),下标i
所对应的值 Set[i]
表示该元素所属的集合(班级)。一个集合的代表(班长)是该集合中的最小下标。
参考实现代码(Python)
class DisjointSet:
'''数组实现,下标表示元素,值表示所属集合;
用集合中的最小下标表示该集合。
'''
def __init__(self, dset=[]):
self.dset = dset
def find(self, x):
'''查找元素x所属的集合'''
return self.dset[x]
def union(self, A, B):
'''合并集合A和B'''
A, B = min(A, B), max(A, B)
for i in range(len(self.dset)):
if self.dset[i] == B:
self.dset[i] = A
return
# example
ds = DisjointSet(dset=[1,2,1,4,2,6,1,6,2,2])
print(ds.find(0)) # 1
ds.union(1,2)
print(ds.dset) # [1, 1, 1, 4, 1, 6, 1, 6, 1, 1]
效率分析
-
查找: O ( 1 ) O(1) O(1),很高效!
-
合并: O ( N ) O(N) O(N),必须搜索全部元素,有待改进!
- 例如合并A班和B班,则需要遍历整个数组,找出属于B班的学生,将其班长改为A的。
※ 该实现方法太过于扁平化,相当于班长直接管理每一位同学。
下面考虑树结构。
实现方法2:树
- 每个集合用一棵“有根树”表示:
- 定义数组
Set[1, ..., n]
:Set[i] = i
,则i
表示本集合,并且是集合对应树的根(老大);Set[i] = j
,若j ≠ i
, 则j
是i
的父节点(直属领导)。
- 定义数组
参考实现代码(Python)
※ 怎么判断根节点:每个人心里装的都是领导,只有最大的boss心里装的只有自己。
class DisjointSet:
'''数组实现的树结构,下标表示元素,值表示父节点;
用树的根节点代表该集合。
'''
def __init__(self, dset=[]):
self.dset = dset
def find(self, x):
'''查找元素x所属的集合'''
r = x
while self.dset[r] != r:
r = self.dset[r]
return r
def union(self, A, B):
'''合并集合A和B'''
self.dset[B] = A
return
ds = DisjointSet(dset=[0,1,2,1,0,2,1,2,2,1])
print(ds.find(0)) # 1
ds.union(1,0)
print(ds.dset) # [1,1,2,1,0,2,1,2,2,1]
效率分析
-
查找:最坏情况 O ( N ) O(N) O(N)(单链条形式),一般情况是 O ( log N ) O(\log N) O(logN)
-
合并: O ( 1 ) O(1) O(1)
优化
如何避免最坏情况?
-
方法:将深度小的树 合并到 深度大的树
-
实现:假设两棵树的深度分别为 h 1 h_1 h1 和 h 2 h_2 h2,则合并后的树的高度 h h h 为:
h = { max ( h 1 , h 2 ) , if h 1 ≠ h 2 h 1 + 1 , if h 1 = h 2 h = \left\{ \begin{aligned} & \max(h_1, h_2), \ & \text{if} \ h_1 \neq h_2 \\ & h_1 + 1, \ & \text{if} \ h_1 = h_2 \end{aligned} \right. h={max(h1,h2), h1+1, if h1=h2if h1=h2 -
效果:任意顺序的合并操作以后,包含 k k k 个节点的树的最大高度不超过 ⌊ lg k ⌋ \lfloor \lg k \rfloor ⌊lgk⌋
class DisjointSet:
'''数组实现的树结构,下标表示元素,值表示父节点;
用树的根节点代表该集合。
'''
def __init__(self, dset=[]):
self.dset = dset
def find(self, x):
'''查找元素x所属的集合'''
r = x
while self.dset[r] != r:
r = self.dset[r]
return r
def union(self, A, B):
'''合并集合A和B'''
if A.height == B.height: # TODO 需要记录节点的高度
A.height += 1
self.dset[B] = A
elif A.height < B.height:
self.dset[A] = B
else:
self.dset[B] = A
return
- 优化后查找的复杂度最坏为 O ( log N ) O(\log N) O(logN) 。
例题1
题目描述
某省调查城镇交通状况,得到现有城镇道路统计表,表中列出了每条道路直接连通的城镇。现在的目标是使全省任何两个城镇间都可以实现交通(不一定要有直接的道路相连,只要有道路可达即可),问最少还需要建设多少条道路?
题目来源:浙江大学-研究生复试(机考)(好像是2005年的?)
解题思路
- 就是要把所有城镇合并成一个集合!(n个城镇,最少需要n-1条路能实现全部连通;n个集合,最少需要新增n-1条路能实现全部连通)
- 怎么判断一个并查集有多少个集合?——数根节点的个数:父节点等于自身的节点的个数。
※ 最赤裸裸的并查集问题。
初始化的时候,每个点都是一个集合;
每来一条边,判断这两个点是不是属于同一个集合;如果不是就合并。
最后返回根节点的个数-1
。
例题2
题目描述
判断一个迷宫是否符合要求:任何两个房间之间有且只有一条通路。
——说明要连通(只能有一个根节点),且不能出现环。
※ 如果两个点已经连通了,再给他们俩加条边,就会出现环
经典应用——最小生成树
什么是——生成树?
- 图:由顶点和边组成,任何两个顶点之间都可以有边
- 树:n个顶点n-1条边,并且是连通的
- 生成树:如果一个图包含了某个图的所有顶点,并且包含了其中的n-1条边,并且是连通的
- 最小生成树:边的权值和最小
- 应用:用最小的代价让6个岛互连互通
如何求——最小生成树?
- Prim算法
- Kruskal算法——并查集
Kruskal算法
克鲁斯卡尔算法
理论基础:MST性质(最小生成树性质)
-
对于一个连通图,至少存在一棵最小生成树,包含了最短的边。
-
反证法:假设所有的最小生成树都不包含最短的那条边,…
算法步骤
- 把原始图的 N N N 个节点看成 N N N 个独立子图;
- 每次选取当前最短的边,看两端是否属于不同的子图(保证不会构成回路);若是,加入;否则,放弃;
- 循环操作步骤2,直到有 N − 1 N-1 N−1 条边。
※ 典型的贪心算法
例题3
题目描述
地图上有
n
n
n 个城市,现在想给这
n
n
n 个城市之间造路,希望能让城市之间两两可达。给出了
m
m
m 种供选择的道路,每种选择是一个三元组 (u, v, w)
,代表给 u
城市和 v
城市之间建造一条长度为 w
的道路。
要求最终的道路总长越小越好。
解题思路
- 要连通,要总长最小——就是最直白的最小生成树
765. 情侣牵手
题目描述
(困难)
N
对情侣坐在连续排列的2N
个座位上,想要牵到对方的手。 计算最少交换座位的次数,以便每对情侣可以并肩坐在一起。 一次交换可选择任意两人,让他们站起来交换座位。人和座位用
0
到2N-1
的整数表示,情侣们按顺序编号,第一对是(0, 1)
,第二对是(2, 3)
,以此类推,最后一对是(2N-2, 2N-1)
。这些情侣的初始座位
row[i]
是由最初始坐在第i
个座位上的人决定的。
相关标签:贪心算法,并查集,图
解题思路
-
「首尾相连」这件事情可以使用 并查集 表示,将输入数组相邻位置的两个 编号 在并查集中进行合并。编写代码基于了下面的事实:
如果一对情侣恰好坐在了一起,并且坐在了成组的座位上,其中一个下标一定是偶数,另一个一定是奇数,并且「偶数的值 + 1 = 奇数的值」。例如编号数对
[2, 3]
、[9, 8]
,这些数对的特点是除以 2(下取整)得到的数相等。 -
并查集:交换之后连通分量个数 - 交换之前连通分量个数 = 最少交换次数
我的题解
def minSwapsCouples(row):
"""
:type row: List[int]
:rtype: int
"""
N = len(row) // 2
# 构造并查集:数组实现树结构
# 代表元法:用集合中的最小编号作为该集合的代表
# 初始化:每对情侣属于一个集合,编号满足a//2==b//2的就是情侣
# 即初始化为[0,0,2,2,4,4,...,2N-1,2N]
# disjoint_set的下标表示每个人的编号
disjoint_set = [i//2*2 for i in range(2*N)]
def find(x):
'查找编号为x的人所属的集合'
r = x
while r != disjoint_set[r]:
r = disjoint_set[r]
return r
def union(a, b):
'将编号为a和b的人所属的集合合并在一起'
disjoint_set[a] = b
return
# 坐在一起的人join到一个集合中
# 例如0和4坐在一起(在row中相邻),则将4归属到0的集合中
for i in range(0, 2*N, 2):
p1, p2 = find(row[i]), find(row[i+1]) #找到所属的集合
union(p1, p2) #合并
# 统计集合的个数,即disjoint_set中根节点的数量
count = 0
for i in range(2*N):
if i==disjoint_set[i]: # 父节点等于自身的就是根节点
count += 1
# 最少交换次数 = N - 连通集合的个数
return N-count
print(minSwapsCouples(row = [0, 2, 1, 3])) # 1
print(minSwapsCouples(row = [3, 2, 0, 1])) # 0
print(minSwapsCouples(row = [5,4,2,6,3,1,0,7])) # 2
print(minSwapsCouples(row = [6,2,1,7,4,5,3,8,0,9])) # 3
复杂度分析:
- 时间复杂度:
O
(
N
log
N
)
O(N \log N)
O(NlogN),这里
N
N
N 是输入数组的长度,
O
(
N
2
log
N
2
)
=
O
(
N
log
N
)
O(\cfrac{N}{2} \log \cfrac{N}{2}) = O(N\log N)
O(2Nlog2N)=O(NlogN) ;查找函数
find()
的复杂度是 O ( log N ) O(\log N) O(logN)。 - 空间复杂度: O ( N ) O(N) O(N)