蓝桥杯第十五届抱佛脚(八)并查集
基本概念
并查集是一种数据结构,用于管理一系列不交集的元素集合,并支持两种操作:
查找(Find):
查找操作用于确定某个元素属于哪个集合,这通常通过追溯元素的父节点直到找到代表元素来完成。
合并(Union):
合并操作用于将两个不相交的集合合并为一个。合并可以通过将一个集合的代表元素的父节点指针指向另一个集合的代表元素来完成。
具体实现时,并查集通过一个数组来维护每个元素的父指针,每个集合由一个根元素代表,该根元素的父指针指向自己,表明它是集合的代表。查找操作通过递归或迭代地跟随元素的父指针直到根元素来完成,而合并操作则是将一个集合的根元素的父指针指向另一个集合的根元素。
并查集的两种实现方式
并查集的两种基本实现:quick-find
和 quick-union
,这两种方法各有优缺点,并用于解决不相交集合的问题。
quick-find(基于 ID)
在 quick-find
策略中,每个元素都有一个唯一的标识符,我们称之为 id
。在初始化时,每个元素的 id
都是不同的,代表它们各自独立的集合。
操作
- Find:要判断两个元素是否在同一个集合中,只需比较它们的
id
是否相同。 - Union:合并两个集合时,我们需要将一个集合中所有元素的
id
全部更新为另一个集合的id
。
特点
- 优点:
Find
操作非常快,时间复杂度为 O(1)。 - 缺点:
Union
操作较慢,因为它需要遍历一个集合中的所有元素来更新id
,时间复杂度为 O(N)。
quick-union(基于 parent)
quick-union
方法使用一个 parent
数组来组织数据,parent[i]
存储的是第 i 个元素的父节点的索引。
操作
- Find:通过连续追溯父节点直到找到根节点(根节点的特点是
parent[root] == root
),这个根节点代表了集合的唯一标识。 - Union:要合并两个元素所在的集合,只需要将一个集合的根节点的
parent
指向另一个集合的根节点。
特点
- 优点:
Union
操作较快,时间复杂度近似为 O(1),只涉及更新一个节点的父节点引用。 - 缺点:
Find
操作可能较慢,最坏情况下时间复杂度为 O(N),特别是在树形结构非常深的情况下。
并查集的存储结构
在Java中,一个并查集通常使用一个整数数组来存储。每个索引代表一个特定的元素,而该索引处的值则存储该元素的父节点索引。在并查集的初始状态下,每个元素的父节点索引指向它自己,表示每个元素构成一个单独的集合。这种结构很容易地支持并查集的两个基本操作:查找(Find)和联合(Union)。
以下是一个简单的Java实现,展示了并查集的存储结构和基本操作:
public class UnionFind {
private int[] parent; // 存储每个节点的父节点
// 构造函数,初始化并查集
public UnionFind(int size) {
parent = new int[size];
for (int i = 0; i < size; i++) {
parent[i] = i; // 初始时,每个元素的父节点指向自己
}
}
// 查找操作,找到元素p的根节点
public int find(int p) {
while (p != parent[p]) {
parent[p] = parent[parent[p]]; // 路径压缩,直接指向根节点
p = parent[p];
}
return p;
}
// 联合操作,将元素p和元素q所在的集合合并
public void union(int p, int q) {
int rootP = find(p);
int rootQ = find(q);
if (rootP != rootQ) {
parent[rootP] = rootQ; // 将一个集合的根节点指向另一个集合的根节点
}
}
}
在这个类中:
parent
数组被用作并查集的主要数据结构。UnionFind
构造函数初始化数据结构,设置每个元素的父节点为自己。find
方法实现了路径压缩,使得每次查找操作后,路径上的所有节点都直接连接到根节点。union
方法将两个元素所在的集合合并为一个集合。
按秩合并优化
当并查集的union
操作非常频繁,且集合的元素数量很大时,如果不进行优化,通过union
操作构造的树可能会非常不平衡,甚至退化成链状结构,这会导致后续的find
操作的效率降低。
按秩合并是一种优化union
操作的方法,它通过比较两个树的“秩”(通常是树的高度)来决定合并的方向,始终将较低秩的树合并到较高秩的树上,从而避免树的不必要高度增长。
操作步骤
- 初始化:对于每个元素,初始化它们的秩(rank)为0,并指定每个元素的父节点为其自身,表示每个元素独立成一个集合。
- 合并(Union)操作:
a. 在合并两个元素所在的集合前,首先找到它们各自的根节点(即集合的代表元素)。
b. 比较两个根节点的秩(代表集合的树的高度)。
c. 将秩较小的树的根节点指向秩较大的树的根节点。
d. 如果两棵树的秩相同,选择其中一棵作为代表,将其秩加1。
代码示例
public class UnionFind {
private int[] parent; // 父节点数组
private int[] rank; // 秩数组
public UnionFind(int size) {
parent = new int[size];
rank = new int[size];
for (int i = 0; i < size; i++) {
// 初始化时,每个元素的父节点指向自身
parent[i] = i;
// 初始化时,每个元素的秩为0
rank[i] = 0;
}
}
// 查找元素x的根节点
public int find(int x) {
if (x != parent[x]) {
parent[x] = find(parent[x]); // 路径压缩优化
}
return parent[x];
}
// 合并元素x和元素y所在的集合
public void union(int x, int y) {
int rootX = find(x);
int rootY = find(y);
if (rootX != rootY) {
if (rank[rootX] < rank[rootY]) {
parent[rootX] = rootY; // 将秩较小的树的根指向秩较大的树的根
} else if (rank[rootX] > rank[rootY]) {
parent[rootY] = rootX;
} else {
parent[rootY] = rootX; // 如果秩相同,任意合并并更新秩
rank[rootX]++;
}
}
}
// 判断两个元素是否在同一个集合中
public boolean isConnected(int x, int y) {
return find(x) == find(y);
}
}
UnionFind
类实现了一个并查集,其中包括了union
和find
操作,以及用于检查两个元素是否在同一个集合中的isConnected
方法。在union
方法中,我们使用了按秩合并的逻辑来优化树的结构。
路径压缩优化
路径压缩主要是针对find
操作进行优化的。在没有优化的并查集中,find
操作可能需要遍历很长的路径来找到根节点,特别是在union
操作导致树高度增加的情况下。
路径压缩在执行find
操作时,会将沿途的每个节点直接连接到根节点,这样不仅优化了当前的find
操作,也加快了未来对这些节点的find
操作。
操作步骤
- 查找(Find)操作:在执行查找操作时,递归地访问父节点直到找到根节点。
- 路径优化:在递归过程中,将访问过的每个节点直接连接到根节点上,这样可以减少后续操作中访问这些节点时的步骤数。
路径压缩通常和“按秩合并”结合使用,但即使单独使用路径压缩,也能显著提高并查集的效率。
代码示例
public class UnionFind {
private int[] parent; // 父节点数组
public UnionFind(int size) {
parent = new int[size];
// 初始化,每个节点的父节点是它自己
for (int i = 0; i < size; i++) {
parent[i] = i;
}
}
// 查找元素p的根节点
public int find(int p) {
// 当我们不是根节点时
while (p != parent[p]) {
// 进行路径压缩
parent[p] = parent[parent[p]];
// 继续向上查找
p = parent[p];
}
return p;
}
// 合并元素p和元素q所在的集合
public void union(int p, int q) {
int rootP = find(p);
int rootQ = find(q);
if (rootP != rootQ) {
parent[rootP] = rootQ; // 任意连接两个根节点
}
}
}
我们定义了一个UnionFind
类,它具有find
和union
方法。在find
方法中,我们实现了路径压缩,通过在查找根节点的过程中更新节点的直接父节点为根节点,来减少后续操作的查找步骤。这种优化可以显著提升并查集的性能,特别是在处理大量find
和union
操作的时候。
并查集例题
2017年第八届蓝桥杯国赛大学C组真题 合根植物
题目描述:
w 星球的一个种植园,被分成 m×n 个小格子(东西方向 m 行,南北方向 n 列)。每个格子里种了一株合根植物。
这种植物有个特点,它的根可能会沿着南北或东西方向伸展,从而与另一个格子的植物合成为一体。
如果我们告诉你哪些小格子间出现了连根现象,你能说出这个园中一共有多少株合根植物吗?
输入描述:
第一行,两个整数 m,n,用空格分开,表示格子的行数、列数(1 ≤ m,n ≤ 1000)。
接下来一行,一个整数 k (0 ≤ k ≤ 100000 ),表示下面还有 k 行数据。
接下来 k 行,每行两个整数 a,b,表示编号为 a 的小格子和编号为 b 的小格子合根了。
格子的编号一行一行,从上到下,从左到右编号。
比如:5×4 的小格子,编号:
行列 | ① | ② | ③ | ④ |
---|---|---|---|---|
① | 1 | 2 | 3 | 4 |
② | 5 | 6 | 7 | 8 |
③ | 9 | 10 | 11 | 12 |
④ | 13 | 14 | 15 | 16 |
⑤ | 17 | 18 | 19 | 20 |
输出描述:
输出植物数量。
输入输出样例:
示例:
输入:
5 4
16
2 3
1 5
5 9
4 8
7 8
9 10
10 11
11 12
10 14
12 16
14 18
17 18
15 19
19 20
9 13
13 17
输出:
5
样例图例如下:
解题思路:
在本题中,我们把每个格子视为一个独立的集合,当两个格子“合根”时,我们将它们所在的集合进行合并。最后,计算并查集中独立集合的数量,即为不同的“合根植物”数量。
-
初始化并查集:我们创建一个并查集,其中每个格子作为一个独立的集合。在这个并查集中,每个格子最初的父节点是它自己。
-
处理合根关系:根据输入的合根关系,我们使用并查集的
union
方法将这些格子所在的集合进行合并。合并的基本原则是:如果两个格子合根,它们应属于同一个集合。 -
计算合根植物的数量:遍历所有格子,计算其父节点是自身的格子数量。这些格子代表了各个不相交集合的根节点,即不同的“合根植物”。
import java.util.Scanner;
class PlantUnionFind {
private int[] parent; // 存储每个元素的父节点索引
// 构造方法,初始化并查集
public PlantUnionFind(int size) {
parent = new int[size + 1]; // 从1开始编号,因此需要 size + 1 的空间
for (int i = 1; i <= size; i++) {
parent[i] = i; // 初始时,每个元素的父节点是它自己
}
}
// 查找元素x的根节点(集合代表)
public int find(int x) {
if (parent[x] != x) {
parent[x] = find(parent[x]); // 路径压缩优化
}
return parent[x];
}
// 合并元素x和y所在的集合
public void union(int x, int y) {
int rootX = find(x);
int rootY = find(y);
if (rootX != rootY) {
parent[rootX] = rootY; // 将一个集合的根节点指向另一个集合的根节点
}
}
// 计算并查集中独立集合的数量
public int countPlants() {
int count = 0;
for (int i = 1; i < parent.length; i++) {
if (parent[i] == i) {
count++; // 如果节点的父节点是它自己,它是一个根节点
}
}
return count;
}
}
public class Main{
public static void main(String[] args) {
Scanner scanner = new Scanner(System.in);
int m = scanner.nextInt();
int n = scanner.nextInt();
int k = scanner.nextInt();
PlantUnionFind unionFind = new PlantUnionFind(m * n);
for (int i = 0; i < k; i++) {
int a = scanner.nextInt();
int b = scanner.nextInt();
unionFind.union(a, b); // 处理合根关系
}
System.out.println(unionFind.countPlants()); // 输出合根植物数量
scanner.close();
}
}
2019年第十届蓝桥杯省赛大学A组真题 修改数组
题目描述: 给定一个长度为 N 的数组A [A1,A2,···,AN],数组中有可能有重复出现的整数。
现在小明要按以下方法将其修改为没有重复整数的数组。小明会依次修改A2,A3,⋅⋅⋅,AN。
当修改 Ai 时,小明会检查 Ai 是否在 A1 ~ Ai - 1中出现过。如果出现过,则小明会给 Ai 加上 1 ;如果新的 Ai 仍在之前出现过,小明会持续给Ai 加 1 ,直 到 Ai 没有在 A1 ~ Ai - 1中出现过。
当 AN 也经过上述修改之后,显然 A 数组中就没有重复的整数了。
现在给定初始的 A 数组,请你计算出最终的 A 数组。
输入:
第一行包含一个整数 N 。
第二行包含 N 个整数 A1,A2,⋅⋅⋅,AN。
其中,1 ≤ N ≤ 100000,1 ≤ Ai ≤ 1000000
输出:
输出 N 个整数,依次是最终的 A1,A2,⋅⋅⋅,AN。
输入输出样例:
输入
5
2 1 1 3 4
输出
2 1 3 4
解题思路:
这道题目可以巧妙地运用并查集的思想来解决。核心在于维护一个记录已出现数字的集合,并针对每个新读入的数字,动态地更新这个集合。具体思路如下:
-
初始化并查集:初始化一个并查集,用于维护每个已出现数字的下一个未出现数字。
-
逐个处理数组中的元素:
- 如果当前数字之前没出现过,则它应该保持原值不变,且加入到集合中。此后,如果再次遇到这个数字,我们就知道需要输出这个数字加1。为此,我们把这个数字在并查集中的根节点设置为这个数字加1。
- 如果当前数字之前已经出现过,则这个数字应变成其在并查集中的根节点的值。由于根节点的值代表着当前数字的下一个未出现的数字,因此直接使用根节点的值作为新值。同时,更新根节点的值为它自己加1,以便下次使用。
-
输出处理后的结果:每处理一个数字后,直接输出它经过处理后的值。不需要额外的数组来存储处理后的结果。
这种方法利用并查集有效地维护了一个动态更新的集合,对于每个新读入的数字,我们可以快速判断它是否已经出现过,并找到对应的新值。这个过程中,利用并查集的“合并”和“查找”操作,保证了算法的高效性。
// 并查集类
class UnionFind {
private int[] parent; // 父节点数组
// 构造函数,初始化并查集
public UnionFind(int size) {
parent = new int[size];
for (int i = 0; i < size; i++) {
parent[i] = i; // 初始时,每个元素的父节点是它自己
}
}
// 查找操作:找到元素 x 的根节点
public int find(int x) {
if (x != parent[x]) {
parent[x] = find(parent[x]); // 路径压缩,将x的父节点设置为其根节点
}
return parent[x];
}
// 合并操作:将元素 x 和 y 所在的集合合并
public void union(int x, int y) {
int rootX = find(x); // 找到x的根节点
int rootY = find(y); // 找到y的根节点
if (rootX != rootY) {
parent[rootX] = rootY; // 将x的根节点的父节点设置为y的根节点
}
}
}
public class Main {
public static void main(String[] args) {
Scanner scanner = new Scanner(System.in);
int n = scanner.nextInt(); // 读入数组的长度
UnionFind uf = new UnionFind(1000001); // 创建一个并查集,大小设为1000001
for (int i = 0; i < n; i++) {
int currentNum = scanner.nextInt(); // 读入当前数字
int root = uf.find(currentNum); // 找到该数字在并查集中的根节点
System.out.print(root + " "); // 输出该根节点(处理后的数字)
if (root != 1000000) { // 如果根节点不是最大值
uf.union(root, root + 1); // 将这个数字与它的下一个数字合并
}
}
scanner.close();
}
}