序
两个点是否连接, 在大型网络中,肉眼很难观察出来。
如何判断一个巨大网络中两个点是否连接,这个网络不一定是互联网,也可能是微信中的人际关系网,两个人是否是好友,是否有连接?巨大数据库中,电影与音乐是否有交集, 网络路由器是否连接?他们的连接路径有多少条?具体路径分别是什么?
想要解决两个点是否连接,是否有交集的问题,最快速的方法就是使用并查集。 而要解决他们的连接路径有多少以及具体路径,就需要使用到 图论,关于图论后面再讲。
个人笔记---------begin:
连接问题和路径问题:
两个点是否连接(这个问题简单)
连接两个点的路径(可能一种或多种 )
为什么在一个有序数组中查找某个数的位置,用二分查找法(OlogN)比 遍历的方式快呢?除了前面的证明 用文字来形容的话,就是 顺序查找法把我们需要查找的元素以及排在它前面的元素的位置都找出来了,只是因为我们不关心前面的元素的位置,所以没存下来,但是由于有这个过程,就很慢。
而二分查找法,每次排除一半,我们只是找出了其中几个元素的位置,也没存起来,知道找到我们想要的位置
算法和数据结构设计出来, 自己思考一下,除了能解决我想要解决的问题,是不是还有其他作用? 如果作用越多,则可能消耗的性能更多,所以如果不需要额外功能,应考虑尽量做减法,较少功能
个人笔记---------end:
并查集
并查集总共有两个关键操作:
- 查,就是查看是否在一个集合,如果两点相连,则存在于同一个集合中。
- 并, 就是求合集, 理论上如果两个集合中的点互不相连,如果要把他们合并到一起,则只需要再两个集合中任意各选一个点连接起来,则所有点都连接起来了;
1、并查集的代码实现(基础版–快速并查集)
比如有10个点,我们用0~9表示,如果他们之间相连,则用相同的一个数字来标记,表示他们是一个集合。
如上所示, 表示 0~4 是一个集合,他们相互之间是连接的, 5~9是另一个相互连接的点的集合。 那么当某些点是同一个集合时,那他们的id就用同一个值来表示。
根据上面的卢纶分析,现在用代码实现并查集的第一个版本
public class UnionFind {
private int[] id;//存放id
private int count;
public UnionFind(int n) {
count = n;
id = new int[n];
for(int i=0; i<n; i++) {
id[i] = i;
}
}
/**
* 返回p所在的集合id
*/
public int find(int p) {
return id[p];
}
public boolean isConnected(int p, int q) {
return find(p) == find(q);
}
public void unionElements(int p, int q) {
int pId = find(p);
int qId = find(q);
if(pId == qId) {
return;
}
for (int i = 0; i < count; i++) {
if(id[i] == pId) {
id[i] = qId;//将两个集合修改为同一个集合,需要将所有id修改为一样的
}
}
}
}
快速并查集 查的速度很快(时间复杂度O(1)), 但是并的过程很慢(O(n^2))。
2、并查集的第一个优化(树形结构)
在前面的实现中,合并时每次都需要去遍历整个集合,效率是比较低的,我们需要考虑优化
继续分析,我们把每一个点当做一个节点, 刚开始时默认他们是相互独立的:
这个时候,每个节点都指向自己,如下图,若5和6相连,则6节点指向5节点, 3和4相连,则4指向3节点,如果这是4要与8相连,则直接由4的根节点,也就是3指向8就可以了。 因为我们要保持每一个节点只指向一个父节点,这样才能更好的用代码实现, 虽然理论上任意两点相连均可以。
那最后就可能形成一种树形结构,下图中6指向了5, 7也指向了5, 刚开始时, 1、2、3三个节点是一个集合, 4、5、6是一个集合,
如果要合并连个集合,只需要其中一个集合的根节点指向两一个集合的根节点,
这样,所有节点都连接在一起了,形成一个新的集合, 这里根节点5指向2即可,结合形成了一棵树形的结构:
代码实现:
public class UnionFind2 {
private int[] parent;//存放父节点
public UnionFind2(int n) {
parent = new int[n];
for(int i=0; i<n; i++) {
parent[i] = i;//初始父节点就是自己
}
}
/**
* 返回p所在的集合父节点
*/
public int find(int p) {
while(p != parent[p]) {//父节点不是自己,则需要找到父节点
p = parent[p];
}
return p;
}
public boolean isConnected(int p, int q) {
return find(p) == find(q);
}
public void unionElements(int p, int q) {
int pRoot = find(p);
int qRoot = find(q);
if(pRoot == qRoot) {
return;
}
parent[pRoot] = qRoot;//将其中一个集合的父节点指向另一个集合
}
}
3、并查集的第二个优化(合并时的优化)
上面我门只是考虑了由一个树的根节点指向两一个树的根节点,其实还有可以优化的点,我们在查找一个节点的根节点时,其实是需要不断向上遍历的, 因此,树的层级越高,则遍历需要的时间越多,所以我们需要尽量减少树的层级。层数的减少将极大的提升时间效率。
那么,我们在合并两个集合时,应该尽量使层数低的那一棵树的根节点指向层数高的树的根节点,这样,就不会增加树的最大层数。
如上图, 如果要合并7、8两个集合,8有三层,7只有两层,那么就应该7指向8,合并后依然是三层。
代码实现:
/**
* 元素少的集合的根节点指向元素多的集合的根节点,使整个树的层数比较少,
* 减少每次从下往上寻找根节点时的时间,提升效率
* @author admin
*
*/
public class UnionFind4 {
private int[] parent;
private int[] rank;//rank[i] 表示以i为根的集合所表示的树的层数
public UnionFind4(int n) {
parent = new int[n];
rank = new int[n];
for(int i=0; i<n; i++) {
parent[i] = i;//初始父节点就是自己
rank[i] = 1;//初始层数为1
}
}
/**
* 返回p所在的集合父节点
*/
public int find(int p) {
while(p != parent[p]) {//父节点不是自己,则需要找到父节点
p = parent[p];
}
return p;
}
public boolean isConnected(int p, int q) {
return find(p) == find(q);
}
public void unionElements(int p, int q) {
int pRoot = find(p);
int qRoot = find(q);
if(pRoot == qRoot) {
return;
}
if(rank[pRoot] < rank[qRoot]) {
//比较谁的层数小,至少小一层, 所以此处合并后层数高度不会超过rank[qRoot]以前的最大层数
parent[pRoot] = qRoot;//将p集合的根节点指向q集合的根节点
} else if (rank[pRoot] > rank[qRoot]){
parent[qRoot] = pRoot;
} else {
//如果层数相等,则任意一个指向另一个即可, 不过层数+1
parent[pRoot] = qRoot;
rank[qRoot] += 1;
}
}
}
4、并查集的第三个优化(路径压缩)
上一个优化的思想,是在合并时用层级低的根节点指向层级高的根节点,减少树的层级。既然减少树的层级可以极大提升效率,那我们是否可以继续减少树的层级呢? 答案是肯定的。
我们在最开始统计数据, 或者说最开始建立一个集合的树形结构时,就可以尽量去减少树的层次。
如上图,如若刚开始0~4是相连的,那么从最底层开始,去查找4的父节点3 是否有父节点,3有父节点为2,那么久将4的父节点指向2
此时,就降低了一个层级了,既然找到2 了,我们继续去查找2的父节点是否有父节点, 1有父节点为0,则将2的父节点有指向0,则将2直接指向0这个节点,如下:
这样,就将原先的5层减少为了三层。
最终,4指向2, 2指向2,可以发现,在压缩路径的过程中,每次条约进行的,都是在寻找其父节点是否有父节点,有就插入到其父节点的父节点上。
代码实现:
/**
* 元素少的集合的根节点指向元素多的集合的根节点,使整个树的层数比较少,
* 减少每次从下往上寻找根节点时的时间,提升效率
* @author admin
*
*/
public class UnionFind5 {
private int[] parent;
private int[] rank;//rank[i] 表示以i为根的集合所表示的树的层数
public UnionFind5(int n) {
parent = new int[n];
rank = new int[n];
for(int i=0; i<n; i++) {
parent[i] = i;//初始父节点就是自己
rank[i] = 1;//初始层数为1
}
}
/**
* 返回p所在的集合父节点
*/
public int find(int p) {
while(p != parent[p]) {//父节点不是自己,则需要找到父节点
parent[p] = parent[parent[p]];
p = parent[p];
}
return p;
}
public boolean isConnected(int p, int q) {
return find(p) == find(q);
}
public void unionElements(int p, int q) {
int pRoot = find(p);
int qRoot = find(q);
if(pRoot == qRoot) {
return;
}
if(rank[pRoot] < rank[qRoot]) {
//比较谁的层数小,至少小一层, 所以此处合并后层数高度不会超过rank[qRoot]以前的最大层数
parent[pRoot] = qRoot;//将p集合的根节点指向q集合的根节点
} else if (rank[pRoot] > rank[qRoot]){
parent[qRoot] = pRoot;
} else {
//如果层数相等,则任意一个指向另一个即可, 不过层数+1
parent[pRoot] = qRoot;
rank[qRoot] += 1;
}
}
}
5、并查集的第三个优化2(递归实现路径压缩)
通过前一个路径压缩的思考,可能有人会有疑问,既然可以压缩路径,为什么不压缩为最短呢?
如果要实现如上图的功能,我们完全可以用递归完成,但是经过测试,实际的时间效率回复 前一种递归压缩要慢 10%左右,原因就在于,需要递归比较的次数太多了,反而消耗了较多的时间, 在前一个路径压缩的代码上只需要修改find方法即可:
public int find(int p) {
// path compression 1
//while(p != parent[p]) {//父节点不是自己,则需要找到父节点
// parent[p] = parent[parent[p]];
// p = parent[p];
//}
//return p;
// path compression 2, 使用递归实现
if( p != parent[p] )
parent[p] = find( parent[p] );
return parent[p];
}