今天要介绍一种非常重要的数据结构-并查集,这种数据结构常常被用于检测有环图的问题。
问题引入
给定一些节点,我们想要确定这些节点哪些属于同一个子集,该怎么做?现在假定这些节点之间通过边来连接,我们尝试用图片来表示一下。
我们可以看到一共有6个节点,其中0、1、2、3这几个节点之间是连通的,4和5也是连通的。所以我们可以把上面6个节点分为两个集合,集合1包含0、1、2、3共4个节点,集合2包含4和5两个节点。
通过图片表示很直观,但是,我们如何用代码实现呢?
思路分析
由上面的内容我们很容易知道,两个节点如果属于同一集合就能够找到一条路径连通这两个节点,但是怎么才能找到这条路径呢?我们需要对这些节点进行等级划分,每个节点只需要记住自己的上级节点即可,这样的话两个节点分别向上查找,一直找到顶层的节点,如果顶层节点相同,我们就判定这两个节点属于同一子集,否则不是同一子集。等等,这不就是树吗?说的没错,维基百科中这样定义并查集:在计算机科学中,并查集是一种树型的数据结构,用于处理一些不交集(Disjoint Sets)的合并及查询问题。怎么样,是不是感觉并查集的设计思路很精妙。
我们来思考一下并查集的代码怎么写,首先咱们需要设置一个parent
数组用来存储上级节点,初始时数组中的所有值均为-1,表示没有上级节点,我们先来写一下这个方法。
private void init(int[] parent) {
for (int i = 0; i < VERTICES; ++i) {
parent[i] = -1;
}
}
下一个问题,怎么寻找根节点?咱们看一下刚刚那个parent
数组是怎么用的。前面我们说过节点之间用边来相连,parent
数组用来存储上级节点的下标。如下图所示,2和3的上级节点是1,1的上级节点是0,0是根节点。同理,5的上级节点是4,4也是根节点,所以一共有两个集合。
很容易发现,当parent
存储的值为-1时表示这个节点就是根节点,我们用代码实现一下寻找根节点的算法。
private int findRoot(int[] parent, int x) {
int x_root = x;
while (parent[x_root] != -1) {
x_root = parent[x_root];
}
return x_root;
}
上面说到寻找根节点的方法,下面咱们谈谈节点之间的边是怎么构建的。还是上面那幅图,我们现在要把上面的两个集合合并为一个集合该怎么做?很简单,把其中一个根节点的父节点设置为另一个根节点,比如像下面这样,把4的父节点设置为0。
在下面的代码实现中,如果两个要构建边的节点在同一集合中就返回false,且不会建立这条边,如果不是在同一集合中就返回true,然后它们就成了同一集合。
//返回true则合并成功,返回false则合并失败
private boolean unionVertices(int[] parent, int x, int y) {
int x_root = findRoot(parent, x);
int y_root = findRoot(parent, y);
if (x_root == y_root) {
return false;
} else {
parent[y_root] = x_root;
return true;
}
}
咱们看一下完整的代码实现。
public class DisjointSets {
private int VERTICES = 6;
private void init(int[] parent) {
for (int i = 0; i < VERTICES; ++i) {
parent[i] = -1;
}
}
private int findRoot(int[] parent, int x) {
int x_root = x;
while (parent[x_root] != -1) {
x_root = parent[x_root];
}
return x_root;
}
//返回true则合并成功,返回false则合并失败
private boolean unionVertices(int[] parent, int x, int y) {
int x_root = findRoot(parent, x);
int y_root = findRoot(parent, y);
if (x_root == y_root) {
return false;
} else {
parent[y_root] = x_root;
return true;
}
}
public static void main(String[] args) {
DisjointSets d = new DisjointSets();
int[] parent = new int[d.VERTICES];
int[] rank = new int[d.VERTICES];
int[][] edges = {{0, 1}, {1, 2}, {1, 3}, {3, 4}, {2, 5}, {0, 2}};
d.init(parent);
for (int i = 0; i < edges.length; ++i) {
int x = edges[i][0];
int y = edges[i][1];
if (!d.unionVertices(parent, x, y)) {
System.out.println("Cycle detected!");
return;
}
}
System.out.println("No cycles found!");
}
}
优化分析
下面请大家考虑一个问题,假设我们有10000个点,边连接是{0, 1}, {1, 2}, {2, 3}...
以此类推,那么用上面的算法我们将得到一条长长的链,也就是每次寻找根节点的时间复杂度为
O
(
n
)
O(n)
O(n),这时候我们就需要对算法进行优化了。
我们来思考一下,当两棵树的高度不同时,应该怎么进行合并呢?还是最上面两棵树的合并,咱们看一下下面两种合并方式。
大家觉得第一种和第二种哪个更好?显然第一种优于第二种,因为第一种是将高度较小的树合并到高度较大的树上的,这样合并后的树的高度仍然是原来较大的树的高度,而相反树的高度则会变大。简单地说,如果使用第二种方式进行合并则会导致高度越来越大。
进行算法优化时,我们需要设置另一个数组rank
,然后用这个数组来储存当前节点高度。实际需要修改的代码只有init
和unionVertices
两个方法,将原始的高度都设置为0,还有就是需要判断两棵树的高度并进行比较。贴一下优化之后的代码供大家与之前的代码进行比较。
public class DisjointSets {
private int VERTICES = 6;
private void init(int[] parent, int[] rank) {
for (int i = 0; i < VERTICES; ++i) {
parent[i] = -1;
rank[i] = 0;
}
}
private int findRoot(int[] parent, int x) {
int x_root = x;
while (parent[x_root] != -1) {
x_root = parent[x_root];
}
return x_root;
}
//返回true则合并成功,返回false则合并失败
private boolean unionVertices(int[] parent, int[] rank, int x, int y) {
int x_root = findRoot(parent, x);
int y_root = findRoot(parent, y);
if (x_root == y_root) {
return false;
} else {
if (rank[x_root] > rank[y_root]) {
parent[y_root] = x_root;
} else if (rank[x_root] < rank[y_root]) {
parent[x_root] = y_root;
} else {
parent[x_root] = y_root;
rank[y_root]++;
}
return true;
}
}
public static void main(String[] args) {
DisjointSets d = new DisjointSets();
int[] parent = new int[d.VERTICES];
int[] rank = new int[d.VERTICES];
int[][] edges = {{0, 1}, {1, 2}, {1, 3}, {3, 4}, {2, 5}, {0, 2}};
d.init(parent, rank);
for (int i = 0; i < edges.length; ++i) {
int x = edges[i][0];
int y = edges[i][1];
if (!d.unionVertices(parent, rank, x, y)) {
System.out.println("Cycle detected!");
return;
}
}
System.out.println("No cycles found!");
}
}
以上是本文的全部内容,并查集的算法需要仔细研究,在leetcode中会出现一些并查集相关的问题,能够掌握相关的知识并灵活运行就能迎刃而解。