并查集

社交网络问题 - 判断是否属于同一个朋友圈

朋友圈问题:已知,有n个人和m对好友关系,如果两个人是直接的或者间接的好友,那么他们属于一个集合,就在一个朋友圈。求这 n 个人中一共有多少个朋友圈。
举个例子,假设有10个人,好友关系如下:有三个独立的朋友圈

{0, 1}, {1, 2},        // 小圈子 A:{0,1,2}
{3, 4}, {4, 5},        // 小圈子 B:{3,4,5}
{2, 3},                // 合并 A 和 B,形成 {0,1,2,3,4,5}
{6, 7},                // 小圈子 C:{6,7}
{8, 9}                 // 小圈子 D:{8,9}

下面是代码示例:使用邻接表 + DFS 求无向图的连通分量,即为朋友圈的个数

import java.util.*;

public class FriendCircles {
    public static void main(String[] args) {
        int n = 10;
        // 好友关系
        int[][] relations = {
            {0, 1}, {1, 2},        // 小圈子 A:{0,1,2}
            {3, 4}, {4, 5},        // 小圈子 B:{3,4,5}
            {2, 3},                // 合并 A 和 B,形成 {0,1,2,3,4,5}
            {6, 7},                // 小圈子 C:{6,7}
            {8, 9}                 // 小圈子 D:{8,9}
        };
        System.out.println(findCircleNum(n, relations));  // 输出:3
    }

    public static int findCircleNum(int n, int[][] relations) {
        // 创建邻接表表示好友关系
        List<List<Integer>> graph = new ArrayList<>();
        for (int i = 0; i < n; i++) {
            graph.add(new ArrayList<>());
        }

        // 填充邻接表
        for (int[] relation : relations) {
            graph.get(relation[0]).add(relation[1]);
            graph.get(relation[1]).add(relation[0]);
        }

        boolean[] visited = new boolean[n];
        int circleCount = 0;

        // 遍历每个人,查找朋友圈
        for (int person = 0; person < n; person++) {
            if (!visited[person]) {
                dfs(person, graph, visited);
                circleCount++;
            }
        }

        return circleCount;
    }

    // 深度优先搜索
    private static void dfs(int person, List<List<Integer>> graph, boolean[] visited) {
        visited[person] = true;
        for (int friend : graph.get(person)) {
            if (!visited[friend]) {
                dfs(friend, graph, visited);
            }
        }
    }
}

设 n 为人数,m 为好友关系数。

  1. 时间复杂度:O(n + m):构建邻接表:O(n + m),DFS 遍历:每个人访问一次,每条边检查两次,O(n + m)
  2. 空间复杂度:O(n + m):邻接表:O(n + m),visited 数组:O(n),DFS 递归栈:最坏 O(n)
    在最坏情况下,如果所有人两两都是好友,则 m = O(n²),所以:最坏时间复杂度:O(n²),最坏空间复杂度:O(n²)

并查集

并查集是一种树型的数据结构,用于处理一些不相交集合的合并及查询问题。下面是使用并查集解决前面的朋友圈问题

import java.util.*;

public class FriendCircles {
    public static void main(String[] args) {
        int n = 10;
        // 好友关系
        int[][] relations = {
            {0, 1}, {1, 2},        // 小圈子 A:{0,1,2}
            {3, 4}, {4, 5},        // 小圈子 B:{3,4,5}
            {2, 3},                // 合并 A 和 B,形成 {0,1,2,3,4,5}
            {6, 7},                // 小圈子 C:{6,7}
            {8, 9}                 // 小圈子 D:{8,9}
        };
        System.out.println(findCircleNum(n, relations));  // 输出: 3
    }

    public static int findCircleNum(int n, int[][] relations) {
        UnionFind unionFind = new UnionFind(n);
        // 进行并查集的合并操作
        for (int[] relation : relations) {
            unionFind.union(relation[0], relation[1]);
        }
        // 计算不同的朋友圈数量
        return unionFind.getCircleCount();
    }
}

class UnionFind {
    // 用于跟踪每个人的父节点
    // 索引 i:表示第 i 个人, parent[i]:表示第 i 个人的父节点是谁
    private int[] parent;
    // 朋友圈的数量
    private int count;

    public UnionFind(int size) {
        parent = new int[size];
        count = size;
        for (int i = 0; i < size; i++) {
            parent[i] = i;  // 初始化,每个人的父节点是自己
        }
    }

    // 使用路径压缩来查找每个人的根节点
    public int find(int x) {
        if (parent[x] != x) {
            parent[x] = find(parent[x]);  // 路径压缩
        }
        return parent[x];
    }

    // 合并两个朋友圈,并更新计数
    public void union(int x, int y) {
        int rootX = find(x);
        int rootY = find(y);
        if (rootX != rootY) {
            parent[rootY] = rootX;  // 直接把 rootX 挂到 rootY 下,即合并两个集合
            count--;  // 每合并一个朋友圈,计数减少
        }
    }

    public int getCircleCount() {
        return count;  // 返回朋友圈的数量
    }

    // 统计集合个数
    public int count() {
        int cnt = 0;
        for (int i = 0; i < parent.length; i++) {
            if (find(i) == i) cnt++;
        }
        return cnt;
    }
}

并查集的核心有以下3点:

  1. parent 数组:表示关联关系, 初始时每个人自成一个集合
int n = 10;                 // 人数,编号 0 ~ 9
int[] parent = new int[n];

for (int i = 0; i < n; i++) {
    parent[i] = i;          // 每个人是自身的根
}

find:查找某个节点所属集合的根节点

如果当前节点的父节点不是自己,也就是说当前节点不是根节点,就继续向上找,直到找到根。在查找过程中,因为是递归,回溯时路径上的每个节点都会执行 parent[x] = ...,所以整条路径上的节点都会直接指向根。

路径压缩会使树变得非常扁平,后续查找接近 O(1)。

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

如果不进行路径压缩,find() 只负责找根,不修改任何节点的 parent。但 union() 仍然会执行 parent[rootY] = rootX,所以 parent 数组在合并时还是会变化。find 实现改为如下形式,union 则不变

public int find(int x) {
    while (parent[x] != x) {
        x = parent[x];
    }
    return x;   // 只返回根,不修改路径上的 parent
}

我们来模拟这个状态,初始 parent = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9]:

  1. union(0, 1):find(0)=0, find(1)=1,parent[1] = 0,此时 parent = [0, 0, 2, 3, 4, 5, 6, 7, 8, 9]
  2. union(1, 2)
    find(1):parent[1]=0,parent[0]=0 → 返回 0,find(2)=2,parent[2] = 0,此时 parent = [0, 0, 0, 3, 4, 5, 6, 7, 8, 9]
  3. union(3, 4):find(3)=3, find(4)=4,parent[4] = 3,此时 parent = [0, 0, 0, 3, 3, 5, 6, 7, 8, 9]
  4. union(4, 5):find(4):parent[4]=3,parent[3]=3 → 返回 3,find(5)=5,parent[5] = 3,此时 parent = [0, 0, 0, 3, 3, 3, 6, 7, 8, 9]
  5. union(2, 3):find(2):parent[2]=0,parent[0]=0 → 返回 0,find(3)=3,parent[3] = 0 ← 注意:这里把根 3 挂到了根 0 下面。parent = [0, 0, 0, 0, 3, 3, 6, 7, 8, 9],此时 4 和 5 仍然指向 3,没有变。
  6. union(6, 7):find(6)=6, find(7)=7,parent[7] = 6,parent = [0, 0, 0, 0, 3, 3, 6, 6, 8, 9]
  7. union(8, 9):find(8)=8, find(9)=9,parent[9] = 8,此时 parent = [0, 0, 0, 0, 3, 3, 6, 6, 8, 8]

最终 parent 数组(不压缩),[0, 0, 0, 0, 3, 3, 6, 6, 8, 8],对应的树形结构:

       0           6        8
      / | \        |        |
     1  2  3       7        9
          / \
         4   5

在这个特定的测试数据中,路径压缩版本的最终 parent 数组也是:[0, 0, 0, 0, 3, 3, 6, 6, 8, 8]
原因是整个 union 过程中,find 的调用深度最多只到 2 层(例如 find(4) 走 4 -> 3 就返回了),并没有触发需要修改 parent 的深层路径。所以压缩没有实际改变任何节点的父指针。但是,如果执行完所有 union 后,再调用一次 find(4):

  1. 不压缩:find(4) 走 4 -> 3 -> 0,返回 0,parent 不变。
  2. 路径压缩:find(4) 会把 parent[4] 直接改成 0,甚至可能把 parent[3] 也改成 0(取决于递归回溯)。这样树会变得更扁,所以压缩后后续的查找非根节点的根节点就只需要 2 次就能找到

所以,不压缩时最终 parent 数组就是上面这个;压缩版本在这个例子中恰好相同,但后续查找时压缩版本会动态改变数组。

union:合并两个集合

给定两个节点 x 和 y,如果它们不在同一个集合,就合并这两个集合。步骤:找到 x 的根 rootX,找到 y 的根 rootY,如果 rootX == rootY,说明已经在同一集合,无需合并,否则,把一个根挂到另一个根下面

void union(int x, int y) {
    int rootX = find(x);
    int rootY = find(y);
    if (rootX == rootY) return;   // 已在同一集合
    parent[rootX] = rootY;        // 把 rootX 挂到 rootY 下
}

每执行一次 union,两个朋友圈就会合并成一个更大的朋友圈。过程中 parent 数组会不断变化,树结构也会动态调整。

并查集的秩

秩是并查集的一个优化措施,用于提高合并操作的效率。路径压缩优化的是 find 操作,让查找路径变短;而秩(rank)优化的是 union 操作,让合并时尽量保持树平衡,避免树退化成一条长链。

之前的 union 总是把 rootY 挂到 rootX 下:parent[rootY] = rootX;
这相当于随意合并,没有考虑两棵树的大小或高度。如果合并顺序不巧,就可能形成很长的链,导致 find 变慢。

秩通常表示树的高度或深度。在并查集中,树的高度是指从某个节点到其最远叶子节点的最长路径的长度。通过维护秩,可以在合并时选择将树较小的根合并到树较大的根下,保持树的平衡。秩(rank)通常表示树的高度上界(也可以表示集合大小,叫 size)。
初始时,每个节点自成一个集合,秩为 0。

  • 合并操作:在合并两个集合时,比较两个根节点的秩:如果一个根的秩大于另一个根的秩,则将较小的根指向较大的根。如果两个根的秩相等,则可以任选一个作为新的根,并将其秩加一。
class UnionFind {
    private int[] parent;
    private int[] rank;  // 秩数组
    private int count;

    public UnionFind(int size) {
        parent = new int[size];
        rank = new int[size];  // 初始化秩为0
        count = size;
        for (int i = 0; i < size; i++) {
            parent[i] = i;  // 每个人的父节点是自己
            rank[i] = 0;    // 初始秩为0
        }
    }

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

    public void union(int x, int y) {
        int rootX = find(x);
        int rootY = find(y);
        if (rootX != rootY) {
            // 合并秩较小的树到秩较大的树
            if (rank[rootX] > rank[rootY]) {
                parent[rootY] = rootX;
            } else if (rank[rootX] < rank[rootY]) {
                parent[rootX] = rootY;
            } else {
                parent[rootY] = rootX;
                rank[rootX]++;  // 只有当两个树的秩相等时,增加新根的秩
            }
            count--;  // 每合并一个朋友圈,计数减少
        }
    }

    public int getCircleCount() {
        return count;  // 返回朋友圈的数量
    }
}

按秩合并的规则,在 union(x, y) 时:

  1. 先找到两个根:rootX = find(x),rootY = find(y)
  2. 如果 rootX == rootY,说明已经在同一个集合,不用合并。
  3. 否则,比较 rank[rootX] 和 rank[rootY]:
    如果 rank[rootX] < rank[rootY]:把 rootX 挂到 rootY 下。
    如果 rank[rootX] > rank[rootY]:把 rootY 挂到 rootX 下。
    如果相等:随便挂一个,比如把 rootY 挂到 rootX 下,然后 rank[rootX]++。

关键点
秩为什么能优化?

  • 避免不平衡:防止树退化成链:总是把矮树挂到高树下,树高增长缓慢。
    保证树高为 O(log n):即使不使用路径压缩,单次 find 最坏也是 O(log n)。
  • 路径压缩与秩:与路径压缩互补:路径压缩让查找路径变短。按秩合并让树在合并时就不容易变高。

两者结合,均摊时间复杂度接近 O(α(n)),其中 α 是阿克曼函数的反函数,增长极慢,几乎可看作常数。这种优化在处理大量数据时尤其重要

假设有 8 个节点,按下面顺序执行 union 操作,union(x, y) 的默认实现是:parent[rootY] = rootX;

union(1, 0);
union(2, 1);
union(3, 2);
union(4, 3);
union(5, 4);
union(6, 5);
union(7, 6);

如果不按秩合并,最终的树像下面这样(这里把树横着放的)

7 —— 6 —— 5 —— 4 —— 3 —— 2 —— 1 —— 0

这是一条高度为 7 的链,根是 7。此时查找 find(0):0 → 1 → 2 → 3 → 4 → 5 → 6 → 7 , 需要走 7 步。
而按秩合并时,仍然按照同样的顺序调用 union。所有其他节点都直接挂在根 1 下面,树高为 1。此时查找 find(0):只需要走 1 步。

       1
   / / /|\  \   \
  0 2 3 4 5  6   7

生成随机朋友圈测试数据

/**
 * 随机生成指定人数的好友关系数组
 * @param n 人数,编号为 0 ~ n-1
 * @return 二维数组,每个元素为 {a, b},表示 a 和 b 是好友
 */
public static int[][] generateRandomRelations(int n) {
    List<int[]> edges = new ArrayList<>();
    Random random = new Random();
    // 随机选择一个连接概率,使每次生成的关系密度不同
    // 概率范围 0.2 ~ 0.7,也可以固定为 0.5
    double probability = 0.2 + random.nextDouble() * 0.5;
    // 遍历所有不重复的两人组合
    for (int i = 0; i < n; i++) {
        for (int j = i + 1; j < n; j++) {
            if (random.nextDouble() < probability) {
                edges.add(new int[]{i, j});
            }
        }
    }
    // 转换为二维数组返回
    int[][] relations = new int[edges.size()][2];
    for (int i = 0; i < edges.size(); i++) {
        relations[i] = edges.get(i);
    }
    return relations;
}
posted @ 2025-08-13 21:29  vonlinee  阅读(34)  评论(0)    收藏  举报