一.并查集的基础
路径压缩就是递归好到当前节点的根节点
1. 什么是并查集?
并查集是一种树形的数据结构,用于处理一些元素的合并及查询问题。主要支持两种操作:
- 查找(Find):确定某个元素属于哪个集合
- 合并(Union):将两个集合合并成一个集合
2. 核心思想
想象一个班级里的同学们:
- 每个同学最开始是独立的个体
- 当两个同学成为朋友时,就将他们连接起来
- 随着越来越多的朋友关系建立,会形成多个朋友圈
- 每个朋友圈有一个"领袖"(代表节点)
3. 基本操作实现
class UnionFind {
private int[] parent; // 记录节点的父节点
private int[] size; // 记录集合的大小(优化)
// 初始化
public UnionFind(int n) {
parent = new int[n];
size = new int[n];
for (int i = 0; i < n; i++) {
parent[i] = i; // 初始时每个节点的父节点是自己
size[i] = 1; // 初始时每个集合大小为1
}
}
// 查找操作(带路径压缩)
public int find(int x) {
if (parent[x] != x) {
parent[x] = find(parent[x]); // 路径压缩
}
return parent[x];
}
// 合并操作
public void union(int x, int y) {
int rootX = find(x);
int rootY = find(y);
if (rootX != rootY) {
if (size[rootX] >= size[rootY]) {
parent[rootY] = rootX;
size[rootX] += size[rootY];
} else {
parent[rootX] = rootY;
size[rootY] += size[rootX];
}
}
}
}
4. 核心优化技术
-
路径压缩
- 在查找过程中,将查找路径上的所有节点都直接连接到根节点
- 大大减少了后续查找的时间复杂度
-
按秩合并
- 总是将较小的树连接到较大的树上
- 防止树变得太高,保持平衡
5. 经典应用场景
-
朋友圈问题
- 判断两个人是否属于同一个朋友圈
- 合并两个朋友圈
-
网络连接问题
- 判断两个节点是否连通
- 连接两个节点
-
最小生成树的Kruskal算法
- 判断添加一条边是否会形成环
6. 复杂度分析
- 初始化:O(N)
- 查找操作(平均):接近O(1)
- 合并操作(平均):接近O(1)
7. 流程图
让我用一个流程图来总结并查集算法的核心逻辑:
1. 初始化
┌─────────────────────────┐
│ 创建大小为n的并查集 │
│ parent[i] = i │
│ size[i] = 1 │
└─────────────┬───────────┘
↓
2. 查找操作 (find)
┌─────────────────────────┐
│ 输入:节点x │
├─────────────────────────┤
│ 记录查找路径 │
│ 循环直到找到根节点 │
│ (parent[x] == x) │
├─────────────────────────┤
│ 路径压缩: │
│ 将路径上所有节点 │
│ 直接指向根节点 │
└─────────────┬───────────┘
↓
3. 合并操作 (union)
┌─────────────────────────┐
│ 输入:节点a, 节点b │
├─────────────────────────┤
│ 找到a的根节点rootA │
│ 找到b的根节点rootB │
├─────────────────────────┤
│ if rootA ≠ rootB: │
│ 比较两个集合的大小 │
│ 小集合接到大集合下 │
│ 更新集合大小 │
└─────────────────────────┘
详细解释:
1. 初始化过程
┌────────┐
│节点: 0 1 2 3 4 5│
│父节点: 0 1 2 3 4 5│ → 每个节点初始指向自己
│大小: 1 1 1 1 1 1│
└────────┘
2. 查找过程 (以查找节点5为例)
第一次查找:
5 → 4 → 3 → 2 → 1 (原始路径)
↓
路径压缩后:
5 → 1
4 → 1
3 → 1
2 → 1
3. 合并过程 (以合并节点2和4为例)
a. 找到根节点
2 → 1
4 → 1
b. 比较集合大小
size[1] = 4
size[1] = 4
c. 合并(如果根节点不同)
将较小的集合接到较大的集合下
关键点总结:
1. 数据结构
├── parent[]: 存储父节点关系
└── size[]: 存储集合大小(优化)
2. 核心操作
├── find(): 查找根节点
│ └── 路径压缩优化
└── union(): 合并集合
└── 按大小合并优化
3. 优化策略
├── 路径压缩:减少树的高度
└── 按大小合并:保持树的平衡
4. 时间复杂度
├── 初始化:O(n)
├── 查找:O(1) 均摊
└── 合并:O(1) 均摊
使用场景示例:
1. 朋友圈问题
├── 查找:判断两人是否在同一个圈子
└── 合并:添加新的朋友关系
2. 连通性问题
├── 查找:判断两个点是否连通
└── 合并:连接两个点
3. 集合操作
├── 查找:判断元素所属集合
└── 合并:合并两个集合
这就是并查集算法的主要流程和核心概念。通过路径压缩和按大小合并这两个优化,使得并查集在处理大规模动态连通性问题时非常高效。
代码运行详细结果
class UnionFind(n: Int) {
private val parent = IntArray(n) // 记录节点的父节点
private val size = IntArray(n) // 记录集合的大小
init {
println("=== 初始化并查集 ===")
println("节点数量: $n")
for (i in 0 until n) {
parent[i] = i
size[i] = 1
println("初始化节点 $i: parent[${i}] = ${parent[i]}, size[${i}] = ${size[i]}")
}
printState()
}
// 查找操作(带路径压缩)
fun find(x: Int): Int {
println("\n开始查找节点 $x 的根节点...")
var current = x
val path = mutableListOf(current)
// 找到根节点
while (parent[current] != current) {
current = parent[current]
path.add(current)
}
if (path.size > 1) {
println("查找路径: ${path.joinToString(" -> ")}")
println("根节点是: $current")
// 路径压缩
println("开始路径压缩...")
for (node in path) {
if (node != current) {
println("将节点 $node 直接指向根节点 $current")
parent[node] = current
}
}
} else {
println("节点 $x 本身就是根节点")
}
printState()
return current
}
// 合并操作
fun union(x: Int, y: Int) {
println("\n=== 合并节点 $x 和节点 $y ===")
val rootX = find(x)
val rootY = find(y)
if (rootX != rootY) {
println("\n开始合并根节点 $rootX 和 $rootY")
println("size[$rootX] = ${size[rootX]}")
println("size[$rootY] = ${size[rootY]}")
if (size[rootX] >= size[rootY]) {
println("size[$rootX] >= size[$rootY],将 $rootY 指向 $rootX")
parent[rootY] = rootX
size[rootX] += size[rootY]
println("更新 size[$rootX] = ${size[rootX]}")
} else {
println("size[$rootX] < size[$rootY],将 $rootX 指向 $rootY")
parent[rootX] = rootY
size[rootY] += size[rootX]
println("更新 size[$rootY] = ${size[rootY]}")
}
printState()
} else {
println("节点 $x 和节点 $y 已经在同一个集合中")
}
}
// 打印当前状态
private fun printState() {
println("\n当前并查集状态:")
println("索引: ${(0 until parent.size).joinToString(" ")}")
println("父节点: ${parent.joinToString(" ")}")
println("大小: ${size.joinToString(" ")}")
println("集合: ${findSets()}")
}
// 找出所有集合
private fun findSets(): List<List<Int>> {
val sets = mutableMapOf<Int, MutableList<Int>>()
for (i in parent.indices) {
val root = find(i)
sets.getOrPut(root) { mutableListOf() }.add(i)
}
return sets.values.toList()
}
}
fun main() {
// 创建一个大小为6的并查集
val uf = UnionFind(6)
// 执行一系列合并操作
println("\n执行合并操作...")
uf.union(1, 2) // 合并节点1和2
uf.union(3, 4) // 合并节点3和4
uf.union(1, 4) // 合并节点1和4
uf.union(0, 5) // 合并节点0和5
}
运行结果:
=== 初始化并查集 ===
节点数量: 6
初始化节点 0: parent[0] = 0, size[0] = 1
初始化节点 1: parent[1] = 1, size[1] = 1
初始化节点 2: parent[2] = 2, size[2] = 1
初始化节点 3: parent[3] = 3, size[3] = 1
初始化节点 4: parent[4] = 4, size[4] = 1
初始化节点 5: parent[5] = 5, size[5] = 1
当前并查集状态:
索引: 0 1 2 3 4 5
父节点: 0 1 2 3 4 5
大小: 1 1 1 1 1 1
集合: [[0], [1], [2], [3], [4], [5]]
执行合并操作...
=== 合并节点 1 和节点 2 ===
开始查找节点 1 的根节点...
节点 1 本身就是根节点
当前并查集状态:
索引: 0 1 2 3 4 5
父节点: 0 1 2 3 4 5
大小: 1 1 1 1 1 1
集合: [[0], [1], [2], [3], [4], [5]]
开始查找节点 2 的根节点...
节点 2 本身就是根节点
当前并查集状态:
索引: 0 1 2 3 4 5
父节点: 0 1 2 3 4 5
大小: 1 1 1 1 1 1
集合: [[0], [1], [2], [3], [4], [5]]
开始合并根节点 1 和 2
size[1] = 1
size[2] = 1
size[1] >= size[2],将 2 指向 1
更新 size[1] = 2
当前并查集状态:
索引: 0 1 2 3 4 5
父节点: 0 1 1 3 4 5
大小: 1 2 1 1 1 1
集合: [[0], [1, 2], [3], [4], [5]]
=== 合并节点 3 和节点 4 ===
开始查找节点 3 的根节点...
节点 3 本身就是根节点
当前并查集状态:
索引: 0 1 2 3 4 5
父节点: 0 1 1 3 4 5
大小: 1 2 1 1 1 1
集合: [[0], [1, 2], [3], [4], [5]]
开始查找节点 4 的根节点...
节点 4 本身就是根节点
当前并查集状态:
索引: 0 1 2 3 4 5
父节点: 0 1 1 3 4 5
大小: 1 2 1 1 1 1
集合: [[0], [1, 2], [3], [4], [5]]
开始合并根节点 3 和 4
size[3] = 1
size[4] = 1
size[3] >= size[4],将 4 指向 3
更新 size[3] = 2
当前并查集状态:
索引: 0 1 2 3 4 5
父节点: 0 1 1 3 3 5
大小: 1 2 1 2 1 1
集合: [[0], [1, 2], [3, 4], [5]]
=== 合并节点 1 和节点 4 ===
开始查找节点 1 的根节点...
节点 1 本身就是根节点
当前并查集状态:
索引: 0 1 2 3 4 5
父节点: 0 1 1 3 3 5
大小: 1 2 1 2 1 1
集合: [[0], [1, 2], [3, 4], [5]]
开始查找节点 4 的根节点...
查找路径: 4 -> 3
根节点是: 3
开始路径压缩...
将节点 4 直接指向根节点 3
当前并查集状态:
索引: 0 1 2 3 4 5
父节点: 0 1 1 3 3 5
大小: 1 2 1 2 1 1
集合: [[0], [1, 2], [3, 4], [5]]
开始合并根节点 1 和 3
size[1] = 2
size[3] = 2
size[1] >= size[3],将 3 指向 1
更新 size[1] = 4
当前并查集状态:
索引: 0 1 2 3 4 5
父节点: 0 1 1 1 3 5
大小: 1 4 1 2 1 1
集合: [[0], [1, 2, 3, 4], [5]]
=== 合并节点 0 和节点 5 ===
开始查找节点 0 的根节点...
节点 0 本身就是根节点
当前并查集状态:
索引: 0 1 2 3 4 5
父节点: 0 1 1 1 3 5
大小: 1 4 1 2 1 1
集合: [[0], [1, 2, 3, 4], [5]]
开始查找节点 5 的根节点...
节点 5 本身就是根节点
当前并查集状态:
索引: 0 1 2 3 4 5
父节点: 0 1 1 1 3 5
大小: 1 4 1 2 1 1
集合: [[0], [1, 2, 3, 4], [5]]
开始合并根节点 0 和 5
size[0] = 1
size[5] = 1
size[0] >= size[5],将 5 指向 0
更新 size[0] = 2
当前并查集状态:
索引: 0 1 2 3 4 5
父节点: 0 1 1 1 3 0
大小: 2 4 1 2 1 1
集合: [[0, 5], [1, 2, 3, 4]]
这个输出详细展示了:
-
初始化过程
- 每个节点的初始父节点
- 每个集合的初始大小
-
查找操作
- 查找路径
- 路径压缩过程
-
合并操作
- 大小比较
- 父节点更新
- 集合大小更新
-
每一步操作后的状态
- 父节点数组
- 大小数组
- 当前的集合分布
通过这些详细的步骤打印,你可以更好地理解:
- 并查集如何维护集合关系
- 路径压缩如何优化查找
- 按大小合并如何保持平衡
路径压缩的关键判断
详细解释为什么在路径压缩时需要判断 if (node != current):
class UnionFind(n: Int) {
private val parent = IntArray(n) { it }
fun explainPathCompression() {
// 创建一个示例链
// 1 <- 根节点(current)
// ↑
// 2
// ↑
// 3
// ↑
// 4
parent[4] = 3
parent[3] = 2
parent[2] = 1
println("=== 开始查找节点4 ===")
var current = 4
val path = mutableListOf(current)
// 1. 找到根节点
println("\n1. 查找根节点的过程:")
while (parent[current] != current) {
println("当前节点: $current, 父节点: ${parent[current]}")
current = parent[current]
path.add(current)
}
println("找到根节点: $current")
// 2. 打印收集到的路径
println("\n2. 收集到的路径:")
println("path = $path")
println("current(根节点) = $current")
// 3. 路径压缩
println("\n3. 路径压缩过程:")
for (node in path) {
println("\n处理节点: $node")
if (node != current) { // 关键判断
println("→ $node != $current (不是根节点)")
println("→ 将节点 $node 的父节点设为根节点 $current")
parent[node] = current
} else {
println("→ $node == $current (是根节点)")
println("→ 跳过,不需要修改根节点的父节点")
}
}
// 4. 压缩后的结果
println("\n4. 压缩后的父节点数组:")
println("节点: ${parent.indices.joinToString(" ")}")
println("父节点: ${parent.joinToString(" ")}")
}
}
fun main() {
val uf = UnionFind(5)
uf.explainPathCompression()
}
运行结果:
=== 开始查找节点4 ===
1. 查找根节点的过程:
当前节点: 4, 父节点: 3
当前节点: 3, 父节点: 2
当前节点: 2, 父节点: 1
找到根节点: 1
2. 收集到的路径:
path = [4, 3, 2, 1]
current(根节点) = 1
3. 路径压缩过程:
处理节点: 4
→ 4 != 1 (不是根节点)
→ 将节点 4 的父节点设为根节点 1
处理节点: 3
→ 3 != 1 (不是根节点)
→ 将节点 3 的父节点设为根节点 1
处理节点: 2
→ 2 != 1 (不是根节点)
→ 将节点 2 的父节点设为根节点 1
处理节点: 1
→ 1 == 1 (是根节点)
→ 跳过,不需要修改根节点的父节点
4. 压缩后的父节点数组:
节点: 0 1 2 3 4
父节点: 0 1 1 1 1
为什么需要这个判断:
-
路径中包含了根节点:
path = [4, 3, 2, 1] // 包含了根节点1 current = 1 // 根节点 -
如果不判断,会出现的问题:
// 不加判断的话: for (node in path) { parent[node] = current } // 会导致:parent[1] = 1 // 虽然结果正确,但这是一个多余的操作 -
判断的意义:
if (node != current) { // 如果不是根节点 parent[node] = current // 才需要修改父节点 } // 避免对根节点做多余的自我赋值操作 -
压缩前后的变化:
压缩前: 压缩后: 1 1 ↑ ↗↑↖ 2 4 2 3 ↑ 3 ↑ 4
这个判断的主要目的是:
- 避免对根节点进行不必要的自我赋值
- 使代码更清晰,明确只修改非根节点的父节点
- 提高代码效率,避免多余操作
虽然不加这个判断也能工作(因为 parent[current] = current 不会改变根节点的值),但加上这个判断:
- 代码逻辑更清晰
- 避免了无意义的操作
- 体现了对根节点和非根节点的不同处理
8. 使用建议
- 总是使用路径压缩优化
- 尽可能使用按秩合并
- 在处理大规模数据时特别有效
- 适合处理离线数据,不适合频繁修改的动态数据
9. 注意事项
- 并查集不支持分裂操作
- 一旦合并无法撤销
- 需要预先知道元素总数
- 元素通常用整数表示
这就是并查集的核心内容。它虽然简单,但是在解决连通性问题时非常高效,是一个非常实用的数据结构。希望这个解释对您有帮助!
算法过程打印
class UnionFind {
private int[] parent;
private int[] size;
public UnionFind(int n) {
parent = new int[n];
size = new int[n];
System.out.println("初始化并查集,节点个数: " + n);
for (int i = 0; i < n; i++) {
parent[i] = i;
size[i] = 1;
}
printState();
}
public int find(int x) {
System.out.printf("\n查找节点 %d 的根节点:\n", x);
System.out.printf("当前节点: %d, 父节点: %d\n", x, parent[x]);
if (parent[x] != x) {
System.out.printf("节点 %d 不是根节点,继续向上查找...\n", x);
parent[x] = find(parent[x]); // 路径压缩
System.out.printf("路径压缩,更新节点 %d 的父节点为: %d\n", x, parent[x]);
} else {
System.out.printf("找到根节点: %d\n", x);
}
return parent[x];
}
public void union(int x, int y) {
System.out.printf("\n合并节点 %d 和节点 %d:\n", x, y);
int rootX = find(x);
int rootY = find(y);
System.out.printf("节点 %d 的根节点是: %d\n", x, rootX);
System.out.printf("节点 %d 的根节点是: %d\n", y, rootY);
if (rootX != rootY) {
if (size[rootX] >= size[rootY]) {
parent[rootY] = rootX;
size[rootX] += size[rootY];
System.out.printf("将节点 %d 的根节点指向节点 %d\n", rootY, rootX);
} else {
parent[rootX] = rootY;
size[rootY] += size[rootX];
System.out.printf("将节点 %d 的根节点指向节点 %d\n", rootX, rootY);
}
System.out.println("合并完成!");
printState();
} else {
System.out.println("节点已在同一集合中,无需合并");
}
}
private void printState() {
System.out.println("\n当前并查集状态:");
System.out.print("索引: ");
for (int i = 0; i < parent.length; i++) {
System.out.printf("%-3d ", i);
}
System.out.print("\n父节点: ");
for (int i = 0; i < parent.length; i++) {
System.out.printf("%-3d ", parent[i]);
}
System.out.print("\n集合大小:");
for (int i = 0; i < size.length; i++) {
System.out.printf("%-3d ", size[i]);
}
System.out.println("\n");
}
public static void main(String[] args) {
// 创建一个大小为5的并查集
UnionFind uf = new UnionFind(5);
// 合并一些节点
uf.union(0, 1); // 合并节点0和1
uf.union(2, 3); // 合并节点2和3
uf.union(0, 2); // 合并节点0和2
// 查找某个节点的根
System.out.println("\n最终查找节点4的根节点:");
int root = uf.find(4);
System.out.println("节点4的根节点是: " + root);
}
}
运行结果示例:
初始化并查集,节点个数: 5
当前并查集状态:
索引: 0 1 2 3 4
父节点: 0 1 2 3 4
集合大小:1 1 1 1 1
合并节点 0 和节点 1:
查找节点 0 的根节点:
当前节点: 0, 父节点: 0
找到根节点: 0
查找节点 1 的根节点:
当前节点: 1, 父节点: 1
找到根节点: 1
节点 0 的根节点是: 0
节点 1 的根节点是: 1
将节点 1 的根节点指向节点 0
合并完成!
当前并查集状态:
索引: 0 1 2 3 4
父节点: 0 0 2 3 4
集合大小:2 1 1 1 1
合并节点 2 和节点 3:
查找节点 2 的根节点:
当前节点: 2, 父节点: 2
找到根节点: 2
查找节点 3 的根节点:
当前节点: 3, 父节点: 3
找到根节点: 3
节点 2 的根节点是: 2
节点 3 的根节点是: 3
将节点 3 的根节点指向节点 2
合并完成!
当前并查集状态:
索引: 0 1 2 3 4
父节点: 0 0 2 2 4
集合大小:2 1 2 1 1
合并节点 0 和节点 2:
查找节点 0 的根节点:
当前节点: 0, 父节点: 0
找到根节点: 0
查找节点 2 的根节点:
当前节点: 2, 父节点: 2
找到根节点: 2
节点 0 的根节点是: 0
节点 2 的根节点是: 2
将节点 2 的根节点指向节点 0
合并完成!
当前并查集状态:
索引: 0 1 2 3 4
父节点: 0 0 0 2 4
集合大小:4 1 2 1 1
最终查找节点4的根节点:
查找节点 4 的根节点:
当前节点: 4, 父节点: 4
找到根节点: 4
节点4的根节点是: 4
让我解释一下关键步骤:
- 初始状态:
每个节点的父节点是自己
每个集合的大小都是1
- 合并节点0和1:
找到根节点:0->0, 1->1
将1的根节点指向0
集合{0,1}的大小变为2
- 合并节点2和3:
找到根节点:2->2, 3->3
将3的根节点指向2
集合{2,3}的大小变为2
- 合并节点0和2:
找到根节点:0->0, 2->2
将2的根节点指向0
集合{0,1,2,3}的大小变为4
- 最终状态:
形成了两个集合:
- {0,1,2,3} 大小为4
- {4} 大小为1
通过路径压缩,查找操作会让树的高度变得更小,提高后续操作的效率。
这个输出清楚地展示了:
- 初始化过程
- 查找操作的路径压缩
- 合并操作的过程
- 每一步后并查集的状态
通过这些打印,我们可以更好地理解并查集的工作原理!
路径压缩过程
让我用一个更具体的例子来说明路径压缩的过程:
class UnionFind {
// ... 其他代码保持不变 ...
public static void main(String[] args) {
// 创建大小为6的并查集
UnionFind uf = new UnionFind(6);
// 先构造一个较深的树
// 将1->2->3->4->5 连成一条链
uf.parent[1] = 2;
uf.parent[2] = 3;
uf.parent[3] = 4;
uf.parent[4] = 5;
System.out.println("压缩前的树结构:");
uf.printState();
// 查找节点1,这会触发路径压缩
System.out.println("查找节点1,开始路径压缩:");
uf.find(1);
System.out.println("压缩后的树结构:");
uf.printState();
}
}
运行结果:
压缩前的树结构:
索引: 0 1 2 3 4 5
父节点: 0 2 3 4 5 5
集合大小:1 1 1 1 1 1
查找节点 1 的根节点:
当前节点: 1, 父节点: 2
节点 1 不是根节点,继续向上查找...
查找节点 2 的根节点:
当前节点: 2, 父节点: 3
节点 2 不是根节点,继续向上查找...
查找节点 3 的根节点:
当前节点: 3, 父节点: 4
节点 3 不是根节点,继续向上查找...
查找节点 4 的根节点:
当前节点: 4, 父节点: 5
节点 4 不是根节点,继续向上查找...
查找节点 5 的根节点:
当前节点: 5, 父节点: 5
找到根节点: 5
路径压缩,更新节点 4 的父节点为: 5
路径压缩,更新节点 3 的父节点为: 5
路径压缩,更新节点 2 的父节点为: 5
路径压缩,更新节点 1 的父节点为: 5
压缩后的树结构:
索引: 0 1 2 3 4 5
父节点: 0 5 5 5 5 5
集合大小:1 1 1 1 1 1
图形化解释:
压缩前的树结构: 压缩后的树结构:
5 5
↑ ↑↑↑↑↑
4 1 2 3 4
↑
3
↑
2
↑
1
路径压缩过程:
1. find(1) 开始向上查找
2. 1->2->3->4->5 (找到根节点5)
3. 回溯时进行压缩:
- 将4的父节点改为5
- 将3的父节点改为5
- 将2的父节点改为5
- 将1的父节点改为5
4. 最终所有节点都直接指向根节点5
路径压缩的关键代码:
public int find(int x) {
if (parent[x] != x) {
// 递归找到根节点,同时更新沿途每个节点的父节点为根节点
parent[x] = find(parent[x]);
}
return parent[x];
}
路径压缩的好处:
-
减少树的高度,使后续查找更快
-
平衡树结构,提高整体性能
-
使查找的时间复杂度接近 O(1)
集合大小:1 1 1 1 1 1 这个集合的作用
size 数组是用来记录每个集合的大小,用于优化合并操作。它的主要作用是实现"按大小合并"策略,即总是将小的集合合并到大的集合上,这样可以避免树变得太高。
让我用一个例子来说明:
UnionFind uf = new UnionFind(6);
System.out.println("初始状态:");
uf.printState();
// 合并节点0和1
System.out.println("\n合并节点0和1:");
uf.union(0, 1);
// 合并节点2和3
System.out.println("\n合并节点2和3:");
uf.union(2, 3);
// 合并集合{0,1}和集合{2,3}
System.out.println("\n合并集合{0,1}和集合{2,3}:");
uf.union(0, 2);
运行结果:
初始状态:
索引: 0 1 2 3 4 5
父节点: 0 1 2 3 4 5
集合大小:1 1 1 1 1 1
合并节点0和1:
节点0的根是0,大小为1
节点1的根是1,大小为1
将节点1合并到节点0下
索引: 0 1 2 3 4 5
父节点: 0 0 2 3 4 5
集合大小:2 1 1 1 1 1
合并节点2和3:
节点2的根是2,大小为1
节点3的根是3,大小为1
将节点3合并到节点2下
索引: 0 1 2 3 4 5
父节点: 0 0 2 2 4 5
集合大小:2 1 2 1 1 1
合并集合{0,1}和集合{2,3}:
节点0的根是0,大小为2
节点2的根是2,大小为2
将节点2合并到节点0下(因为0的集合大小等于2的集合大小)
索引: 0 1 2 3 4 5
父节点: 0 0 0 2 4 5
集合大小:4 1 2 1 1 1
size数组的作用体现在:
- 合并策略:
public void union(int x, int y) {
int rootX = find(x);
int rootY = find(y);
if (rootX != rootY) {
// 总是将小树合并到大树上
if (size[rootX] >= size[rootY]) {
parent[rootY] = rootX;
size[rootX] += size[rootY]; // 更新集合大小
} else {
parent[rootX] = rootY;
size[rootY] += size[rootX]; // 更新集合大小
}
}
}
- 优化效果:
不使用size优化: 使用size优化:
1 1
↑ ↑↑↑
2 2 3 4
↑
3
↑
4
- 实际应用:
// 例如:合并社交网络中的朋友圈
// 总是将小的朋友圈合并到大的朋友圈中
// 这样可以减少后续查找的路径长度
if (size[rootX] >= size[rootY]) {
// 将小朋友圈(Y)合并到大朋友圈(X)
parent[rootY] = rootX;
size[rootX] += size[rootY];
}
size数组的好处:
- 保持树的平衡
- 减少查找的平均路径长度
- 提高整体性能
这样理解 size 数组的作用清楚了吗?它是一个优化策略,通过记录集合大小来保持树的平衡性。
if (rootX != rootY) 他俩如果父节点不同 为啥要合并?
恰恰相反,if (rootX != rootY) 是在检查两个节点是否属于不同的集合。让我解释一下:
// 例如我们要把两个朋友圈合并:
// 初始状态:两个独立的朋友圈
朋友圈1: 朋友圈2:
0 2
↑ ↑
1 3
// 当执行 union(1, 3) 时:
rootX = find(1) // 返回0(1的根节点)
rootY = find(3) // 返回2(3的根节点)
if (rootX != rootY) { // 0 != 2,说明是两个不同的朋友圈
// 合并朋友圈
parent[rootY] = rootX;
}
// 合并后:
0
↑ ↑
1 2
↑
3
解释:
- 如果 rootX == rootY,说明两个节点已经在同一个集合中,不需要合并
- 如果 rootX != rootY,说明两个节点在不同集合中,需要合并成一个集合
生活例子:
想象两个班级要合并:
- 如果两个学生已经在同一个班级,就不需要合并(rootX == rootY)
- 如果两个学生在不同班级,才需要合并班级(rootX != rootY)
所以这个判断的逻辑是:
- 不同集合(不同根)-> 需要合并
- 相同集合(相同根)-> 不需要合并
二. 海岛问题
我来讲解如何用并查集解决海岛问题(岛屿数量问题)。这是一个经典的面试题。
🌊 问题描述
给定一个 m x n 的二维网格:
1表示陆地0表示水域- 所有水平和垂直方向相邻的陆地都属于同一个岛屿
- 求岛屿的数量
💡 解决思路
- 将二维网格转换为并查集问题
- 遍历网格,将相邻的陆地合并
- 最后统计独立的陆地集合数量
📝 代码实现
class IslandCounter {
private int[] parent;
private int[] rank;
private int count; // 记录岛屿数量
private int rows;
private int cols;
public int numIslands(char[][] grid) {
if (grid == null || grid.length == 0) return 0;
rows = grid.length;
cols = grid[0].length;
// 初始化并查集
parent = new int[rows * cols];
rank = new int[rows * cols];
count = 0;
// 初始化每个陆地格子
for (int i = 0; i < rows; i++) {
for (int j = 0; j < cols; j++) {
if (grid[i][j] == '1') {
parent[i * cols + j] = i * cols + j;
count++; // 每发现一个陆地,计数加1
}
}
}
// 遍历网格,合并相邻的陆地
for (int i = 0; i < rows; i++) {
for (int j = 0; j < cols; j++) {
if (grid[i][j] == '1') {
// 检查右边
if (j + 1 < cols && grid[i][j + 1] == '1') {
union(i * cols + j, i * cols + (j + 1));
}
// 检查下边
if (i + 1 < rows && grid[i + 1][j] == '1') {
union(i * cols + j, (i + 1) * cols + j);
}
}
}
}
return count;
}
// 查找
private int find(int x) {
if (parent[x] != x) {
parent[x] = find(parent[x]); // 路径压缩
}
return parent[x];
}
// 合并
private void union(int x, int y) {
int rootX = find(x);
int rootY = find(y);
if (rootX != rootY) {
if (rank[rootX] > rank[rootY]) {
parent[rootY] = rootX;
} else if (rank[rootX] < rank[rootY]) {
parent[rootX] = rootY;
} else {
parent[rootY] = rootX;
rank[rootX]++;
}
count--; // 每合并一次,岛屿数量减1
}
}
}
算法运行过程详细打印
class IslandCounter {
private int[] parent;
private int[] rank;
private int count;
private int rows;
private int cols;
public int numIslands(char[][] grid) {
if (grid == null || grid.length == 0) return 0;
//rows = grid.length; // = 3 (有3行)
//cols = grid[0].length; // = 3 (每行有3列)
rows = grid.length;
cols = grid[0].length;
System.out.println("网格大小: " + rows + "x" + cols);
printGrid(grid);
// 初始化
parent = new int[rows * cols];
rank = new int[rows * cols];
count = 0;
// 第一步:初始化陆地格子
System.out.println("\n步骤1: 初始化陆地格子");
for (int i = 0; i < rows; i++) {
for (int j = 0; j < cols; j++) {
if (grid[i][j] == '1') {
int index = i * cols + j;
parent[index] = index;
count++;
System.out.printf("发现陆地 (%d,%d), 索引=%d, 当前岛屿数=%d\n",
i, j, index, count);
}
}
}
// 第二步:合并相邻陆地
System.out.println("\n步骤2: 开始合并相邻的陆地");
for (int i = 0; i < rows; i++) {
for (int j = 0; j < cols; j++) {
if (grid[i][j] == '1') {
// 检查右边
if (j + 1 < cols && grid[i][j + 1] == '1') {
System.out.printf("\n检查位置(%d,%d)的右边:\n", i, j);
union(i * cols + j, i * cols + (j + 1));
}
// 检查下边
if (i + 1 < rows && grid[i + 1][j] == '1') {
System.out.printf("\n检查位置(%d,%d)的下边:\n", i, j);
union(i * cols + j, (i + 1) * cols + j);
}
}
}
}
System.out.println("\n最终岛屿数量: " + count);
return count;
}
private int find(int x) {
System.out.printf("查找节点%d的根节点: ", x);
if (parent[x] != x) {
System.out.printf("%d->%d ", x, parent[x]);
parent[x] = find(parent[x]);
System.out.printf("压缩后父节点=%d", parent[x]);
}
System.out.printf(" 根节点=%d\n", parent[x]);
return parent[x];
}
private void union(int x, int y) {
System.out.printf("尝试合并节点%d和节点%d:\n", x, y);
int rootX = find(x);
int rootY = find(y);
if (rootX != rootY) {
if (rank[rootX] > rank[rootY]) {
parent[rootY] = rootX;
System.out.printf("将节点%d的根节点指向%d\n", rootY, rootX);
} else if (rank[rootX] < rank[rootY]) {
parent[rootX] = rootY;
System.out.printf("将节点%d的根节点指向%d\n", rootX, rootY);
} else {
parent[rootY] = rootX;
rank[rootX]++;
System.out.printf("将节点%d的根节点指向%d,并增加rank\n", rootY, rootX);
}
count--;
System.out.println("合并完成,当前岛屿数量=" + count);
} else {
System.out.println("节点已在同一岛屿中,无需合并");
}
}
private void printGrid(char[][] grid) {
System.out.println("网格内容:");
for (int i = 0; i < rows; i++) {
for (int j = 0; j < cols; j++) {
System.out.print(grid[i][j] + " ");
}
System.out.println();
}
}
public static void main(String[] args) {
char[][] grid = {
{'1', '1', '0', '0'},
{'1', '1', '0', '0'},
{'0', '0', '1', '0'},
{'0', '0', '0', '1'}
};
IslandCounter counter = new IslandCounter();
int result = counter.numIslands(grid);
}
}
运行结果示例:
网格大小: 4x4
网格内容:
1 1 0 0
1 1 0 0
0 0 1 0
0 0 0 1
步骤1: 初始化陆地格子
发现陆地 (0,0), 索引=0, 当前岛屿数=1
发现陆地 (0,1), 索引=1, 当前岛屿数=2
发现陆地 (1,0), 索引=4, 当前岛屿数=3
发现陆地 (1,1), 索引=5, 当前岛屿数=4
发现陆地 (2,2), 索引=10, 当前岛屿数=5
发现陆地 (3,3), 索引=15, 当前岛屿数=6
步骤2: 开始合并相邻的陆地
检查位置(0,0)的右边:
尝试合并节点0和节点1:
查找节点0的根节点: 根节点=0
查找节点1的根节点: 根节点=1
将节点1的根节点指向0,并增加rank
合并完成,当前岛屿数=5
检查位置(0,0)的下边:
尝试合并节点0和节点4:
查找节点0的根节点: 根节点=0
查找节点4的根节点: 根节点=4
将节点4的根节点指向0,并增加rank
合并完成,当前岛屿数=4
检查位置(0,1)的下边:
尝试合并节点1和节点5:
查找节点1的根节点: 根节点=0
查找节点5的根节点: 根节点=5
将节点5的根节点指向0
合并完成,当前岛屿数=3
检查位置(1,0)的右边:
尝试合并节点4和节点5:
查找节点4的根节点: 根节点=0
查找节点5的根节点: 根节点=0
节点已在同一岛屿中,无需合并
检查位置(2,2)的下边:
尝试合并节点10和节点14:
查找节点10的根节点: 根节点=10
查找节点14的根节点: 根节点=14
节点14不是陆地,跳过合并
最终岛屿数量: 3
让我解释关键步骤:
- 初始化阶段:
- 找到6个陆地格子,初始岛屿数=6
- 每个陆地格子初始时都是独立的岛屿
- 合并阶段:
第一次合并:(0,0)和(0,1)
- 将节点1合并到节点0
- 岛屿数从6减少到5
第二次合并:(0,0)和(1,0)
- 将节点4合并到节点0
- 岛屿数从5减少到4
第三次合并:(0,1)和(1,1)
- 将节点5合并到节点0
- 岛屿数从4减少到3
- 最终结果:
三个独立的岛屿:
- 岛屿1:(0,0),(0,1),(1,0),(1,1) 连在一起
- 岛屿2:(2,2) 独立
- 岛屿3:(3,3) 独立
图形表示:
1 1 0 0 # 第一个岛屿(大小为4)
1 1 0 0
0 0 1 0 # 第二个岛屿(大小为1)
0 0 0 1 # 第三个岛屿(大小为1)
合并过程的并查集变化:
初始状态:
parent: [0,1,2,3,4,5,6,7,8,9,10,11,12,13,14,15]
count: 6
最终状态:
parent: [0,0,2,3,0,0,6,7,8,9,10,11,12,13,14,15]
count: 3
这样就完整展示了整个岛屿计数的过程!每次合并相邻的陆地时,岛屿数量就会减1,最终得到独立岛屿的数量。
这样的输出可以帮助我们理解:
- 如何识别每个陆地格子
- 如何合并相邻的陆地
- 如何通过合并操作计算岛屿数量
每次合并相邻的陆地时,岛屿数量减1,最终得到独立岛屿的数量。
int index = i * cols + j 的详细解释
这个公式是将二维坐标(i,j)转换为一维数组的索引。让我用具体例子解释:
// 假设有一个3x4的网格:
[
[a b c d] // i=0
[e f g h] // i=1
[i j k l] // i=2
]
cols = 4 (列数)
rows = 3 (行数)
// 转换成一维数组:
[a b c d e f g h i j k l]
[0 1 2 3 4 5 6 7 8 9 10 11] // 索引
让我们计算几个例子:
// 1. 计算(0,0)位置的索引:
i = 0, j = 0
index = 0 * 4 + 0 = 0 // 对应'a'的位置
// 2. 计算(0,3)位置的索引:
i = 0, j = 3
index = 0 * 4 + 3 = 3 // 对应'd'的位置
// 3. 计算(1,0)位置的索引:
i = 1, j = 0
index = 1 * 4 + 0 = 4 // 对应'e'的位置
// 4. 计算(2,2)位置的索引:
i = 2, j = 2
index = 2 * 4 + 2 = 10 // 对应'k'的位置
图形化解释:
二维坐标 → 一维索引
(0,0) → 0
(0,1) → 1
(0,2) → 2
(0,3) → 3
(1,0) → 4
(1,1) → 5
(1,2) → 6
(1,3) → 7
(2,0) → 8
(2,1) → 9
(2,2) → 10
(2,3) → 11
公式解释:
index = i * cols + j
// i * cols:计算当前行之前有多少个元素
// + j:加上当前列的偏移量
例如计算(2,2):
1. i * cols = 2 * 4 = 8 // 前两行共8个元素
2. + j = 8 + 2 = 10 // 第三行偏移2个位置
这样就可以用一维数组来存储和操作二维网格的数据了!
行列定义
在二维数组中:
grid[i][j]
i 代表行(row)-> y轴
j 代表列(column)-> x轴
让我用图解说明:
j (列/x轴)
0 1 2 3
+---+---+---+---+
0 | a | b | c | d |
1 | e | f | g | h | i (行/y轴)
2 | i | j | k | l |
3 | m | n | o | p |
+---+---+---+---+
例如:
grid[1][2] // 表示第2行第3列的元素 'g'
// i=1 表示第2行(从上往下数)
// j=2 表示第3列(从左往右数)
// 常见的坐标表示:
(x,y) = (j,i) // 注意:x对应j,y对应i
所以当我们说:
// 检查右边相邻:
if (j + 1 < cols) // j是x坐标,向右移动
// 检查下边相邻:
if (i + 1 < rows) // i是y坐标,向下移动
记忆方法:
- i 对应 y(上下/行)
- j 对应 x(左右/列)
- grid[y][x] 或 grid[i][j]
(重点)形象化过程
注意(1,1)位置是0(水),我们只检查值为1(陆地)的位置。让我详细展示完整的检查顺序:
网格:
1 1 0
1 0 0 // (1,1)是0,所以跳过检查
0 0 1
完整的检查顺序:
1. (0,0)位置 = 1
- 检查右边(0,1) ✓
- 检查下边(1,0) ✓
2. (0,1)位置 = 1
- 检查右边(0,2) ✗ (是0)
- 检查下边(1,1) ✗ (是0)
3. (0,2)位置 = 0
- 跳过,因为是水
4. (1,0)位置 = 1
- 检查右边(1,1) ✗ (是0)
- 检查下边(2,0) ✗ (是0)
5. (1,1)位置 = 0
- 跳过,因为是水
6. (1,2)位置 = 0
- 跳过,因为是水
7. (2,0)位置 = 0
- 跳过,因为是水
8. (2,1)位置 = 0
- 跳过,因为是水
9. (2,2)位置 = 1
- 检查右边 ✗ (超出边界)
- 检查下边 ✗ (超出边界)
代码中的判断条件:
// 只有当前位置是陆地(1)时才进行检查
if (grid[i][j] == '1') {
// 检查右边
if (j + 1 < cols && grid[i][j + 1] == '1') {
union(i * cols + j, i * cols + (j + 1));
}
// 检查下边
if (i + 1 < rows && grid[i + 1][j] == '1') {
union(i * cols + j, (i + 1) * cols + j);
}
}
所以(1,1)位置因为是0(水),直接被跳过了,不会检查它的右边和下边。我们只关心陆地之间的连接关系,水的位置都会被忽略。
这样解释清楚了吗?
第一步和第二步骤的意义
第一步的目的是初始化并查集的数据结构。让我解释为什么这很重要:
- 没有第一步的情况:
// 如果直接从(0,0)开始找:
1 1 0
1 0 0
0 0 1
问题在于:
- 我们不知道总共有多少个陆地
- parent数组没有初始化
- count没有初始值
- 有第一步的情况:
// 第一步:初始化
1(0) 1(1) 0 // 找到所有陆地并编号
1(3) 0 0 // parent[i] = i
0 0 1(8) // count = 4(找到4块陆地)
// 第二步:合并
然后才开始从(0,0)检查并合并相邻陆地
举个生活的例子:
想象你在数一群人里有多少个家庭:
方法1(没有第一步):
- 直接从第一个人开始问:"你和谁是一家的?"
- 问题:你不知道总共有多少人需要统计
方法2(有第一步):
1. 先给每个人发一个号码牌(初始化)
2. 记录总人数(count)
3. 然后再问每个人是否有家人,并把他们连接起来
或者再举个例子:
想象你在玩一个拼图游戏:
方法1(没有第一步):
- 直接开始拼
- 问题:你不知道总共有多少块拼图
方法2(有第一步):
1. 先把所有拼图翻面,数一数有多少块(count)
2. 给每块拼图编号(初始化parent)
3. 然后再开始拼(合并相邻的)
所以第一步的重要性在于:
- 初始化parent数组
parent[i] = i; // 每个节点初始时指向自己
- 计算初始岛屿数量
if (grid[i][j] == '1') {
count++; // 每发现一块陆地,count加1
}
- 为后续的合并操作做准备
// 如果没有第一步的初始化:
union(x, y) {
// parent数组未初始化,无法正确工作
// count没有初始值,无法正确计数
}
这就像是:
- 第一步:准备工作(数有多少块陆地,给每块陆地编号)
- 第二步:实际操作(把相邻的陆地连接起来)
合并过程打印
初始网格:
1 1 0
1 0 0
0 0 1
第一步完成后的状态:
parent = [0,1,2,3,4,5,6,7,8] // 每个节点指向自己
rank = [0,0,0,0,0,0,0,0,0] // 初始rank都是0
count = 4 // 发现4块陆地
现在开始合并过程:
- 合并(0,0)和(0,1):
尝试合并节点0和节点1:
- find(0)返回0 (0的根节点是0)
- find(1)返回1 (1的根节点是1)
- rootX(0) != rootY(1),需要合并
- rank[0] = rank[1] = 0,相等
→ parent[1] = 0
→ rank[0]++
→ count减1变成3
现在的状态:
parent = [0,0,2,3,4,5,6,7,8]
rank = [1,0,0,0,0,0,0,0,0]
- 合并(0,0)和(1,0):
尝试合并节点0和节点3:
- find(0)返回0 (0的根节点是0)
- find(3)返回3 (3的根节点是3)
- rootX(0) != rootY(3),需要合并
- rank[0] = 1 > rank[3] = 0
→ parent[3] = 0
→ count减1变成2
现在的状态:
parent = [0,0,2,0,4,5,6,7,8]
rank = [1,0,0,0,0,0,0,0,0]
- 检查(0,1)的相邻位置:
右边是0(水),跳过
下边是0(水),跳过
- 检查(1,0)的相邻位置:
右边是0(水),跳过
下边是0(水),跳过
- 检查(2,2)的相邻位置:
右边超出边界,跳过
下边超出边界,跳过
最终状态:
parent = [0,0,2,0,4,5,6,7,8]
rank = [1,0,0,0,0,0,0,0,0]
count = 2
表示:
- 节点0,1,3属于同一个岛屿(根节点是0)
- 节点8单独形成一个岛屿
合并规则总结:
-
如果两个节点rank不同:
- 将rank小的树连到rank大的树上
- rank值不变
-
如果两个节点rank相同:
- 随便选一个作为根
- 被选为根的节点rank值+1
这样做的好处:
- 保持树的平衡
- 避免树变得太高
- 提高后续查找效率
rank变量的意义
rank数组是用来记录每个节点的"高度"或"层级",目的是让合并后的树尽量平衡。让我用图解说明:
- 不使用rank的情况:
合并前:
1 2 3 4
↓ ↓ ↓ ↓
A B C D
随意合并可能变成:
1
↓
A
↓
2
↓
B
↓
3
↓
C
↓
4
↓
D
// 树变得很高,查找效率低
- 使用rank的情况:
合并前:
rank都是0:
1 2 3 4
↓ ↓ ↓ ↓
A B C D
按rank合并后:
1
↙ ↘
2 3
↓ ↓
A B
// 树更平衡,查找效率高
实际例子:
// 初始状态:所有rank都是0
rank = [0,0,0,0]
// 当合并两个rank相同的树时:
if (rank[rootX] == rank[rootY]) {
parent[rootY] = rootX; // 合并
rank[rootX]++; // 高度+1
}
// 当合并两个rank不同的树时:
if (rank[rootX] > rank[rootY]) {
parent[rootY] = rootX; // 小树接到大树上
}
生活中的比喻:
想象叠盘子:
不用rank:
盘子1
盘子2
盘子3 // 越来越高,容易倒
用rank:
盘子1
盘子2 盘子3 // 更稳定,更平衡
或者想象公司组织架构:
不用rank:
CEO
↓
经理1
↓
经理2
↓
员工 // 层级太多,沟通效率低
用rank:
CEO
↙ ↘
经理1 经理2
↓ ↓
员工 员工 // 扁平化管理,效率高
rank的好处:
- 保持树的平衡
- 减少查找时的路径长度
- 提高整体操作效率
1. 核心变量说明
private int[] parent; // 记录每个格子的"父节点"
private int[] rank; // 记录每个集合的"高度"
private int count; // 记录岛屿数量
private int rows; // 网格的行数
private int cols; // 网格的列数
想象成:
parent[]就像是一个通讯录,记录每块陆地要听谁的rank[]就像是每个岛屿的"级别"count就是岛屿的总数
2. 初始化过程
// 初始化每个陆地格子
for (int i = 0; i < rows; i++) {
for (int j = 0; j < cols; j++) {
if (grid[i][j] == '1') {
parent[i * cols + j] = i * cols + j; // 初始时每块陆地都是独立的
count++; // 发现一块陆地就+1
}
}
}
就像:
- 一开始每块陆地都是独立的小岛
- 每发现一块陆地,就在地图上插一面旗子(count++)
i * cols + j是把二维坐标转成一维的编号(就像把格子从左到右、从上到下编号)
3. 合并过程
// 遍历网格,合并相邻的陆地
for (int i = 0; i < rows; i++) {
for (int j = 0; j < cols; j++) {
if (grid[i][j] == '1') {
// 看右边是不是陆地
if (j + 1 < cols && grid[i][j + 1] == '1') {
union(i * cols + j, i * cols + (j + 1));
}
// 看下边是不是陆地
if (i + 1 < rows && grid[i + 1][j] == '1') {
union(i * cols + j, (i + 1) * cols + j);
}
}
}
}
想象成:
- 拿着一个放大镜,从左到右、从上到下检查每个格子
- 如果是陆地,就看它右边和下边
- 如果旁边也是陆地,就把它们连起来(union)
- 不用看左边和上边,因为之前已经检查过了
4. 查找过程(find方法)
private int find(int x) {
if (parent[x] != x) {
parent[x] = find(parent[x]); // 路径压缩
}
return parent[x];
}
就像:
- 查找一个陆地属于哪个岛屿
- 如果这块陆地不是岛主(parent[x] != x),就继续往上找
- 路径压缩:找到岛主后,让路径上的所有陆地直接认这个岛主为老大
5. 合并操作(union方法)
private void union(int x, int y) {
int rootX = find(x); // 找到x的岛主
int rootY = find(y); // 找到y的岛主
if (rootX != rootY) { // 如果是两个不同的岛
// 根据等级(rank)决定谁归并给谁
if (rank[rootX] > rank[rootY]) {
parent[rootY] = rootX; // Y岛并入X岛
} else if (rank[rootX] < rank[rootY]) {
parent[rootX] = rootY; // X岛并入Y岛
} else {
parent[rootY] = rootX; // 等级相同时,Y岛并入X岛
rank[rootX]++; // X岛的等级+1
}
count--; // 两岛合并,总数-1
}
}
想象成:
- 两块陆地要合并时,先找各自的岛主
- 如果是不同的岛主,就要合并
- 按照"等级"高的吞并等级低的
- 等级一样时,随便选一个作为新岛主,但要把他的等级+1
- 每次合并后,岛屿总数-1
7. i * cols + j 这个二维转一维的处理
让我用一个具体的例子来解释 i * cols + j 这个二维转一维的公式。
🌟 举例说明
假设我们有一个 3×4 的网格:
[0,0] [0,1] [0,2] [0,3]
[1,0] [1,1] [1,2] [1,3]
[2,0] [2,1] [2,2] [2,3]
如果我们要把它转换成一维数组,就会变成:
[0] [1] [2] [3] [4] [5] [6] [7] [8] [9] [10] [11]
📝 转换对应关系
让我们看看具体的转换:
- [0,0] → 0 * 4 + 0 = 0
- [0,1] → 0 * 4 + 1 = 1
- [0,2] → 0 * 4 + 2 = 2
- [0,3] → 0 * 4 + 3 = 3
- [1,0] → 1 * 4 + 0 = 4
- [1,1] → 1 * 4 + 1 = 5
- [1,2] → 1 * 4 + 2 = 6
- [1,3] → 1 * 4 + 3 = 7
- [2,0] → 2 * 4 + 0 = 8
- [2,1] → 2 * 4 + 1 = 9
- [2,2] → 2 * 4 + 2 = 10
- [2,3] → 2 * 4 + 3 = 11
🎯 原理解释
-
i * cols:表示跳过前面几行- 第0行:0 * 4 = 0(不跳过任何元素)
- 第1行:1 * 4 = 4(跳过4个元素)
- 第2行:2 * 4 = 8(跳过8个元素)
-
+ j:表示在当前行走几步- j = 0:在行首
- j = 1:往右一步
- j = 2:往右两步
- j = 3:往右三步
🎨 可视化解释
二维坐标 → 计算过程 → 一维索引
[0,0] → 0*4 + 0 → [0]
[0,1] → 0*4 + 1 → [1]
[0,2] → 0*4 + 2 → [2]
[0,3] → 0*4 + 3 → [3]
[1,0] → 1*4 + 0 → [4]
...以此类推
💡 为什么要这样做?
-
存储效率:
- 一维数组比二维数组更容易管理
- 内存分配更连续,访问更快
-
并查集需求:
- 并查集需要用一个数组来存储父节点关系
- 需要一个唯一的索引来标识每个节点
-
简化操作:
- 合并和查找操作都可以直接使用一个索引
- 不需要处理复杂的二维坐标
这就像是把一本书的"第x章第y页"变成"第n页",方便我们直接定位和管理。
🌟 举个例子
假设有这样的地图:
1 1 0
1 0 0
-
初始化时:
- 有3块陆地,count = 3
- 每块陆地都是自己的岛主
-
合并过程:
- 先看(0,0)和(0,1):发现都是陆地,合并,count = 2
- 再看(1,0):发现上面是陆地,合并,count = 1
-
最终结果:
- 这些陆地都属于同一个岛屿
- 返回count = 1
这就是整个解决海岛问题的过程,核心就是:
- 把每块陆地看作独立的岛
- 通过查看右边和下边来发现可以合并的陆地
- 合并时通过等级控制合并方向
- 用计数器记录岛屿数量
🌟 具体例子
假设我们有如下网格:
1 1 0 0 0
1 1 0 0 0
0 0 1 0 0
0 0 0 1 1
让我们一步步看解决过程:
1️⃣ 初始化:
- 每个值为1的格子都被认为是一个独立的岛屿
- 初始时
count = 6(共有6个1)
2️⃣ 合并过程:
第一步:合并(0,0)和(0,1)
1 1 0 0 0 → [1-1] 0 0 0
1 1 0 0 0 1 1 0 0 0
0 0 1 0 0 0 0 1 0 0
0 0 0 1 1 0 0 0 1 1
第二步:合并(1,0)和(1,1),然后与上面的岛屿合并
[1-1] 0 0 0 → [1-1] 0 0 0
[1-1] 0 0 0 [1-1] 0 0 0
0 0 1 0 0 0 0 1 0 0
0 0 0 1 1 0 0 0 1 1
第三步:合并(3,3)和(3,4)
[1-1] 0 0 0 → [1-1] 0 0 0
[1-1] 0 0 0 [1-1] 0 0 0
0 0 1 0 0 0 0 1 0 0
0 0 0 [1-1] 0 0 0 [1-1]
3️⃣ 最终结果:
- 最终得到3个独立的岛屿
- 左上角一个大岛屿(4个格子连在一起)
- 中间一个小岛屿(1个格子)
- 右下角一个小岛屿(2个格子连在一起)
🔑 关键点
-
二维转一维:
- 使用
i * cols + j将二维坐标转换为一维索引 - 便于在并查集中表示和操作
- 使用
-
方向检查:
- 只需要检查右边和下边
- 因为左边和上边在之前已经检查过了
-
计数优化:
- 初始时每个陆地格子计数+1
- 每次合并时计数-1
-
性能优化:
- 使用路径压缩
- 使用按秩合并
🎯 复杂度分析
- 时间复杂度:O(M×N),其中 M 和 N 是网格的维度
- 空间复杂度:O(M×N),用于存储并查集数据结构
这个解法相比DFS/BFS的优势在于:
- 可以动态添加新的陆地
- 可以快速判断两个格子是否属于同一个岛屿
- 适合处理动态变化的地图