并查集(UnionFindSet)
并查集(UnionFindSet)用于维护多个集合之间的关系,其名称显示了其功能,它提供了三个API:
- 合并:合并两个集合(只需从不同集合中分别给出两个元素即可)
- 查找:查看两元素是否属于同一集合
- 集合:维护多个集合的关系,查询当前有多少个独立的集合
并查集常可应用与图的有关问题,因为它与图中连通分量的概念相关:例如在遍历树/图的过程中,需要不断将两个不同的连通分量连接合并在一起,最后求一共有几个连通分量/求两元素是否属于同一连通分量等
具体而言,可用于判断图中是否有环的问题:LeetCode 684. 冗余连接、LeetCode 1559. 二维网格图中探测环
并查集的实现
并查集的核心思想:一个集合就是一棵树,通过树根来唯一标识
- 并查集中,同一个集合的元素,位于同一棵多叉树上(但一定只有一个根节点)
因此,两元素在同一集合中,等价于[两元素的树根是同一个节点] - 合并两个集合,就是把一个集合的树根接到另一个树根上
- 实现非常简单:维护一个数组
father[n]
初始化:最初每个元素都是一棵树,本身就是树根
寻找树根:不断迭代寻找father,father[i]==i
的点就是树根
合并两个集合(两棵树):分别找出两个树根,father[root1]=root2
class UnionFind(object):
"""并查集类"""
def __init__(self, n):
"""元素总数为n的并查集
集合:count返回当前的独立集合数量"""
self.father = [i for i in range(n)] # 列表0位置空出
self.count = n # 判断并查集里共有几个集合, 初始化默认互相独立
def findRoot(self, p):
"""寻找p所在集合的根节点"""
while p != self.father[p]:
p = self.father[p] # 不断向上追溯父亲,直至根部
return p
def union(self, p, q):
"""合并:连通p,q"""
proot = self.findRoot(p)
qroot = self.findRoot(q)
if proot != qroot: # 仅对于不在同一集合的元素,才有必要合并
self.father[proot] = qroot # 将一个树根接到另一个树根即可
self.count -= 1 # 连通后集合总数减一
def is_connected(self, p, q):
"""查找:判断pq是否已经连通"""
return self.findRoot(p) == self.findRoot(q) # 即判断两个结点是否是属于同一个祖先
并查集优化:路径压缩
- 极端情况下,如果上述的多叉树被建立为类似“链表”的结构,查询根节点的效率极低
- 优化措施:并查集的核心就是一个集合(一棵树)的树根,我们不再让树随意地“野蛮生长”为任意形状的多叉树,而是保证:同一棵树上的所有节点,其father都是根节点(树的高度永远为2,一个根节点+若干个子节点)
- 具体实现:对于
findRoot
函数,在寻找根的过程中,将没有直接指向根的节点进行改造
①按原先写法找到树根
②重走一遍路线,把路线上所有点的father
都指向树根
def findFather(self, p):
"""寻找p所在集合的根节点"""
node_now = p # 当前节点
while p != self.father[p]:
p = self.father[p] # 不断向上追溯父亲,直至根部
root = p # 树根
# 对于路径上的节点,father都指向树根
while node_now != self.father[node_now]:
previous_father = self.father[node_now] # 原先指向的下一个节点
self.father[node_now] = root # 更改当前结点的father为root
node_now = previous_father # 走到下一个节点
return root
路径压缩后,查找函数的均摊效率为O(1)
应用例题
并查集主要解决的两类问题
- 求连通分量个数、各连通分量内部元素个数
- 检查图中是否有环
使用并查集算法的关键是:把原问题转化为图的动态连通性问题
并查集与图中连通分量的概念相关
- 在遍历图的过程中,不断将两个不同的集合(连通分量)union在一起,最后就能获得图的各个连通分量
- 也正是基于上面一点,并查集可用于判断图中是否有环的问题:如果本次要union的两个连通块,本来就是同一个连通块,那么说明有环。(例题:LeetCode 684. 冗余连接、LeetCode 1559. 二维网格图中探测环)
使用并查集的要领
- 每个元素最初都应视为一个独立的集合(连通块),即
father[i]=i
- 使用
root=find(v)
唯一标识一个连通块 - 可以维护总的连通块个数
cnt
:最初cnt=元素总数
,每次成功union,cnt-=1
- 也可以维护某连通块内的元素个数
cnt[root]
:最初cnt[root]=1
,每次成功union(r1,r2),cnt[r1]+=cnt[r2], cnt[r2]=0
LeetCode 684. 冗余连接
一棵多叉树中,有一条多余的边,使得树中产生了环,找出这个边(若有多个可能答案,返回edges中最后出现的那条边)
输入edges = [[1,2], [1,3], [2,3]],返回[2,3]
分析:
- 最初,每个节点可以视作单独的一个连通块
- 添加一条边,等价于连接了两个不同连通块
- 而导致树成环的条件:新增边的两个节点,属于同一个连通块
class Solution:
def findRedundantConnection(self, edges: List[List[int]]) -> List[int]:
n = len(edges)
father = list(range(n + 1)) # father[0]废弃
def findRoot(v):
"""寻找集合的树根,同时路径压缩"""
while v != father[v]:
v = father[v]
root = v
# 路径上所有节点指向树根
while v != father[v]:
previousFather = father[v]
father[v] = root
v = previousFather
return root
def union(v1, v2):
r1 = findRoot(v1)
r2 = findRoot(v2)
if r1 != r2: # 只有属于不同集合,才有必要合并
father[r1] = r2
for v1, v2 in edges:
# 成环的条件:新加入的边连接了同一个连通块
if findRoot(v1) == findRoot(v2):
return [v1, v2]
union(v1, v2)
LeetCode 721. 账户合并
给定一个列表 accounts, 对于accounts[i] ,第一个元素 accounts[i][0] 是用户名称 (name),其余元素是 emails 表示该账户的邮箱地址,合并这些账户。(如果两个账户都有一些共同的邮箱地址,则两个账户必定属于同一个人;如果两个账户名称相同,不一定属于同一个人,可能是重名用户)
思路:先把每个账户的邮箱地址视作一个集合,我们要合并不同的集合,要看两个集合中是否有相同的邮箱地址,这正符合并查集的特性:先合并不同的accounts[i]邮箱集合,然后打印结果时,查询accounts[i]集合和accounts[j]集合是否在同一个集合中,如果在的话,把他们放到同一个账户中
先对于邮箱地址建立并查集:
- 对于每个accounts[i],任取其中的一个邮箱地址作为root
- 然后遍历每一个邮箱地址mail:
①如果遇到以前没有处理过的邮箱地址(not in father),新建并查集,让它自成一个集合(father[mail]=mail)
②遇到以前处理过的邮箱地址,不操作,保持其原先所属集合(不改变father[mail])
最后,邮箱地址mail与root做union操作: - 这样就保证了不同账户只要有重复的邮箱的地址,就属于同一连通块
然后打印答案:
class Solution:
def accountsMerge(self, accounts: List[List[str]]) -> List[List[str]]:
L = len(accounts)
visited = set()
father = {} # 邮箱的并查集
def findRoot(v):
nonlocal father
root = v
while root != father[root]:
root = father[root]
# 路径压缩
while v != father[v]:
previous_father = father[v]
father[v] = root
v = previous_father
return root
def union(v1, v2):
"""合并v1所处集合与mails集合"""
nonlocal father
r1, r2 = findRoot(v1), findRoot(v2)
if r1 != r2: # 根不同再合并
father[r2] = r1
for i in range(L):
# 预处理:自身先去重
account = [accounts[i][0]] + list(set(accounts[i][1:]))
accounts[i] = account
root = account[1] # 所有邮箱与它合并
for mail in account[1:]:
# 最初自己就是一个集合
if mail not in father: # 以前没有读取过的邮箱
father[mail] = mail
union(root, mail)
ans = []
# print(father)
for k in range(L):
account = accounts[k]
if account:
name = account[0]
mail = account[1]
mails = set(account[1:])
# 寻找处于同一个集合中的账户
for i in range(k, L):
if accounts[i]:
n, m = accounts[i][0], accounts[i][1:] # 检查其他的账户
if n == name and findRoot(m[0]) == findRoot(mail): # 合并
mails.update(m)
accounts[i] = [] # 合并后清理
ans.append([name] + sorted(mails))
return ans
LeetCode 1202. 交换字符串中的元素
给出一个字符串 s ,pairs指定了一些索引,指定的两个索引处的字符可以相互交换位置,次数无限制 ,求任意多次交换后可以得到的字典序最小的字符串
s = “dcab”, pairs = [[0,3],[1,2],[0,2]],返回"abcd"
交换 s[0] 和 s[3], s = “bcad”,交换 s[0] 和 s[2], s = “acbd”,交换 s[1] 和 s[2], s = “abcd”
分析:
- 首先,这题的数据规模达到10^5,使用暴力BFS穷举肯定是不行的,我们就应该转而寻找一些规律
- 关键在于发现 交换关系具有传递性 :[0,1]可以交换,[1,2]可以交换,则[0,2]可以交换, i.e. 交换次数无限制时,[0,1,2]三个位置上的字符可以任意安排位置
- 不止三个位置有这样的关系,推广后可知,只要任意数量的索引属于同一个连通块,该连通块内的字符可以任意交换位置
实现:
涉及连通块问题,仍然用并查集解决
- 首先建立并查集,把同一连通块内的元素归为同一集合
- 找出同一连通块内的所有字符,并排序(一个连通块/集合 使用root唯一标识)
- 构造答案,对于位置i,找出其所属连通块,并取出2中的一个最小字典序字母,放入位置i
class Solution:
def smallestStringWithSwaps(self, s: str, pairs: List[List[int]]) -> str:
"""同一连通块内的字符可以任意交换位置,因此找出所有连通块,按照字典序安排字母即可"""
L = len(s)
father = [i for i in range(L)]
def findRoot(v):
nonlocal father
root = v
while root != father[root]:
root = father[root]
# 路径压缩
while v != father[v]:
preFather = father[v]
father[v] = root
v = preFather
return root
def union(a, b):
nonlocal father
r1, r2 = findRoot(a), findRoot(b)
if r1 != r2:
father[r1] = r2
# 找连通块
for v1, v2 in pairs:
union(v1, v2)
# 找出同一连通块内的所有字符
# 遍历字符串,对于根为root的连通块,取出该连通块内所有字符,保存在characters[root]中,并排序
characters = {}
for i, ch in enumerate(s):
root = findRoot(i)
characters.setdefault(root, [])
characters[root].append(ch)
for k in characters.keys():
characters[k] = sorted(characters[k], reverse=True)
# 构造答案,保证每个连通块都是最小字典序
# 遍历字符串,如果当前位置属于连通块root,从该连通块的可用字符characters[root]中取出最小的,放入当前位置
ans = []
for i in range(L):
root = findRoot(i)
ans.append(characters[root].pop()) # 取连通块内字典序最小的字符
return ''.join(ans)
LeetCode 990. 等式方程的可满足性
给出一些等式形如["a==b","b!=c","c==a"]
,判断能否让每个等式都成立
思路:
- 先建立并查集,
'=='
连接的相等变量属于同一集合 - 检查冲突,如果
'!='
连接了属于同一集合的变量,则有冲突