算法讲解056 必备 并查集-上

来源:算法讲解056【必备】并查集-上 · 2026-10-21
依据:视频字幕 2000 字

一句话概括

并查集(Union-Find / Disjoint Set Union, DSU)维护一堆不相交集合,支持「查某元素属于哪个集合(find)」「判断两元素是否同集合(isSameSet)」「合并两集合(union)」三种操作,均摊复杂度接近 O(1)。

它解决什么问题

基础知识(前置概念,用大白话解释)

原理(为什么这么做是对的,尽量给直觉)

步骤(编号,可照着写)

  1. 初始化:parent[i] = i,size[i] = 1(按大小合并用)。
  2. find(x):若 parent[x] != x,递归/迭代找根;找到后把路径上节点都指向根(路径压缩)。
  3. isSameSet(a, b):find(a) == find(b)。
  4. union(a, b):

- 分别找根 ra = find(a), rb = find(b);

- 若 ra == rb 直接返回(已同集合);

- 比较 size[ra] 与 size[rb],小根挂到大根下:parent[小] = 大,size[大] += size[小]。

  1. 需要统计连通块数量时,维护一个计数器:初始为 n,每次成功合并减 1。

样例(一个具体小例子,带每步变化)

n = 5,元素 1..5,初始 parent = [1,2,3,4,5],size = [1,1,1,1,1],连通块数 = 5。

| 操作 | 说明 | parent 变化 | size 变化 | 连通块 |

|---|---|---|---|---|

| union(1,2) | 根 1、2,size 相等,2 挂 1 下 | parent[2]=1 | size[1]=2 | 4 |

| union(3,4) | 根 3、4,4 挂 3 下 | parent[4]=3 | size[3]=2 | 3 |

| union(2,3) | find(2)=1,find(3)=3,size[1]=2, size[3]=2,3 挂 1 下 | parent[3]=1 | size[1]=4 | 2 |

| isSameSet(4,5) | find(4)=1(路径 4→3→1 压缩为 4→1),find(5)=5,不等 | parent[4]=1 | — | 2 |

| union(4,5) | find(4)=1,find(5)=5,size[1]=4 > size[5]=1,5 挂 1 下 | parent[5]=1 | size[1]=5 | 1 |

最终所有元素根都是 1,连通块数 = 1。

代码(Python 一份:好读、用于理解;C++ 一份:竞赛风,能直接抄进比赛,带注释,不用玄学宏)

Python(好读版)

class DSU:
    def __init__(self, n):
        # 下标 0..n-1
        self.parent = list(range(n))
        self.size = [1] * n
        self.components = n  # 连通块数量

    def find(self, x):
        # 迭代路径压缩,避免递归爆栈
        root = x
        while self.parent[root] != root:
            root = self.parent[root]
        # 第二遍:把路径上所有点直接挂到根
        while self.parent[x] != root:
            nxt = self.parent[x]
            self.parent[x] = root
            x = nxt
        return root

    def is_same_set(self, a, b):
        return self.find(a) == self.find(b)

    def union(self, a, b):
        ra, rb = self.find(a), self.find(b)
        if ra == rb:
            return False
        # 按大小合并:小树挂大树
        if self.size[ra] < self.size[rb]:
            ra, rb = rb, ra
        self.parent[rb] = ra
        self.size[ra] += self.size[rb]
        self.components -= 1
        return True

C++(竞赛风,可直接抄)

#include <bits/stdc++.h>
using namespace std;

struct DSU {
    vector<int> parent, sz;
    int components;  // 连通块数量

    DSU() {}
    explicit DSU(int n) { init(n); }

    // 下标 0..n-1
    void init(int n) {
        parent.resize(n);
        sz.assign(n, 1);
        components = n;
        for (int i = 0; i < n; ++i) parent[i] = i;
    }

    // 迭代路径压缩(防爆栈,常数也小)
    int find(int x) {
        int root = x;
        while (parent[root] != root) root = parent[root];
        while (parent[x] != root) {
            int nxt = parent[x];
            parent[x] = root;
            x = nxt;
        }
        return root;
    }

    bool isSameSet(int a, int b) { return find(a) == find(b); }

    // 返回是否真的合并了(原来不同集合)
    bool unite(int a, int b) {
        int ra = find(a), rb = find(b);
        if (ra == rb) return false;
        if (sz[ra] < sz[rb]) swap(ra, rb);  // 小挂大
        parent[rb] = ra;
        sz[ra] += sz[rb];
        --components;
        return true;
    }
};

int main() {
    ios::sync_with_stdio(false);
    cin.tie(nullptr);

    int n, m;
    cin >> n >> m;
    DSU dsu(n);
    while (m--) {
        int op, a, b;
        cin >> op >> a >> b;
        if (op == 1) dsu.unite(a, b);
        else cout << (dsu.isSameSet(a, b) ? "YES\n" : "NO\n");
    }
    return 0;
}

复杂度(时间/空间,最好/平均/最坏,顺带说常数大不大)

- 单次 find / union / isSameSet:均摊 O(α(n)),α(n) ≤ 4(n ≤ 1e6),实际就是常数。

- 最坏单次(不摊还)O(log n),但摊还后极低。

- m 次操作总时间 O(m·α(n)),1e6 次操作轻松过。

竞赛里什么时候用(看到什么题面特征就该想到它)

竞赛模板(C++,可直接背下来的写法;变量名清楚)

// 并查集模板:路径压缩 + 按大小合并
// 下标 0..n-1,若题目 1..n 请自行 -1 或把 init 改成 1..n
struct DSU {
    vector<int> parent, sz;
    int components;

    void init(int n) {
        parent.resize(n);
        sz.assign(n, 1);
        components = n;
        for (int i = 0; i < n; ++i) parent[i] = i;
    }

    int find(int x) {
        int root = x;
        while (parent[root] != root) root = parent[root];
        while (parent[x] != root) {
            int nxt = parent[x];
            parent[x] = root;
            x = nxt;
        }
        return root;
    }

    bool unite(int a, int b) {
        int ra = find(a), rb = find(b);
        if (ra == rb) return false;
        if (sz[ra] < sz[rb]) swap(ra, rb);
        parent[rb] = ra;
        sz[ra] += sz[rb];
        --components;
        return true;
    }

    bool isSameSet(int a, int b) { return find(a) == find(b); }
};

多测时每次 dsu.init(n) 即可(resize + assign 会重置,不会残留上一组数据)。

边界与易错点(WA 的常见原因,越具体越好)

  1. 下标从 0 还是 1:模板用 0..n-1;题目若给 1..n,读入后 --a; --b; 或 init(n+1) 用 1..n。混用必 WA。
  2. 多测不清空:parent 和 sz 必须每组测试重新 init,否则残留上一组数据。用 init(n) 最稳。
  3. 递归 find 爆栈:n 到 1e6 且树退化成链时递归 find 可能栈溢出(尤其没按秩合并时)。用迭代版。
  4. 忘记路径压缩:只写 while(parent[x]!=x) x=parent[x]; return x; 不压缩,最坏 O(n) 单次,大数据 TLE。
  5. union 时没比较 size 直接挂:可能退化成链,虽然路径压缩能救,但常数变大,最好按大小合并。
  6. components 只在成功合并时减:ra == rb 时不能减,否则连通块数错。
  7. isSameSet 写成比较 parent 而非根:必须 find(a)==find(b),不能 parent[a]==parent[b]。
  8. long long:并查集本身只用 int 下标,不需要 long long;但若在并查集外累加边权(如 Kruskal 求 MST 总权),那个累加变量要 long long。
  9. Kruskal 里边权排序 + union 判环:union 返回 false 说明这条边会成环,跳过;返回 true 才计入 MST。
  10. 网格题坐标映射:id = r * m + c,注意行列哪个是 m,别搞反。

同类题 / 变形(说清楚怎么改)

常见坑

拓展(变体、实际用途、面试常问)

自测 3 题(题目 + 折叠答案)

题 1:n=6,依次执行 union(1,2), union(3,4), union(2,3), union(5,6), union(4,5)(下标 1..6)。问最终连通块数和元素 1 所在集合的大小。

答案

初始 6 块。

最终连通块数 = 1,元素 1 所在集合大小 = 6。

</details>

题 2:给 n 个点 m 条无向边(可能有重边自环),问至少加几条边使整张图连通?

答案

对所有边做 union,最后 components 就是连通块数。答案 = components - 1(若 n=0 特判为 0)。重边自环不影响(union 返回 false 直接跳过)。

</details>

题 3:Kruskal 中,为什么 union 返回 false 的边要跳过?如果图不连通,Kruskal 结束后 components 是多少?

答案

union 返回 false 说明两端点已连通,加入这条边会成环,MST 不允许环,故跳过。图不连通时 Kruskal 只能得到最小生成森林,结束后 components = 原图连通块数(>1),此时不存在 MST,只能求最小生成森林。

</details>