CC++ & Algorithm

并查集的经典应用

极难2
语言版本:通用
概述:并查集广泛应用于连通性问题、最小生成树、冗余连接、等式方程等实际问题中,是图论和算法竞赛的必备工具。

并查集不止能查朋友圈——经典应用全解析

并查集是什么?它能帮你解决哪些问题?

想象一下:你刚转学到一所新学校,班上有很多同学,你要快速知道谁和谁是朋友(可能通过“朋友的朋友”也是朋友)。如果每个同学都有一张“朋友卡”,卡上写着他的“朋友圈老大”是谁,那么你只要看两个人的老大是不是同一个人,就能判断他们是不是同一个朋友圈的。这就是并查集(Union-Find Set)的核心思想——用一棵树代表一个集合,通过“找根”和“合并”两个操作,快速判断元素是否属于同一集合

并查集专门用来处理动态连通性问题,比如:

  • 社交网络里,两个人是否间接认识?
  • 地图上,两块陆地之间有没有路连接?
  • 游戏中,两个角色是否在同一个队伍?
  • 修路时,还需要修多少条路才能把所有城市连起来?

它的两个核心操作:

  • 查找(Find):找到某个元素所在集合的“根”(即老大)。
  • 合并(Union):把两个集合合并成一个(让一个集合的老大认另一个做老大)。

加上路径压缩(让每个节点直接指向根)和按秩合并(让矮树挂到高树下),几乎能在常数时间内完成操作,效率非常高。


并查集的基本实现(复习)

先回忆一下并查集的模板代码,后面所有应用都基于它。我们以 Python 为例(C++ 类似),每行都加了注释:

class DSU:
    def __init__(self, n):
        # 初始化:每个节点自己就是一个集合,父节点指向自己
        self.parent = list(range(n))  # parent[i] 表示 i 的父节点
        self.rank = [0] * n           # rank[i] 表示以 i 为根的树的高度(近似)
    
    def find(self, x):
        # 查找 x 的根,同时做路径压缩(让 x 直接指向根)
        if self.parent[x] != x:
            self.parent[x] = self.find(self.parent[x])  # 递归压缩
        return self.parent[x]
    
    def unite(self, x, y):
        # 合并 x 和 y 所在的集合
        root_x = self.find(x)
        root_y = self.find(y)
        if root_x == root_y:
            return  # 已经在同一集合,不用合并
        # 按秩合并:把高度低的树挂到高度高的树下
        if self.rank[root_x] < self.rank[root_y]:
            self.parent[root_x] = root_y
        elif self.rank[root_x] > self.rank[root_y]:
            self.parent[root_y] = root_x
        else:
            self.parent[root_y] = root_x
            self.rank[root_x] += 1  # 两棵树高度相等,合并后高度加1
    
    def same(self, x, y):
        # 判断 x 和 y 是否在同一集合
        return self.find(x) == self.find(y)

常见错误

  1. 忘记初始化 parent 数组:每个节点必须初始化为自己,否则 find 会无限递归。
  2. 路径压缩不彻底:写 find 时如果用循环而不是递归,容易只压缩一层。建议用递归写法,或者用 while 循环加两次遍历。
  3. 合并时忘记判断根是否相同:如果不判断就直接改 parent,会导致循环引用。
  4. 节点编号从 1 开始:很多题目节点从 1 到 n,初始化时要 DSU(n+1),否则会漏掉节点 n。

经典应用一:统计图的连通分量(有几个“朋友圈”)

场景:班级里有 N 个同学,老师告诉你 M 条“谁和谁是朋友”的信息,问这个班级里有多少个互不相通的“朋友圈”?(假设朋友关系是相互的,且可以传递)

思路:把每个同学看作一个节点,每对朋友关系就合并一次。合并完后,数一数有多少个同学的“老大”是自己(即 parent[i] == i),这个数量就是朋友圈的个数。

生活例子:比如班上有 5 个同学:小明、小红、小刚、小丽、小强。已知:小明和小红是朋友,小红和小刚是朋友,小丽和小强是朋友。那么朋友圈有两个:{小明、小红、小刚} 和 {小丽、小强}。

完整代码(Python)

# 假设有一个 DSU 类(上面已定义)
def count_friend_circles(n, friendships):
    """
    n: 人数(编号从 0 到 n-1)
    friendships: 列表,每个元素是 (u, v) 表示 u 和 v 是朋友
    返回朋友圈个数
    """
    dsu = DSU(n)
    for u, v in friendships:
        dsu.unite(u, v)
    # 统计有多少个不同的根
    circles = 0
    for i in range(n):
        if dsu.find(i) == i:  # 如果自己的父节点是自己,说明它是一个集合的根
            circles += 1
    return circles

# 测试
n = 5
friendships = [(0,1), (1,2), (3,4)]
print(count_friend_circles(n, friendships))  # 输出 2

经典应用二:检测冗余连接(破环)

场景:修路队要修一条连接所有村庄的公路,但施工队不小心多修了一段,导致出现了环路(即有些路是多余的)。现在给你所有道路的清单(按修建顺序),找出最后一条导致环路的道路。

思路:从第一条路开始,每次尝试连接两个村庄。如果两个村庄已经在同一个集合里(说明之前已经有路连通了),那么这条新路就会形成环路,它就是答案。否则,把两个村庄合并。

生活例子:比如有 3 个村庄 A、B、C。先修了 A-B,再修 B-C,此时三个村庄都连通了(集合 {A,B,C})。如果再修 A-C,会发现 A 和 C 已经在同一个集合里,所以 A-C 是多余的边,会导致环路(三角形)。

C++ 关键代码(保留原有内容):

vector<int> findRedundantConnection(vector<vector<int>>& edges) {
    int n = edges.size();                 // 题目保证有 n 条边,节点从 1 到 n
    DSU dsu(n + 1);                       // 节点编号从 1 开始,所以要 n+1
    for (auto& e : edges) {
        int u = e[0], v = e[1];
        if (dsu.same(u, v)) return {u, v}; // 如果已经连通,这条边多余
        dsu.unite(u, v);                  // 否则合并
    }
    return {};
}

常见错误:忘记节点编号从 1 开始,导致数组越界。一定要用 DSU(n+1) 而不是 DSU(n)


经典应用三:最小生成树(Kruskal 算法)

场景:学校要在几个校区之间修路,每条路有造价,希望用最少的钱把所有校区连通(即最小生成树)。Kruskal 算法就是:先把所有道路按造价从小到大排序,然后依次尝试,只要加入这条路不会形成环路(即两个端点还没连通),就修这条路。

生活例子:有 4 个校区 A、B、C、D,可能的道路及造价:A-B 100 万,A-C 200 万,B-C 50 万,C-D 150 万。排序后先修 B-C(50),再修 A-B(100),然后 C-D(150),此时四个校区都连通了,总造价 300 万。A-C 虽然便宜(200)但会导致环路,被跳过。

核心代码(保留原有内容,补充注释):

struct Edge { int u, v, w; };            // 边的结构体:起点,终点,权重
bool cmp(Edge a, Edge b) { return a.w < b.w; } // 按权重排序

int kruskal(int n, vector<Edge>& edges) {
    sort(edges.begin(), edges.end(), cmp); // 按权重从小到大排序
    DSU dsu(n);                            // 初始化并查集
    int totalWeight = 0;                   // 总造价
    for (auto& e : edges) {
        if (!dsu.same(e.u, e.v)) {         // 如果两个端点不在同一集合
            dsu.unite(e.u, e.v);           // 合并(修这条路)
            totalWeight += e.w;            // 累加造价
        }
    }
    return totalWeight;                    // 返回最小总造价
}

注意:Kruskal 算法要求图是连通的,如果不连通会返回“最小生成森林”的权值和。通常题目保证图连通。


经典应用四:等式方程的可满足性

场景:给你一些关于字母的等式,比如 a == b, b != c, a == c。问这些等式能不能同时成立?答案是:不能,因为由 a==ba==c 可得 b==c,但题目说 b != c

思路:先处理所有 == 等式,把相等的字母放在同一个集合里。然后检查所有 != 等式,如果发现两个变量已经在同一个集合,就矛盾了。

生活例子:老师给班级分组,说“小明和小红一组,小红和小刚一组,小刚和小明不同组”,这显然矛盾——三人只能在一组或不同组,不能同时满足。

Python 关键代码(保留原有内容,加注释解释):

def equationsPossible(equations):
    dsu = DSU(26)  # 26个小写字母,用 0-25 表示 'a'~'z'
    for eq in equations:
        if eq[1] == '=':  # 处理所有相等关系
            u = ord(eq[0]) - ord('a')  # 将字符转为编号
            v = ord(eq[3]) - ord('a')
            dsu.unite(u, v)
    for eq in equations:
        if eq[1] == '!':  # 检查所有不等关系
            u = ord(eq[0]) - ord('a')
            v = ord(eq[3]) - ord('a')
            if dsu.same(u, v):  # 如果已经相等,冲突
                return False
    return True

常见错误:只处理了 != 而不先处理 ==,或者顺序颠倒。一定要先做相等合并,再来检查不等。


经典应用五:计算岛屿数量(二维网格中的连通分量)

场景:给你一张地图,用 1 表示陆地,0 表示水,相邻的陆地(上下左右)算同一个岛屿。问有多少个岛屿?虽然用 DFS/BFS 更简单,但并查集也能做,而且能体现“二维坐标映射为一维”的技巧。

思路:把每个陆地格子看作一个节点,编号为 row*col + col。遍历整个网格,对于每个陆地格子,检查它右边和下边的格子,如果也是陆地,就合并它们的编号。最后统计有多少个不同的根(只统计陆地格子的根)。

生活例子:就像在一个游泳池里,一些浮板漂浮着,相邻的浮板连在一起算一个岛。你想知道游泳池里有几个浮板岛。

Python 实现

def numIslands(grid):
    if not grid or not grid[0]:
        return 0
    rows = len(grid)
    cols = len(grid[0])
    dsu = DSU(rows * cols)  # 最大节点数
    water = 0               # 用来统计水的数量(不是必须)
    
    for r in range(rows):
        for c in range(cols):
            if grid[r][c] == '1':
                idx = r * cols + c  # 一维编号
                # 检查右边邻居
                if c + 1 < cols and grid[r][c+1] == '1':
                    dsu.unite(idx, r * cols + (c+1))
                # 检查下边邻居
                if r + 1 < rows and grid[r+1][c] == '1':
                    dsu.unite(idx, (r+1) * cols + c)
            else:
                water += 1
    # 统计陆地根的数量
    roots = set()
    for r in range(rows):
        for c in range(cols):
            if grid[r][c] == '1':
                roots.add(dsu.find(r * cols + c))
    return len(roots)

常见错误:忘记将二维坐标转一维时,要注意列数乘的是 cols 而不是 rows。另外,只检查右边和下边即可,不需要检查全部四个方向(因为遍历顺序已经保证不重复)。


应用六:动态连通性(离线反向处理)

场景:给你一个图,一开始所有节点都不连通,然后依次执行“添加边”操作,问你每次操作后有多少个连通分量?或者反过来,给你一个图,依次“删除边”,问每次删除后连通性变化。因为并查集只支持添加边,不支持删除边,怎么办?

技巧离线反向处理。先把所有操作读进来,然后从后往前处理:删除边变成添加边。例如,题目要求每次删边后查询连通分量个数,我们可以先假设所有边都删除了(即只保留从未被删过的边),然后从最后一次操作往前,每次“删除”变成“添加”,用并查集处理,最后把答案倒序输出。

生活例子:一个网络公司有多个服务器,每天会移除一些连接(故障)。运营想知道每次移除后,还有多少个子网络。我们可以先记录所有移除操作,然后从最后一天往前模拟,把移除的连接“恢复”回来,就能算出每一天的状态。


完整可运行示例:统计朋友圈个数

下面是一个完整的 Python 程序,包含 DSU 类的实现和测试用例。你可以直接复制运行。

# 并查集类(带路径压缩和按秩合并)
class DSU:
    def __init__(self, n):
        self.parent = list(range(n))  # 父节点数组,初始指向自己
        self.rank = [0] * n           # 树的高度(近似)
    
    def find(self, x):
        # 查找 x 的根,并做路径压缩
        if self.parent[x] != x:
            self.parent[x] = self.find(self.parent[x])
        return self.parent[x]
    
    def unite(self, x, y):
        # 合并 x 和 y 所在的集合
        root_x = self.find(x)
        root_y = self.find(y)
        if root_x == root_y:
            return
        # 按秩合并:将矮树挂到高树下
        if self.rank[root_x] < self.rank[root_y]:
            self.parent[root_x] = root_y
        elif self.rank[root_x] > self.rank[root_y]:
            self.parent[root_y] = root_x
        else:
            self.parent[root_y] = root_x
            self.rank[root_x] += 1
    
    def same(self, x, y):
        return self.find(x) == self.find(y)

def count_friend_circles(n, friendships):
    """
    n: 人数(编号0~n-1)
    friendships: 列表,每个元素是 (u, v)
    返回朋友圈个数
    """
    dsu = DSU(n)
    for u, v in friendships:
        dsu.unite(u, v)
    circles = 0
    for i in range(n):
        if dsu.find(i) == i:
            circles += 1
    return circles

# 测试
if __name__ == "__main__":
    # 5个人,朋友关系:0-1, 1-2, 3-4
    n = 5
    friendships = [(0,1), (1,2), (3,4)]
    print("朋友圈个数:", count_friend_circles(n, friendships))  # 输出 2

    # 另一个测试:所有人都连通
    friendships2 = [(0,1), (1,2), (2,3)]
    print("朋友圈个数:", count_friend_circles(4, friendships2))  # 输出 1

总结与扩展

应用核心思想生活比喻
连通分量个数合并所有边,数根的数量班级里的朋友圈数
冗余连接(环检测)合并前检查是否已在同一集合修路时发现多余的路
最小生成树(Kruskal)排序边,用并查集避免环路花最少的钱修通所有校区
等式方程可满足性先合并相等关系,再检查不等关系分组冲突检测
岛屿数量二维坐标转一维,合并相邻陆地游泳池里的浮板岛
动态连通性(离线)把删除边变成添加边,反向处理网络故障恢复模拟

相关知识点指引

  • 带权并查集:除了知道是否连通,还能知道两个元素之间的距离或差值(比如“食物链”问题)。
  • 可撤销并查集:支持撤销最近的一次合并操作(用于某些回溯算法)。
  • 启发式合并:合并时按集合大小(或高度)优化,保证树高为 O(log n)。
  • 并查集在其他算法中的应用:如 Tarjan 的 LCA 算法、离线 RMQ 等。

掌握了并查集,你就拥有了一把处理“关系”的瑞士军刀。在算法竞赛、面试甚至日常编程中,它都能帮你快速解决连通性问题。下次遇到“判断是否连通”、“是否成环”、“有多少个团体”这类问题,不妨第一个想到并查集!

例题精讲

1单选题

在无向图中使用并查集检测冗余连接时,当处理一条边,若它的两个端点已经属于同一个集合,则这条边被称为:

A关键边
B冗余边
C桥边
D生成树边
2判断题

在Kruskal算法求最小生成树的过程中,每次加入一条边前,使用并查集判断该边的两个顶点是否在同一集合中。若不在同一集合,则加入该边;若在同一集合,则跳过。这种说法正确吗?

3填空题
下面是一个带路径压缩的并查集查找函数,请补全空缺部分。

int find(int x) {
    if (parent[x] != x) {
        parent[x] = ___;
    }
    return parent[x];
}
4单选题

给定一组形如“a==b”的等式和“a!=b”的不等式,使用并查集判断是否冲突(所有等式与不等式能否同时成立)。通常的处理策略是:

A先处理所有不等式,再处理等式
B先处理所有等式,再处理不等式
C等式与不等式交替处理
D按输入顺序依次处理
5填空题
下面是并查集的合并函数,实现了按秩合并(秩存储在数组rank中)。请补全空缺部分。

void unionSet(int x, int y) {
    int rx = find(x), ry = find(y);
    if (rx == ry) return;
    if (rank[rx] < rank[ry]) {
        ___;
    } else if (rank[rx] > rank[ry]) {
        parent[ry] = rx;
    } else {
        parent[ry] = rx;
        rank[rx]++;
    }
}