大概半年前在力扣上写了一个题,947.移除最多的同行或同列石头,里面用到了一个二维的并查集,一直以来我都只会一维的,看到这个二维的半天没想明白,后来终于懂了觉得挺有意思,所以半年后来写个博客记录一下…另外这个题还有深搜的解法,每种都可以用两种(一维/二维)方法来写,对应将其理解成点还是线。在此博客中汇总一下方法。这篇博客是小白向的,写的很详细。
#什么是并查集?
首先介绍一下并查集的概念。并查集一般用在图论中来计算连通分量的个数,它把每个连通分量看成一个集合,不在意内部究竟是如何连接的,结点之间只有“属于”和“不属于”同一连通分量的区别,所以无需在意点与点之间是单向连接还是双向连接。
并查集的主要用途是存储和查找某连通分量根结点。在并查集中,同一连通分量的根节点相同,即属于一个集合,可以用根节点的编号来表示这个集合。并查集中共有多少个这样的集合,就代表着图共有多少个连通分量。假设并查集为root[1..n]
,根结点i在并查集中的值为root[i]=i
,子结点j在并查集中的值为他的父辈结点root[j]=j_father_node
。要找到j的根节点,只需要根据它在并查集中的值不断向上索引即可,同一连通分量里的所有结点向上索引得到的结果都是根节点。示例如下:
如图有两个连通分量,根节点为红色结点,蓝色结点为子节点。将这个图转成并查集root,下方的0~6对应结点编号。
根据并查集来找结点3的根节点,首先root[3]=1,即它的父结点是1,但是root[1]=0,所以结点1不是根节点,且1的父结点是0,继续找0的父结点,root[0]=0,父结点是自己,即意味到达了根节点,因此结点3所在树的根结点是0。同理,{0,1,2,3}属于连通分量(集合)0,{4,5,6}属于连通分量4。
为了避免链式结构里面不断重复地找根节点,因此在查找根节点的时候需要使用路径压缩,即在查找过程中,每次都直接将子节点的值改成根节点。这段过程的代码如下:
def findroot(node):
if(node!=root[node]):
root[node]=findroot(root[node]) #路径压缩
return root[node] #注意不是node
在使用并查集时,一般先初始化并查集里的元素root[i]=i,意为在遍历边之前,每个结点都可以看做是一个连通分量(互相独立),然后根据边e连接的结点u和v,使用find函数找到它们的根节点fu和fv,如果根节点不同,那么要修改其中一个根节点的值为另一个根节点的值(边e将两个连通分量连接起来,因此得到一个连通分量,于是只存在一个根节点),即root[fu]=fv或者root[fv]=fu。
#原文
题目
n块石头放置在二维平面中的一些整数坐标点上。每个坐标点上最多只能有一块石头。如果一块石头的同行或者同列上有其他石头存在,那么就可以移除这块石头。
给你一个长度为n的数组stones ,其中stones[i]=[xi,yi]表示第i块石头的位置,返回可以移除的石子的最大数量。
示例
输入:stones = [[0,0],[0,1],[1,0],[1,2],[2,1],[2,2]]
输出:5
解释:一种移除 5 块石头的方法如下所示:
移除石头 [2,2] ,因为它和 [2,1] 同行。
移除石头 [2,1] ,因为它和 [0,1] 同列。
移除石头 [1,2] ,因为它和 [1,0] 同行。
移除石头 [1,0] ,因为它和 [0,0] 同列。
移除石头 [0,1] ,因为它和 [0,0] 同行。
石头 [0,0] 不能移除,因为它没有与另一块石头同行/列。
#题意理解
题目中要返回可以移除的石子的最大数量。这句话意味着移除石子的方法其实很多,那么最大是什么含义呢?
考虑下面两种移法:
可以看到,对于横纵坐标中有一个相同的石头群,只要按照x/y方向移除,而不是留下对角线石头,最终只会剩下一个石头,这就是移除最大石头个数的情况,于是可知一个石头群最后只会有一个石头留下,其他都被移走。所以可以将抽象的题目转化成这个意思:给出一群石头的坐标,两块石头的横纵坐标中只要有一个相同,那么它们同属于一个石头群(连通分量)中。请计算石头个数-石头群的结果。因此,题目转化为计算连通分量的个数的问题。
#并查集版
并查集是用来计算图中连通分量个数的高效方法,在本题中由于结点之间的连接方式和通常情况下的不同,因此有两种解法。
#一维并查集版:
顺着题意,脑海里最先出现的应该是坐标轴上的点,这些点很自然的可以看成单个结点,所以我们可以把它当成普通的图来看。需要注意的是,本题中两个结点连通的定义是横纵坐标中一个值相同。
使用邻接表表示各点之间的连通情况。对于每个结点i,顺序遍历它后面的结点j(优化时间,并查集中只需要知道连通就行,无序在意是单向连接还是双向连接),如果横纵坐标中有一个值相同,那么graph[i]中加入j,意味i和j相通。
得到图的邻接表之后,依次遍历每个结点,对于结点i首先通过并查集查找和他连通的结点集合graph[i]中结点的根节点是否和节点i根节点一致,不一致修改其中一个根节点的值(按照之前并查集中所说)。当对所有结点遍历结束之后,只需查找并查集中root[i]=i的元素个数即可。
代码如下:
class Solution:
def removeStones(self, stones: List[List[int]]) -> int:
#把每个坐标看成单独的结点,一维并查集版
lens=len(stones)
graph=[[] for i in range(lens)] #存图,graph[i]是和节点i在同一横坐标和纵坐标的结点结合
root=[i for i in range(lens)] #并查集
cnt=0 #连通分量个数
#建表
for i in range(lens):
for j in range(i+1,lens):
if(stones[i][0]==stones[j][0] or stones[i][1]==stones[j][1]):
graph[i].append(j)
#并查集找树根
def findroot(node):
if(node!=root[node]):
root[node]=findroot(root[node]) #路径压缩
return root[node] #注意不是node
#合并树
for u in range(lens):
for v in graph[u]:
fu=findroot(u)
fv=findroot(v)
if(fu!=fv):
root[fv]=fu
#计算连通分量
for i in range(lens):
if(root[i]==i):
cnt+=1
return lens-cnt
#二维并查集版
上面的一维并查集建图的时候用了一个二重循环,如果石头个数很多会有很大的时间开销,因此考虑二维的并查集,也就是不建图,直接根据石头坐标查找根节点。
刚刚看这种题解的时候我一直都没法从过去常见的图论中抽离,搞不懂单独根据一个坐标要怎么使用并查集,很久才想通。 说几个我理解的重点:
- 必须从本质上理解并查集的作用,并查集要用来查找节点的根节点。假如情况从一维变成二维、三维的,它也必须能够满足多维查找,因此不管有几维,都必须只能使用一个并查集,才满足跨维度查找的需要(否则不同维度之间的结点没法对比和合并),但是不同维度之间需要加以区分,x=1和y=1在并查集中应该处于不同的位置,一般将其中一个维度往后推一个举距离就行。,以此题为例,0≤x/y≤1e4,将y往后移动1e4+1就可以避免和x冲突,坐标(1,1)在并查集中可以写为root[1],root[1+1e4+1]。
- 多维中,可能不存在通常意义的“点”,而是以线的思想去理解。此时需要根据具体题目来分析怎样才可以属于同一个连通分量。在本题中,单个点一定在一个连通分量中,两个坐标只要xy其中一个值相同就属于一个连通分量,把它转化为维度的思想就是:一个坐标的x/y轴这两条线属于同一连通分量,所以它们(合并之后)在并查集中的值root[x],root[y+1e4+1]要相同,对于其他的坐标,如果它与这个坐标的x/y轴值有一个相同,假设是x,那么这三条线x,y1,y2都属于同一个连通分量,即合并之后root[x],root[y1+1e4+1],root[y2+1e4+1]值相同。
由于使用二维写法,那么root[i](0≤i≤1e4)表示x=i的根节点,root[i](1e4+1≤i≤1e4*2+1)表示y=i的根节点,这些线不饱和,即其中一些x轴上的线和y轴上的线可能不存在,所以不能直接初始化并查集root的每个元素为它的下标,因为一旦这么写了,就意味着一个不存在的线也被当成一个连通分量。所以使用-1来表示不存在的线在并查集root中的值。
对于新的线x的初始化放在查找函数中,如果它还没有被放入并查集中,就修改root[x]的值-1为x,表示它为一个连通分量的根节点。
详细代码如下:
class Solution:
def removeStones(self, stones: List[List[int]]) -> int:
#二维并查集
lens=len(stones)
cnt=0 #连通分量个数
root=[-1 for i in range(int((1e4+1)*2))] #xy轴都通过root来查找根节点
def findroot(x):
if(root[x]==-1): #新的线,初始化它为自己的根节点
root[x]=x
if(x!=root[x]): #查找根节点
root[x]=findroot(root[x])
return root[x]
#一个新的点会在这个操作中将x和y归到同一连通分量中
for i in range(lens):
fx=findroot(stones[i][0]) #找线x的根节点
fy=findroot(stones[i][1]+int(1e4+1)) #找线y的根节点
if(fx!=fy): #合并
root[fy]=fx
for i in range(int((1e4+1)*2)): #查找连通分量个数
if(root[i]==i):
cnt+=1
return lens-cnt
上面的写法中,并查集使用列表,但是这个表不一定饱和所以有一定的空间浪费,且最后还有一层遍历计算连通分量个数,其实在合并连通分量的时候就可以计算了。对这两点优化后,代码如下:
class Solution:
def removeStones(self, stones: List[List[int]]) -> int:
#二维并查集
lens=len(stones)
cnt=0 #连通分量个数
root={} #xy轴都通过root来查找根节点,key:value x/y轴值:所属连通分量标号
def findroot(x):
nonlocal cnt
if(x not in root):
root[x]=x
cnt+=1 #新出现一个“独立的结点”,连通数+1
if(x!=root[x]):
root[x]=findroot(root[x])
return root[x]
#一个新的点会在这个操作中将x和y归到同一连通分量中
for i in range(lens):
fx=findroot(stones[i][0])
fy=findroot(stones[i][1]+int(1e4+1))
if(fx!=fy):
root[fy]=fx
cnt-=1 #两个连通分量结合,连通数-1
return lens-cnt
#深搜版
看题解的时候看到了根据xy轴搜索的dfs方法,觉得很有意思,然后想到把它按照单个节点看也可以使用dfs解,因此深搜版也有两种方法。
#一维(结点)深度搜索版
该题解中,把每个stone看成一个结点,因此有多少块石头就有多少个结点,按照之前建图的方式建立双向连接图。
图建好以后,依次遍历结点,对每个结点进行深搜,从而找到跟这个结点属于同一连通分量的点,标记已访问,之后就不会再被访问了。每个最初能进行dfs的结点就是它所在连通分量的根节点,计算这种结点的个数就知道有多少个连通分量,代码如下:
class Solution:
def removeStones(self, stones: List[List[int]]) -> int:
#把每个坐标看成单独的结点,一维并查集版
lens=len(stones)
graph=[[] for i in range(lens)] #存图,graph[i]是和节点i在同一横坐标和纵坐标的结点结合
cnt=0 #连通分量个数
vis=set()
#建表,必须是双向连接,否则会有遗漏
for i in range(lens):
for j in range(lens):
if(stones[i][0]==stones[j][0] or stones[i][1]==stones[j][1]):
graph[i].append(j)
#通过结点标号来搜索
def dfs(node):
vis.add(node)
for next_node in graph[node]:
if(next_node not in vis):
dfs(next_node)
for node in range(lens):
if(node not in vis):
cnt+=1
dfs(node)
return lens-cnt
#二维(坐标轴)深度搜索版:
二维的深搜用的也是线的思想,它通过坐标来找到相关联的x/y轴,然后把这些搜索到的x/y轴上的线都归为一组,计算一共有多少组即可。
在这一版中,图的建立有所不同。因为是以线为单位查找,因此需要两个字典来分别存储与x、y相关联的y、x。xdict={key / value : i / [1…j]}用来存x=i这条线上石头的纵坐标j集合,ydict={key / value : i / [1…j]}用来存y=i这条线上石头的横坐标j集合。
深搜时使用坐标进行,例:此时通过坐标(1,1)来搜索与x=1和y=1“相连”的其他线。首先最先开始深搜的结点(1,1)是根节点,连通分量+1,然后dfs((1,1))。从xdict中找到在x=1上的石头的纵坐标集合,假设为2,4,然后依次dfs((1,2)),dfs((1,4)),纵坐标遍历结束后,从ydict中找到在y=1上的石头的横坐标集合,假设为5,7,依次dfs((5,1)),dfs((7,1)),对这些结点都继续递归搜索,最终能找到所有属于同一连通分量的石头,每次遍历到的石头坐标都加入vis集合以防无限循环搜索。
具体代码如下:
class Solution:
def removeStones(self, stones: List[List[int]]) -> int:
# 二维深搜版
lens=len(stones)
xdict={} # key:坐标x value:横坐标为x的纵坐标集合
ydict={} # key:坐标y value:纵坐标为y的横坐标集合
vis=set() #visit集合,防重复访问
cnt=0 #连通分量个数
#建表
for i in range(lens):
if(stones[i][0] not in xdict):
xdict[stones[i][0]]=[stones[i][1]]
else:
xdict[stones[i][0]].append(stones[i][1])
if(stones[i][1] not in ydict):
ydict[stones[i][1]]=[stones[i][0]]
else:
ydict[stones[i][1]].append(stones[i][0])
#深搜
def dfs(node): #node为tuple
vis.add(node)
#横坐标相同的点为一个连通分量
for y in xdict[node[0]]:
next_node=(node[0],y)
if(next_node not in vis):
dfs(next_node)
#纵坐标相同的点为一个连通分量
for x in ydict[node[1]]:
next_node=(x,node[1])
if(next_node not in vis):
dfs(next_node)
#计算连通分量
for i in range(lens):
node=tuple(stones[i]) #set内要是可哈希对象,所以转成元组,不能用列表
# print(node)
if(node not in vis):
cnt+=1
dfs(node)
return lens-cnt