🔗 并查集(Union-Find):朋友圈问题的解决方案!

58 阅读10分钟

"你的朋友的朋友就是你的朋友!" 🤝


📖 一、什么是并查集?从朋友圈说起

1.1 生活中的场景

场景:社交网络中的朋友圈

10个人:A, B, C, D, E, F, G, H, I, J

关系:
- AB是朋友
- B和C是朋友
- D和E是朋友
- F、G、H、I、J都是独立的

问题:
1. A和C在同一个朋友圈吗?(是,通过B连接)
2. A和D在同一个朋友圈吗?(否)
3. 总共有几个朋友圈?(7个:{ABC}, {DE}, {F}, {G}, {H}, {I}, {J})

用并查集解决:

1. 初始化:每个人都是独立的圈子
   {A} {B} {C} {D} {E} {F} {G} {H} {I} {J}

2. 合并朋友关系:
   Union(A, B): {AB} {C} {D} {E} {F} {G} {H} {I} {J}
   Union(B, C): {ABC} {D} {E} {F} {G} {H} {I} {J}
   Union(D, E): {ABC} {DE} {F} {G} {H} {I} {J}

3. 查询:
   Find(A) == Find(C)? → 是(同一个圈子)
   Find(A) == Find(D)? → 否(不同圈子)

1.2 专业定义

并查集(Union-Find / Disjoint Set Union, DSU) 是一种树形的数据结构,用于处理一些不相交集合的合并及查询问题。

核心操作:

  • Find:查找元素所在集合的代表(根节点)
  • Union:合并两个集合
  • ⚡ 时间复杂度:近乎 O(1) (使用路径压缩和按秩合并)

典型应用:

  • 判断图的连通性
  • 最小生成树(Kruskal算法)
  • 社交网络朋友圈
  • 图像处理(连通区域)

🎨 二、并查集的结构

2.1 基本结构(数组实现)

核心思想:用数组存储每个元素的父节点

parent[i] = i的父节点

示例:{ABC} {DE} {F}

元素:  A   B   C   D   E   F
索引:  0   1   2   3   4   5
parent: 0   0   1   3   3   5

树形表示:
    A(0)       D(3)     F(5)
   / \          |
  B   C         E

2.2 初始化

class UnionFind {
    private int[] parent;  // 父节点数组
    private int count;     // 集合数量
    
    public UnionFind(int n) {
        parent = new int[n];
        count = n;
        
        // 初始化:每个元素的父节点是自己
        for (int i = 0; i < n; i++) {
            parent[i] = i;
        }
    }
}

初始状态图:

索引:  0   1   2   3   4
parent: 0   1   2   3   4

每个元素都是独立的集合:
{0} {1} {2} {3} {4}

💻 三、核心操作

3.1 Find操作(查找根节点)

基础版本:

// 查找x的根节点
public int find(int x) {
    while (parent[x] != x) {
        x = parent[x];
    }
    return x;
}

查找过程:

查找元素2的根:

    0
   /|\
  1 2 3
  |
  4

步骤:
2 → parent[2]=0 → parent[0]=0 (找到根!)
返回:0

优化版(路径压缩):⭐

// 路径压缩:让查找路径上的所有节点直接指向根
public int find(int x) {
    if (parent[x] != x) {
        parent[x] = find(parent[x]);  // 递归压缩
    }
    return parent[x];
}

// 迭代版本
public int findIterative(int x) {
    int root = x;
    // 找到根
    while (parent[root] != root) {
        root = parent[root];
    }
    
    // 路径压缩:将路径上所有节点直接连到根
    while (x != root) {
        int next = parent[x];
        parent[x] = root;
        x = next;
    }
    
    return root;
}

路径压缩效果:

压缩前:
    0
   /|\
  1 2 3
  |
  4
  |
  5

查找5:5 → 4 → 1 → 0

压缩后:
    0
  / | \ \
 1  2  3  4
          |
          5

再次查找5:5 → 4 → 0 (更快!)

多次压缩后:
    0
  / | \ \ \
 1  2  3  4  5

所有节点直接连到根!查找O(1)!

3.2 Union操作(合并集合)

基础版本:

// 合并x和y所在的集合
public void union(int x, int y) {
    int rootX = find(x);
    int rootY = find(y);
    
    if (rootX != rootY) {
        parent[rootX] = rootY;  // 将x的根连到y的根
        count--;  // 集合数量减1
    }
}

问题:可能导致树很深!

Union(0,1), Union(1,2), Union(2,3), Union(3,4)

结果:
4
|
3
|
2
|
1
|
0

退化成链表!查找O(n)!😰

优化版(按秩合并):⭐

class UnionFind {
    private int[] parent;
    private int[] rank;  // 树的高度(秩)
    private int count;
    
    public UnionFind(int n) {
        parent = new int[n];
        rank = new int[n];
        count = n;
        
        for (int i = 0; i < n; i++) {
            parent[i] = i;
            rank[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 (rank[rootX] < rank[rootY]) {
                parent[rootX] = rootY;
            } else if (rank[rootX] > rank[rootY]) {
                parent[rootY] = rootX;
            } else {
                parent[rootY] = rootX;
                rank[rootX]++;  // 高度相同时,合并后高度+1
            }
            count--;
        }
    }
    
    public boolean connected(int x, int y) {
        return find(x) == find(y);
    }
    
    public int getCount() {
        return count;
    }
}

按秩合并效果:

合并两棵树:

树1 (rank=2):    树2 (rank=1):
    0               3
   /               
  1               

按秩合并后(矮树连到高树):
    0
   / \
  1   3

rank[0] = 2(高度不变)

如果都是rank=1:
    0       2
          
合并后:
    0
    |
    2

rank[0] = 2(高度+1)

🎯 四、完整实现与应用

4.1 完整的并查集类

public class UnionFind {
    private int[] parent;
    private int[] rank;
    private int count;
    
    public UnionFind(int n) {
        parent = new int[n];
        rank = new int[n];
        count = n;
        
        for (int i = 0; i < n; i++) {
            parent[i] = i;
            rank[i] = 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 (rank[rootX] < rank[rootY]) {
                parent[rootX] = rootY;
            } else if (rank[rootX] > rank[rootY]) {
                parent[rootY] = rootX;
            } else {
                parent[rootY] = rootX;
                rank[rootX]++;
            }
            count--;
        }
    }
    
    // 判断是否连通
    public boolean connected(int x, int y) {
        return find(x) == find(y);
    }
    
    // 获取集合数量
    public int getCount() {
        return count;
    }
    
    // 测试
    public static void main(String[] args) {
        UnionFind uf = new UnionFind(10);
        
        System.out.println("初始集合数:" + uf.getCount());
        
        // 建立连接
        uf.union(0, 1);
        uf.union(1, 2);
        uf.union(3, 4);
        
        System.out.println("建立连接后集合数:" + uf.getCount());
        
        // 查询连通性
        System.out.println("0和2连通吗?" + uf.connected(0, 2));  // true
        System.out.println("0和3连通吗?" + uf.connected(0, 3));  // false
        
        // 继续合并
        uf.union(2, 3);
        System.out.println("合并后0和4连通吗?" + uf.connected(0, 4));  // true
        System.out.println("最终集合数:" + uf.getCount());
    }
}

4.2 应用1:朋友圈数量(LeetCode 547)

// 有n个人,给定朋友关系矩阵,求朋友圈数量
public int findCircleNum(int[][] isConnected) {
    int n = isConnected.length;
    UnionFind uf = new UnionFind(n);
    
    // 遍历所有朋友关系
    for (int i = 0; i < n; i++) {
        for (int j = i + 1; j < n; j++) {
            if (isConnected[i][j] == 1) {
                uf.union(i, j);
            }
        }
    }
    
    return uf.getCount();
}

// 测试
int[][] friends = {
    {1, 1, 0, 0, 0},
    {1, 1, 0, 0, 0},
    {0, 0, 1, 1, 0},
    {0, 0, 1, 1, 0},
    {0, 0, 0, 0, 1}
};
System.out.println("朋友圈数量:" + findCircleNum(friends));  // 3

4.3 应用2:岛屿数量(LeetCode 200)

// 给定二维网格,1表示陆地,0表示水,求岛屿数量
public int numIslands(char[][] grid) {
    if (grid == null || grid.length == 0) return 0;
    
    int rows = grid.length;
    int cols = grid[0].length;
    UnionFind uf = new UnionFind(rows * cols);
    
    int waterCount = 0;
    
    for (int i = 0; i < rows; i++) {
        for (int j = 0; j < cols; j++) {
            if (grid[i][j] == '0') {
                waterCount++;
            } else {
                // 与右边和下边的陆地合并
                if (j + 1 < cols && grid[i][j + 1] == '1') {
                    uf.union(i * cols + j, i * cols + j + 1);
                }
                if (i + 1 < rows && grid[i + 1][j] == '1') {
                    uf.union(i * cols + j, (i + 1) * cols + j);
                }
            }
        }
    }
    
    return uf.getCount() - waterCount;
}

// 测试
char[][] grid = {
    {'1','1','0','0','0'},
    {'1','1','0','0','0'},
    {'0','0','1','0','0'},
    {'0','0','0','1','1'}
};
System.out.println("岛屿数量:" + numIslands(grid));  // 3

4.4 应用3:冗余连接(LeetCode 684)

// 找出导致图中出现环的那条边
public int[] findRedundantConnection(int[][] edges) {
    int n = edges.length;
    UnionFind uf = new UnionFind(n + 1);
    
    for (int[] edge : edges) {
        int u = edge[0];
        int v = edge[1];
        
        // 如果已经连通,说明这条边是冗余的
        if (uf.connected(u, v)) {
            return edge;
        }
        
        uf.union(u, v);
    }
    
    return new int[0];
}

// 测试
int[][] edges = {{1,2}, {1,3}, {2,3}};
int[] result = findRedundantConnection(edges);
System.out.println("冗余边:[" + result[0] + "," + result[1] + "]");  // [2,3]

4.5 应用4:最小生成树(Kruskal算法)

class Edge implements Comparable<Edge> {
    int u, v, weight;
    
    public Edge(int u, int v, int weight) {
        this.u = u;
        this.v = v;
        this.weight = weight;
    }
    
    @Override
    public int compareTo(Edge other) {
        return this.weight - other.weight;
    }
}

// Kruskal算法求最小生成树
public int minCostConnectPoints(int[][] edges, int n) {
    List<Edge> edgeList = new ArrayList<>();
    
    // 构建边列表
    for (int[] edge : edges) {
        edgeList.add(new Edge(edge[0], edge[1], edge[2]));
    }
    
    // 按权重排序
    Collections.sort(edgeList);
    
    UnionFind uf = new UnionFind(n);
    int totalCost = 0;
    int edgeCount = 0;
    
    // 贪心选边
    for (Edge edge : edgeList) {
        if (!uf.connected(edge.u, edge.v)) {
            uf.union(edge.u, edge.v);
            totalCost += edge.weight;
            edgeCount++;
            
            // n个节点的树有n-1条边
            if (edgeCount == n - 1) {
                break;
            }
        }
    }
    
    return totalCost;
}

📊 五、时间复杂度分析

5.1 不同实现的复杂度

实现方式FindUnion说明
基础版O(n)O(n)树可能退化成链
路径压缩O(log n)O(log n)改进查找
按秩合并O(log n)O(log n)改进合并
路径压缩+按秩合并O(α(n))O(α(n))近乎O(1) ⭐

α(n) 是什么?

α(n) = 反阿克曼函数
- 增长极其缓慢
- α(10^80) ≈ 4
- 实际应用中可以认为是常数

所以:优化后的并查集近乎O(1)!⚡

5.2 空间复杂度

空间复杂度:O(n)
- parent数组:n个元素
- rank数组:n个元素
总共:2n = O(n)

🎓 六、经典面试题

面试题1:并查集的两大优化是什么?

答案:

  1. 路径压缩:查找时将路径上所有节点直接连到根
  2. 按秩合并:合并时将矮树连到高树

面试题2:为什么需要路径压缩?

答案: 防止树退化成链表,保证查找效率。

没有压缩:
43210  查找O(n)

路径压缩后:
40
30
20  查找O(1)
10
0

面试题3:并查集能做什么?

答案:

  1. 判断图的连通性
  2. 检测环
  3. 最小生成树(Kruskal)
  4. 动态连通性问题

面试题4:并查集和DFS/BFS的区别?

答案:

特性并查集DFS/BFS
时间复杂度O(α(n)) ≈ O(1)O(V+E)
动态性支持动态合并 ⭐静态
空间复杂度O(n)O(n)
适用场景动态连通性静态遍历

面试题5:如何判断图中是否有环?

答案:

public boolean hasCycle(int[][] edges, int n) {
    UnionFind uf = new UnionFind(n);
    
    for (int[] edge : edges) {
        int u = edge[0];
        int v = edge[1];
        
        // 如果两个顶点已连通,添加边会形成环
        if (uf.connected(u, v)) {
            return true;
        }
        
        uf.union(u, v);
    }
    
    return false;
}

🎪 七、趣味小故事

故事:江湖帮派的合并

从前,江湖上有很多独立的帮派。

初始状态:

{少林} {武当} {峨眉} {华山} {昆仑}
每个帮派都是独立的

结盟过程(Union操作):

1. 少林和武当结盟
   → {少林武当} {峨眉} {华山} {昆仑}

2. 峨眉和华山结盟
   → {少林武当} {峨眉华山} {昆仑}

3. 武当和峨眉结盟(少林武当 + 峨眉华山合并)
   → {少林武当峨眉华山} {昆仑}

查询盟友(Find操作):

问:少林和华山是盟友吗?
答:Find(少林) == Find(华山)? → 是!

问:少林和昆仑是盟友吗?
答:Find(少林) == Find(昆仑)? → 否!

路径压缩的妙用:

原来的盟约关系链:
少林 → 武当 → 峨眉 → 华山

路径压缩后:
少林 ⟍
武当 ─→ 盟主
峨眉 ⟋
华山 ⟋

每个帮派直接认识盟主,查询更快!

这就是并查集的智慧——高效管理联盟关系!🎯


📚 八、知识点总结

核心要点 ✨

  1. 定义:处理不相交集合的合并与查询
  2. 核心操作:Find查找、Union合并
  3. 两大优化
    • 路径压缩
    • 按秩合并
  4. 时间复杂度:O(α(n)) ≈ O(1)
  5. 应用:连通性、检测环、最小生成树

记忆口诀 🎵

并查集来真神奇,
集合合并和查询。
路径压缩很关键,
按秩合并也重要。
朋友圈问题它能解,
岛屿数量不用愁。
近乎常数时间度,
面试必考要记牢!

模板代码 📝

class UnionFind {
    private int[] parent, rank;
    private int count;
    
    public UnionFind(int n) {
        parent = new int[n];
        rank = new int[n];
        count = n;
        for (int i = 0; i < n; i++) {
            parent[i] = i;
            rank[i] = 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), 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]++;
            }
            count--;
        }
    }
    
    public boolean connected(int x, int y) {
        return find(x) == find(y);
    }
    
    public int getCount() {
        return count;
    }
}

🌟 九、总结彩蛋

恭喜你!🎉 你已经掌握了并查集这个强大的工具!

记住:

  • 🔗 查找 + 合并
  • ⚡ 路径压缩 + 按秩合并
  • 🎯 O(α(n)) ≈ O(1)
  • 💪 动态连通性问题的利器

最后送你一张图

    优化前:         优化后:
    0               0
    |             / | \
    1           1  2  3
    |
    2
    |
    3
    
    树深:O(n)     树浅:O(1)

已完成10个知识点文档!继续加油! 💪😄


📖 参考资料

  1. 《算法导论》第21章 - 不相交集合的数据结构
  2. 《算法(第4版)》- Union-Find
  3. LeetCode并查集专题
  4. Robert Sedgewick - 并查集详解

作者: AI算法导师
最后更新: 2025年11月
难度等级: ⭐⭐⭐ (中级)
预计学习时间: 2-3小时

💡 温馨提示:并查集是解决连通性问题的最佳选择,模板代码一定要记住!