目录
一、前言
二、并查集概念
1、并查集的初始化
2、并查集的合并
3、并查集的查找
4、初始化、查找、合并代码
5、复杂度
二、路径压缩
三、例题
1、蓝桥幼儿园(lanqiaoOJ题号1135)
2、合根植物(2017年决赛,lanqiaoOJ题号110)
3、修改数组(2019年省赛,lanqiaoOJ题号185)
(1)暴力法1
(2)暴力法2
(3)查重,hash或set()
(4)新思路——并查集
4、七段码(2020年省赛,lanqiaoOJ题号595,填空题)
一、前言
本文主要讲了并查集的概念、路径压缩,讨论了一题多解的思路和一些例题。
二、并查集概念
- 并查集(Disjoint Set):一种非常精巧而实用的数据结构。
- 用于处理不相交集合的合并问题。
- 经典应用:
- 连通子图
- 最小生成树 Kruskal 算法
- 最近公共祖先
【场景1】
- 有 n 个人,他们属于不同的帮派;
- 已知这些人的关系,例如1号、2号是朋友,1号、3号 也是朋友,那么他们都属于一个帮派;
- 问有多少帮派,每人属于哪个帮派。
- 用并查集可以很简洁地表示这个关系。
【场景2】
有n个人一起吃饭,有些人互相认识。
认识的人想坐在一起,而不想跟陌生人坐。
例如A认识B,B认识C,那么A、B、C会坐在一张桌子上。
给出认识的人,问需要多少张桌子。
1、并查集的初始化
- 定义 s[i] 是以结点 i 为元素的并查集。
- 初始化:令 s[i]=i。(某人的号码是i,他属于帮派 s[i])
def init_set(): #初始化
for i in range(N):
a.append(i)
#s=list(range(N)) #init_set()可以简化为这一行
2、并查集的合并
- 例:加入第一个朋友关系 (1,2)。
- 在并查集 s 中,把结点 1 合并到结点 2,也就是把结点 1 的集 1 改成结点 2 的集 2。
- 加入第二个朋友关系 (1,3)
- 查找结点1的集,是2,递归查找元素2的集是2;
- 把元素2的集2合并到结点3的集3。
- 此时,结点1、2、3都属于一个集。
- 加入第三个朋友关系 (2,4);
def merge_set(x,y): #合并
x=find_set(x)
y=find_set(y)
if x!=y:
s[x]=s[y]
3、并查集的查找
查找元素的集,是一个递归的过程,直到元素的值和它的集相等,就找到了根结点的集。
def find_set(x):
if x!=s[x]:
return find_set(s[x])
else:
return x
这棵搜索树,可能很细长,复杂度 O(n),变成了一个链表,出现了树的 “退化” 现象。
4、初始化、查找、合并代码
def init_set(): #初始化
for i in range(N):
a.append(i)
def find_set(x):
if x!=s[x]:
return find_set(s[x])
else:
return x
def merge_set(x,y): #合并
x=find_set(x)
y=find_set(y)
if x!=y:
s[x]=s[y]
5、复杂度
- 查找 find_set()、合并 merge_set() 的搜索深度是树的长度,复杂度都是 O(n)。
- 性能差。
- 能优化吗?
- 目标:优化之后,复杂度 ≈ O(1)。
二、路径压缩
- 查询程序 find_set():沿着搜索路径找到根结点,这条路径可能很长。
- 优化:沿路径返回时,顺便把 i 所属的集改成根结点。下次再搜,复杂度是 O(1)。
def find_set(x): #有路径压缩优化的查询
if x!=s[x]:
s[x]=find_set(s[x]) #递归实现
else:
return x
- 路径压缩:整个搜索路径上的元素,在递归过程中,从元素 i 到根结点的所有元素,它们所属的集都被改为根结点。
- 路径压缩不仅优化了下次查询,而且也优化了合并,因为合并时也用到了查询。
三、例题
1、蓝桥幼儿园(lanqiaoOJ题号1135)
【题目描述】
蓝桥幼儿园的学生天真无邪,朋友的朋友就是自己的朋友。小明是蓝桥幼儿园的老师,这天他决定为学生们举办一个交友活动,活动规则如下:小明用红绳连接两名学生,被连中的两个学生将成为朋友。请你帮忙写程序判断某两个学生是否为朋友。
【输入描述】
第 1 行包含两个正整数 N,M,N 表示蓝桥幼儿园的学生数量,学生的编号分别为1- N。之后的第 2- M+1 行每行输入三个整数 op,x,y。如果 op=1,表示小明用红绳连接了学生 x 和学生 y。如果op=2,请你回答小明学生 x 和学生 y 是否为朋友。
1<=N,M<=2×10^5,1<=x,y<=N。
【输出描述】
对于每个 op=2 的输入,如果 x 和 y 是朋友,则输出一行 YES,否则输出一行 NO。
def init_set():
for i in range(N):
parent.append(i)
def find_set(x):
if x!=parent[x]:
parent[x]=find_set(parent[x])
return parent[x]
def merge_set(x,y):
x=find_set(x)
y=find_set(y)
if x!=y:
parent[x]=parent[y]
n,m=map(int,input().split())
N=800_005
parent=[] #并查集
init_set()
for i in range(m):
op,x,y=map(int,input().split())
if op==1:
merge_set(x,y)
if op==2:
if find_set(x)==find_set(y):
print("YES")
else:
print("NO")
2、合根植物(2017年决赛,lanqiaoOJ题号110)
【题目描述】
w 星球的一个种植园,被分成 m×n 个小格子(东西方向 m 行,南北方向 n 列)。每个格子里种了一株合根植物,这种植物有个特点,它的根可能会沿着南北或东西方向伸展,从而与另一个格子的植物合成为一体。如果我们告诉你哪些小格子间出现了连根现象,你能说出这个园中一共有多少株合根植物吗?
【输入格式】
第一行,两个整数 m,n,用空格分开,表示格子的行数、列数 (1<m,n<1000)。接下来一行,一个整数 k,表示下面有 k 行数据 (0<k<100000)。接下来 k 行,每行两个整数 a,b,表示编号为 a 的小格子和编号为 b 的小格子合根了。格子的编号一行一行,从上到下,从左到右编号。
【输出格式】
输出一个整数表示答案。
【常规做法】
- 用并查集处理所有的合并;
- 处理完后,检查所有 S[i] = i 的数量,也就是集等于自己的数量,就是答案。
- 初始化时,假设所有植物都不合根,初始答案:ans = m×n。
- 然后用并查集处理合根,合根一次,ans减一。
def find_set(x):
if x!=parent[x]:
parent[x]=find_set(parent[x])
return parent[x]
def merge_set(x,y):
x=find_set(x)
y=find_set(y)
if x==y: # 同根
return False
parent[y]=parent[x]
return True # 合根一次
m,n=map(int,input().split())
k=int(input())
parent=list(range(m*n))
ans=m*n
for i in range(k):
x,y=map(int,input().split())
if merge_set(x,y):
ans-=1
print(ans)
3、修改数组(2019年省赛,lanqiaoOJ题号185)
【题目描述】
给定一个长度为 N 的数组 A=[A1, A2,...,AN],数组中有可能有重复出现的整数。现在小明要按以下方法将其修改为没有重复整数的数组。小明会依次修改 A2,A3,...,AN。当修改 Ai 时,小明会检查 A 是否在 A1~Ai 中出现过。如果出现过,则小明会给 Ai 加上1:如果新的 Ai 仍在之前出现过,小明会持续给 Ai 加1,直到 Ai 没有在 A1~Ai 中出现过。当 AN 也经过上述修改之后,显然 A 数组中就没有重复的整数了。现在给定初始的 A 数组,请你计算出最终的 A 数组。
【输入】
第一行包含一个整数 N (1<=N<=100000),第二行包含 N 个整数 A1, A2, ..., AN (1<=Ai<=1000000)。
【输出】
输出 N 个整数,依次是最终的 A1, A2, ..., AN
【输入示例】
5
2 1 1 3 4
【输出示例】
2 1 3 4 5
功能:把数组的数字转换为都不重复
数组 A = [A1, A2, ... ,AN]
依次修改 A2, A3, ..., AN
修改 Ai 时,检查 Ai 是否在 A1~Ai 中出现过
如果出现过,给Ai加上1;
如果新的 Ai 仍在之前出现过,持续给 Ai 加 1,直到 Ai 没有在 A1~Ai-1中出现过。
(1)暴力法1
1<=N<=100000
每读入一个新的数,就检查前面是否出现过,每一次需要检查前面所有的数。共有 n 个数,每个数检查 O(n) 次,总复杂度 O(n^3),超时。
n=int(input())
a=[int(i) for i in input().split()]
for i in range(1,n): #从第2个开始:a[1]
for k in range(i): #检查它前面的所有数,做k次
for j in range(i):
if a[i]==a[j]:
a[i]+=1
for i in range(n):
print(a[i],end=' ')
#for i in a:
# print(i,end=' ')
(2)暴力法2
n=int(input())
a=[int(i) for i in input().split()]
for i in range(1,n): #从第2个开始:a[1]
for j in range(i): #检查它前面的所有数
while a[i] in a[0:i]:
a[i]+=1
for i in range(n):
print(a[i],end=' ')
#for i in a:
# print(i,end=' ')
(3)查重,hash或set()
- 改进,用hash。定义 vis[] 数组,vis[i] 表示数字 i 是否已经出现过。这样就不用检查前面所有的数了,基本上可以在 O(1) 的时间内定位到。
- 或:直接用 set 判断是否重复,也是 O(1)。
n=int(input())
a=[int(i) for i in input().split()]
s=set()
for i in range(n):
while a[i] in s:
a[i]+=1
s.add(a[i])
for i in range(n):
print(a[i],end=' ')
#for i in a:
# print(i,end=' ')
(4)新思路——并查集
本题特殊要求:“如果新的 Ai 仍在之前出现过,小明会持续给 Ai 加 1,直到 Ai 没有在 A1~Ai-1 中出现过。” 这导致在某些情况下,仍然需要大量的检查。
以 5 个 6 为例: A[]= {6,6, 6,6,6)。
第一次读 A[1]=6,设置 vis[6]=1。
第二次读 A[2]=6,先查到 vis[6]=1,则把 A[2] 加 1,变为 A[2]=7;再查 vis[7]=0,设置 vis[7]=1。检查了 2 次。
第三次读 A[3]=6,先查到 vis[6]=1,则把 A[3] 加 1 得 A[3]=7;再查到 vis[7]=1,再把 A[3] 加 1 得A[3]=8,设置 vis[8]=1;最后查 vis[8]=0,设置 vis[8]=1。
........
检查了 3 次。每次读一个数,仍需检查 O(n) 次,总复杂度 O(n^2)。
- 本题用 Hash,在特殊情况下仍然需要大量的检查。
- 问题出在 “持续给 Ai 加 1,直到 Ai 没有在 A1~Ai-1 中出现过”。
- 也就是说,问题出在那些相同的数字上。当处理一个新的 Ai 时,需要检查所有与它相同的数字。
- 如果把这些相同的数字看成一个集合,就能用并查集处理。
用并查集处理就非常巧妙了!
用并查集 s[i] 表示访问到 i 这个数时应该将它换成的数字。
以 A[]= {6,6,6,6,6) 为例。初始化 set[i]=i
图(1)读第一个数 A[0]=6。 6 的集 set[6]=6。紧接着更新 set[6]=set[7]=7,作用是后面再读到某个A[k] =6时,可以直接赋值A[k]=set[6] =7。
图(2)读第二个数 A[1]=6。 6 的集 set[6]=7,更新 A[1]=7。紧接着更新 set[7]=set[8]=8。如果后面再读到 A[k]=6或7 时,可以直接赋值 A[k]=set[6]=8 或者 A[k]=set[7]=8.
- 只用到并查集的查询,没用到合并。
- 必须是“路径压缩”优化的,才能加快查询速度。没有路径压缩的并查集,仍然超时。
- 复杂度O(n)
上面的处理还是很值得思考一下的。
def find_set(x):
if x!=parent[x]:
parent[x]=find_set(parent[x])
return parent[x]
N=1000002
parent=list(range(N))
n=int(input())
a=[int(i) for i in input().split()]
for i in range(n):
root=find_set(a[i])
a[i]=root
parent[root]=find_set(root+1) #加一
for i in a:
print(i,end=' ')
4、七段码(2020年省赛,lanqiaoOJ题号595,填空题)
【问题描述】
七段数码管,一共有 7 个发光二极管,问能表示多少种不同的字符,要求发光的二极管是相连的。
- 标准思路:“灯的组合+连通性检查”
- 编码:“DFS+并查集”
a b c d e f g
字符用数字表示
1 2 3 4 5 6 7
N=10 e=[[0]*N for i in range(N)] s=[0]*N vis=[0]*N ans=0 e[1][2]=e[1][6]=1 e[2][1]=e[2][3]=e[2][7]=1 e[3][2]=e[3][4]=e[3][7]=1 e[4][3]=e[4][5]=1 e[5][4]=e[5][6]=e[5][7]=1 e[6][1]=e[6][5]=e[6][7]=1 e[7][2]=e[7][3]=e[7][5]=e[7][6]=1 dfs(1) #从第一个灯开始深搜 print(ans)
- 灯的所有组合用 DFS 得到,用 “自写组合算法”。
- 选或不选第 k 个灯,就实现了各种组合。
- check() 函数判断一种组合的连通性。
def dfs(k): #深搜到第k个灯 if k==8: check() #检查连通性 else: vis[k]=1 #点亮这个灯 dfs(k+1) #继续搜下一个灯 vis[k]=0 #关闭这个灯 dfs(k+1) #继续搜下一个灯
- check() 函数判断一种组合的连通性。
- 连通性检查用并查集。
- 判断灯 i、j 都在组合中且相连,那么合并到一个并查集。
- flag=1,表示这个组合中的所有灯都合并到了同一个并查集,说明它们是连通的。
def check(): global ans init() for i in range(1,8): for j in range(1,8): if e[i][j]==1 and vis[i]==1 and vis[j]==1: merge_set(i,j) flag=0 for j in range(1,8): if vis[j]==1 and s[j]==j: flag+=1 if flag==1: ans+=1
【完整代码】
def init():
for i in range(N):
s[i]=i
def find_set(x):
if x!=s[x]:
s[x]=find_set(s[x])
return s[x]
def merge_set(x,y):
x=find_set(x)
y=find_set(y)
if x!=y:
s[x]=s[y]
def check():
global ans
init()
for i in range(1,8):
for j in range(1,8):
if e[i][j]==1 and vis[i]==1 and vis[j]==1:
merge_set(i,j)
flag=0
for j in range(1,8):
if vis[j]==1 and s[j]==j:
flag+=1
if flag==1:
ans+=1
def dfs(k): #深搜到第k个灯
if k==8:
check() #检查连通性
else:
vis[k]=1 #点亮这个灯
dfs(k+1) #继续搜下一个灯
vis[k]=0 #关闭这个灯
dfs(k+1) #继续搜下一个灯
N=10
e=[[0]*N for i in range(N)]
s=[0]*N
vis=[0]*N
ans=0
e[1][2]=e[1][6]=1
e[2][1]=e[2][3]=e[2][7]=1
e[3][2]=e[3][4]=e[3][7]=1
e[4][3]=e[4][5]=1
e[5][4]=e[5][6]=e[5][7]=1
e[6][1]=e[6][5]=e[6][7]=1
e[7][2]=e[7][3]=e[7][5]=e[7][6]=1
dfs(1) #从第一个灯开始深搜
print(ans)
以上,并查集的入门与应用
祝好