并查集
社交网络问题 - 判断是否属于同一个朋友圈
朋友圈问题:已知,有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 为好友关系数。
- 时间复杂度:O(n + m):构建邻接表:O(n + m),DFS 遍历:每个人访问一次,每条边检查两次,O(n + m)
- 空间复杂度: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点:
- 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]:
- union(0, 1):find(0)=0, find(1)=1,parent[1] = 0,此时 parent = [0, 0, 2, 3, 4, 5, 6, 7, 8, 9]
- 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] - union(3, 4):find(3)=3, find(4)=4,parent[4] = 3,此时 parent = [0, 0, 0, 3, 3, 5, 6, 7, 8, 9]
- 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]
- 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,没有变。
- union(6, 7):find(6)=6, find(7)=7,parent[7] = 6,parent = [0, 0, 0, 0, 3, 3, 6, 6, 8, 9]
- 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):
- 不压缩:find(4) 走 4 -> 3 -> 0,返回 0,parent 不变。
- 路径压缩: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) 时:
- 先找到两个根:rootX = find(x),rootY = find(y)
- 如果 rootX == rootY,说明已经在同一个集合,不用合并。
- 否则,比较 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;
}

浙公网安备 33010602011771号