图论之并查集——含例题

目录

介绍

秩是什么

例子------快速入门

例题

使用路径压缩,不使用秩合并

使用路径压缩和秩合并

无向图和有向图


介绍

并查集是一种用于 处理不相交集合的合并与查询问题的数据结构。它主要涉及以下基本概念和操作:
基本概念:

  • 集合:并查集中的集合是由一组元素组成的,这些元素具有相同的属性或特征,集合之间相互不相交。
  • 代表元素:每个集合都有一个代表元素,用于标识该集合。集合中的其他元素都可以通过一定的关系与代表元素相连。
    基本操作:
  • 初始化:将每个元素都初始化为一个独立的集合,每个集合的代表元素就是该元素本身。
  • 合并:将两个不同集合合并为一个集合。通常是将一个集合的代表元素连接到另一个集合的代表元素上,使得两个集合成为一个更大的集合。
  • 查找:查找某个元素所在集合的代表元素。通过不断地沿着元素的父指针追溯,最终找到代表元素,从而确定该元素属于哪个集合。
    并查集通常使用数组来实现,数组的下标表示元素,数组中存储的是该元素的父元素或代表元素的下标。在一些复杂的应用场景中,为了提高并查集的操作效率,还会采用路径压缩和按秩合并等优化策略。
    并查集在 图论、数据分类、连通性问题等领域有广泛的应用。例如,在处理图的连通分量问题时,可以使用并查集来快速判断两个顶点是否属于同一个连通分量,以及合并不同的连通分量。

秩是什么

定义:秩可以看作是树的高度的一个估计值。在并查集的初始化阶段,每个元素都自成一个集合,此时集合的秩通常被初始化为 1,表示单个元素构成的树高度为 1。
作用: 在合并两个集合时,通过比较两个集合的秩来决定如何合并,以尽量保持树的平衡性,避免出现退化的树结构(即高度过高的树,会导致查找操作的时间复杂度增加)。
按秩合并策略:

  • 当合并两个集合时,比较它们的秩。如果两个集合的秩不同,将秩较小的集合合并到秩较大的集合中。这样做的原因是,将较小的树连接到较大的树上,对整体树的高度影响较小,有助于保持树的平衡性。例如,一个秩为 2 的树和一个秩为 3 的树合并,会将秩为 2 的树连接到秩为 3 的树下面,合并后新树的秩不变,仍为 3。
  • 如果两个集合的秩相同,那么可以任选一个集合作为合并的目标集合,并将另一个集合合并到该集合中。在这种情况下,合并后新集合的秩会增加 1。例如,两个秩都为 2 的树合并,合并后新树的秩变为 3。
    通过使用秩和按秩合并策略,可以有效地降低并查集操作的时间复杂度,使得在大多数情况下,查找和合并操作都能在接近常数时间内完成。

例子------快速入门

假设有一群人,他们之间存在着不同的朋友关系。我们把每个人看作一个节点,朋友关系看作是连接节点的边,现在需要判断两个人是否在同一个朋友圈中,以及统计朋友圈的数量。

  • 初始化:假设有 5 个人,分别用编号 0 - 4 表示。一开始,每个人都属于自己独立的朋友圈,即每个节点的父节点都是它自己。可以用一个数组parent来表示,parent[i]表示节点i的父节点,初始化为parent = [0, 1, 2, 3, 4]。
  • 合并朋友圈:
    • 已知 0 和 1 是朋友,通过union操作合并他们所在的集合。找到 0 和 1 的根节点,即 0 和 1 本身,将 1 的父节点设置为 0,此时parent = [0, 0, 2, 3, 4],表示 0 和 1 在同一个朋友圈中。
    • 接着,2 和 3 是朋友,进行同样的合并操作,将 3 的父节点设置为 2,parent = [0, 0, 2, 2, 4]。
    • 然后,1 和 3 是朋友,再次合并。先找到 1 的根节点是 0,3 的根节点是 2,将 2 的父节点设置为 0,parent = [0, 0, 0, 0, 4],此时 0、1、2、3 都在同一个朋友圈中。
  • 查找:
    • 要判断 4 和 3 是否在同一个朋友圈,通过find操作查找 4 的根节点是 4,3 的根节点是 0,根节点不同,所以 4 和 3 不在同一个朋友圈。
    • 要判断 0 和 2 是否在同一个朋友圈,查找 0 和 2 的根节点都是 0,根节点相同,所以 0 和 2 在同一个朋友圈。
  • 统计朋友圈数量:最后,通过遍历parent数组,统计根节点的数量,即不同的代表元素的数量,就可以得到朋友圈的数量。在这个例子中,有两个不同的根节点 0 和 4,所以朋友圈数量为 2。
Go 复制代码
package main

import "fmt"

// UnionFind 定义并查集结构体
type UnionFind struct {
    parent []int // parent 切片用于存储每个元素的父节点,初始时每个元素的父节点是其自身
    // 在合并两个集合时,通过比较两个集合的秩来决定如何合并,以尽量保持树的平衡性,避免出现退化的树结构(即高度过高的树,会导致查找操作的时间复杂度增加)。
    rank  []int // rank 切片用于记录每个集合的秩(通常是树的高度)
    count int   // 朋友圈的数量
}

// NewUnionFind 初始化并查集
func NewUnionFind(n int) *UnionFind {
    parent := make([]int, n)
    rank := make([]int, n)

    for i := range parent {
       parent[i] = i
       rank[i] = 1
    }
    return &UnionFind{
       parent: parent,
       rank:   rank,
       count:  n,
    }
}

// Find 查找元素所在集合的代表元素
func (uf *UnionFind) Find(x int) int {
    // 如果元素x的父节点(parent[x])不是它自己,就递归的查找它(parent[x]元素)的父节点
    if uf.parent[x] != x {
       uf.parent[x] = uf.Find(uf.parent[x])
    }
    return uf.parent[x]
}

// Union 合并两个元素所在的集合
func (uf *UnionFind) Union(x, y int) {
    rootX := uf.Find(x)
    rootY := uf.Find(y)
    if rootX == rootY {
       return
    }
    if uf.rank[rootX] < uf.rank[rootY] {
       rootX, rootY = rootY, rootX
    }
    uf.parent[rootY] = rootX         // 更改 rootY 的父节点为 rootX
    uf.rank[rootX] += uf.rank[rootY] // 更改 rootX 的秩
    uf.count--                       // 朋友圈数量--
}

// GetCount 获取连通分量的数量
func (uf *UnionFind) GetCount() int {
    return uf.count
}

func main() {
    // 假设有 5 个人
    n := 5
    uf := NewUnionFind(n)

    // 合并操作,模拟朋友关系
    uf.Union(0, 1)
    uf.Union(2, 3)
    uf.Union(1, 3)

    // 判断 4 和 3 是否在同一个朋友圈
    sameCircle1 := uf.Find(4) == uf.Find(3)
    fmt.Printf("4 和 3 是否在同一个朋友圈: %v\n", sameCircle1)

    // 判断 0 和 2 是否在同一个朋友圈
    sameCircle2 := uf.Find(0) == uf.Find(2)
    fmt.Printf("0 和 2 是否在同一个朋友圈: %v\n", sameCircle2)

    // 统计朋友圈的数量
    circleCount := uf.GetCount()
    fmt.Printf("朋友圈的数量: %d\n", circleCount)
    
    // 4 和 3 是否在同一个朋友圈: false
    // 0 和 2 是否在同一个朋友圈: true
    // 朋友圈的数量: 2
}

例题

在并查集的实现中,rank 数组(或类似用于记录秩的机制)并不是必需的,有些题目里的并查集没有使用 rank 数组主要有以下原因:

简化实现:对于一些简单的问题场景,不需要通过按秩合并来优化并查集的性能,仅使用路径压缩就可以满足时间复杂度要求。此时可以省略 rank 数组,代码实现会更简洁。比如在一些数据规模较小或者对时间复杂度要求不高的问题中,单纯的路径压缩就能让并查集的操作效率足够高。

采用其他优化方式:有些并查集的实现可能不使用 rank 数组来记录秩,而是采用其他方式来优化合并操作。例如,记录每个集合的大小,在合并时将较小的集合合并到较大的集合中,这种方法也能在一定程度上避免树结构的退化,提高查找和合并的效率。

使用路径压缩,不使用秩合并

Go 复制代码
// 使用路径压缩,不使用秩合并

package main

type UnionFind struct {
    parent []int
}

func NewUnionFind(n int) *UnionFind {
    parent := make([]int, n)
    for i := range parent {
       parent[i] = i
    }
    return &UnionFind{
       parent: parent,
    }
}

// Find 查找
func (uf *UnionFind) Find(x int) int {
    if uf.parent[x] != x {
       uf.parent[x] = uf.Find(uf.parent[x])
    }
    return uf.parent[x]
}

// Union 合并
func (uf *UnionFind) Union(x, y int) {
    rootX := uf.Find(x)
    rootY := uf.Find(y)
    uf.parent[rootY] = rootX
}

// IsConnected 判断两个元素是否在同一个集合中
func (uf *UnionFind) IsConnected(x, y int) bool {
    return uf.Find(x) == uf.Find(y)
}

相应的例题:

力扣:547. 省份数量(并查集,也可以用dfs、bfs)

力扣:684. 冗余连接(并查集)

使用路径压缩和秩合并

Go 复制代码
// 使用路径压缩和秩合并(优化并查集的性能)

package main

// UnionFind 定义并查集结构体
type UnionFind struct {
    parent []int
    rank   []int
}

// NewUnionFind 初始化并查集
func NewUnionFind(n int) *UnionFind {
    parent := make([]int, n)
    rank := make([]int, n)
    for i := range parent {
       parent[i] = i
       rank[i] = 1
    }
    return &UnionFind{
       parent: parent,
       rank:   rank,
    }
}

// Find 查找元素所在集合的代表元素,使用路径压缩
func (uf *UnionFind) Find(x int) int {
    if uf.parent[x] != x {
       uf.parent[x] = uf.Find(uf.parent[x])
    }
    return uf.parent[x]
}

// Union 合并两个元素所在的集合,使用按秩合并
func (uf *UnionFind) Union(x, y int) {
    rootX := uf.Find(x)
    rootY := uf.Find(y)
    if rootX == rootY {
       return
    }
    if uf.rank[rootX] < uf.rank[rootY] {
       rootX, rootY = rootY, rootX
    }
    uf.parent[rootY] = rootX
    uf.rank[rootX] += uf.rank[rootY]
}

// IsConnected 判断两个元素是否在同一个集合中
func (uf *UnionFind) IsConnected(x, y int) bool {
    return uf.Find(x) == uf.Find(y)
}

相应的例题:

力扣:1584. 连接所有点的最小费用(Kruskal算法、最小生成树、并查集)

无向图和有向图

并查集在无向图中的应用更为直接和常见。(当然,在一些有向图的问题中也能通过适当的转化和处理来发挥作用)

相应的例题:

力扣:2101. 引爆最多的炸弹(有向图)

问:这道题为什么不能用并查集?

答:注意本题是有向图。例如炸弹 0 可以引爆炸弹 2,炸弹 1 可以引爆炸弹 2,对应有向边 0→2,1→2,那么正确答案是 2。如果用并查集做的话,会把 0,1,2 三个点合并起来,计算出错误的答案 3。

相关推荐
Susea&28 分钟前
数据结构初阶:队列
c语言·开发语言·数据结构
慕容静漪31 分钟前
如何本地安装Python Flask并结合内网穿透实现远程开发
开发语言·后端·golang
ErizJ33 分钟前
Golang|锁相关
开发语言·后端·golang
GOTXX37 分钟前
【Qt】Qt Creator开发基础:项目创建、界面解析与核心概念入门
开发语言·数据库·c++·qt·图形渲染·图形化界面·qt新手入门
搬砖工程师Cola1 小时前
<C#>在 .NET 开发中,依赖注入, 注册一个接口的多个实现
开发语言·c#·.net
巨龙之路1 小时前
Lua中的元表
java·开发语言·lua
徐行1101 小时前
C++核心机制-this 指针传递与内存布局分析
开发语言·c++
划水哥~1 小时前
Kotlin作用域函数
开发语言·kotlin
小臭希1 小时前
python蓝桥杯备赛常用算法模板
开发语言·python·蓝桥杯
mosaicwang1 小时前
dnf install openssl失败的原因和解决办法
linux·运维·开发语言·python