并查集基础算法总结 C++ 实现

并查集:从入门到精通

什么是并查集?

并查集是一种树形数据结构,用于处理不相交集合的合并与查询问题。它的核心思想非常朴素:每个集合用一棵树表示,树根作为集合的代表元素。

生活中的例子

想象一个社交网络:

  • 起初,每个人都自成一个群体
  • 当两个人成为朋友,他们所属的群体就合并了
  • 朋友的朋友也是朋友,所以群体具有传递性
  • 我们要能快速判断两个人是否在同一群体

这就是并查集的典型应用场景。

核心操作

并查集主要支持两个操作:

  • 查找(Find):确定元素属于哪个集合,即找到树的根节点
  • 合并(Union):将两个集合合并为一个集合

基础实现

1. 朴素实现

最简单的想法是用数组 parent[i] 记录每个元素的父节点:
每个元素独立成集,父节点指向自己,秩(树高度)初始为 1。

查找(Find):找根节点(含路径压缩)
普通查找 :沿父节点向上寻根

cpp 复制代码
class DisjointSet {
private:
    vector<int> parent;
    
public:
    // 初始化:每个元素自成一个集合
    DisjointSet(int n) {
        parent.resize(n);
        for (int i = 0; i < n; i++) {
            parent[i] = i;  // 自己是自己的父节点
        }
    }
    
    // 查找:沿着父节点向上找到根
    int find(int x) {
        while (parent[x] != x) {
            x = parent[x];
        }
        return x;
    }
    
    // 合并:让一个集合的根指向另一个
    void unionSet(int x, int y) {
        int rootX = find(x);
        int rootY = find(y);
        if (rootX != rootY) {
            parent[rootX] = rootY;
        }
    }
    
    // 判断是否在同一集合
    bool isConnected(int x, int y) {
        return find(x) == find(y);
    }
};

问题:这种实现在最坏情况下会退化成链,查找复杂度 O(n)。

2. 路径压缩优化

在查找过程中,将路径上的所有节点直接连接到根节点:

cpp 复制代码
int find(int x) {
    if (parent[x] != x) {
        parent[x] = find(parent[x]);  // 递归向上查找并压缩路径
    }
    return parent[x];
}

效果展示

复制代码
查找前:1 → 2 → 3 → 4(根)
查找(1)后:1 → 4
           2 → 4  
           3 → 4

3. 按秩合并优化

合并时,将深度较小的树合并到深度较大的树上:

cpp 复制代码
class DisjointSet {
private:
    vector<int> parent;
    vector<int> rank;  // 记录树的高度(秩)
    
public:
    DisjointSet(int n) {
        parent.resize(n);
        rank.resize(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 unionSet(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]++;  // 只有秩相等时才会增加高度
        }
    }
};

优化后的完整实现

综合路径压缩和按秩合并,得到近似 O(α(n)) 的时间复杂度(α 是反阿克曼函数,增长极慢):

cpp 复制代码
#include <iostream>
#include <vector>
using namespace std;

class UnionFind {
private:
    vector<int> parent;
    vector<int> rank;
    int count;  // 连通分量数量
    
public:
    UnionFind(int n) : count(n) {
        parent.resize(n);
        rank.resize(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 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]++;
        }
        count--;  // 合并后连通分量减少
    }
    
    // 判断是否连通
    bool connected(int x, int y) {
        return find(x) == find(y);
    }
    
    // 获取连通分量数量
    int getCount() {
        return count;
    }
    
    // 打印当前状态(调试用)
    void printStatus() {
        cout << "Parent array: ";
        for (int i = 0; i < parent.size(); i++) {
            cout << parent[i] << " ";
        }
        cout << endl;
        
        cout << "Rank array:   ";
        for (int i = 0; i < rank.size(); i++) {
            cout << rank[i] << " ";
        }
        cout << endl;
    }
};

经典应用示例

1. 判断图中是否有环

cpp 复制代码
bool hasCycle(int n, vector<pair<int, int>>& edges) {
    UnionFind uf(n);
    
    for (auto& edge : edges) {
        int u = edge.first;
        int v = edge.second;
        
        if (uf.connected(u, v)) {
            return true;  // 已在同一集合,再加这条边就会形成环
        }
        uf.unite(u, v);
    }
    return false;
}

2. 朋友圈问题

cpp 复制代码
int findCircleNum(vector<vector<int>>& M) {
    int n = M.size();
    UnionFind uf(n);
    
    for (int i = 0; i < n; i++) {
        for (int j = i + 1; j < n; j++) {
            if (M[i][j] == 1) {
                uf.unite(i, j);
            }
        }
    }
    return uf.getCount();  // 返回朋友圈数量
}

复杂度分析

操作 时间复杂度 空间复杂度
初始化 O(n) O(n)
查找 O(α(n)) ≈ O(1) O(1)
合并 O(α(n)) ≈ O(1) O(1)
判断连通 O(α(n)) ≈ O(1) O(1)

其中 α(n) 是反阿克曼函数,对于任何实际输入都可以视为常数。

进阶技巧

1. 带权并查集

维护节点到父节点的权值关系:

cpp 复制代码
class WeightedUnionFind {
private:
    vector<int> parent;
    vector<int> weight;  // 到父节点的权值
    
public:
    WeightedUnionFind(int n) {
        parent.resize(n);
        weight.resize(n, 0);
        for (int i = 0; i < n; i++) parent[i] = i;
    }
    
    int find(int x) {
        if (parent[x] != x) {
            int root = find(parent[x]);
            weight[x] += weight[parent[x]];
            parent[x] = root;
        }
        return parent[x];
    }
    
    void unite(int x, int y, int value) {
        int rootX = find(x);
        int rootY = find(y);
        if (rootX != rootY) {
            parent[rootX] = rootY;
            weight[rootX] = weight[y] - weight[x] + value;
        }
    }
};

2. 可撤销并查集

支持撤销最近的合并操作(需按秩合并,不用路径压缩):

cpp 复制代码
class UndoableUnionFind {
private:
    vector<int> parent, rank;
    stack<tuple<int, int, bool>> history;  // 记录修改信息
    
public:
    void unite(int x, int y) {
        int rootX = find(x), rootY = find(y);
        if (rootX == rootY) {
            history.push({-1, -1, false});
            return;
        }
        
        if (rank[rootX] < rank[rootY]) swap(rootX, rootY);
        history.push({rootY, parent[rootY], rank[rootX] == rank[rootY]});
        
        parent[rootY] = rootX;
        if (rank[rootX] == rank[rootY]) rank[rootX]++;
    }
    
    void undo() {
        auto [y, originalParent, rankChanged] = history.top();
        history.pop();
        if (y == -1) return;
        
        int x = parent[y];
        parent[y] = originalParent;
        if (rankChanged) rank[x]--;
    }
};

总结

并查集虽然简单,但极其精妙。记住三个核心要点:

  1. 路径压缩让查找变快
  2. 按秩合并防止树退化
  3. 两者结合达到近乎常数的时间复杂度

这个数据结构虽然代码量少,但在图论、动态连通性等领域有着广泛应用。掌握了它,你就多了一个处理集合合并问题的利器!

并查集的刷题应用场景全解析

识别并查集题目的特征

遇到以下特征时,考虑使用并查集:

  1. "是否连通"、"是否在同一集合" - 最直接的信号
  2. "合并"、"连接"、"组队" - 涉及动态合并操作
  3. 传递关系 - 如 a 和 b 有关系,b 和 c 有关系,问 a 和 c
  4. 动态添加边 - 边逐步添加,询问连通性
  5. 需要 O(1) 或近似 O(1) 查询连通性
  6. "最少操作次数让图连通" - 连通分量数 - 1

解题模板

cpp 复制代码
// 标准解题步骤
void solve() {
    // 1. 初始化并查集
    UnionFind uf(n);
    
    // 2. 遍历数据,进行合并
    for (...) {
        if (需要合并的条件) {
            uf.unite(x, y);
        }
    }
    
    // 3. 查询结果
    // - 连通分量数: uf.getCount()
    // - 两点是否连通: uf.connected(x, y)
    // - 每个点的根: uf.find(x)
}

并查集虽然代码简单,但应用场景非常广泛。我按照题型难度和考察频率,把常见应用分为以下几类:

一、基础连通性问题

1. 图的连通分量计数

LeetCode 547. 省份数量

cpp 复制代码
int findCircleNum(vector<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] == 1) {
                uf.unite(i, j);
            }
        }
    }
    return uf.getCount();
}

类似题目

  • LeetCode 323. 无向图中连通分量的数目
  • LeetCode 1319. 连通网络的操作次数(需要的最少操作次数 = 连通分量数 - 1)

2. 判环问题

LeetCode 684. 冗余连接

找出图中冗余的边,使得图变成树:

cpp 复制代码
vector<int> findRedundantConnection(vector<vector<int>>& edges) {
    int n = edges.size();
    UnionFind uf(n + 1);
    
    for (auto& edge : edges) {
        int u = edge[0], v = edge[1];
        if (uf.connected(u, v)) {
            return edge;  // 已经连通,再加这条边就形成环
        }
        uf.unite(u, v);
    }
    return {};
}

类似题目

  • LeetCode 685. 冗余连接 II(有向图,需要分情况讨论)
  • LeetCode 1559. 二维网格图中探测环

二、二维网格问题

3. 岛屿问题

LeetCode 200. 岛屿数量

cpp 复制代码
int numIslands(vector<vector<char>>& grid) {
    int m = grid.size(), n = grid[0].size();
    UnionFind uf(m * n);
    int water = 0;
    int dirs[4][2] = {{1,0}, {-1,0}, {0,1}, {0,-1}};
    
    for (int i = 0; i < m; i++) {
        for (int j = 0; j < n; j++) {
            if (grid[i][j] == '0') {
                water++;
            } else {
                // 只合并右和下两个方向(避免重复)
                for (auto& dir : dirs) {
                    int ni = i + dir[0], nj = j + dir[1];
                    if (ni >= 0 && ni < m && nj >= 0 && nj < n 
                        && grid[ni][nj] == '1') {
                        uf.unite(i * n + j, ni * n + nj);
                    }
                }
            }
        }
    }
    return uf.getCount() - water;  // 连通分量数减去水域数量
}

同类题

  • LeetCode 305. 岛屿数量 II(动态添加陆地)
  • LeetCode 695. 岛屿的最大面积(需要维护集合大小)
  • LeetCode 827. 最大人工岛

三、等式/不等式传递性问题

4. 变量关系判定

LeetCode 990. 等式方程的可满足性

cpp 复制代码
bool equationsPossible(vector<string>& equations) {
    UnionFind uf(26);  // 26个小写字母
    
    // 先处理所有等式
    for (auto& eq : equations) {
        if (eq[1] == '=') {
            uf.unite(eq[0] - 'a', eq[3] - 'a');
        }
    }
    
    // 再检查不等式
    for (auto& eq : equations) {
        if (eq[1] == '!') {
            if (uf.connected(eq[0] - 'a', eq[3] - 'a')) {
                return false;  // 在同一集合中,矛盾
            }
        }
    }
    return true;
}

同类题

  • LeetCode 399. 除法求值(带权并查集)
  • LeetCode 952. 按公因数计算最大组件大小

四、动态连通性问题

5. 连续区间合并

LeetCode 128. 最长连续序列

要求 O(n) 时间复杂度:

cpp 复制代码
int longestConsecutive(vector<int>& nums) {
    unordered_map<int, int> parent;
    unordered_map<int, int> count;  // 每个根节点的集合大小
    
    // 初始化
    for (int num : nums) {
        parent[num] = num;
        count[num] = 1;
    }
    
    for (int num : nums) {
        // 如果相邻数字存在,合并
        if (parent.count(num + 1)) {
            unite(parent, count, num, num + 1);
        }
        if (parent.count(num - 1)) {
            unite(parent, count, num - 1, num);
        }
    }
    
    int maxLen = 0;
    for (auto& [_, cnt] : count) {
        maxLen = max(maxLen, cnt);
    }
    return maxLen;
}

// 带集合大小的合并函数
int find(unordered_map<int, int>& parent, int x) {
    if (parent[x] != x) {
        parent[x] = find(parent, parent[x]);
    }
    return parent[x];
}

void unite(unordered_map<int, int>& parent, unordered_map<int, int>& count, 
           int x, int y) {
    int rootX = find(parent, x);
    int rootY = find(parent, y);
    if (rootX != rootY) {
        parent[rootX] = rootY;
        count[rootY] += count[rootX];
    }
}

同类题

  • LeetCode 1562. 查找大小为 M 的最新分组
  • LeetCode 1488. 避免洪水泛滥

五、带权并查集

6. 有相对关系的问题

LeetCode 399. 除法求值

cpp 复制代码
class WeightedUnionFind {
    vector<int> parent;
    vector<double> weight;  // weight[i] = i / parent[i]
    
public:
    WeightedUnionFind(int n) {
        parent.resize(n);
        weight.resize(n, 1.0);  // 初始化为 1
        for (int i = 0; i < n; i++) parent[i] = i;
    }
    
    int find(int x) {
        if (parent[x] != x) {
            int origin = parent[x];
            parent[x] = find(parent[x]);
            weight[x] *= weight[origin];  // 路径压缩时更新权重
        }
        return parent[x];
    }
    
    void unite(int x, int y, double value) {
        // value = x / y
        int rootX = find(x), rootY = find(y);
        if (rootX != rootY) {
            parent[rootX] = rootY;
            // weight[rootX] = (weight[y] * value) / weight[x]
            weight[rootX] = weight[y] * value / weight[x];
        }
    }
    
    double query(int x, int y) {
        int rootX = find(x), rootY = find(y);
        if (rootX != rootY) return -1.0;
        return weight[x] / weight[y];  // x/y = (x/root) / (y/root)
    }
};

同类题

  • LeetCode 1631. 最小体力消耗路径(也可以用二分+Dijkstra)
  • 食物链问题(经典信息学竞赛题)

六、离线查询处理

7. 边权值限制的连通性查询

LeetCode 1697. 检查边长度限制的路径是否存在

cpp 复制代码
vector<bool> distanceLimitedPathsExist(int n, vector<vector<int>>& edgeList, 
                                       vector<vector<int>>& queries) {
    // 按边权重排序
    sort(edgeList.begin(), edgeList.end(), 
         [](auto& a, auto& b) { return a[2] < b[2]; });
    
    // 查询按限制排序(但需要保留原始索引)
    vector<int> idx(queries.size());
    iota(idx.begin(), idx.end(), 0);
    sort(idx.begin(), idx.end(), 
         [&](int i, int j) { return queries[i][2] < queries[j][2]; });
    
    UnionFind uf(n);
    vector<bool> ans(queries.size());
    int edgeIdx = 0;
    
    for (int i : idx) {
        int u = queries[i][0], v = queries[i][1], limit = queries[i][2];
        
        // 添加所有小于限制的边
        while (edgeIdx < edgeList.size() && edgeList[edgeIdx][2] < limit) {
            uf.unite(edgeList[edgeIdx][0], edgeList[edgeIdx][1]);
            edgeIdx++;
        }
        ans[i] = uf.connected(u, v);
    }
    return ans;
}

七、字符串/数组问题

8. 相似字符串组

LeetCode 839. 相似字符串组

cpp 复制代码
int numSimilarGroups(vector<string>& strs) {
    int n = strs.size();
    UnionFind uf(n);
    
    // 两两比较,如果是相似字符串则合并
    for (int i = 0; i < n; i++) {
        for (int j = i + 1; j < n; j++) {
            if (isSimilar(strs[i], strs[j])) {
                uf.unite(i, j);
            }
        }
    }
    return uf.getCount();
}

bool isSimilar(string& a, string& b) {
    int diff = 0;
    for (int i = 0; i < a.size(); i++) {
        if (a[i] != b[i]) diff++;
        if (diff > 2) return false;
    }
    return true;
}
相关推荐
凤凰院凶涛QAQ7 小时前
《C++转Java快速入手系列》String篇:在C++里拼字符串像搬砖,在Java里拼字符串像玩乐高 —— 还是带垃圾回收的那种。
java·开发语言·c++
雪度娃娃7 小时前
Asio——socket的创建和连接
linux·运维·服务器·c++·网络协议
轻刀快马7 小时前
讲明白Lambda 表达式的进化史
java·开发语言
故事和你917 小时前
洛谷-【图论2-2】最短路3
开发语言·数据结构·c++·算法·动态规划·图论
那个失眠的夜7 小时前
SpringBoot
java·开发语言·spring boot·spring·mvc·mybatis
yong99907 小时前
基于VC++的图像匹配金字塔算法
c++·算法·计算机视觉
范范@7 小时前
python基础-5大容器
开发语言·python
会编程的土豆7 小时前
Go 连接 Redis 代码详细解析
开发语言·redis·golang