题目
并查集路径压缩动画演示
手动演示
分布式队列
解题思路
核心观察:可见性由“最慢副本”决定
题目规定:一个元素只有在主节点和所有副节点都同步完成后,才具有可见性。
换句话说:
当前可见的元素数量 = 所有节点(包括主节点)中已同步元素数量的最小值。
因为只要有一个节点还没同步到第 k 个元素,那么第 k 个元素就不可见。
操作含义转化为计数
- add:主节点(编号 0)的元素数量 +1。
- sync id:副节点
id尝试同步下一个缺失元素,即cnt[id]++,但不能超过主节点当前总数(防止过度同步)。 - query:输出
min(cnt[0], cnt[1], ..., cnt[n-1])。
注意:我们不需要存储具体元素值,只需维护每个节点当前同步了多少个元素。
边界处理:同步不能超过主节点
副节点同步时,必须保证:
cnt[id] = min(cnt[id] + 1, cnt[0])
这样即使多次对同一个副节点 sync,也不会让它“超前”于主节点。
输入终止:使用 hasNext() 判断 EOF
题目未给出操作总数,需持续读取直到文件结束。
在 Java 中,sc.hasNext() 在遇到 EOF(如本地 Ctrl+D 或在线评测输入结束)时返回 false,自然退出循环。
参考代码
import java.util.Scanner;
public class Main {
public static void main(String[] args) {
Scanner sc = new Scanner(System.in);
int n = sc.nextInt(); // 节点总数, 0 为主节点, 1~n-1 为副节点
// cnt[i] 表示节点 i 当前已同步的元素个数
int[] cnt = new int[n];
while (sc.hasNext()) {
String op = sc.next();
switch (op) {
case "add":
// 添加元素: 只增加主节点计数, 不关心具体数值
sc.nextInt(); // 读掉 element, 但不用
cnt[0]++;
break;
case "sync":
// 同步操作: 指定副节点尝试同步下一个元素
int id = sc.nextInt();
// 同步后不能超过主节点当前总数
cnt[id] = Math.min(cnt[id] + 1, cnt[0]);
break;
case "query":
// 查询: 可见元素数 = 所有节点同步数量的最小值
int minCnt = Integer.MAX_VALUE;
for (int i = 0; i < n; i++) {
minCnt = Math.min(minCnt, cnt[i]);
}
System.out.println(minCnt);
break;
}
}
}
}
最大开支
解题思路(贪心 + 优先队列解法)
核心观察:最坏情况 = 最大总花费
题目问的是:“小蓝至少要准备多少钱,才能应对所有人任意选择项目的情况”。
这等价于:在所有可能的人员分配方案中,找出总门票费用最大的那种情况。
因为只要能 cover 最坏情况(花得最多的情况),就一定能 cover 所有其他情况。
所以问题转化为:如何分配 N 个人到 M 个项目,使得总花费最大?
门票函数的性质
每个项目的单价为:max(K_i * X + B_i, 0),其中 K_i < 0,所以:
- 随着人数 X 增加,单价单调下降。
- 当
K_i * X + B_i <= 0时,单价为 0,再多人也不花钱。
但注意:总花费 = 单价 × 人数,不是单价本身!
虽然单价在降,但人数在增,总花费可能先升后降。
关键突破:考虑“新增一个人”的边际收益
我们不直接决定每个项目分多少人,而是一个一个地安排人,每次把当前这个人安排到“能让总花费增加最多”的项目里。
这就是贪心思想:每一步都做当前看起来最好的选择。
对于项目 i,如果已经有 x 个人,那么再加第 x+1 个人带来的新增花费为:
新总花费 - 旧总花费
=max(K_i*(x+1)+B_i, 0) * (x+1) - max(K_i*x+B_i, 0) * x = k_i(2x_i - 1)+b
这个值就是“边际贡献”。我们每次都选边际贡献最大的项目来加人。
💡 小技巧:因为
K_i < 0,随着 x 增大,这个边际贡献会越来越小(甚至变负或零)。所以每个项目“越往后加人,越不划算”。
为什么贪心是对的?
想象你是游乐园老板,想赚最多钱。你会怎么安排?
- 第一个人肯定去“单人票价最高”的项目。
- 第二个人呢?不能只看单价,要看“加他之后总钱变多多少”。
- 因为每个项目的边际收益是递减的,所以永远优先选当前收益增量最大的,不会错过更优解。
不需要复杂证明,记住口诀:“收益递减,贪心最优”。
实现工具:优先队列(最大堆)
用优先队列维护每个项目“如果现在加一个人,能多赚多少钱”。
初始时,每个项目加第 1 个人的收益是多少,先全放进去。
然后循环 N 次:
- 取出收益最大的项目;
- 如果收益 ≤ 0,说明再加人不赚钱了(甚至免费),后面的人都不用加了(因为收益只会更小);
- 否则,累加这笔收益,并更新该项目“下一个人”的边际收益,重新放回堆中。
参考代码
import java.util.PriorityQueue;
import java.util.Scanner;
public class Main {
public static void main(String[] args) {
Scanner sc = new Scanner(System.in);
int n = sc.nextInt(); // 总人数
int m = sc.nextInt(); // 项目个数
int[] K = new int[m + 5];
int[] B = new int[m + 5];
for (int i = 1; i <= m; i++) {
K[i] = sc.nextInt();
B[i] = sc.nextInt();
}
// 最大堆:按“新增一个人带来的花费增量”从大到小排序
// 数组内容: [新增花费, 项目编号, 加入后的人数]
PriorityQueue<long[]> pq = new PriorityQueue<>((a, b) -> Long.compare(b[0], a[0]));
// 初始化:每个项目加入第1个人的新增花费
for (int i = 1; i <= m; i++) {
long nextCost = calCost(1, i, K, B);
pq.offer(new long[]{nextCost, i, 1});
}
long totalCost = 0;
// 依次安排每个人
for (int person = 0; person < n; person++) {
long[] top = pq.poll();
long cost = top[0]; // 这个人加入带来的新增花费
int projectId = (int) top[1];
long cnt = top[2]; // 加入后该项目的人数
if (cost <= 0) {
// 如果新增花费 <= 0,说明再加人不会增加总花费
break;
}
totalCost += cost;
// 计算该项目当前总花费(用于后续计算增量)
long currentCost = calCost(cnt, projectId, K, B);
// 下一个人加入后的人数
cnt++;
// 下一个人加入后的总花费
long nextCost = calCost(cnt, projectId, K, B);
// 新增的花费 = nextTotal - currentTotal
// 把更新后的项目信息放回堆中
pq.offer(new long[]{nextCost - currentCost, projectId, cnt});
}
System.out.println(totalCost);
}
/**
* 计算某个项目在指定人数下的总花费
*
* @param cnt 人数
* @param id 项目编号
* @param K 系数数组
* @param B 基础值数组
* @return 总花费 = max(K[id] * count + B[id], 0) * count
*/
private static long calCost(long cnt, int id, int[] K, int[] B) {
// 单价 * 人数
return Math.max(K[id] * cnt + B[id], 0) * cnt;
}
}
冷热数据队列
解题思路(模拟 · 基于 LinkedHashMap 的高效实现)
核心观察:需要支持快速查找、插入和按访问顺序淘汰
题目要求维护两个队列:
- 热队列 q₁:存放最近被重复访问的数据页,容量为
n1。 - 冷队列 q₂:存放首次访问的数据页,容量为
n2。
关键操作包括:
- 判断某数据页是否在任一队列中(需 O(1) 查找)。
- 将数据页移动到队首(即“最新”位置)。
- 队列满时从尾部(最旧)淘汰元素。
这正是 LRU(Least Recently Used)缓存的变种,而 Java 中没有像 C++ 那样能直接通过迭代器 O(1) 删除链表节点的机制。但我们可以借助 LinkedHashMap 巧妙实现。
为什么选择 LinkedHashMap?
LinkedHashMap 是一个哈希表 + 双向链表的结构,具备以下特性:
- O(1) 插入、删除、查找(继承自 HashMap)。
- 维护插入顺序:默认按“插入先后”排序,最早插入的在前,最新在后。
- 虽然不能直接访问“尾部”,但我们可以通过
keySet().iterator().next()获取第一个(即最旧)的键。
💡 提示:
map.keySet().iterator().next()返回的是 LinkedHashMap 中第一个插入的 key,也就是逻辑上的“队尾”(最久未用),正好用于淘汰。
操作流程梳理
- 读入数据页
num。 - 检查是否已在热/冷队列中:
- 如果在,先从原队列中删除(确保唯一性)。
- 分情况处理:
- 首次出现:加入冷队列;若冷队列超容,淘汰最旧元素。
- 已存在过:加入热队列;若热队列超容,淘汰最旧元素,并将其移入冷队列首部。
- 输出时注意顺序:
- 题目要求输出“从新到旧”,而 LinkedHashMap 默认是“旧到新”,所以需逆序输出。
额外知识点:LinkedHashMap 关键 API 解释
-
map.containsKey(key):判断是否存在。 -
map.remove(key):O(1) 删除。 -
map.put(key, value):插入到末尾(最新位置)。 -
map.keySet().iterator().next():获取第一个(最旧)的 key。keySet()返回所有 key 的集合。iterator()获取迭代器。next()取第一个元素(因为 LinkedHashMap 有序,第一个就是最早插入的)。
这个技巧是本题在 Java 中高效实现的核心!
参考代码
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Scanner;
public class Main {
public static void main(String[] args) {
Scanner sc = new Scanner(System.in);
int n1 = sc.nextInt(); // 热队列容量
int n2 = sc.nextInt(); // 冷队列容量
int m = sc.nextInt(); // 操作次数
// 使用 LinkedHashMap 模拟有序队列
// Boolean 值无实际意义, 仅占位;关键是利用其有序性和 O(1) 操作
LinkedHashMap<Integer, Boolean> hotQueue = new LinkedHashMap<>();
LinkedHashMap<Integer, Boolean> coldQueue = new LinkedHashMap<>();
while (m-- > 0) {
int num = sc.nextInt();
// 检查当前数据页是否已在热队列或冷队列中
boolean inHot = hotQueue.containsKey(num);
boolean inCold = coldQueue.containsKey(num);
// 无论在哪, 先彻底移除(保证唯一性)
if (inHot) hotQueue.remove(num);
if (inCold) coldQueue.remove(num);
if (!inHot && !inCold) {
// 第一次见啊, 进冷队列
coldQueue.put(num, true);
if (coldQueue.size() > n2) {
// 若冷队列超容, 淘汰最旧元素(第一个插入的)
int first = coldQueue.keySet().iterator().next();
coldQueue.remove(first);
}
} else {
// 老朋友, 进热队列
hotQueue.put(num, true);
if (hotQueue.size() > n1) {
// 若热队列超容, 淘汰最旧元素, 并移入冷队列
int evict = hotQueue.keySet().iterator().next();
hotQueue.remove(evict);
// 直接放, 不需要考虑冷队列会满, 因为刚刚从冷队列扔了一个, 肯定有位置
coldQueue.put(evict, true);
}
}
}
// 输出两个队列: 从新到旧(逆序)
printReverse(hotQueue);
printReverse(coldQueue);
}
// 辅助函数:逆序打印 LinkedHashMap 的 key(从最新到最旧)
static void printReverse(LinkedHashMap<Integer, Boolean> map) {
List<Integer> keys = new ArrayList<>(map.keySet());
for (int i = keys.size() - 1; i >= 0; i--) {
System.out.print(keys.get(i) + " ");
}
System.out.println();
}
}
左移右移
解题思路(基于虚拟顺序标记的高效解法)
核心观察:只需维护元素的相对顺序,无需真实移动
题目要求多次将某个元素移到最左或最右,最终输出整个数组。
如果直接模拟(如用 ArrayList 删除再插入),每次操作是 O(N),总复杂度 O(MN),在 N, M ≤ 200000 时会超时。
但注意:我们只关心最终顺序,不关心中间过程。因此可以为每个元素分配一个“虚拟顺序值”,通过调整这个值来表示其在序列中的相对位置。
- 初始时,元素 i 的顺序值设为 i。
- 每次 左移 x:将 x 的顺序值设为比当前最小值还小(如
--left)。 - 每次 右移 x:将 x 的顺序值设为比当前最大值还大(如
++right)。
最后按“顺序值”从小到大排序,即可得到最终数组。
💡 “左减右加,排序即答” —— 不动数组,只改权重,一次排序搞定。
为什么这样可行?
因为:
- 所有左移操作产生的顺序值都小于初始值(1~N)。
- 所有右移操作产生的顺序值都大于初始值。
- 后执行的左移比先执行的更靠左(因为
left递减),同理右移也满足后执行更靠右。 - 排序天然保证了这种偏序关系。
这种方法将每次操作降为 O(1),总复杂度 O(N log N),轻松通过大数据。
备选方案:双向链表 + 哈希定位
另一种思路是手动实现带哈希索引的双向链表:
- 链表支持 O(1) 删除和头/尾插入。
- 哈希表
pos存储每个值对应的节点指针,实现 O(1) 查找。
虽然理论上也是 O(M),但常数较大,代码复杂,且 Java 无内置支持(LinkedHashMap 无法任意插入到头部/尾部并保持全局顺序)。因此虚拟顺序标记法更简洁高效。
参考代码
方法一:虚拟顺序标记(推荐)
import java.util.Arrays;
import java.util.Comparator;
import java.util.Scanner;
public class Main {
public static void main(String[] args) {
Scanner sc = new Scanner(System.in);
int n = sc.nextInt();
int m = sc.nextInt();
// nums[i][0] 存元素值, nums[i][1] 存其虚拟顺序值
int[][] nums = new int[n + 5][2];
for (int i = 1; i <= n; i++) {
// 初始顺序值等于自身
nums[i][0] = nums[i][1] = i;
}
// left 和 right 动态扩展顺序值范围
int left = 1; // 下一个左移元素的顺序值将设为 --left
int right = n; // 下一个右移元素的顺序值将设为 ++right
while (m-- > 0) {
String op = sc.next();
int num = sc.nextInt();
switch (op) {
case "L":
// 左移: 顺序值设为比当前最小还小
nums[num][1] = --left;
break;
case "R":
// 右移: 顺序值设为比当前最大还大
nums[num][1] = ++right;
break;
}
}
// 按虚拟顺序值升序排序, 得到最终数组
Arrays.sort(nums, 1, n + 1, Comparator.comparingInt(a -> a[1]));
for (int i = 1; i <= n; i++) {
System.out.print(nums[i][0] + " ");
}
}
}
方法二:双向链表 + 哈希定位(备选)
import java.util.HashMap;
import java.util.Map;
import java.util.Scanner;
public class Main {
public static void main(String[] args) {
Scanner sc = new Scanner(System.in);
int n = sc.nextInt();
int m = sc.nextInt();
MyLinkedList list = new MyLinkedList();
// 初始化双向链表
for (int i = 1; i <= n; i++) {
list.addLast(i);
}
while (m-- > 0) {
String op = sc.next();
int num = sc.nextInt();
switch (op) {
case "L":
// 把num提到最左边
list.remove(num);
list.addFirst(num);
break;
case "R":
// 把num提到最右边
list.remove(num);
list.addLast(num);
break;
}
}
// 遍历链表输出
MyLinkedList.ListNode head = list.head;
while (head.next != list.tail) {
System.out.print(head.next.val + " ");
head = head.next;
}
}
}
// 带哈希索引的双向链表
class MyLinkedList {
static class ListNode {
int val;
ListNode next;
ListNode pre;
public ListNode(int val) {
this.val = val;
}
}
ListNode head;
ListNode tail;
// 值到节点的映射
Map<Integer, ListNode> pos = new HashMap<>();
public MyLinkedList() {
head = new ListNode(0);
tail = head;
head.next = tail.pre = head;
}
// 在链表尾部添加元素
public void addLast(int val) {
// 先创建新节点
ListNode node = new ListNode(val);
// 插入到tail前面
node.pre = tail.pre;
node.next = tail;
node.pre.next = node;
node.next.pre = node;
// 更新pos
pos.put(val, node);
}
// 在链表头部添加元素
public void addFirst(int val) {
// 先创建新节点
ListNode node = new ListNode(val);
// 插入到head后面
node.pre = head;
node.next = head.next;
node.pre.next = node;
node.next.pre = node;
// 更新pos
pos.put(val, node);
}
// 删除指定值的节点
public void remove(int val) {
// 获取节点
ListNode node = pos.get(val);
// 删除
node.pre.next = node.next;
node.next.pre = node.pre;
// 更新pos(其实也不用, 因为后面一定会再次插入并更新)
pos.remove(val);
}
}
聚合一块
解题思路(并查集统计连通分量数)
核心观察:答案 = 连通分量数量 - 1
题目要求最少加多少条边,使整张图变成一个连通块。
关键结论:
若当前图中有
k个连通分量,则至少需要添加k - 1条边才能将它们全部连接成一个整体。
这是因为每加一条边最多只能合并两个连通分量,所以从 k 个变成 1 个,恰好需要 k - 1 次合并。
因此,问题转化为:统计初始图中有多少个连通分量。
为什么用并查集?
- 图中只有无向边,且只关心“是否连通”,不关心路径细节。
- 并查集(Union-Find)天然支持:
- 快速判断两点是否在同一集合(通过
find)。 - 合并两个集合(通过
merge或union)。 - 动态维护连通分量总数。
- 快速判断两点是否在同一集合(通过
初始时每个点自成一个连通分量(共 n 个)。
每读入一条边 (u, v),若 u 和 v 不在同一集合,就合并它们,并将连通分量数减 1。
最终答案就是 连通分量数 - 1。
提示:避免术语障碍
未学过图论的同学,可以这样类比:
- 每个点是一个“人”,边是“朋友关系”。
- 连通分量就是“朋友圈”——圈内任意两人可通过朋友链认识。
- 问:最少介绍多少对新朋友,能让所有人变成一个大朋友圈?
- 答:有几个朋友圈,就介绍“朋友圈数 - 1”对。
参考代码
import java.util.Scanner;
public class Main {
static int[] parent; // parent[i] 表示 i 的父节点
static int size; // 当前连通分量的数量
public static void main(String[] args) {
Scanner sc = new Scanner(System.in);
int n = sc.nextInt(); // 点数
int m = sc.nextInt(); // 边数
// 初始化并查集:每个点独立成一个集合
parent = new int[n + 1];
size = n; // 初始有 n 个连通分量
for (int i = 1; i <= n; i++) {
parent[i] = i;
}
// 处理每条边
for (int i = 0; i < m; i++) {
int u = sc.nextInt();
int v = sc.nextInt();
merge(u, v); // 尝试合并 u 和 v 所在的集合
}
// 最少需要 (连通分量数 - 1) 条边才能连成一个整体
System.out.println(size - 1);
sc.close();
}
// 路径压缩的 find 操作:找到 x 的根节点
private static int find(int x) {
if (parent[x] == x) {
return x;
}
parent[x] = find(parent[x]); // 路径压缩,提升后续查询效率
return parent[x];
}
// 合并 x 和 y 所在的集合
private static void merge(int x, int y) {
int rootX = find(x);
int rootY = find(y);
if (rootX != rootY) {
parent[rootX] = rootY; // 合并两个集合
size--; // 连通分量总数减一
}
// 如果 rootX == rootY,说明已在同一连通分量,无需操作
}
}
合根植物
解题思路(二维网格连通性统计)
核心观察:本质仍是连通分量计数
本题看似是二维网格问题,但输入直接给出了哪些格子已经“合根”(即连通),因此无需考虑网格结构本身如何建边。
- 总共有
m × n个格子,初始时每株植物独立,即有m × n个连通分量。 - 每给出一条合根关系
(a, b),就相当于在图中添加一条无向边。 - 最终答案就是剩余的连通分量数量。
这与上一题“最少加几条边使图连通”高度相似,只是本题不需要输出 size - 1,而是直接输出 size。
为什么不用处理二维坐标?
题目已将格子编号为 1 到 m×n(按行优先顺序),所有输入的合根关系也直接使用这个编号。
因此我们可以完全忽略二维结构,把问题当作一个普通的无向图连通性问题处理。
💡 提示:本题强调“问题抽象”的能力——无论原始场景是网格、树还是社交网络,只要能转化为“点与边”,并查集就能派上用场。
并查集的作用
- 初始化:每个编号自成一个集合。
- 对每条合根关系,尝试合并两个格子所在的集合。
- 若两者原本不在同一集合,则合并后总连通分量数减 1。
- 最终
size即为合根植物的株数(每株对应一个连通块)。
参考代码
import java.util.Scanner;
public class Main {
// parent[i] 表示编号 i 的格子所属集合的根节点
static int[] parent;
// 当前连通分量(即合根植物)的数量
static int size;
public static void main(String[] args) {
Scanner sc = new Scanner(System.in);
int m = sc.nextInt(); // 行数
int n = sc.nextInt(); // 列数
int k = sc.nextInt(); // 合根关系数量
// 初始化并查集:每个点独立成一个集合
parent = new int[m * n + 5];
// 初始每格一株, 共 m*n 株
size = m * n;
// 初始化并查集:每个格子独立
for (int i = 1; i <= m * n; i++) {
parent[i] = i;
}
// 处理每一条合根关系
while (k-- > 0) {
int u = sc.nextInt();
int v = sc.nextInt();
// 合并 a 和 b 所在的植物(连通块)
merge(u, v);
}
// 输出最终的合根植物数量(即连通分量数)
System.out.println(size);
}
// 路径压缩的 find 操作:找到 x 的根节点
private static int find(int x) {
if (parent[x] == x) {
return x;
}
parent[x] = find(parent[x]); // 路径压缩, 提升后续查询效率
return parent[x];
}
// 合并 x 和 y 所在的集合
private static void merge(int x, int y) {
int xRoot = find(x);
int yRoot = find(y);
if (xRoot == yRoot) {
// 如果 rootX == rootY, 说明已在同一连通分量, 无需操作
return;
}
// 合并两个集合
parent[xRoot] = yRoot;
size--; // 连通分量总数减一
}
}
传送阵
解题思路(基于并查集的环合并优化)
核心观察:排列构成若干独立环,魔法可连接相邻环
题目给出一个长度为 n 的排列 a,表示从第 i 个传送阵会传送到 a[i]。
由于是排列,每个点出度和入度均为 1,整个结构由若干个互不相交的环组成。
- 在不使用魔法时,小蓝一旦进入某个环,就只能在该环内循环,最多访问该环的所有节点。
- 使用一次魔法,可以从位置
j走到j-1或j+1(物理相邻),从而跳到另一个环。 - 因此,最优策略是:选择两个物理位置相邻的环,通过魔法将它们连通,总访问数 = 两环大小之和。
💡 关键突破口:魔法不能任意连接两个环,只能连接“在数组中下标相邻”的两个环。
算法设计:并查集 + 枚举相邻对
- 构建环的连通分量:
- 对每个
i,将i与a[i]合并(因为它们在同一环中)。 - 并查集自动将每个环缩成一个连通分量,并记录其大小。
- 对每个
- 计算答案:
- 初始答案为最大单个环的大小(不用魔法的情况)。
- 枚举所有相邻位置对
(i, i+1):- 若它们属于不同连通分量,则考虑合并后的大小
size[find(i)] + size[find(i+1)]。 - 更新最大值。
- 若它们属于不同连通分量,则考虑合并后的大小
⚠️ 注意:我们不实际执行合并,只是查看“如果连接这两个分量能得到多大”,因为魔法只能用一次。
为什么并查集适合?
- 自然支持“环内连通”建模。
- 支持 O(α(n)) 的查找与合并。
- 可高效维护每个连通分量的大小。
参考代码
import java.util.Scanner;
public class Main {
// parent[i] 表示 i 所在集合的根节点
static int[] parent;
// size[i] 表示以 i 为根的集合的元素个数
static int[] size;
public static void main(String[] args) {
Scanner sc = new Scanner(System.in);
int n = sc.nextInt();
// 初始化并查集
parent = new int[n + 5];
size = new int[n + 5];
int[] next = new int[n + 5];
for (int i = 1; i <= n; i++) {
next[i] = sc.nextInt();
parent[i] = i;
size[i] = 1;
}
// 构建环: 将每个 i 与其传送目标 a[i] 合并
for (int i = 1; i <= n; i++) {
merge(i, next[i]);
}
// 初始答案: 最大单个环的大小(不用魔法的情况)
int maxSize = 0;
for (int i = 1; i <= n; i++) {
maxSize = Math.max(maxSize, size[i]);
}
// 枚举所有物理相邻的位置对 (i, i+1), 尝试用魔法连接
for (int i = 1; i < n; i++) {
int root1 = find(i);
int root2 = find(i + 1);
if (root1 != root2) {
maxSize = Math.max(maxSize, size[root1] + size[root2]);
}
}
// 输出最大连通分量大小
System.out.println(maxSize);
}
// 路径压缩的 find 操作: 找到 x 的根节点
private static int find(int x) {
if (parent[x] != x) {
parent[x] = find(parent[x]); // 路径压缩, 提升后续查询效率
}
return parent[x];
}
// 按大小合并(带秩合并)
private static void merge(int x, int y) {
int xRoot = find(x);
int yRoot = find(y);
// 如果 rootX == rootY, 说明已在同一连通分量, 无需操作
if (xRoot == yRoot) return;
// 按秩合并
if (size[xRoot] < size[yRoot]) {
parent[xRoot] = yRoot;
size[yRoot] += size[xRoot];
} else {
parent[yRoot] = xRoot;
size[xRoot] += size[yRoot];
}
}
}