算法学习笔记——数据结构:并查集Union-find Set

并查集是一种用于维护集合关系的数据结构,提供合并、查找和查询集合个数等功能。常见应用场景包括判断图中环、账户合并及字符串元素交换等。通过路径压缩优化,查找效率可达O(1)。在解决LeetCode684和1559等题目时,利用并查集可高效判断冗余连接和二维网格图中是否存在环。

摘要生成于 C知道 ,由 DeepSeek-R1 满血版支持, 前往体验 >

并查集(UnionFindSet)

并查集(UnionFindSet)用于维护多个集合之间的关系,其名称显示了其功能,它提供了三个API:

  1. 合并:合并两个集合(只需从不同集合中分别给出两个元素即可)
  2. 查找:查看两元素是否属于同一集合
  3. 集合:维护多个集合的关系,查询当前有多少个独立的集合

并查集常可应用与图的有关问题,因为它与图中连通分量的概念相关:例如在遍历树/图的过程中,需要不断将两个不同的连通分量连接合并在一起,最后求一共有几个连通分量/求两元素是否属于同一连通分量等
具体而言,可用于判断图中是否有环的问题: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)

应用例题

并查集主要解决的两类问题

  1. 求连通分量个数、各连通分量内部元素个数
  2. 检查图中是否有环

使用并查集算法的关键是:把原问题转化为图的动态连通性问题

并查集与图中连通分量的概念相关

  1. 在遍历图的过程中,不断将两个不同的集合(连通分量)union在一起,最后就能获得图的各个连通分量
  2. 也正是基于上面一点,并查集可用于判断图中是否有环的问题:如果本次要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]集合是否在同一个集合中,如果在的话,把他们放到同一个账户中
先对于邮箱地址建立并查集:

  1. 对于每个accounts[i],任取其中的一个邮箱地址作为root
  2. 然后遍历每一个邮箱地址mail:
    ①如果遇到以前没有处理过的邮箱地址(not in father),新建并查集,让它自成一个集合(father[mail]=mail)
    ②遇到以前处理过的邮箱地址,不操作,保持其原先所属集合(不改变father[mail])
    最后,邮箱地址mail与root做union操作:
  3. 这样就保证了不同账户只要有重复的邮箱的地址,就属于同一连通块

然后打印答案:

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”

分析:

  1. 首先,这题的数据规模达到10^5,使用暴力BFS穷举肯定是不行的,我们就应该转而寻找一些规律
  2. 关键在于发现 交换关系具有传递性 :[0,1]可以交换,[1,2]可以交换,则[0,2]可以交换, i.e. 交换次数无限制时,[0,1,2]三个位置上的字符可以任意安排位置
  3. 不止三个位置有这样的关系,推广后可知,只要任意数量的索引属于同一个连通块,该连通块内的字符可以任意交换位置

实现:
涉及连通块问题,仍然用并查集解决

  1. 首先建立并查集,把同一连通块内的元素归为同一集合
  2. 找出同一连通块内的所有字符,并排序(一个连通块/集合 使用root唯一标识)
  3. 构造答案,对于位置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"],判断能否让每个等式都成立

思路:

  • 先建立并查集,'=='连接的相等变量属于同一集合
  • 检查冲突,如果'!='连接了属于同一集合的变量,则有冲突
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值