并查集:朋友圈问题的终极解法


前言

并查集(Union-Find)是处理动态连通性问题的利器。很多人觉得它很高深,其实核心思想超简单:给每个帮派选一个老大,想知道两个人是不是一伙的,就看他们的老大是不是同一个

我并没有能力让你看完就精通图论,我只是想让你理解并查集的核心思想和两个优化技巧。掌握这些,90%的连通性问题都能秒。

摘要

从"判断朋友圈数量"问题出发,剖析并查集的核心思想与优化策略。通过路径压缩和按秩合并的图解演示,揭秘如何把连通性判断从O(n)优化到O(α(n))。配合LeetCode高频题目与完整代码模板,给出并查集问题的解题套路。


一、从朋友圈问题说起

周末,哈吉米遇到一道题:

LeetCode 547 - 省份数量(朋友圈)

有 n 个人,编号 0 到 n-1
isConnected[i][j] = 1 表示第 i 个人和第 j 个人是朋友
朋友关系具有传递性:AB是朋友,B和C是朋友,那么A和C也是朋友
计算有多少个朋友圈

示例:
输入:isConnected = [[1,1,0],
                     [1,1,0],
                     [0,0,1]]
输出:2
解释:[0,1]一个朋友圈,[2]一个朋友圈

哈吉米写了个DFS:

Java版本

public int findCircleNum(int[][] isConnected) {
    int n = isConnected.length;
    boolean[] visited = new boolean[n];
    int count = 0;
    
    for (int i = 0; i < n; i++) {
        if (!visited[i]) {
            dfs(isConnected, visited, i);
            count++;
        }
    }
    
    return count;
}

private void dfs(int[][] isConnected, boolean[] visited, int i) {
    visited[i] = true;
    for (int j = 0; j < isConnected.length; j++) {
        if (isConnected[i][j] == 1 && !visited[j]) {
            dfs(isConnected, visited, j);
        }
    }
}

南北绿豆:"DFS可以,但并查集更优雅,而且扩展性更强。"

阿西噶阿西:"比如要动态加边、判断是否连通,并查集更快。"


二、江湖帮派的类比

南北绿豆:"想象武侠小说里的江湖帮派。"

场景

江湖中有10个人,分散在不同帮派

青龙帮:[张三, 李四, 王五]
白虎帮:[赵六, 孙七]
独行侠:[周八, 吴九]

问题:张三和王五是不是一个帮派的?

暴力方法:遍历所有帮派,看张三和王五在不在同一个帮派里。

并查集方法

  • 每个帮派选一个帮主(代表元素)
  • 张三的帮主是谁?王五的帮主是谁?
  • 如果帮主相同,说明是同一个帮派
青龙帮帮主:张三
白虎帮帮主:赵六

张三的帮主 = 张三(自己就是帮主)
王五的帮主 = 张三
帮主相同 → 同一个帮派 ✓

哈吉米:"懂了,每个连通分量选一个老大,判断的时候只看老大是不是同一个。"


三、并查集的数据结构

阿西噶阿西:"并查集用数组实现,超简单。"

3.1 parent数组

int[] parent = new int[n]; // parent[i]表示i的父节点

初始化:每个元素的父节点是自己(各自为王)

parent = [0, 1, 2, 3, 4, 5]
         ↑  ↑  ↑  ↑  ↑  ↑
索引:    0  1  2  3  4  5

表示:0的父节点是0(自己是老大)
     1的父节点是1(自己是老大)
     ...

初始化图示

flowchart TB
    A["节点0<br/>parent[0]=0"]
    B["节点1<br/>parent[1]=1"]
    C["节点2<br/>parent[2]=2"]
    D["节点3<br/>parent[3]=3"]
    
    style A fill:#e1f5ff
    style B fill:#e1f5ff
    style C fill:#e1f5ff
    style D fill:#e1f5ff

四、核心操作:find和union

4.1 find:找老大

问题:给定一个元素,找它的代表元素(老大)。

实现:沿着parent数组往上找,直到找到parent[x] == x的节点。

示例

parent = [0, 0, 1, 2, 2, 5]

元素3的老大是谁?
parent[3] = 2 → 3的父节点是2
parent[2] = 1 → 2的父节点是1
parent[1] = 0 → 1的父节点是0
parent[0] = 0 → 0的父节点是0(找到了!)

所以元素3的老大是0

find操作图示

flowchart TB
    N0["0 老大"]
    N1["1"]
    N2["2"]
    N3["3"]
    
    N3 --> N2
    N2 --> N1
    N1 --> N0
    
    style N0 fill:#ffe6e6
    style N3 fill:#e6ffe6

代码(基础版)

Java版本

public int find(int x) {
    while (parent[x] != x) {
        x = parent[x]; // 往上找
    }
    return x; // 返回老大
}

C++版本

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

Python版本

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

4.2 union:合并两个集合

问题:把元素x和元素y所在的集合合并。

实现:找到x和y的老大,让一个老大指向另一个老大。

union操作图示

flowchart TB
    subgraph 合并前有两个集合
        A0["集合1老大: 0"]
        A1["1"]
        A2["2"]
        A5["集合2老大: 5"]
        A6["6"]
    end
    
    A1 --> A0
    A2 --> A1
    A6 --> A5
flowchart TB
    subgraph 合并后
        B5["5 新老大"]
        B0["0"]
        B1["1"]
        B2["2"]
        B6["6"]
    end
    
    B0 --> B5
    B1 --> B0
    B2 --> B1
    B6 --> B5
    
    style B5 fill:#e1ffe1

代码(基础版)

Java版本

public void union(int x, int y) {
    int rootX = find(x); // 找x的老大
    int rootY = find(y); // 找y的老大
    
    if (rootX != rootY) {
        parent[rootX] = rootY; // x的老大认y的老大当老大
    }
}

C++版本

void union_sets(int x, int y) {
    int rootX = find(x);
    int rootY = find(y);
    
    if (rootX != rootY) {
        parent[rootX] = rootY;
    }
}

Python版本

def union(x, y):
    rootX = find(x)
    rootY = find(y)
    
    if rootX != rootY:
        parent[rootX] = rootY

五、优化1:路径压缩

5.1 路径压缩对比

压缩前

flowchart TB
    N0["0 老大"]
    N1["1"]
    N2["2"]
    N3["3"]
    N4["4"]
    N5["5"]
    
    N1 --> N0
    N2 --> N1
    N3 --> N2
    N4 --> N3
    N5 --> N4

执行find(5)后

flowchart TB
    M0["0 老大"]
    M1["1"]
    M2["2"]
    M3["3"]
    M4["4"]
    M5["5"]
    
    M1 --> M0
    M2 --> M0
    M3 --> M0
    M4 --> M0
    M5 --> M0

哈吉米:"所有节点都直接指向老大,查找变成O(1)了!"

5.2 路径压缩代码

Java版本

public int find(int x) {
    if (parent[x] != x) {
        parent[x] = find(parent[x]); // 递归找老大,同时压缩路径
    }
    return parent[x];
}

C++版本

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

Python版本

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

六、优化2:按秩合并

6.1 为什么需要按秩合并

阿西噶阿西:"路径压缩解决了查找慢的问题,但union的时候,如果随便合并,可能会让树变高。"

反例

集合1:0 ← 1 ← 2 ← 3(树高4)
集合2:5 ← 6(树高2)

如果让5指向0(小树合并到大树):
树高还是4 ✓

如果让0指向5(大树合并到小树):
树高变成5 ❌

南北绿豆:"按秩合并的思想:让小树合并到大树,保持树高尽量小。"

6.2 按秩合并对比

错误合并(大树合并到小树)

flowchart TB
    N5["5 老大"]
    N6["6"]
    N0["0"]
    N1["1"]
    N2["2"]
    N3["3"]
    
    N6 --> N5
    N0 --> N5
    N1 --> N0
    N2 --> N1
    N3 --> N2
    
    style N5 fill:#ffe6e6

正确合并(小树合并到大树)

flowchart TB
    M0["0 老大"]
    M1["1"]
    M2["2"]
    M3["3"]
    M5["5"]
    M6["6"]
    
    M1 --> M0
    M2 --> M1
    M3 --> M2
    M5 --> M0
    M6 --> M5
    
    style M0 fill:#e1ffe1

用rank数组记录树的高度

int[] rank = new int[n]; // rank[i]表示以i为根的树的高度

6.3 按秩合并代码

完整的并查集模板

Java版本

class UnionFind {
    private int[] parent;
    private int[] rank; // 树的高度
    
    public UnionFind(int n) {
        parent = new int[n];
        rank = new int[n];
        for (int i = 0; i < n; i++) {
            parent[i] = i;
            rank[i] = 1;
        }
    }
    
    // find:找老大(路径压缩)
    public int find(int x) {
        if (parent[x] != x) {
            parent[x] = find(parent[x]); // 路径压缩
        }
        return parent[x];
    }
    
    // union:合并两个集合(按秩合并)
    public void union(int x, int y) {
        int rootX = find(x);
        int rootY = find(y);
        
        if (rootX != rootY) {
            // 按秩合并:小树合并到大树
            if (rank[rootX] < rank[rootY]) {
                parent[rootX] = rootY;
            } else if (rank[rootX] > rank[rootY]) {
                parent[rootY] = rootX;
            } else {
                parent[rootY] = rootX;
                rank[rootX]++;
            }
        }
    }
    
    // 判断是否连通
    public boolean connected(int x, int y) {
        return find(x) == find(y);
    }
}

C++版本

class UnionFind {
private:
    vector<int> parent, rank;
    
public:
    UnionFind(int n) : parent(n), rank(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];
    }
    
    void union_sets(int x, int y) {
        int rootX = find(x), rootY = find(y);
        
        if (rootX != rootY) {
            if (rank[rootX] < rank[rootY]) {
                parent[rootX] = rootY;
            } else if (rank[rootX] > rank[rootY]) {
                parent[rootY] = rootX;
            } else {
                parent[rootY] = rootX;
                rank[rootX]++;
            }
        }
    }
    
    bool connected(int x, int y) {
        return find(x) == find(y);
    }
};

Python版本

class UnionFind:
    def __init__(self, n):
        self.parent = list(range(n))
        self.rank = [1] * n
    
    def find(self, x):
        if self.parent[x] != x:
            self.parent[x] = self.find(self.parent[x])
        return self.parent[x]
    
    def union(self, x, y):
        rootX, rootY = self.find(x), self.find(y)
        
        if rootX != rootY:
            if self.rank[rootX] < self.rank[rootY]:
                self.parent[rootX] = rootY
            elif self.rank[rootX] > self.rank[rootY]:
                self.parent[rootY] = rootX
            else:
                self.parent[rootY] = rootX
                self.rank[rootX] += 1
    
    def connected(self, x, y):
        return self.find(x) == self.find(y)

七、例题1:省份数量

7.1 思路分析

南北绿豆:"回到最开始的问题,用并查集解决。"

思路

  1. 初始化并查集,n个人各自为王
  2. 遍历isConnected,如果isConnected[i][j] == 1,合并i和j
  3. 最后统计有多少个不同的老大

为什么统计老大数量?

阿西噶阿西:"每个连通分量(朋友圈)只有一个老大,有多少个老大就有多少个朋友圈。"

7.2 执行过程演示

示例isConnected = [[1,1,0],[1,1,0],[0,0,1]]

步骤操作parent说明
初始-[0,1,2]三个人各自为王
1union(0,1)[0,0,2]1认0当老大
2union(1,0)[0,0,2]已经连通,跳过
3-[0,0,2]其他都不是朋友
统计--parent[0]=0 ✓,parent[2]=2 ✓

结果:2个老大 = 2个朋友圈

7.3 代码实现

Java版本

public int findCircleNum(int[][] isConnected) {
    int n = isConnected.length;
    UnionFind uf = new UnionFind(n);
    
    // 遍历所有关系,合并朋友
    for (int i = 0; i < n; i++) {
        for (int j = i + 1; j < n; j++) {
            if (isConnected[i][j] == 1) {
                uf.union(i, j);
            }
        }
    }
    
    // 统计有多少个不同的老大
    int count = 0;
    for (int i = 0; i < n; i++) {
        if (uf.find(i) == i) {
            count++;
        }
    }
    
    return count;
}

C++版本

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.union_sets(i, j);
            }
        }
    }
    
    int count = 0;
    for (int i = 0; i < n; i++) {
        if (uf.find(i) == i) {
            count++;
        }
    }
    
    return count;
}

Python版本

def findCircleNum(isConnected):
    n = len(isConnected)
    uf = UnionFind(n)
    
    for i in range(n):
        for j in range(i + 1, n):
            if isConnected[i][j] == 1:
                uf.union(i, j)
    
    count = 0
    for i in range(n):
        if uf.find(i) == i:
            count += 1
    
    return count

八、例题2:冗余连接

8.1 题目

LeetCode 684 - 冗余连接

树可以看成是一个连通且无环的无向图。

给定一个有 n 个节点的图,edges[i] = [ai, bi] 表示边。
找出一条可以删去的边,使得结果图仍然连通且是一棵树。

示例:
输入:edges = [[1,2],[1,3],[2,3]]
输出:[2,3]
解释:删除边[2,3]后,图变成树

8.2 思路分析

南北绿豆:"这题的核心:找到第一条让图产生环的边。"

树的性质:n个节点的树有n-1条边,无环。

思路

  1. 按顺序加边
  2. 加边前,判断两个节点是否已经连通
  3. 如果已经连通,再加边就会形成环,这条边就是冗余的

8.3 执行过程演示

示例edges = [[1,2],[1,3],[2,3]]

步骤加边操作parent结果
初始--[0,1,2,3]-
1[1,2]find(1)=1, find(2)=2
不连通,union
[0,1,1,3]-
2[1,3]find(1)=1, find(3)=3
不连通,union
[0,1,1,1]-
3[2,3]find(2)=1, find(3)=1
已经连通!
-返回[2,3] ✓

8.4 代码实现

Java版本

public int[] findRedundantConnection(int[][] edges) {
    int n = edges.length;
    UnionFind uf = new UnionFind(n + 1); // 节点编号从1开始
    
    for (int[] edge : edges) {
        int u = edge[0];
        int v = edge[1];
        
        // 如果u和v已经连通,这条边就是冗余的
        if (uf.connected(u, v)) {
            return edge;
        }
        
        uf.union(u, v);
    }
    
    return new int[]{};
}

C++版本

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.union_sets(u, v);
    }
    
    return {};
}

Python版本

def findRedundantConnection(edges):
    n = len(edges)
    uf = UnionFind(n + 1)
    
    for u, v in edges:
        if uf.connected(u, v):
            return [u, v]
        
        uf.union(u, v)
    
    return []

九、例题3:等式方程的可满足性

9.1 题目

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

给定一个由等式组成的数组 equations,equations[i] 的形式为 "a==b" 或 "a!=b"。
判断这些等式是否互相矛盾。

示例:
输入:equations = ["a==b","b!=a"]
输出:false
解释:a==b 说明 ab 相等,但 b!=a 说明不相等,矛盾。

输入:equations = ["a==b","b==c","a==c"]
输出:true

输入:equations = ["a==b","b!=c","c==a"]
输出:false

9.2 思路分析

南北绿豆:"这题巧妙:先处理所有==关系(合并),再检查!=关系(判断连通性)。"

思路

  1. 第一遍:遍历所有==,合并相等的变量
  2. 第二遍:遍历所有!=,检查是否连通
  3. 如果a!=b但a和b连通,说明矛盾

阿西噶阿西:"比如a==bb==c合并后,a、b、c是一个集合。如果再有a!=c,就矛盾了。"

9.3 代码实现

Java版本

public boolean equationsPossible(String[] equations) {
    UnionFind uf = new UnionFind(26); // 26个字母
    
    // 第一遍:处理所有==,合并相等的变量
    for (String eq : equations) {
        if (eq.charAt(1) == '=') {
            int x = eq.charAt(0) - 'a';
            int y = eq.charAt(3) - 'a';
            uf.union(x, y);
        }
    }
    
    // 第二遍:检查所有!=
    for (String eq : equations) {
        if (eq.charAt(1) == '!') {
            int x = eq.charAt(0) - 'a';
            int y = eq.charAt(3) - 'a';
            // 如果连通,说明矛盾
            if (uf.connected(x, y)) {
                return false;
            }
        }
    }
    
    return true;
}

C++版本

bool equationsPossible(vector<string>& equations) {
    UnionFind uf(26);
    
    for (auto& eq : equations) {
        if (eq[1] == '=') {
            int x = eq[0] - 'a';
            int y = eq[3] - 'a';
            uf.union_sets(x, y);
        }
    }
    
    for (auto& eq : equations) {
        if (eq[1] == '!') {
            int x = eq[0] - 'a';
            int y = eq[3] - 'a';
            if (uf.connected(x, y)) {
                return false;
            }
        }
    }
    
    return true;
}

Python版本

def equationsPossible(equations):
    uf = UnionFind(26)
    
    for eq in equations:
        if eq[1] == '=':
            x = ord(eq[0]) - ord('a')
            y = ord(eq[3]) - ord('a')
            uf.union(x, y)
    
    for eq in equations:
        if eq[1] == '!':
            x = ord(eq[0]) - ord('a')
            y = ord(eq[3]) - ord('a')
            if uf.connected(x, y):
                return False
    
    return True

十、时间复杂度分析

南北绿豆:"路径压缩+按秩合并,时间复杂度接近O(1)。"

准确说法:O(α(n)),α是阿克曼函数的反函数,增长极慢。

n = 10^9 时,α(n) ≈ 4

几乎可以看成常数时间

哈吉米:"这么快?"

阿西噶阿西:"路径压缩让树高接近1,查找就快;按秩合并让树高始终很小。"


十一、并查集总结

11.1 识别技巧

阿西噶阿西

  • 看到连通性、是否在同一个集合,想并查集
  • 看到朋友圈、省份、网络,想并查集
  • 看到动态加边、判断环,想并查集

11.2 核心要点

南北绿豆

  1. 核心思想:每个连通分量选一个代表元素
  2. 两个操作:find(找老大)、union(合并)
  3. 两个优化:路径压缩(查找快)、按秩合并(树高小)
  4. 时间复杂度:O(α(n)),接近常数

哈吉米:"记住模板,直接套就行了。"


参考资料

  • 《算法第四版》- Robert Sedgewick
  • LeetCode题解 - 并查集专题
  • 《算法竞赛进阶指南》- 李煜东