并查集的入门与应用

目录

一、前言

二、并查集概念

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)

以上,并查集的入门与应用

祝好

  • 0
    点赞
  • 2
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 1
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

吕飞雨的头发不能秃

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值