前言

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

先放个Python的板子

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
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

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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
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及以上的版本用

1
2
3
4
5
6
7
8
9
10
11
12
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) 合并方法

特殊变化–带权并查集

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

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

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

在寻址(find)过程中,我们需要进行如下操作:

w[i]=iroot=iparenti×parentirootw[i]=\frac{i}{root}=\frac{i}{parent_i}\times\frac{parent_i}{root}

这是一条权重链,确保并查集中每个子集都能以根节点为基准进行定标。

在加入(union)的过程中,我们需要对其进行如下更新:

假设 y 加入 x w[rooty]=rootyrootx=y  w[x]w[y]  x=yx  w[x]w[y]k=yx,即:w[rooty]=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]}

如此,便在查询和合并的过程中实现了权值的链式更新。所有树结构上的值都是以根节点作为基准进行变换得到的,因此,同分支上(同个连通块)实现了可比性。


板子怎么写?

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

python

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
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’(水)组成的的二维网格,请你计算网格中岛屿的数量。

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

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

示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
输入: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)] 可以思考为什么能做剪枝、

实质

连通块的大小

实现

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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
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直接写到代码块中。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
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 表示二者不直接相连。

返回矩阵中 省份 的数量。

示例

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

思路

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

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

实质

连通块的数量

实现

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
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 。

示例

1
2
3
4
5
6
7
8
9
输入: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

思想

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

实质

连通块的大小

实现

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
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]之间才有一条边。
返回 图中最大连通组件的大小 。

示例

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

思想

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

实质

寻找质因数+并查集

实现

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
# 欧拉筛
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 中,每一个方格的值 grid[i][j]grid[i][j]表示位置 (i, j) 的平台高度。

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

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

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

思路

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

核心

加入连通块后判断连通性

实现

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
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 形成。

示例

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

思路

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

核心

连通块大小+可变连通块

实现

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
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) 的算法解决此问题。

示例

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

思路

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

实现

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
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表的做法

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
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 中最后出现的边。

示例

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

思路

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

核心

判断节点连通性

实现

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
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 不 会标记为受限节点。

示例

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

思路

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

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

实质

受限制条件下的连通

实现

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
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 的情况,且不存在任何矛盾的结果。

示例

1
2
3
4
5
6
输入: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 ]

思路

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

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

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

在寻址(find)过程中,我们需要进行如下操作:

w[i]=iroot=iparenti×parentirootw[i]=\frac{i}{root}=\frac{i}{parent_i}\times\frac{parent_i}{root}

这是一条权重链,确保并查集中每个子集都能以根节点为基准进行定标。

在加入(union)的过程中,我们需要对其进行如下更新:

假设 y 加入 x w[rooty]=rootyrootx=y  w[x]w[y]  x=yx  w[x]w[y]k=yx,即:w[rooty]=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]}

如此,便在查询和合并的过程中实现了权值的链式更新。所有树结构上的值都是以根节点作为基准进行变换得到的,因此,同分支上(同个连通块)实现了可比性。

实质

带权边上的变换

实现

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
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 可以变成的按字典序最小的字符串。

示例

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

思路

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

于是,本题可转化为:

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

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

实质

抽象思想

实现

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
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)。

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

示例

1
2
3
4
5
6
7
输入: 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奇数 上的情侣同属于一个类,或者说,row[i]//2=row[i+1]//2row[i]//2=row[i+1]//2

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

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

至此,问题转变为:

求解row中闭环的数量。

有如下公式,设闭环数量为xx,则有:

t=(xi1)+(x21)++(xn1)t=(x_i-1)+(x_2-1)+\dots+(x_n-1)

tt为交换次数。

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

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

于是,t=nxt=n-x

实质

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

实现

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
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 视为同一条路径。单个节点也视为一条合法路径。

示例

1
2
3
4
5
6
7
输入: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(n2)O(n^2),不如反阿克曼常数的O(nα)O(n\alpha)有意思。

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

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

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

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

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

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

val[x]val[x]也就是个映射。

实现

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
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