并查集:连通性问题

并查集:连通性问题

1. 为什么需要并查集

假设你刷朋友圈,看到 A 点赞了 B,B 评论了 C,C 转发了 D。直觉告诉你——A、B、C、D 在一个圈子里。判断"两个人是否在同一个圈子",就是连通性问题

并查集(Union-Find / Disjoint Set Union)专门解决这类问题,支持两个操作:

  • Union(x, y):把 x 和 y 所在的集合合并
  • Find(x):找出 x 所属的集合的代表元素

2. Quick-Find:暴力连通

最朴素的想法:给每个元素一个"组号",同组元素编号相同。

#include <vector>
#include <iostream>

class QuickFind {
    std::vector<int> id;  // 组号
public:
    QuickFind(int n) : id(n) {
        for (int i = 0; i < n; ++i)
            id[i] = i;          // 初始化:每个人自成一组
    }

    bool connected(int p, int q) {
        return id[p] == id[q];   // O(1) 判断连通
    }

    void unite(int p, int q) {   // union 是关键字,用 unite
        int pid = id[p];
        int qid = id[q];
        if (pid == qid) return;
        for (int i = 0; i < id.size(); ++i)
            if (id[i] == pid)    // 遍历所有元素,改组号
                id[i] = qid;     // O(n)
    }
};

问题:union 操作要遍历整个数组,O(n)。n 一大的数据场景直接崩溃。

3. Quick-Union:树形结构

把"组"换成"树"——每个元素只存自己的父节点,根节点代表整个集合。

#include <vector>

class QuickUnion {
    std::vector<int> parent;
public:
    QuickUnion(int n) : parent(n) {
        for (int i = 0; i < n; ++i)
            parent[i] = i;
    }

    int find(int x) {
        while (parent[x] != x)
            x = parent[x];       // 沿着父指针向上找根
        return x;
    }

    bool connected(int p, int q) {
        return find(p) == find(q);
    }

    void unite(int p, int q) {
        int rootP = find(p);
        int rootQ = find(q);
        if (rootP == rootQ) return;
        parent[rootP] = rootQ;   // 一棵树挂到另一棵树根
    }
};

问题:树可能退化成链表。1→2→3→4→5,find(5) 要走 5 步,union 也是 O(n)。

Quick-Union + 路径压缩

1

2

3

4

5

find(5) 走 4 步找到根,路径压缩把沿途节点直接指向根:

5

4

3

2

1

压缩后结构:

2

1

3

4

5

所有节点直接指向根,下次 find 就是 O(1)。

4. 按秩合并

光压缩还不够——如果 union 时总是把大树挂到小树上,树还是会变高。按秩合并(Union by Rank):记录每棵树的高度(秩),把矮树挂到高树下。

#include <vector>

class UnionFind {
    std::vector<int> parent;
    std::vector<int> rank;   // 树的高度上界

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

    int find(int x) {
        if (parent[x] != x)
            parent[x] = find(parent[x]);  // 路径压缩
        return parent[x];
    }

    void unite(int x, int y) {
        int rx = find(x), ry = find(y);
        if (rx == ry) return;
        if (rank[rx] < rank[ry]) {
            parent[rx] = ry;
        } else if (rank[rx] > rank[ry]) {
            parent[ry] = rx;
        } else {
            parent[ry] = rx;      // 同高度,选一个做根
            ++rank[rx];           // 高度 +1
        }
    }

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

5. 复杂度

方法unionfind最坏
Quick-FindO(n)O(1)O(n²)
Quick-UnionO(n)O(n)O(n²)
路径压缩几乎 O(1)几乎 O(1)O(log n)
按秩合并O(log n)O(log n)O(log n)
路径压缩 + 按秩合并α(n)α(n)α(n)

α(n) 是反阿克曼函数。对于 n ≤ 10⁸⁰,α(n) ≤ 4。所以实际复杂度是常数。

6. 基础模板(面试通用)

#include <vector>

class UnionFind {
    std::vector<int> parent;
    std::vector<int> rank;
    int count;                  // 连通分量数

public:
    UnionFind(int n) : parent(n), rank(n, 0), count(n) {
        for (int i = 0; i < n; ++i)
            parent[i] = i;
    }

    int find(int x) {
        if (parent[x] != x)
            parent[x] = find(parent[x]);
        return parent[x];
    }

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

    int getCount() const { return count; }
};

7. 面试题

7.1 岛屿数量

题目'1' 是陆地,'0' 是水,求岛屿数。

思路:每个 '1' 是一个节点,相邻的 '1' 合并。最后统计有多少个集合。

#include <vector>

class UnionFind {
    std::vector<int> parent, rank;
    int count;
public:
    UnionFind(int n) : parent(n), rank(n, 0), count(n) {
        for (int i = 0; i < n; ++i) parent[i] = i;
    }
    int find(int x) {
        if (parent[x] != x) parent[x] = find(parent[x]);
        return parent[x];
    }
    void unite(int x, int y) {
        int rx = find(x), ry = find(y);
        if (rx == ry) return;
        if (rank[rx] < rank[ry])      parent[rx] = ry;
        else if (rank[rx] > rank[ry]) parent[ry] = rx;
        else { parent[ry] = rx; ++rank[rx]; }
        --count;
    }
    int getCount() const { return count; }
};

class Solution {
public:
    int numIslands(std::vector<std::vector<char>>& grid) {
        if (grid.empty() || grid[0].empty()) return 0;
        int m = grid.size(), n = grid[0].size();
        UnionFind uf(m * n);
        int water = 0;
        auto id = [n](int r, int c) { return r * n + c; };

        for (int i = 0; i < m; ++i) {
            for (int j = 0; j < n; ++j) {
                if (grid[i][j] == '0') { ++water; continue; }
                if (i > 0 && grid[i - 1][j] == '1')
                    uf.unite(id(i, j), id(i - 1, j));
                if (j > 0 && grid[i][j - 1] == '1')
                    uf.unite(id(i, j), id(i, j - 1));
            }
        }
        return uf.getCount() - water;
    }
};

7.2 朋友圈 / 省份数量

题目isConnected[i][j] = 1 表示 i 和 j 直接相连,求省份数。

#include <vector>

class UnionFind {
    std::vector<int> parent, rank;
    int count;
public:
    UnionFind(int n) : parent(n), rank(n, 0), count(n) {
        for (int i = 0; i < n; ++i) parent[i] = i;
    }
    int find(int x) {
        if (parent[x] != x) parent[x] = find(parent[x]);
        return parent[x];
    }
    void unite(int x, int y) {
        int rx = find(x), ry = find(y);
        if (rx == ry) return;
        if (rank[rx] < rank[ry])      parent[rx] = ry;
        else if (rank[rx] > rank[ry]) parent[ry] = rx;
        else { parent[ry] = rx; ++rank[rx]; }
        --count;
    }
    int getCount() const { return count; }
};

class Solution {
public:
    int findCircleNum(std::vector<std::vector<int>>& isConnected) {
        int n = isConnected.size();
        UnionFind uf(n);
        for (int i = 0; i < n; ++i)
            for (int j = i + 1; j < n; ++j)
                if (isConnected[i][j])
                    uf.unite(i, j);
        return uf.getCount();
    }
};

7.3 冗余连接

题目:n 个节点 n 条边的无向图,删掉一条边使其变成树。返回那条多余边。

#include <vector>

class UnionFind {
    std::vector<int> parent, rank;
public:
    UnionFind(int n) : parent(n), rank(n, 0) {
        for (int i = 0; i < n; ++i) parent[i] = i;
    }
    int find(int x) {
        if (parent[x] != x) parent[x] = find(parent[x]);
        return parent[x];
    }
    bool unite(int x, int y) {
        int rx = find(x), ry = find(y);
        if (rx == ry) return false;  // 已经在同集合 → 形成环
        if (rank[rx] < rank[ry])      parent[rx] = ry;
        else if (rank[rx] > rank[ry]) parent[ry] = rx;
        else { parent[ry] = rx; ++rank[rx]; }
        return true;
    }
};

class Solution {
public:
    std::vector<int> findRedundantConnection(
            std::vector<std::vector<int>>& edges) {
        int n = edges.size();
        UnionFind uf(n + 1);
        for (auto& e : edges) {
            if (!uf.unite(e[0], e[1]))
                return {e[0], e[1]};
        }
        return {};
    }
};

解释:树有 n 个节点、n−1 条边。多出一条边后,遍历所有边,当发现两个节点已经连通时,这条边就是冗余的——它形成了环。

7.4 最长连续序列

题目:无序数组 [100, 4, 200, 1, 3, 2],最长连续序列 [1,2,3,4] 长度为 4。

并查集解法:连续的数合并,统计最大集合大小。

#include <vector>
#include <unordered_map>
#include <algorithm>

class UnionFind {
    std::vector<int> parent, rank, size;
public:
    UnionFind(int n) : parent(n), rank(n, 0), size(n, 1) {
        for (int i = 0; i < n; ++i) parent[i] = i;
    }
    int find(int x) {
        if (parent[x] != x) parent[x] = find(parent[x]);
        return parent[x];
    }
    int unite(int x, int y) {
        int rx = find(x), ry = find(y);
        if (rx == ry) return size[rx];
        if (rank[rx] < rank[ry]) {
            parent[rx] = ry;
            size[ry] += size[rx];
            return size[ry];
        } else if (rank[rx] > rank[ry]) {
            parent[ry] = rx;
            size[rx] += size[ry];
            return size[rx];
        } else {
            parent[ry] = rx;
            size[rx] += size[ry];
            ++rank[rx];
            return size[rx];
        }
    }
};

class Solution {
public:
    int longestConsecutive(std::vector<int>& nums) {
        if (nums.empty()) return 0;
        int n = nums.size();
        UnionFind uf(n);
        std::unordered_map<int, int> valToIdx;
        int ans = 1;
        for (int i = 0; i < n; ++i) {
            if (valToIdx.count(nums[i])) continue;
            valToIdx[nums[i]] = i;
            if (valToIdx.count(nums[i] - 1))
                ans = std::max(ans, uf.unite(i, valToIdx[nums[i] - 1]));
            if (valToIdx.count(nums[i] + 1))
                ans = std::max(ans, uf.unite(i, valToIdx[nums[i] + 1]));
        }
        return ans;
    }
};

对比哈希解法:哈希法(存端点)O(n) 时间 O(n) 空间,代码更短。并查集解法思路更直观——连续的数天然"连通",但常数更大。面试中哈希法更推荐,但掌握并查集解法体现你举一反三的能力。

7.5 连通网络的操作次数

题目:n 台电脑,用网线 connections[i] = [a, b] 连接。求最少移动网线次数使所有电脑连通。

#include <vector>

class UnionFind {
    std::vector<int> parent, rank;
    int count;
public:
    UnionFind(int n) : parent(n), rank(n, 0), count(n) {
        for (int i = 0; i < n; ++i) parent[i] = i;
    }
    int find(int x) {
        if (parent[x] != x) parent[x] = find(parent[x]);
        return parent[x];
    }
    bool unite(int x, int y) {
        int rx = find(x), ry = find(y);
        if (rx == ry) return false;
        if (rank[rx] < rank[ry])      parent[rx] = ry;
        else if (rank[rx] > rank[ry]) parent[ry] = rx;
        else { parent[ry] = rx; ++rank[rx]; }
        --count;
        return true;
    }
    int getCount() const { return count; }
};

class Solution {
public:
    int makeConnected(int n, std::vector<std::vector<int>>& connections) {
        if (connections.size() < n - 1) return -1;  // 线不够
        UnionFind uf(n);
        int extra = 0;
        for (auto& c : connections)
            if (!uf.unite(c[0], c[1])) ++extra;
        int need = uf.getCount() - 1;   // 连通分量数 - 1
        return need <= extra ? need : -1;
    }
};

解释:n 台电脑连通至少需要 n−1 条线。多余的线可以移动。统计多余线和孤立分量数即可。

7.6 等式方程的可满足性

题目:给定 ["a==b","b!=c","c==a"],判断是否矛盾。

#include <vector>
#include <string>

class UnionFind {
    std::vector<int> parent, rank;
public:
    UnionFind(int n) : parent(n), rank(n, 0) {
        for (int i = 0; i < n; ++i) parent[i] = i;
    }
    int find(int x) {
        if (parent[x] != x) parent[x] = find(parent[x]);
        return parent[x];
    }
    void unite(int x, int y) {
        int rx = find(x), ry = find(y);
        if (rx == ry) return;
        if (rank[rx] < rank[ry])      parent[rx] = ry;
        else if (rank[rx] > rank[ry]) parent[ry] = rx;
        else { parent[ry] = rx; ++rank[rx]; }
    }
    bool connected(int x, int y) { return find(x) == find(y); }
};

class Solution {
public:
    bool equationsPossible(std::vector<std::string>& equations) {
        UnionFind uf(26);  // 只有小写字母
        // 第一遍:处理所有 ==
        for (auto& eq : equations)
            if (eq[1] == '=')
                uf.unite(eq[0] - 'a', eq[3] - 'a');
        // 第二遍:检查 != 是否矛盾
        for (auto& eq : equations)
            if (eq[1] == '!' && uf.connected(eq[0] - 'a', eq[3] - 'a'))
                return false;
        return true;
    }
};

解释:先用 == 建立连通关系,再用 != 检查是否两个本应不同的变量已经被连通。若连通则矛盾。

8. 总结

并查集是少数能在教科书和面试题中保持完全一致实现的数据结构。背下模板,面试遇到"连通"、“集合”、“分组”、"检测环"这类关键词,直接套用。

关键记忆点:

  • 路径压缩:find 时顺手把沿途节点挂到根上
  • 按秩合并:矮树挂高树,避免树退化
  • count 变量:用来统计连通分量数,很多题目直接返回这个值

反阿克曼函数的常数极小,可以用一辈子的数据结构。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

打赏作者

Ricky_Theseus

感谢大家,祝您生活愉快

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

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

打赏作者

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

抵扣说明:

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

余额充值