左哥算法 - 并查集及应用

1,205 阅读40分钟

一.并查集的基础

路径压缩就是递归好到当前节点的根节点

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. 核心优化技术

  1. 路径压缩

    • 在查找过程中,将查找路径上的所有节点都直接连接到根节点
    • 大大减少了后续查找的时间复杂度
  2. 按秩合并

    • 总是将较小的树连接到较大的树上
    • 防止树变得太高,保持平衡

5. 经典应用场景

  1. 朋友圈问题

    • 判断两个人是否属于同一个朋友圈
    • 合并两个朋友圈
  2. 网络连接问题

    • 判断两个节点是否连通
    • 连接两个节点
  3. 最小生成树的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为例)
   第一次查找:
   54321   (原始路径)
   ↓
   路径压缩后:
   51
   41
   31
   21

3. 合并过程 (以合并节点24为例)
   a. 找到根节点
      21
      41
   
   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]]

这个输出详细展示了:

  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

为什么需要这个判断:

  1. 路径中包含了根节点:

    path = [4, 3, 2, 1]  // 包含了根节点1
    current = 1          // 根节点
    
  2. 如果不判断,会出现的问题:

    // 不加判断的话:
    for (node in path) {
        parent[node] = current
    }
    // 会导致:parent[1] = 1
    // 虽然结果正确,但这是一个多余的操作
    
  3. 判断的意义:

    if (node != current) {  // 如果不是根节点
        parent[node] = current  // 才需要修改父节点
    }
    // 避免对根节点做多余的自我赋值操作
    
  4. 压缩前后的变化:

    压缩前:        压缩后:
       1              1
       ↑            ↗↑↖
       2           4 2 3
       ↑
       3
       ↑
       4
    

这个判断的主要目的是:

  1. 避免对根节点进行不必要的自我赋值
  2. 使代码更清晰,明确只修改非根节点的父节点
  3. 提高代码效率,避免多余操作

虽然不加这个判断也能工作(因为 parent[current] = current 不会改变根节点的值),但加上这个判断:

  • 代码逻辑更清晰
  • 避免了无意义的操作
  • 体现了对根节点和非根节点的不同处理

8. 使用建议

  1. 总是使用路径压缩优化
  2. 尽可能使用按秩合并
  3. 在处理大规模数据时特别有效
  4. 适合处理离线数据,不适合频繁修改的动态数据

9. 注意事项

  1. 并查集不支持分裂操作
  2. 一旦合并无法撤销
  3. 需要预先知道元素总数
  4. 元素通常用整数表示

这就是并查集的核心内容。它虽然简单,但是在解决连通性问题时非常高效,是一个非常实用的数据结构。希望这个解释对您有帮助!

算法过程打印

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. 初始状态:
每个节点的父节点是自己
每个集合的大小都是1
  1. 合并节点0和1:
找到根节点:0->0, 1->11的根节点指向0
集合{0,1}的大小变为2
  1. 合并节点2和3:
找到根节点:2->2, 3->33的根节点指向2
集合{2,3}的大小变为2
  1. 合并节点0和2:
找到根节点:0->0, 2->22的根节点指向0
集合{0,1,2,3}的大小变为4
  1. 最终状态:
形成了两个集合:
- {0,1,2,3} 大小为4
- {4} 大小为1

通过路径压缩,查找操作会让树的高度变得更小,提高后续操作的效率。

这个输出清楚地展示了:

  1. 初始化过程
  2. 查找操作的路径压缩
  3. 合并操作的过程
  4. 每一步后并查集的状态

通过这些打印,我们可以更好地理解并查集的工作原理!

路径压缩过程

让我用一个更具体的例子来说明路径压缩的过程:

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];
}

路径压缩的好处:

  1. 减少树的高度,使后续查找更快

  2. 平衡树结构,提高整体性能

  3. 使查找的时间复杂度接近 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数组的作用体现在:

  1. 合并策略:
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];  // 更新集合大小
        }
    }
}
  1. 优化效果:
不使用size优化:        使用size优化:
     1                      1
     ↑                     ↑↑↑
     2                     2 3 434
  1. 实际应用:
// 例如:合并社交网络中的朋友圈
// 总是将小的朋友圈合并到大的朋友圈中
// 这样可以减少后续查找的路径长度
if (size[rootX] >= size[rootY]) {
    // 将小朋友圈(Y)合并到大朋友圈(X)
    parent[rootY] = rootX;
    size[rootX] += size[rootY];
}

size数组的好处:

  1. 保持树的平衡
  2. 减少查找的平均路径长度
  3. 提高整体性能

这样理解 size 数组的作用清楚了吗?它是一个优化策略,通过记录集合大小来保持树的平衡性。

if (rootX != rootY) 他俩如果父节点不同 为啥要合并?

恰恰相反,if (rootX != rootY) 是在检查两个节点是否属于不同的集合。让我解释一下:

// 例如我们要把两个朋友圈合并:

// 初始状态:两个独立的朋友圈
朋友圈1:      朋友圈20             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 23

解释:

  1. 如果 rootX == rootY,说明两个节点已经在同一个集合中,不需要合并
  2. 如果 rootX != rootY,说明两个节点在不同集合中,需要合并成一个集合

生活例子:

想象两个班级要合并:
- 如果两个学生已经在同一个班级,就不需要合并(rootX == rootY)
- 如果两个学生在不同班级,才需要合并班级(rootX != rootY)

所以这个判断的逻辑是:

  • 不同集合(不同根)-> 需要合并
  • 相同集合(相同根)-> 不需要合并

二. 海岛问题

我来讲解如何用并查集解决海岛问题(岛屿数量问题)。这是一个经典的面试题。

🌊 问题描述

给定一个 m x n 的二维网格:

  • 1 表示陆地
  • 0 表示水域
  • 所有水平和垂直方向相邻的陆地都属于同一个岛屿
  • 求岛屿的数量

💡 解决思路

  1. 将二维网格转换为并查集问题
  2. 遍历网格,将相邻的陆地合并
  3. 最后统计独立的陆地集合数量

📝 代码实现

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

让我解释关键步骤:

  1. 初始化阶段:
- 找到6个陆地格子,初始岛屿数=6
- 每个陆地格子初始时都是独立的岛屿
  1. 合并阶段:
第一次合并:(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. 最终结果:
三个独立的岛屿:
- 岛屿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. 如何识别每个陆地格子
  2. 如何合并相邻的陆地
  3. 如何通过合并操作计算岛屿数量

每次合并相邻的陆地时,岛屿数量减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坐标,向下移动

记忆方法:

  1. i 对应 y(上下/行)
  2. j 对应 x(左右/列)
  3. 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(水),直接被跳过了,不会检查它的右边和下边。我们只关心陆地之间的连接关系,水的位置都会被忽略。

这样解释清楚了吗?

第一步和第二步骤的意义

第一步的目的是初始化并查集的数据结构。让我解释为什么这很重要:

  1. 没有第一步的情况:
// 如果直接从(0,0)开始找:
1 1 0
1 0 0
0 0 1

问题在于:
- 我们不知道总共有多少个陆地
- parent数组没有初始化
- count没有初始值
  1. 有第一步的情况:
// 第一步:初始化
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. 然后再开始拼(合并相邻的)

所以第一步的重要性在于:

  1. 初始化parent数组
parent[i] = i;  // 每个节点初始时指向自己
  1. 计算初始岛屿数量
if (grid[i][j] == '1') {
    count++;  // 每发现一块陆地,count加1
}
  1. 为后续的合并操作做准备
// 如果没有第一步的初始化:
union(x, y) {
    // parent数组未初始化,无法正确工作
    // count没有初始值,无法正确计数
}

这就像是:

  1. 第一步:准备工作(数有多少块陆地,给每块陆地编号)
  2. 第二步:实际操作(把相邻的陆地连接起来)

合并过程打印

初始网格:
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块陆地

现在开始合并过程:

  1. 合并(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]
  1. 合并(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]
  1. 检查(0,1)的相邻位置:
右边是0(水),跳过
下边是0(水),跳过
  1. 检查(1,0)的相邻位置:
右边是0(水),跳过
下边是0(水),跳过
  1. 检查(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单独形成一个岛屿

合并规则总结:

  1. 如果两个节点rank不同:

    • 将rank小的树连到rank大的树上
    • rank值不变
  2. 如果两个节点rank相同:

    • 随便选一个作为根
    • 被选为根的节点rank值+1

这样做的好处:

  • 保持树的平衡
  • 避免树变得太高
  • 提高后续查找效率

rank变量的意义

rank数组是用来记录每个节点的"高度"或"层级",目的是让合并后的树尽量平衡。让我用图解说明:

  1. 不使用rank的情况:
合并前:
1     2     3     4
↓     ↓     ↓     ↓
A     B     C     D

随意合并可能变成:
1A2B3C4D

// 树变得很高,查找效率低
  1. 使用rank的情况:
合并前:
rank都是01     2     3     4
↓     ↓     ↓     ↓
A     B     C     Drank合并后:
    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. 保持树的平衡
  2. 减少查找时的路径长度
  3. 提高整体操作效率
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
🎯 原理解释
  1. i * cols:表示跳过前面几行

    • 第0行:0 * 4 = 0(不跳过任何元素)
    • 第1行:1 * 4 = 4(跳过4个元素)
    • 第2行:2 * 4 = 8(跳过8个元素)
  2. + 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]
...以此类推
💡 为什么要这样做?
  1. 存储效率

    • 一维数组比二维数组更容易管理
    • 内存分配更连续,访问更快
  2. 并查集需求

    • 并查集需要用一个数组来存储父节点关系
    • 需要一个唯一的索引来标识每个节点
  3. 简化操作

    • 合并和查找操作都可以直接使用一个索引
    • 不需要处理复杂的二维坐标

这就像是把一本书的"第x章第y页"变成"第n页",方便我们直接定位和管理。

🌟 举个例子

假设有这样的地图:

1 1 0
1 0 0
  1. 初始化时:

    • 有3块陆地,count = 3
    • 每块陆地都是自己的岛主
  2. 合并过程:

    • 先看(0,0)和(0,1):发现都是陆地,合并,count = 2
    • 再看(1,0):发现上面是陆地,合并,count = 1
  3. 最终结果:

    • 这些陆地都属于同一个岛屿
    • 返回count = 1

这就是整个解决海岛问题的过程,核心就是:

  1. 把每块陆地看作独立的岛
  2. 通过查看右边和下边来发现可以合并的陆地
  3. 合并时通过等级控制合并方向
  4. 用计数器记录岛屿数量

🌟 具体例子

假设我们有如下网格:

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个格子连在一起)

🔑 关键点

  1. 二维转一维

    • 使用 i * cols + j 将二维坐标转换为一维索引
    • 便于在并查集中表示和操作
  2. 方向检查

    • 只需要检查右边和下边
    • 因为左边和上边在之前已经检查过了
  3. 计数优化

    • 初始时每个陆地格子计数+1
    • 每次合并时计数-1
  4. 性能优化

    • 使用路径压缩
    • 使用按秩合并

🎯 复杂度分析

  • 时间复杂度:O(M×N),其中 M 和 N 是网格的维度
  • 空间复杂度:O(M×N),用于存储并查集数据结构

这个解法相比DFS/BFS的优势在于:

  1. 可以动态添加新的陆地
  2. 可以快速判断两个格子是否属于同一个岛屿
  3. 适合处理动态变化的地图