并查集基础(超详细)

并查集基础:从零理解数据结构中的“集合管家”

在算法世界里,有些数据结构像“幕后英雄”,不张扬却极为关键。并查集(Union-Find)就是其中之一。它虽然不像二叉树或哈希表那样频繁出现在面试题的“热搜榜”上,但一旦遇到“连通性问题”,它往往能用极简的代码给出优雅解法。今天,我们就来聊一聊并查集基础,带你从零开始掌握这个小而美的工具。

并查集的核心能力是:快速判断两个元素是否属于同一个集合,以及合并两个集合。它特别适合处理动态连通性问题——比如社交网络中判断两个人是否“认识”,或者地图上判断两个城市是否通过道路连通。

如果你正在准备算法面试,或者想提升代码设计的思维,掌握并查集基础是必经之路。接下来,我们一步步拆解它的原理与实现。


理解并查集的核心思想

想象你有若干个独立的盒子,每个盒子里放着一些人。一开始,每个人都是独立的,属于自己的“小圈子”。当你发现两个人是朋友时,就把他们所在的盒子合并成一个更大的盒子。这个过程,就是“合并集合”。

并查集就是模拟这种“合并与查询”的操作。它支持两个核心操作:

  • union(x, y):把 x 和 y 所在的集合合并
  • find(x):找出 x 所在集合的代表元(根节点)

举个生活化的例子:你有一堆散落的乐高积木,每个积木是一个独立的块。当你把两个积木拼在一起,它们就属于同一个“组合体”。并查集就是记录这些组合关系的“拼接图谱”。


创建数组与初始化

并查集最基础的实现方式是用一个数组 parent 来表示每个元素的父节点。初始时,每个元素的父节点是自己,表示它们各自独立成一个集合。

def init_union_find(n):
    parent = [i for i in range(n)]  # 每个元素的父节点初始化为自己
    return parent

这里的关键是:parent[i] == i 表示 i 是所在集合的根节点。当两个元素的根节点相同时,说明它们属于同一个集合。

注释说明:

  • parent 数组的索引表示元素编号,值表示它的父节点编号。
  • 初始时,每个元素的父节点是自己,表示“独立集合”。
  • 后续通过 union 操作改变父节点关系,最终形成树形结构。

实现 find 操作:找根节点

find(x) 的目标是找到 x 所在集合的根节点。由于并查集是用树形结构表示集合的,我们需要沿着父节点一路向上找,直到找到根(即 parent[x] == x)。

def find(parent, x):
    # 递归方式:一直找父节点,直到根节点
    if parent[x] != x:
        parent[x] = find(parent, parent[x])  # 路径压缩优化
    return parent[x]

注释说明:

  • 如果 parent[x] == x,说明 x 是根节点,直接返回。
  • 否则,递归查找父节点的根,并将 parent[x] 更新为根节点,这叫“路径压缩”——大幅减少后续查找时间。
  • 路径压缩是并查集性能优化的关键技巧之一。

举个例子:
假设 parent = [0, 1, 2, 3, 4],调用 find(3) 会返回 3。
如果之后执行 union(3, 4),把 4 的父节点设为 3,再调用 find(4),结果会是 3。


实现 union 操作:合并两个集合

union(x, y) 的逻辑是:先找到 x 和 y 的根节点,如果不同,就把其中一个根节点指向另一个,从而合并两个集合。

def union(parent, x, y):
    root_x = find(parent, x)  # 找到 x 的根
    root_y = find(parent, y)  # 找到 y 的根

    if root_x == root_y:
        return  # 已经在同一集合,无需合并

    # 将 root_x 的父节点设为 root_y,实现合并
    parent[root_x] = root_y

注释说明:

  • 先用 find 找到两个元素的根节点。
  • 如果根相同,说明已在同一集合,直接返回。
  • 否则,将一个根的父节点设为另一个根,完成合并。
  • 注意:这里我们选择把 root_x 指向 root_y,但也可以反过来,不影响逻辑。

优化策略:路径压缩与按秩合并

虽然上面的实现已经能工作,但性能可能在极端情况下下降。为此,我们引入两个优化:

路径压缩(Path Compression)

find 操作中,将所有经过的节点直接指向根节点,让后续查找更快。

def find(parent, x):
    if parent[x] != x:
        parent[x] = find(parent, parent[x])  # 递归压缩路径
    return parent[x]

效果:原本的树形结构会被“拉直”,所有节点都直接连到根。

按秩合并(Union by Rank)

用一个额外数组 rank 记录每个根节点的“深度”(或高度)。合并时,总是把矮树接到高树上,避免树变得过深。

def init_union_find_with_rank(n):
    parent = [i for i in range(n)]
    rank = [0] * n  # 每个节点的秩初始化为 0
    return parent, rank

def union(parent, rank, x, y):
    root_x = find(parent, x)
    root_y = find(parent, y)

    if root_x == root_y:
        return

    # 按秩合并:秩小的树接到秩大的树上
    if rank[root_x] < rank[root_y]:
        parent[root_x] = root_y
    elif rank[root_x] > rank[root_y]:
        parent[root_y] = root_x
    else:
        parent[root_y] = root_x
        rank[root_x] += 1  # 秩相等时,新根的秩加 1

注释说明:

  • rank 不是实际高度,而是估计值,用于优化合并顺序。
  • 保证树尽量“扁平”,减少 find 操作的时间。

实际应用:岛屿数量问题(LeetCode 200)

让我们用并查集解决一个经典问题:岛屿数量。

题目:给定一个由 '1'(陆地)和 '0'(水)组成的二维网格,计算岛屿的数量。一个岛屿由相邻的陆地连接形成。

我们可以用并查集维护每个陆地单元的连通关系。

def num_islands(grid):
    if not grid or not grid[0]:
        return 0

    rows, cols = len(grid), len(grid[0])
    parent = [i for i in range(rows * cols)]
    rank = [0] * (rows * cols)

    # 方向数组:上下左右
    directions = [(-1, 0), (1, 0), (0, -1), (0, 1)]

    def find(x):
        if parent[x] != x:
            parent[x] = find(parent[x])
        return parent[x]

    def union(x, y):
        root_x = find(x)
        root_y = find(y)

        if root_x == root_y:
            return

        if rank[root_x] < rank[root_y]:
            parent[root_x] = root_y
        elif rank[root_x] > rank[root_y]:
            parent[root_y] = root_x
        else:
            parent[root_y] = root_x
            rank[root_x] += 1

    # 遍历每个格子
    for i in range(rows):
        for j in range(cols):
            if grid[i][j] == '0':
                continue  # 水,跳过

            # 将当前格子映射为一维索引
            idx = i * cols + j

            # 检查四个方向
            for di, dj in directions:
                ni, nj = i + di, j + dj
                if 0 <= ni < rows and 0 <= nj < cols and grid[ni][nj] == '1':
                    nidx = ni * cols + nj
                    union(idx, nidx)

    # 统计根节点的数量(即岛屿数量)
    roots = set()
    for i in range(rows * cols):
        if grid[i // cols][i % cols] == '1':
            roots.add(find(i))

    return len(roots)

注释说明:

  • 将二维网格转换为一维数组索引,方便并查集操作。
  • 遍历每个陆地格子,尝试与相邻的陆地合并。
  • 最后统计所有“根节点”数量,就是岛屿数。
  • 时间复杂度接近 O(MN × α(MN)),α 是阿克曼函数的反函数,几乎恒为常数。

并查集基础的适用场景总结

并查集基础虽然简单,但威力巨大。它最适合以下场景:

  • 动态连通性判断(如社交网络、网络拓扑)
  • 图的连通分量统计(如岛屿数量、冗余连接)
  • 构造最小生成树(Kruskal 算法中使用)
  • 实现等价类划分(如编译器中的符号解析)

它的优势在于:代码简洁、逻辑清晰、性能优秀。在多数情况下,经过优化的并查集,单次操作的时间复杂度接近 O(1),远超普通 DFS/BFS。


小结:掌握并查集基础,提升算法思维

今天我们从并查集的核心思想出发,一步步实现了初始化、查找、合并操作,并引入了路径压缩与按秩合并两大优化技巧。通过岛屿数量这一实际案例,我们验证了并查集在真实问题中的强大表现。

并查集基础不是“高阶算法”,但它能让你在面对连通性问题时,不再手忙脚乱。它教会我们如何用简单的数据结构,解决复杂的逻辑问题。

算法不是堆砌代码,而是理解本质。当你下次遇到“是否连通”“合并集合”这类问题时,不妨先想想:能不能用并查集?

别小看这个小小的数据结构。它可能正是你算法能力跃迁的关键一步。