前言
这个总结拖了好久hhh,一个月前就想搞了,一直拖到现在。这个总结并不是从【并查集】的Introduction
和background
开始的,侧重点在于运用方面。
先放个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=n∗n−1的时刻一定能够遍历,所以我们可以通过二分法的方式找到最小可以满足条件的时间。或者呢,我们通过并查集的方式,在每一时刻联通新的节点,判断起点和终点的连通性就可以。当然,为了方便加入联通节点,我们也可以先用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 x−1次就行了。
至此,问题转变为:
求解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=(xi−1)+(x2−1)+⋯+(xn−1)
t
t
t为交换次数。
又:我们可以假设牵上手的情侣也是一个环,其交换次数为:
t = ( 1 − 1 ) t=(1-1) t=(1−1)
于是, t = n − x t=n-x t=n−x
实质
考察抽象思想,拓扑关系联通
实现
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条路径,所以,我们有必要记录连通块中的最大值,以及最大值的数量。
事实上,并查集本就是记录最大表征而生的,那数量如何去记录呢?
我们开辟一个空间size
,size[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
未完待续