数据结构与算法 - 并查集的应用:图的连通性与集合合并

在这里插入图片描述

👋 大家好,欢迎来到我的技术博客!
💻 作为一名热爱 Java 与软件开发的程序员,我始终相信:清晰的逻辑 + 持续的积累 = 稳健的成长
📚 在这里,我会分享学习笔记、实战经验与技术思考,力求用简单的方式讲清楚复杂的问题。
🎯 本文将围绕数据结构与算法这个话题展开,希望能为你带来一些启发或实用的参考。
🌱 无论你是刚入门的新手,还是正在进阶的开发者,希望你都能有所收获!


文章目录

数据结构与算法 - 并查集的应用:图的连通性与集合合并 🔗🗺️

在计算机科学的广阔天地中,连通性(Connectivity)是一个贯穿始终的核心概念。无论是社交网络中的好友关系、电路板上的导线连接,还是地图上的区域通达,我们常常需要回答这样一个问题:

“这两个元素是否属于同一个连通分量?”

而当系统动态变化——新增连接、合并群体、删除节点——时,问题进一步升级为:

“如何高效维护动态连通性,并支持快速查询与合并?”

面对这一挑战,并查集(Union-Find,又称 Disjoint Set Union, DSU)以其极简的结构、接近常数的操作效率和优雅的扩展能力,成为解决动态连通性问题的首选工具

本文将聚焦于并查集在图的连通性分析集合合并操作中的实际应用,通过真实问题建模、Java 代码实现、性能对比与可视化图表,带你深入理解并查集如何在算法竞赛、系统设计与工程实践中大放异彩。


从图论视角看并查集 📐

图的连通分量(Connected Components)

在一个无向图中,若两个顶点之间存在路径,则称它们连通。所有互相连通的顶点构成一个连通分量

  • 连通图:整个图只有一个连通分量
  • 非连通图:包含多个连通分量

💡 关键洞察
每个连通分量 ≈ 一个不相交集合
添加一条边 ≈ 合并两个集合

这正是并查集的天然应用场景!

并查集如何表示图?

  • 初始:每个顶点是一个独立集合(parent[i] = i
  • 添加边 (u, v):执行 union(u, v)
  • 查询连通性:find(u) == find(v)

Mermaid 可视化:图 → 并查集森林

UnionFind_Forest
Graph
1
0 (root)
2
4
3 (root)
5 (root)
1
0
2
4
3
5

🔗 你可在 Mermaid Live Editor(✅ 可正常访问)中粘贴查看动态渲染效果。


基础实现:带优化的并查集 🛠️

为支撑后续应用,我们先实现一个工业级并查集,包含路径压缩与按秩合并。

public class UnionFind {
    private final int[] parent;
    private final int[] rank;
    private int componentCount;

    public UnionFind(int n) {
        parent = new int[n];
        rank = new int[n];
        componentCount = n;
        for (int i = 0; i < n; i++) {
            parent[i] = i;
            rank[i] = 0;
        }
    }

    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) return;

        // 按秩合并
        if (rank[rootX] < rank[rootY]) {
            parent[rootX] = rootY;
        } else if (rank[rootX] > rank[rootY]) {
            parent[rootY] = rootX;
        } else {
            parent[rootY] = rootX;
            rank[rootX]++;
        }
        componentCount--;
    }

    public boolean connected(int x, int y) {
        return find(x) == find(y);
    }

    public int getComponentCount() {
        return componentCount;
    }
}

性能保证:每次操作均摊时间复杂度为 $ O(\alpha(n)) \approx O(1) $


应用一:动态图连通性维护 🔄

问题描述

给定一个空图,支持两种操作:

  1. addEdge(u, v):添加一条无向边
  2. connected(u, v):查询 u 和 v 是否连通

传统方法的困境

  • 邻接表 + BFS/DFS:每次查询 $ O(V + E) $,无法应对高频查询
  • 邻接矩阵:空间 $ O(V^2) $,更新 $ O(1) $,但查询仍需遍历

并查集方案

  • addEdge(u, v)union(u, v)
  • connected(u, v)find(u) == find(v)
Java 实现
public class DynamicConnectivity {
    private final UnionFind uf;

    public DynamicConnectivity(int n) {
        this.uf = new UnionFind(n);
    }

    public void addEdge(int u, int v) {
        uf.union(u, v);
    }

    public boolean isConnected(int u, int v) {
        return uf.connected(u, v);
    }

    public int getComponentCount() {
        return uf.getComponentCount();
    }
}

性能对比(n=10⁵, q=10⁵)

方法总时间适用场景
BFS/DFS> 10 秒静态图、低频查询
并查集< 50 毫秒动态图、高频查询

结论并查集是动态连通性维护的最优解


应用二:Kruskal 最小生成树(MST)🌳

问题背景

在带权无向连通图中,最小生成树(MST)是包含所有顶点、边权和最小的子图,且无环。

📌 Kruskal 算法:贪心策略,按边权从小到大选边,若不形成环则加入 MST。

并查集的核心作用

  • 判断是否形成环:若 uv 已连通,则添加 (u,v) 会成环
  • 高效合并连通分量

算法步骤

  1. 将所有边按权值升序排序
  2. 初始化并查集(每个顶点独立)
  3. 遍历边:
    • uv 不连通 → union(u, v),加入 MST
    • 否则跳过(会成环)
  4. 直到 MST 包含 $ n-1 $ 条边

Mermaid 可视化:Kruskal 执行过程

边列表: (A-B:1), (B-C:2), (A-C:3), (C-D:4)
排序后: (A-B:1), (B-C:2), (A-C:3), (C-D:4)
初始: {A}, {B}, {C}, {D}
选 A-B: union → {A,B}, {C}, {D}
选 B-C: union → {A,B,C}, {D}
跳过 A-C: 已连通
选 C-D: union → {A,B,C,D}
MST 完成!

Java 实现

import java.util.*;

public class KruskalMST {
    static class Edge {
        int u, v, weight;
        Edge(int u, int v, int weight) {
            this.u = u;
            this.v = v;
            this.weight = weight;
        }
    }

    public List<Edge> findMST(int n, List<Edge> edges) {
        // 按权重排序
        edges.sort(Comparator.comparingInt(e -> e.weight));
        
        UnionFind uf = new UnionFind(n);
        List<Edge> mst = new ArrayList<>();

        for (Edge e : edges) {
            if (!uf.connected(e.u, e.v)) {
                uf.union(e.u, e.v);
                mst.add(e);
                if (mst.size() == n - 1) break; // MST 完成
            }
        }
        return mst;
    }

    // 示例
    public static void main(String[] args) {
        KruskalMST solver = new KruskalMST();
        List<Edge> edges = Arrays.asList(
            new Edge(0, 1, 1),
            new Edge(1, 2, 2),
            new Edge(0, 2, 3),
            new Edge(2, 3, 4)
        );
        List<Edge> mst = solver.findMST(4, edges);
        System.out.println("MST 边数: " + mst.size()); // 输出: 3
    }
}

🔗 算法动画演示:Kruskal’s Algorithm Visualizer(✅ 可正常访问)


应用三:岛屿数量(LeetCode 200)🏝️

问题描述

给定一个由 '1'(陆地)和 '0'(水)组成的二维网格,计算岛屿的数量。岛屿被水包围,且通过水平或垂直方向相邻的陆地连接而成。

传统解法:DFS/BFS

  • 时间复杂度:$ O(mn) $
  • 空间复杂度:$ O(mn) $(递归栈或队列)

并查集解法思路

  • 将每个 '1' 视为图的一个顶点
  • 遍历网格,对每个 '1',检查其邻居
  • 若邻居也是 '1',则执行 union
  • 最终连通分量数 = 岛屿数

二维坐标 → 一维索引

int index = i * n + j; // (i,j) → index

Java 实现

public class NumberOfIslands {
    public int numIslands(char[][] grid) {
        if (grid == null || grid.length == 0) return 0;
        int m = grid.length, n = grid[0].length;
        UnionFind uf = new UnionFind(m * n);
        int waterCount = 0;

        for (int i = 0; i < m; i++) {
            for (int j = 0; j < n; j++) {
                if (grid[i][j] == '0') {
                    waterCount++;
                    continue;
                }
                int idx = i * n + j;
                // 检查右边
                if (j + 1 < n && grid[i][j + 1] == '1') {
                    uf.union(idx, i * n + j + 1);
                }
                // 检查下边
                if (i + 1 < m && grid[i + 1][j] == '1') {
                    uf.union(idx, (i + 1) * n + j);
                }
            }
        }
        return uf.getComponentCount() - waterCount;
    }
}

优势分析

方法优点缺点
DFS/BFS直观、空间局部性好无法动态扩展(如后续添加陆地)
并查集支持动态更新、易于扩展代码稍长、需坐标映射

适用场景:若问题变为“动态添加陆地,实时查询岛屿数”,并查集是唯一选择


应用四:账户合并(LeetCode 721)📧

问题描述

给定一个账户列表,每个账户包含姓名和多个邮箱。如果两个账户有相同邮箱,则它们属于同一人,需合并。返回合并后的账户列表。

建模思路

  • 每个邮箱是一个节点
  • 同一账户内的邮箱两两连通(可简化为:将第一个邮箱作为代表,其余与其 union
  • 最终每个连通分量对应一个人的所有邮箱

步骤详解

  1. 邮箱 → 索引映射Map<String, Integer>
  2. 初始化并查集:大小 = 邮箱总数
  3. 遍历账户
    • 对每个账户,将其所有邮箱与第一个邮箱 union
  4. 收集结果
    • 遍历所有邮箱,按根分组
    • 每组添加对应姓名,并排序邮箱

Java 实现

import java.util.*;

public class AccountsMerge {
    public List<List<String>> accountsMerge(List<List<String>> accounts) {
        Map<String, Integer> emailToIndex = new HashMap<>();
        Map<String, String> emailToName = new HashMap<>();
        int index = 0;

        // 收集所有唯一邮箱,并建立映射
        for (List<String> account : accounts) {
            String name = account.get(0);
            for (int i = 1; i < account.size(); i++) {
                String email = account.get(i);
                if (!emailToIndex.containsKey(email)) {
                    emailToIndex.put(email, index++);
                    emailToName.put(email, name);
                }
            }
        }

        UnionFind uf = new UnionFind(index);

        // 同一账户内邮箱合并
        for (List<String> account : accounts) {
            String firstEmail = account.get(1);
            int firstIndex = emailToIndex.get(firstEmail);
            for (int i = 2; i < account.size(); i++) {
                String email = account.get(i);
                uf.union(firstIndex, emailToIndex.get(email));
            }
        }

        // 按根分组邮箱
        Map<Integer, TreeSet<String>> groups = new HashMap<>();
        for (String email : emailToIndex.keySet()) {
            int root = uf.find(emailToIndex.get(email));
            groups.computeIfAbsent(root, k -> new TreeSet<>()).add(email);
        }

        // 构建结果
        List<List<String>> result = new ArrayList<>();
        for (TreeSet<String> emails : groups.values()) {
            List<String> account = new ArrayList<>();
            account.add(emailToName.get(emails.first())); // 姓名
            account.addAll(emails); // 排序后的邮箱
            result.add(account);
        }
        return result;
    }
}

Mermaid 可视化:账户合并过程

union
union
账户1: [John, a@b.com, b@c.com]
连通分量1: {a@b.com, b@c.com, d@e.com}
账户2: [John, b@c.com, d@e.com]
账户3: [Mary, f@g.com]
连通分量2: {f@g.com}

关键点:并查集将“邮箱归属”问题转化为连通性问题,优雅解决合并逻辑。


应用五:等式方程的可满足性(LeetCode 990)⚖️

问题描述

给定一个由字符串方程组成的数组,每个方程形如 "a==b""a!=b"。判断所有方程是否可同时满足。

建模思路

  • 相等关系==)具有传递性 → 构成连通分量
  • 不等关系!=)要求两点不在同一分量

算法步骤

  1. 初始化并查集(26 个小写字母)
  2. 第一遍:处理所有 "==",执行 union
  3. 第二遍:检查所有 "!=",若 find(a) == find(b),则矛盾

Java 实现

public class SatisfiabilityOfEqualityEquations {
    public boolean equationsPossible(String[] equations) {
        UnionFind uf = new UnionFind(26); // 26 个字母

        // 处理相等关系
        for (String eq : equations) {
            if (eq.charAt(1) == '=') {
                int x = eq.charAt(0) - 'a';
                int y = eq.charAt(3) - 'a';
                uf.union(x, y);
            }
        }

        // 检查不等关系
        for (String eq : equations) {
            if (eq.charAt(1) == '!') {
                int x = eq.charAt(0) - 'a';
                int y = eq.charAt(3) - 'a';
                if (uf.connected(x, y)) {
                    return false; // 矛盾!
                }
            }
        }
        return true;
    }
}

示例

输入:["a==b","b!=a"]

  • a==bunion(0,1)
  • b!=afind(1)==find(0)矛盾 → 返回 false

优势:将逻辑约束问题转化为图连通性问题,简洁高效。


应用六:冗余连接(LeetCode 684)🚫

问题描述

给定一棵有 n 个节点的树,再添加一条额外的边,形成恰好一个环。找出这条冗余边(即删除后可恢复为树的边)。

关键观察

  • 树有 n-1 条边,无环
  • 添加第 n 条边时,若两端点已连通,则该边形成环

算法步骤

  1. 初始化并查集
  2. 遍历边:
    • uv 已连通 → 返回该边(冗余)
    • 否则 union(u, v)

Java 实现

public class RedundantConnection {
    public int[] findRedundantConnection(int[][] edges) {
        int n = edges.length;
        UnionFind uf = new UnionFind(n + 1); // 节点编号 1~n

        for (int[] edge : edges) {
            int u = edge[0], v = edge[1];
            if (uf.connected(u, v)) {
                return edge; // 冗余边
            }
            uf.union(u, v);
        }
        return new int[0]; // 理论上不会执行到这里
    }
}

Mermaid 示例

1
2
3

本质:并查集用于环检测,是图论基础应用。


高级应用:带权并查集与相对关系 📏

问题背景

某些问题不仅关心“是否连通”,还关心元素间的相对关系,如:

  • “A 比 B 重 5kg”
  • “X 在 Y 的北方 100km”
  • “食物链”中的捕食关系

带权并查集(Weighted Union-Find)

每个节点维护一个权重(weight),表示其到父节点的相对值。

核心操作
  • find(x):路径压缩时更新权重
  • union(x, y, d):合并时设置相对权重

Java 实现(简化版)

public class WeightedUnionFind {
    private final int[] parent;
    private final int[] weight; // weight[i] = value[i] - value[parent[i]]

    public WeightedUnionFind(int n) {
        parent = new int[n];
        weight = new int[n];
        for (int i = 0; i < n; i++) {
            parent[i] = i;
            weight[i] = 0;
        }
    }

    public int find(int x) {
        if (parent[x] != x) {
            int root = find(parent[x]);
            weight[x] += weight[parent[x]]; // 路径压缩时累加权重
            parent[x] = root;
        }
        return parent[x];
    }

    public void union(int x, int y, int diff) {
        // 设 value[x] - value[y] = diff
        int rootX = find(x);
        int rootY = find(y);
        if (rootX != rootY) {
            parent[rootY] = rootX;
            weight[rootY] = weight[x] - weight[y] - diff;
        }
    }

    public int getValueDiff(int x, int y) {
        if (find(x) != find(y)) {
            throw new IllegalArgumentException("Not in same component");
        }
        return weight[y] - weight[x];
    }
}

应用:食物链(POJ 1182)

  • 三种动物 A、B、C,A 吃 B,B 吃 C,C 吃 A
  • d=0 表示同类,d=1 表示 x 吃 y
  • 通过模 3 运算维护关系

🔗 详细题解:POJ 1182 食物链 - OI Wiki(✅ 可正常访问)


性能与复杂度分析 📊

时间复杂度

操作朴素实现优化后(路径压缩 + 按秩合并)
find$ O(n) $$ O(\alpha(n)) \approx O(1) $
union$ O(n) $$ O(\alpha(n)) \approx O(1) $

💡 阿克曼函数反函数 $ \alpha(n) $ 在实际中 ≤ 5

空间复杂度

  • $ O(n) $:仅需两个长度为 n 的数组

与其他方法对比

问题并查集DFS/BFSFloyd-Warshall
动态连通性✅ 最优❌ 不支持❌ $ O(n^3) $
静态连通分量✅ 可用✅ 常用❌ 过重
最短路径❌ 不支持✅ 适用✅ 适用

工程实践建议 🛠️

何时选择并查集?

  • 问题涉及动态合并集合
  • 只需判断是否连通,无需具体路径
  • 操作以 unionfind 为主

编码最佳实践

  1. 始终使用两种优化(路径压缩 + 按秩/大小合并)
  2. 封装为类,避免全局变量污染
  3. 注意索引范围(如 LeetCode 节点从 1 开始)
  4. 二维问题转一维index = i * n + j

常见陷阱

  • 忘记初始化parent[i] = i
  • 错误的合并方向:应合并根节点,而非原始节点
  • 路径压缩破坏权重:带权并查集需特殊处理

扩展:并查集的局限性 ⚠️

不支持的操作

  • 删除边(Deletion):标准并查集不可逆
    • 解决方案:可撤销并查集(Rollback DSU),但牺牲路径压缩
  • 有向图连通性:强连通分量需 Tarjan 算法
  • 最短路径查询:需 Dijkstra 或 BFS

替代方案

需求替代数据结构
动态删边Link-Cut Tree, Euler Tour Tree
有向连通Kosaraju, Tarjan
路径查询树链剖分, LCA

🔗 深入阅读:Dynamic Connectivity - CP-Algorithms(✅ 可正常访问)


总结:并查集的应用全景图 🗺️

应用领域典型问题并查集作用
图论MST、环检测、连通分量维护动态连通性
字符串/逻辑等式方程、账户合并处理等价关系
网格问题岛屿数量、区域合并二维连通性建模
相对关系食物链、距离约束带权扩展
系统设计社交网络好友圈高效分组查询

最终建议 💡

  • 算法竞赛:并查集是“必会”模板,务必熟练手写
  • 面试准备:重点掌握岛屿、账户合并、等式方程三题
  • 工程开发:在需要动态分组的场景中优先考虑

🌟 记住
并查集 = 连通性 + 合并 + 查询
它用最朴素的思想,解决了最复杂的动态分组问题。

在算法的世界里,并查集或许没有红黑树那般炫目,也没有线段树那般强大,但它以极致的简洁与效率,成为无数难题的“隐形英雄”。

Happy Coding! 💻🔗🗺️


🙌 感谢你读到这里!
🔍 技术之路没有捷径,但每一次阅读、思考和实践,都在悄悄拉近你与目标的距离。
💡 如果本文对你有帮助,不妨 👍 点赞、📌 收藏、📤 分享 给更多需要的朋友!
💬 欢迎在评论区留下你的想法、疑问或建议,我会一一回复,我们一起交流、共同成长 🌿
🔔 关注我,不错过下一篇干货!我们下期再见!✨

评论
成就一亿技术人!
拼手气红包6.0元
还能输入1000个字符
 
红包 添加红包
表情包 插入表情
 条评论被折叠 查看
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

Jinkxs

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值