【算法】并查集介绍与运用【Python板子】

前言

这个总结拖了好久hhh,一个月前就想搞了,一直拖到现在。这个总结并不是从【并查集】的Introductionbackground开始的,侧重点在于运用方面。

先放个Python的板子

class UnionSet:

    def __init__(self,n):
        # 多少个帮派
        self.parents=[i for i in range(n)]
        # 每个帮派有多少实力
        self.rank[0]*n
        # 吞并之后的帮派数量
        self.count=0

    def find(self,x):
        # 摇人规则
        if self.parents[x]!=x:
            # 一直找到大老板为止
            # 把沿途遇到的小老板的联系方式替换成大老板
            self.parents[x]=self.find(self.parents[x])
        return self.parents[x]

    def union(self,a,b):
        # 吞并规则
        roota,rootb=self.find(a),self.find(b)
        # 不是一派
        if roota!=rootb:
            # 小帮派并入大帮派
            if self.rank[roota]<self.rank[rootb]:
                roota,rootb=rootb,roota
            self.parents[rootb]=roota
            
            if self.rank[roota]==self.rank[rootb]:
                # 同级别的情况下,能够百尺竿头更进一步
                self.rank[roota]+=1
            self.count-=1

    def getCount(self):
        return self.count

当然这个板子略显复杂,我们也不需要额外开一个类去定义并查集:

class Solution:
	def function(self,data):
        n=len(data)
        pa=[i for i in range(n)]
        
        def find(x):
            if pa[x]!=x:
                pa[x]=find(pa[x])
            return pa[x]
       	
        # 看情况修改
        for i in data:
            for j in data:
                if i<j: # 是否连通
                    ri,rj=find(i),find(j) # 合并规则
                    if ri!=rj:
                        pa[rj]=ri # 合并
        return sum([1 for i,j in enumerate(pa) if pa[i]==j]) # 看情况修改 这里是统计连通块数量

再贴一个Python3的精简版本,:=赋值符号只能在py3.8及以上的版本用

class UnionFind:
	def __init__(self,n):
		self.pa=[i for i in range(n)]
    def find(self,x):
		if self.pa[x]!=x:
            self.pa[x]=self.find(self.pa[x])
         return pa[x]
   	def union(self,a,b):
        if (roota:=self.find(a))!=(rootb:=self.find(b)):
            self.pa[rootb]=roota
    def isConnect(self,a,b):
        return self.find(a)==self.find(b)

并查集

一、概念介绍

所谓并查集(Union-find Data Structure),是一种用于快速处理不交集合并及查询问题的树形结构


为什么需要提出并查集呢?

在图结构中,除却节点、边本身的性质,我们注意到还有不少性质尤为重要,其中就包括了拓扑结构。而最简单的一种拓扑结构,就是连通关系

如何处理这种连通关系呢?如果通过新开一个数组的方式存储,那么每次查询都需要遍历每个元素,时间复杂度会比较高,且空间开销也比较大。

我们注意到,许多连通的元素可以单独构成一个整体,能不能选择其中有代表性的一个,作为代言人呢?这样,我们检查连通性的时候,只需要找到这个代言人,看看两个连通块之间的代言人是否相同,就能处理连通关系啦。

举个栗子

现在有五个城市A,B,C,D,E,他们之间有的开通了高铁,有的并没有。比如A,B,C之间开通了高铁,那么假如D要跟其中之一也开通高铁,此时,D和其他三个都能连通,形成了连通块。我们就不需要一一去存储三个城市啦,只需要将D保存到A,B,C连通块中,将连通块变成A,B,C,D就阔以啦。


并查集的结构是怎么样的呢?🏛

通过前文的论述,我们发现,并查集本质上就是一个存储连通关系的结构!这个结构为每个元素维护了一个代表数据,这个代表数据也就是每个连通块的区分数据。在实际中,这个代表数据可以是节点,可以是最大值,可以是统计量等。为了找到这个代表数据,我们还需要一个寻址方法。进一步的,可以通过路径压缩寻址方法进行优化

除却上述两个最重要的元素,并查集还可以维护一个rank数组,用来评估块的信息量。

元素说明
parents连通块的代表元素,或者说,节点的boss
rank连通块的信息量
find(x)寻址方法
merge(a,b)合并方法

特殊变化–带权并查集

我们说对于无权图的连通性,并查集能够很好的进行适配与处理,那么对于有权图呢?是否也可以通过并查集进行处理?

答案是肯定的,并查集可以起到统一度量衡的作用,但是必须在结构上做一定的修改。

我们假设带权边的权重数组为 w w w,其中 w [ i ] w[i] w[i]表示第 i i i条边相对于 r o o t root root节点的权重。

在寻址(find)过程中,我们需要进行如下操作:
w [ i ] = i r o o t = i p a r e n t i × p a r e n t i r o o t w[i]=\frac{i}{root}=\frac{i}{parent_i}\times\frac{parent_i}{root} w[i]=rooti=parentii×rootparenti
这是一条权重链,确保并查集中每个子集都能以根节点为基准进行定标。

在加入(union)的过程中,我们需要对其进行如下更新:
假设  y  加入  x   w [ r o o t y ] = r o o t y r o o t x = y   ⋅   w [ x ] w [ y ]   ⋅   x = y x   ⋅   w [ x ] w [ y ] 令 k = y x ,即 : w [ r o o t y ] = k   ⋅   w [ x ] w [ y ] 假设 \ y\ 加入\ x\ \\ w[root_y]=\frac{root_y}{root_x}=\frac{y\ \cdot\ w[x]}{w[y]\ \cdot\ x} \\ =\frac{y}{x}\ \cdot\ \frac{w[x]}{w[y]}\\ 令k=\frac{y}{x},即:w[root_y]=k\ \cdot\ \frac{w[x]}{w[y]} 假设 y 加入 x w[rooty]=rootxrooty=w[y]  xy  w[x]=xy  w[y]w[x]k=xy,即:w[rooty]=k  w[y]w[x]
如此,便在查询和合并的过程中实现了权值的链式更新。所有树结构上的值都是以根节点作为基准进行变换得到的,因此,同分支上(同个连通块)实现了可比性。


板子怎么写?

这边不图快,正二八经的贴一个并查集板子。

python

class UnionSet:

    def __init__(self,n):
        # 多少个帮派
        self.parents=[i for i in range(n)]
        # 每个帮派有多少实力
        self.rank[0]*n
        # 吞并之后的帮派数量
        self.count=0

    def find(self,x):
        # 摇人规则
        if self.parents[x]!=x:
            # 一直找到大老板为止
            # 把沿途遇到的小老板的联系方式替换成大老板
            self.parents[x]=self.find(self.parents[x])
        return self.parents[x]

    def union(self,a,b):
        # 吞并规则
        roota,rootb=self.find(a),self.find(b)
        # 不是一派
        if roota!=rootb:
            # 小帮派并入大帮派
            if self.rank[roota]<self.rank[rootb]:
                roota,rootb=rootb,roota
            self.parents[rootb]=roota
            
            if self.rank[roota]==self.rank[rootb]:
                # 同级别的情况下,能够百尺竿头更进一步
                self.rank[roota]+=1
            self.count-=1

    def getCount(self):
        return self.count
    
    def isConnect(self,a,b):
        return find(a)==find(b)

Java

class Solution {
    class UnionFind {
        int[] parent;
        int[] rank;
        int count;

        public UnionFind(int n) {
            parent=new int[n];
            rank=new int[n];
            count=n;
            
        	for(int i=0;i<n;i++){
                parent[i]=i;
                rank[i]=1;
            }
            
        }

        public int find(int i) {
            if (parent[i] != i) parent[i] = find(parent[i]);
            return parent[i];
        }

        public void union(int x, int y) {
            int rootx = find(x);
            int rooty = find(y);
            if (rootx != rooty) {
                if (rank[rootx] > rank[rooty]) {
                    parent[rooty] = rootx;
                } else if (rank[rootx] < rank[rooty]) {
                    parent[rootx] = rooty;
                } else {
                    parent[rooty] = rootx;
                    rank[rootx] += 1;
                }
                count--;
            }
        }
        }
    }
}

二、题目类型

每种类型的题大概会挑比较经典的两三题讲一讲,并提炼思路与套路。

常见类型:连通块大小

此类问题是最常见的问题,例如经典的岛屿问题


1️⃣ LC200 岛屿数量

来自:https://leetcode.cn/problems/number-of-islands

给你一个由 ‘1’(陆地)和 ‘0’(水)组成的的二维网格,请你计算网格中岛屿的数量。

岛屿总是被水包围,并且每座岛屿只能由水平方向和/或竖直方向上相邻的陆地连接形成。

此外,你可以假设该网格的四条边均被水包围。

示例:

输入:grid = [
  ["1","1","1","1","0"],
  ["1","1","0","1","0"],
  ["1","1","0","0","0"],
  ["0","0","0","0","0"]
]
输出:1
    
输入:grid = [
  ["1","1","0","0","0"],
  ["1","1","0","0","0"],
  ["0","0","1","0","0"],
  ["0","0","0","1","1"]
]
输出:3

思路

因为是UF的归纳,所以这边我们不用其他方法做了。

我们只需要将上下左右都是"1"的元素加入并查集即可。

注意点

  • 最开始初始化并查集的时候,需要用一个字段c记录元素的数量
  • 当任意两个集合合并为一个集合时,令字段c减一
  • 从右上往左下遍历,我们不需要找四个方向,只需要找左下两个方向即可(做个小剪枝)
  • 即:for x,y in [(i+1,j),(i,j+1)] 可以思考为什么能做剪枝、

实质

连通块的大小

实现

由于这是第一题,我们贴一个低耦合的写法。

class Solution:
    def numIslands(self, grid: List[List[str]]) -> int:


        m=len(grid)
        if m==0:
            return 0
        n=len(grid[0])
        u=UnionSet(grid)
        
        for i in range(m):
            for j in range(n):

                if grid[i][j]=="1":
                # 避免重复计数
                    # 往左下找
                    for a,b in [(i+1,j),(i,j+1)]:
                        if a<m and b<n and grid[a][b]=="1":
                            # 找到小弟了,吸收进帮派
                            u.union(i*n+j,a*n+b)

        return u.getCount()


class UnionSet:

    def __init__(self,grid):
        # 多少个帮派
        self.m,self.n=len(grid),len(grid[0])
        self.parents=[-1]*(self.m*self.n)
        # 每个帮派有多少实力
        self.rank=[0]*(self.m*self.n)
        # 吞并之后的帮派数量
        self.count=0

        # 生成江湖中
        for i in range(self.m):
            for j in range(self.n):
                # 有能力自成一派
                if grid[i][j]=="1":
                    self.parents[i*self.n+j]=i*self.n+j
                    self.count+=1

    def find(self,x):
        # 摇人规则
        if self.parents[x]!=x:
            self.parents[x]=self.find(self.parents[x])
        return self.parents[x]

    def union(self,a,b):
        # 吞并规则
        roota,rootb=self.find(a),self.find(b)
        # 不是一派
        if roota!=rootb:
            # 小帮派并入大帮派
            if self.rank[roota]<self.rank[rootb]:
                roota,rootb=rootb,roota
            self.parents[rootb]=roota
            if self.rank[roota]==self.rank[rootb]:
                # 同级别的情况下,能够百尺竿头更进一步
                self.rank[roota]+=1
            self.count-=1

    def getCount(self):
        return self.count

上述版本中我们单独维护了UF的结构,方便维护和重复使用。而为了快,也可以将UF直接写到代码块中。

class Solution:
    def numIslands(self, grid: List[List[str]]) -> int:
        m=len(grid)
        if m<=0:return 0
        n=len(grid[0])
        
        cnt=m*n# 计数
        pa=[i for i in range(m*n)] # 二维展平成一维的父节点
        
        def find(x):# 寻址+路径压缩
            if pa[x]!=x:
                pa[x]=find(pa[x])
            return pa[x]

        for i in range(m):
            for j in range(n):
                if grid[i][j]=="1":# "1"的时候合并
                    for x,y in [(i+1,j),(i,j+1)]:
                        if x<m and y<n and grid[x][y]=="1":# 合并     
                            roota,rootb=find(i*n+j),find(x*n+y)
                            if roota!=rootb:
                                pa[rootb]=roota
                                cnt-=1
                else:
                    cnt-=1 # "0"的时候计数减一
        return cnt

2️⃣ LC547省份数量

来源:https://leetcode.cn/problems/number-of-provinces/

有 n 个城市,其中一些彼此相连,另一些没有相连。如果城市 a 与城市 b 直接相连,且城市 b 与城市 c 直接相连,那么城市 a 与城市 c 间接相连。

省份 是一组直接或间接相连的城市,组内不含其他没有相连的城市。

给你一个 n x n 的矩阵 isConnected ,其中 isConnected[i][j] = 1 表示第 i 个城市和第 j 个城市直接相连,而 isConnected[i][j] = 0 表示二者不直接相连。

返回矩阵中 省份 的数量。

示例

输入:isConnected = [[1,1,0],[1,1,0],[0,0,1]]
输出:2

思路

这题跟200是换汤不换药啊,只不过变成数组了。

给的是邻接表,这也是图结构中比较重要的一环。通过邻接表,能够快速实现连通关系的构建。

实质

连通块的数量

实现

class Solution:
    def findCircleNum(self, isConnected: List[List[int]]) -> int:
        n=len(isConnected)
        pa=[i for i in range(n)]
        cnt=n
        
        def find(x):
            if pa[x]!=x:
                pa[x]=find(pa[x])
            return pa[x]

        for i in range(n):
            for j in range(i+1,n): # 我们只要上三角矩阵就好了
                if isConnected[i][j]:
                    ri,rj=find(i),find(j)
                    if ri!=rj:
                        pa[ri]=rj
                        cnt-=1
        return cnt

3️⃣ LC695 岛屿的最大面积

来源:https://leetcode.cn/problems/max-area-of-island/

给你一个大小为 m x n 的二进制矩阵 grid 。

岛屿 是由一些相邻的 1 (代表土地) 构成的组合,这里的「相邻」要求两个 1 必须在 水平或者竖直的四个方向上 相邻。你可以假设 grid 的四个边缘都被 0(代表水)包围着。

岛屿的面积是岛上值为 1 的单元格的数目。

计算并返回 grid 中最大的岛屿面积。如果没有岛屿,则返回面积为 0 。

示例

输入:grid = [[0,0,1,0,0,0,0,1,0,0,0,0,0],
           [0,0,0,0,0,0,0,1,1,1,0,0,0],
           [0,1,1,0,1,0,0,0,0,0,0,0,0],
           [0,1,0,0,1,1,0,0,1,0,1,0,0],
           [0,1,0,0,1,1,0,0,1,1,1,0,0],
           [0,0,0,0,0,0,0,0,0,0,1,0,0],
           [0,0,0,0,0,0,0,1,1,1,0,0,0],
           [0,0,0,0,0,0,0,1,1,0,0,0,0]]
输出:6

思想

遍历整个区域,构建连通块,维护一个最大连通块面积

实质

连通块的大小

实现

class Solution:
    def maxAreaOfIsland(self, grid: List[List[int]]) -> int:
        m,n=len(grid),len(grid[0])
        pa=[i for i in range(m*n)]
        rank=[1]*(m*n)

        def find(x):
            if pa[x]!=x:
                pa[x]=find(pa[x])
            return pa[x]
        
        for i in range(m):
            for j in range(n):

                if grid[i][j]==1:
                    for x,y in [(i+1,j),(i,j+1)]:
                        if x<m and y<n and grid[x][y]==1:
                            rx,ry=find(i*n+j),find(x*n+y)
                            if rx!=ry:
                                if rank[rx]<rank[ry]:
                                    rx,ry=ry,rx
                                pa[ry]=rx
                                rank[rx]+=rank[ry]
                else:
                    rank[i*n+j]=0

        return max(rank)

技巧

  • 我们将每一个像素点视为一个独立个体,因而整个树的叶子结点有m*n个,pa应该开到这样的空间
  • 两个连通块合并的时候,可以得到一个新的连通块,这个连通块的大小等于两个连通块之和
  • 从左上往右下遍历,每次只需要找到右下方向就可以了

4️⃣ LC952 按公因数计算最大组件大小

链接:https://leetcode.cn/problems/largest-component-size-by-common-factor

给定一个由不同正整数的组成的非空数组 nums ,考虑下面的图:

有 nums.length 个节点,按从 nums[0] 到 nums[nums.length - 1] 标记;
只有当 nums[i] 和 nums[j] 共用一个大于 1 的公因数时,nums[i] 和 nums[j]之间才有一条边。
返回 图中最大连通组件的大小 。

示例

输入:nums = [4,6,15,35]
输出:4

思想

判断公因数,存在公因数时,相当于存在边。因而公因数作为桥梁,可以构建某个点到另一个点的连通块。

实质

寻找质因数+并查集

实现

# 欧拉筛
n=int(10e5+1)
isPrime=[1]*n
prime=[]
for i in range(2,n):
    if isPrime[i]:
        prime.append(i)
    
    for j in prime:
        if j*i>=n:
            break
        isPrime[j*i]=0
        if i%j==0:
            break

class Solution:
    def largestComponentSize(self, nums: List[int]) -> int:
        # 这题考察点有两个:
        # + 质因数分解
        # + 连通性
        # 质因数分解通过欧拉筛就可以解决

        n=max(nums)
        pa=[i for i in range(n+1)]
        def find(x):
            if pa[x]!=x:
                pa[x]=find(pa[x])
            return pa[x]
        def union(a,b):
            if (ra:=find(a))!=(rb:=find(b)):
                pa[rb]=ra
        
        for idx,i in enumerate(nums):
            tem=nums[idx]
            for j in prime:
                if j**2>tem:
                    break
                # 公因子连接
                if tem%j==0:
                    union(i,j)
                # 去除公因子
                while tem%j==0:
                    tem//=j
            if tem!=1:
                union(tem,i)
        return max(Counter(find(num) for num in nums).values())

常见问题: 加入连通块

此类问题在构建当前连通块后,往往通过加入新的连通块增加难度

1️⃣ LC778 水位上升游泳池

来源: https://leetcode.cn/problems/swim-in-rising-water/

在一个 n x n 的整数矩阵 grid 中,每一个方格的值 g r i d [ i ] [ j ] grid[i][j] grid[i][j]表示位置 (i, j) 的平台高度。

当开始下雨时,在时间为 t 时,水池中的水位为 t 。你可以从一个平台游向四周相邻的任意一个平台,但是前提是此时水位必须同时淹没这两个平台。假定你可以瞬间移动无限距离,也就是默认在方格内部游动是不耗时的。当然,在你游泳的时候你必须待在坐标方格里面。

你从坐标方格的左上平台 (0,0) 出发。返回 你到达坐标方格的右下平台 (n-1, n-1) 所需的最少时间 。

输入: grid = [[0,2],[1,3]]
输出: 3
解释:
时间为0时,你位于坐标方格的位置为 (0, 0)。
此时你不能游向任意方向,因为四个相邻方向平台的高度都大于当前时间为 0 时的水位。
等时间到达 3 时,你才可以游向平台 (1, 1). 因为此时的水位是 3,坐标方格中的平台没有比水位 3 更高的,所以你可以游向坐标方格中的任意位置

思路

因为时间区间给定了,在 t = n ∗ n − 1 t=n*n-1 t=nn1的时刻一定能够遍历,所以我们可以通过二分法的方式找到最小可以满足条件的时间。或者呢,我们通过并查集的方式,在每一时刻联通新的节点,判断起点和终点的连通性就可以。当然,为了方便加入联通节点,我们也可以先用hash表记录位置。

核心

加入连通块后判断连通性

实现

class Solution(object):
    def swimInWater(self, grid):
        """
        :type grid: List[List[int]]
        :rtype: int
        """
        n=len(grid)
        u=UnionFind(n**2)
        dir=[[1,0],[-1,0],[0,1],[0,-1]]

        # 为了方便遍历
        # 我们可以用贪心思想:每次从最小满足阈值t的点出发,联通周围的点
        # 由于t是唯一的 所以我们只需要注重当下即可
        # 因为到t时刻,所有能够连通的都已经联通了

        # 我们可以用hash表记录位置
        hashmap=collections.defaultdict(list)
        for i in range(n):
            for j in range(n):
                hashmap[grid[i][j]]=[i,j]

        # t次循环
        for t in range(n**2):
            idx=hashmap[t]
            for dx,dy in dir:
                x=idx[0]+dx
                y=idx[1]+dy
                if x>=0 and x<n and y>=0 and y<n and grid[x][y]<=t:
                    u.union(idx[0]*n+idx[1],x*n+y)
            # 判断此时的连通性
            if u.isConnect(0,n*n-1):
                return t
        


class UnionFind:
    def __init__(self,n):
        self.pa=[i for i in range(n)]

    def find(self,x):
        if self.pa[x]!=x:
            self.pa[x]=self.find(self.pa[x])
        return self.pa[x]

    def union(self,a,b):
        roota,rootb=self.find(a),self.find(b)
        if roota!=rootb:
            self.pa[rootb]=roota

    def isConnect(self,a,b):
        return self.find(a)==self.find(b)

2️⃣ LC827 最大人工岛

来源: https://leetcode.cn/problems/making-a-large-island/

给你一个大小为 n x n 二进制矩阵 grid 。最多 只能将一格 0 变成 1 。

返回执行此操作后,grid 中最大的岛屿面积是多少?

岛屿 由一组上、下、左、右四个方向相连的 1 形成。

示例

输入: grid = [[1, 0], [0, 1]]
输出: 3
解释: 将一格0变成1,最终连通两个小岛得到面积为 3 的岛屿。

思路

827相当于695的进阶,在695的板子上,加上了可以diy位置的连通块,所以我们可以通过回溯+并查集的方式实现(穷举的本质罢了)

核心

连通块大小+可变连通块

实现

class Solution:
    def largestIsland(self, grid):
        # 并查集+回溯
        n=len(grid)
        pa=[i for i in range(n*n)]
        rank=[1]*(n*n)
        maxVal=0
        
        def find(x):
            if pa[x]!=x:
                pa[x]=find(pa[x])
            return pa[x]
        
        # 初始化并查集
        for i in range(n):
            for j in range(n):
                if grid[i][j]==1:
                    
                    for x,y in [(i+1,j),(i,j+1)]:
                        if x<n and y<n and grid[x][y]==1:
                            ra,rb=find(i*n+j),find(x*n+y)
                            if ra!=rb:
                                pa[rb]=ra
                                rank[ra]+=rank[rb]
                else:
                    rank[i*n+j]=0
        maxVal=max(rank) # 记录啥也不加的最大值
        
        #--------------------------------------------------------------------#
        #                           到这里位置完全是695                       #
        #--------------------------------------------------------------------#
        
        # 回溯
        for i in range(n):
            for j in range(n):
                if grid[i][j]==0:
                    # 合并上下左右不同根节点的连通块
                    tem_rank=0
                    used=set()
                    for x,y in [(i-1,j),(i+1,j),(i,j-1),(i,j+1)]:
                        if x>=0 and x<n and y>=0 and y<n and grid[x][y]==1 and (val:=find(x*n+y)) not in used:
                            tem_rank+=rank[val]
                            used.add(val)
                    maxVal=max(maxVal,tem_rank+1)
        
        return max(maxv,maxVal)
     

3️⃣ LC128 最长连续序列

链接:https://leetcode.cn/problems/longest-consecutive-sequence

给定一个未排序的整数数组 nums ,找出数字连续的最长序列(不要求序列元素在原数组中连续)的长度。

请你设计并实现时间复杂度为 O(n) 的算法解决此问题。

示例

输入:nums = [100,4,200,1,3,2]
输出:4
解释:最长数字连续序列是 [1, 2, 3, 4]。它的长度为 4

思路

通过hash表维护元素之间的位置,利用并查集保证多个连通块之间的比较。在加入上,不需要再去考虑位置问题,只需要着眼于当下。

实现

class Solution:
    def longestConsecutive(self, nums: List[int]) -> int:

        '''
        这题是个好题啊

        hash表、并查集、DP等等都能做(UF会不会TLE?真的是O(n)吗)
        '''

        # 并查集
        n=len(nums)
        if n==0:
            return 0
        hashset={}
        u=UnionSet(n)
        # 讲道理,最后还是要用hash表辅助啊
        # 要不然之前的结构不太好找

        for i,j in enumerate(nums):
            if j in hashset.keys():
                continue # 找过了
            if j-1 in hashset.keys():
                u.union(i,hashset[j-1])
            if j+1 in hashset.keys():
                u.union(i,hashset[j+1])
            hashset[j]=i
        
        return u.getV()

class UnionSet:

    def __init__(self,x):
        self.pa=[i for i in range(x)]
        self.rank=[1 for i in range(x)]
    
    def find(self,x):
        if self.pa[x]!=x:
            self.pa[x]=self.find(self.pa[x])
        return self.pa[x]
    
    def union(self,a,b):
        roota,rootb=self.find(a),self.find(b)
        if roota!=rootb:
            self.pa[rootb]=roota
            self.rank[roota]+=self.rank[rootb]
            # self.rank[rootb]=0

    def getV(self):
        return max(self.rank)

我们来看看hash表的做法

class Solution:
    def longestConsecutive(self, nums: List[int]) -> int:
        hashset=set(nums)
        ans=0
        
        for i in nums:
            val=i
            if val-1 not in hashset:
                start=1
                while val+1 in hashset:
                    start+=1
                    val+=1
                ans=max(ans,start)
        
        return ans

常见问题: 判断重复连接

此类问题一般跟环结构有关,判断是否产生了重复的连接

1️⃣ LC684 冗余连接

**链接:**https://leetcode.cn/problems/redundant-connection

树可以看成是一个连通且 无环 的 无向 图。

给定往一棵 n 个节点 (节点值 1~n) 的树中添加一条边后的图。添加的边的两个顶点包含在 1 到 n 中间,且这条附加的边不属于树中已存在的边。图的信息记录于长度为 n 的二维数组 edges ,edges[i] = [ai, bi] 表示图中在 ai 和 bi 之间存在一条边。

请找出一条可以删去的边,删除后可使得剩余部分是一个有着 n 个节点的树。如果有多个答案,则返回数组 edges 中最后出现的边。

示例

输入: edges = [[1,2], [1,3], [2,3]]
输出: [2,3]

思路

这题的本质在于树不可以出现环,且不存在节点间的先后序,所以可以直接采用并查集构建联通节点。当某个节点加入时,若存在环,则合并的两个节点将具有相同的父节点。

核心

判断节点连通性

实现

class Solution:
    def findRedundantConnection(self, edges: List[List[int]]) -> List[int]:
        # 这题我差点想用合并代替删除了

        # 实际上不需要
        # 这个只需要判断是否出现环就行了
        # 直接建图的话有点浪费时间

        # 用并查集判断连通性就可以了

        pa={}

        def find(x):
            if pa[x]!=x:
                pa[x]=find(pa[x])
            return pa[x]
        
        def union(a,b):
            pa[find(b)]=find(a)
        
        for i,j in edges:

            if i not in pa:
                pa[i]=i
            if j not in pa:
                pa[j]=j

            if find(i)!=find(j):
                union(i,j)
            
            else:
                return [i,j]
        return []

技巧

  • 动态开并查集节点

常见问题: 堵塞

这类问题往往在树的构建上,通过增加限制条件,限制路径

1️⃣ LC2368 受限制条件下可达到的节点数目

链接:https://leetcode.cn/problems/reachable-nodes-with-restrictions

现有一棵由 n 个节点组成的无向树,节点编号从 0 到 n - 1 ,共有 n - 1 条边。

给你一个二维整数数组 edges ,长度为 n - 1 ,其中 edges[i] = [ai, bi] 表示树中节点 ai 和 bi 之间存在一条边。另给你一个整数数组 restricted 表示 受限 节点。

在不访问受限节点的前提下,返回你可以从节点 0 到达的 最多 节点数目。

注意,节点 0 不 会标记为受限节点。

示例

输入:n = 7, edges = [[0,1],[1,2],[3,1],[4,0],[0,5],[5,6]], restricted = [4,5]
输出:4
解释:上图所示正是这棵树。
在不访问受限节点的前提下,只有节点 [0,1,2,3] 可以从节点 0 到达。

思路

存在阻碍节点视为不可通行,由于需要我们构建树,所以并不能直接通过遍历的方式找到。于是,当我们在构建树的时候,可以通过计算零节点的连通块实现求节点的目的。

在遇到受限节点的时候,不进行联通,那么如果没有其他支路,该路径后续的节点都不会被加入零节点的连通块,而是独立形成连通块。(一共有三种类型: 零节点连通块,独立连通块,受限制节点)

实质

受限制条件下的连通

实现

class Solution:
    def reachableNodes(self, n: int, edges: List[List[int]], restricted: List[int]) -> int:

        # 看到无向图 就该考虑并查集了
        pa=[i for i in range(n)]
        # set在查询的速度远超于list 
        # 而且会有重复的
        
        restricted=set(restricted)

        def find(x):
            if pa[x]!=x:
                pa[x]=find(pa[x])
            return pa[x]
        
        def union(a,b):
            if (va:=find(a))!=(vb:=find(b)):
                pa[vb]=va

        for i,j in edges:
            if i not in restricted and j not in restricted:
                union(i,j)
        # print(pa)
        return sum([1 for i in range(n) if find(i)==find(0)])

常见问题:带权并查集

1️⃣ LC399 除法求值

链接:https://leetcode.cn/problems/evaluate-division

给你一个变量对数组 equations 和一个实数值数组 values 作为已知条件,其中 equations[i] = [Ai, Bi] 和 values[i] 共同表示等式 Ai / Bi = values[i] 。每个 Ai 或 Bi 是一个表示单个变量的字符串。

另有一些以数组 queries 表示的问题,其中 queries[j] = [Cj, Dj] 表示第 j 个问题,请你根据已知条件找出 Cj / Dj = ? 的结果作为答案。

返回 所有问题的答案 。如果存在某个无法确定的答案,则用 -1.0 替代这个答案。如果问题中出现了给定的已知条件中没有出现的字符串,也需要用 -1.0 替代这个答案。

注意:输入总是有效的。你可以假设除法运算中不会出现除数为 0 的情况,且不存在任何矛盾的结果。

示例

输入:equations = [["a","b"],["b","c"]], values = [2.0,3.0], queries = [["a","c"],["b","a"],["a","e"],["a","a"],["x","x"]]
输出:[6.00000,0.50000,-1.00000,1.00000,-1.00000]
解释:
条件:a / b = 2.0, b / c = 3.0
问题:a / c = ?, b / a = ?, a / e = ?, a / a = ?, x / x = ?
结果:[6.0, 0.5, -1.0, 1.0, -1.0 ]

思路

首先明确,不同类型(可以看做不同量纲)之间是无法进行除法的,这一块跟连通性很类似,因此我们可以想到并查集。

在面对权值的时候,需要考虑用带权并查集。

我们假设带权边的权重数组为 w w w,其中 w [ i ] w[i] w[i]表示第 i i i条边相对于 r o o t root root节点的权重。

在寻址(find)过程中,我们需要进行如下操作:
w [ i ] = i r o o t = i p a r e n t i × p a r e n t i r o o t w[i]=\frac{i}{root}=\frac{i}{parent_i}\times\frac{parent_i}{root} w[i]=rooti=parentii×rootparenti
这是一条权重链,确保并查集中每个子集都能以根节点为基准进行定标。

在加入(union)的过程中,我们需要对其进行如下更新:
假设  y  加入  x   w [ r o o t y ] = r o o t y r o o t x = y   ⋅   w [ x ] w [ y ]   ⋅   x = y x   ⋅   w [ x ] w [ y ] 令 k = y x ,即 : w [ r o o t y ] = k   ⋅   w [ x ] w [ y ] 假设 \ y\ 加入\ x\ \\ w[root_y]=\frac{root_y}{root_x}=\frac{y\ \cdot\ w[x]}{w[y]\ \cdot\ x} \\ =\frac{y}{x}\ \cdot\ \frac{w[x]}{w[y]}\\ 令k=\frac{y}{x},即:w[root_y]=k\ \cdot\ \frac{w[x]}{w[y]} 假设 y 加入 x w[rooty]=rootxrooty=w[y]  xy  w[x]=xy  w[y]w[x]k=xy,即:w[rooty]=k  w[y]w[x]
如此,便在查询和合并的过程中实现了权值的链式更新。所有树结构上的值都是以根节点作为基准进行变换得到的,因此,同分支上(同个连通块)实现了可比性。

实质

带权边上的变换

实现

class Solution:
    def calcEquation(self, equations: List[List[str]], values: List[float], queries: List[List[str]]) -> List[float]:
        u=UnionFind()
        for i,v in enumerate(equations):
            u.merge(v[0],v[1],values[i])
        
        ans=[]
        for i,j in queries:
            ans.append(u.divide(i,j))
        return ans


class UnionFind:
    def __init__(self):
        self.pa={}
        self.val={}
        
    def find(self,x):
        if x not in self.pa:
            return None
        
        if self.pa[x]!=x:
            fa=self.find(self.pa[x])
            self.val[x]=self.val[x]*self.val[self.pa[x]]  # 更新权重
            self.pa[x]=fa
        return self.pa[x]
    
    def merge(self,a,b,val):
        if a not in self.pa:
            self.pa[a]=a
            self.val[a]=1
        if b not in self.pa:
            self.pa[b]=b
            self.val[b]=1

        roota,rootb=self.find(a),self.find(b)
        if roota==rootb or (roota==None or rootb==None):
            return
        
        self.pa[roota]=rootb
        # w[b]=b/roob
        # roob/rooa = b/w[b] / a/w[a]
        # = b/a * w[a]/w[b]
        self.val[roota]=val*(self.val[b]/self.val[a])
    
    def divide(self,a,b):
        if (roota:=self.find(a))!=(rootb:=self.find(b)) or (roota==None or rootb==None):
            return -1
        return self.val[a]/self.val[b]

常见问题:关系型连接

这类题一般是某个小类下有着各种分量,通过分量间的关系进行处理

1️⃣ LC1202 交换字符串中的元素

链接:https://leetcode.cn/problems/smallest-string-with-swaps

给你一个字符串 s,以及该字符串中的一些「索引对」数组 pairs,其中 pairs[i] = [a, b] 表示字符串中的两个索引(编号从 0 开始)。

你可以 任意多次交换 在 pairs 中任意一对索引处的字符。

返回在经过若干次交换后,s 可以变成的按字典序最小的字符串。

示例

输入:s = "dcab", pairs = [[0,3],[1,2]]
输出:"bacd"
解释: 
交换 s[0] 和 s[3], s = "bcad"
交换 s[1] 和 s[2], s = "bacd"

思路

我们不难发现,索引对自身存在联通关系,且一旦其中的分量作为其他索引对的分量,那么这两个分量就是联通的。

于是,本题可转化为:

求解各个连通块所能实现的最小字典序。实现路径为:

  • 构建联通块,并获取连通块中的元素
  • 对每个连通块元素进行排序
  • 根据原始元素的位置,判断所属的连通块

实质

抽象思想

实现

class Solution:
    def smallestStringWithSwaps(self, s: str, pairs: List[List[int]]) -> str:

        # 这题找规律
        # 我们发现 任意索引对内部可以进行交换
        # 而且,任意两对索引中,如果出现了相同的值,那么这两对索引对应的元素可以两两互换

        # 这也意味着 我们需要处理的也就是所有连通块 每个连通块都是最小,那么由局部推得全局最优解
        # 考虑连通量 自然而然想到并查集

        u=uf(len(s))
        for i,j in pairs:
            u.union(i,j)
        # 对于每一个连通块,我们只需要对其进行排序就可以了
        # 当然,我们也要获取到连通块内的元素
        mp=collections.defaultdict(list)
        for i,v in enumerate(s):
            mp[u.find(i)].append(v) # 构建连通块
        
        for vec in mp.values():
            vec.sort(reverse=True)
        ans=[]

        for i in range(len(s)):
            # 每一个占位都用连通块内最小的进行占位
            x=u.find(i)
            ans.append(mp[x][-1])
            mp[x].pop()
        
        return "".join(ans)


class uf:
    def __init__(self,n):
        self.pa=[i for i in range(n)]
    def find(self,x):
        if self.pa[x]!=x:
            self.pa[x]=self.find(self.pa[x])
        return self.pa[x]
    def union(self,a,b):
        if (ra:=self.find(a))!=(rb:=self.find(b)):
            self.pa[rb]=ra

2️⃣ LC765 情侣牵手

链接:https://leetcode.cn/problems/couples-holding-hands

n 对情侣坐在连续排列的 2n 个座位上,想要牵到对方的手。

人和座位由一个整数数组 row 表示,其中 row[i] 是坐在第 i 个座位上的人的 ID。情侣们按顺序编号,第一对是 (0, 1),第二对是 (2, 3),以此类推,最后一对是 (2n-2, 2n-1)。

返回 最少交换座位的次数,以便每对情侣可以并肩坐在一起。 每次交换可选择任意两人,让他们站起来交换座位。

示例

输入: row = [0,2,1,3]
输出: 1
解释: 只需要交换row[1]和row[2]的位置即可。

输入: row = [3,2,0,1]
输出: 0
解释: 无需交换座位,所有的情侣都已经可以手牵手了。

思路

情侣能够牵手的充要条件是:

位置 ( i , i + 1 ) i ∈ 奇数 (i,i+1)i\in奇数 (i,i+1)i奇数 上的情侣同属于一个类,或者说, r o w [ i ] / / 2 = r o w [ i + 1 ] / / 2 row[i]//2=row[i+1]//2 row[i]//2=row[i+1]//2

而对于不能牵手的情侣而言,他们之间构成了一个,即通过一对情侣中的两个节点关系,能够实现多对情侣的闭环。

不难发现,我们应该开 n / / 2 n//2 n//2个空间,代表有这么多对情侣。接着,对于一个大小为 x x x(包含了 x x x对情侣)的闭环,我们最少只需要移动 x − 1 x-1 x1次就行了。

至此,问题转变为:

求解row中闭环的数量。

有如下公式,设闭环数量为 x x x,则有:
t = ( x i − 1 ) + ( x 2 − 1 ) + ⋯ + ( x n − 1 ) t=(x_i-1)+(x_2-1)+\dots+(x_n-1) t=(xi1)+(x21)++(xn1)
t t t为交换次数。

又:我们可以假设牵上手的情侣也是一个环,其交换次数为:

t = ( 1 − 1 ) t=(1-1) t=(11)

于是, t = n − x t=n-x t=nx

实质

考察抽象思想,拓扑关系联通

实现

class Solution(object):
    def minSwapsCouples(self, row):
        """
        :type row: List[int]
        :rtype: int
        """
        # 本题考察的是抽象能力
        # 什么情况情侣不能牵手?
        # 一对情侣 Aa 一定能牵手
        # 两队情侣 BA BA 不能牵手 而 AB BA 或者 BA AB是有一对可以牵手的
        # 三对情侣只有在情况 AB CB AC 或者 AC BC AB 之类的情况下才算不饿能牵手
        # 而此时我们认为,情侣间的顺序关系并不重要 AB CB AC 等价于 AC BC AB 等价于其他
        # 也就是说,这三对情侣满足一个环关系 Ab cB aC
        # 因为A要联通a 我们令Ab联通,能顺着b联通cB,顺着c联通aC
        # 他们三位之间形成了环(通过情侣关系)

        # 那么对于一个大小为n的错误环,只需要换位n-1次即可
        # 考虑连通关系,可以选用并查集
        n=len(row)
        u=UnionFind(n//2)

        i=0
        while i<=n-2:
            u.union(row[i]//2,row[i+1]//2)
            i+=2


        return n//2-u.getCount()


class UnionFind:
    def __init__(self,n):
        self.pa=[i for i in range(n)]
        self.count=n
    def find(self,x):
        if self.pa[x]!=x:
            self.pa[x]=self.find(self.pa[x])
        return self.pa[x]
    def union(self,a,b):
        ra=self.find(a)
        rb=self.find(b)
        if ra!=rb:
        # if (ra:=self.find(a))!=(rb:=self.find(b)):
            self.pa[rb]=ra
            self.count-=1
    def getCount(self):
        return self.count

进阶:倒序并查集🏷

此类问题通常用于优化删除问题,既然中间变量可以从删除操作中得到,那也能从构建操作中得到。组件的构成跟并查集的连通本质上是一致的。

1️⃣ LC2421 好路径的数量

链接:https://leetcode.cn/problems/number-of-good-paths

给你一棵 n 个节点的树(连通无向无环的图),节点编号从 0 到 n - 1 且恰好有 n - 1 条边。

给你一个长度为 n 下标从 0 开始的整数数组 vals ,分别表示每个节点的值。同时给你一个二维整数数组 edges ,其中 edges[i] = [ai, bi] 表示节点 ai 和 bi 之间有一条 无向 边。

一条 好路径 需要满足以下条件:

开始节点和结束节点的值 相同 。
开始节点和结束节点中间的所有节点值都 小于等于 开始节点的值(也就是说开始节点的值应该是路径上所有节点的最大值)。
请你返回不同好路径的数目。

注意,一条路径和它反向的路径算作 同一 路径。比方说, 0 -> 1 与 1 -> 0 视为同一条路径。单个节点也视为一条合法路径。

示例

输入:vals = [1,3,2,1,3], edges = [[0,1],[0,2],[2,3],[2,4]]
输出:6
解释:总共有 5 条单个节点的好路径。
还有 1 条好路径:1 -> 0 -> 2 -> 4 。
(反方向的路径 4 -> 2 -> 0 -> 1 视为跟 1 -> 0 -> 2 -> 4 一样的路径)
注意 0 -> 2 -> 3 不是一条好路径,因为 vals[2] > vals[0]

思路

这题暴力思路:先计算所有最大值节点的路径,然后删除他们,再进行递归往复。

但是,这样子的时间复杂度也太大了吧!最坏的情况下达到了 O ( n 2 ) O(n^2) O(n2),不如反阿克曼常数的 O ( n α ) O(n\alpha) O(nα)有意思。

删除操作可以转变思路为添加! 这也就是倒序并查集的由来!

一个结构,删除是添加的反过程,而我们追寻中间信息,那么删除和添加都将会达到这个中间状态!添加,就是一个一个小组件聚集形成一个物体,那就是并查集!

这题的话,我们考虑从最小值节点开始添加,当然,要先生成邻接图结构才行。

添加连通块时,如果当前节点遇到了相同的节点,那么将新增 C x 2 C^2_x Cx2条路径,所以,我们有必要记录连通块中的最大值,以及最大值的数量。

事实上,并查集本就是记录最大表征而生的,那数量如何去记录呢?

我们开辟一个空间sizesize[x]表示连通块中大小为 v a l [ x ] val[x] val[x]的节点的数量。

v a l [ x ] val[x] val[x]也就是个映射。

实现

class Solution:
    def numberOfGoodPaths(self, vals: List[int], edges: List[List[int]]) -> int:
        
        n=len(vals)
        # 如何创建并查集?
        # 首先有n个节点
        pa=list(range(n))
        # 然后构建图结构
        # 无向图需要记录两边
        g=[[] for _ in range(n)]
        for x,y in edges:
            g[x].append(y)
            g[y].append(x)

        # 我们利用生成代替删除

        # size[x] 表示 x 所处的连通块中 ,值等于 vals[x] 的节点个数
        # size用来处理同级别的加减
        size=[1]*n

        def find(x):
            if pa[x]!=x:
                pa[x]=find(pa[x])
            return pa[x]
        ans=n # 初始路径(自己)
        # 然后是等长路径C2x=(x*(x-1)/2)
        for vx,x in sorted(zip(vals,range(n))):
            # 从小到大生成
            fx=find(x) # 第几个节点的父节点
            for y in g[x]:
                # 这节点所能到达的所有节点
                y=find(y)
                # 开始遍历邻接边
                if y==fx or vals[y]>vx:# 大的后面加进去c
                    continue
                if vals[y]==vx:
                    ans+=size[fx]*size[y]
                    size[fx]+=size[y]
                pa[y]=fx
        return ans

未完待续

  • 0
    点赞
  • 5
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值