【并查集】U000_并查集初探

一、并查集要解决的问题

给出两个节点,判断它们是否连通;如果连通,不需要给出具体的路径

二、应用场景

网络连接判断:

  • 如果每个 p a i r pair 中的两个整数分别代表一个网络节点(坐标),那么该 p a i r pair 就是用来表示这两个节点是需要连通的。那么为所有的 p a i r s pairs 建立了动态连通图后,就能够尽可能少的减少布线的需要,因为已经连通的两个节点会被直接忽略掉。

变量名等同性(类似于指针的概念):

  • 在程序中,可以声明多个引用来指向同一对象,这个时候就可以通过为程序中声明的引用和实际对象建立动态连通图来判断哪些引用实际上是指向同一对象。

三、编码的思想

对于连通的所有节点,我们可以认为它们属于一个组,因此不连通的节点必然就属于不同的组。通过判断它们属于的组,然后看看这两个组是否相同,如果相同,那么这两个节点连通,反之不连通。

N N 个节点使用 0 0 N 1 N-1 的整数表示

for(int i = 0; i < size; i++)
	id[i] = i;  

四、并查集的迭代版本

并查集 V 1.0 V 1.0

public class UnionFind01 {
  private int[] id;  // 存储结点所在的组 id
  private int count; // 结点数量
  public UnionFind01(int N) {
    count = N;
    id = new int[N];
    for(int i = 0; i < N; i++)
      id[i] = i;
  }

  public int count() { return count; }

  public int find(int p) {
    if(p < 0 || p >= id.length)
      throw new IllegalArgumentException("p is out of bound");
    return id[p];
  }

  public boolean connected(int p, int q) {
	  return find(p) == find(q);
  }

  // 合并元素 p 和元素 q 所在的组
  public void union(int p, int q) {
    // 获得 p 和 q 的组号
    int pID = find(p);
    int qID = find(q);
    if (pID == qID)
      return;
    for (int i = 0; i < id.length; i++)
      if (id[i] == pID) id[i] = qID;
    count--;
  }
}

优缺点

  • 优点 f i n d find 方法十分高效,因为仅仅需要一次数组读取操作就能够找到该节点的组号。
  • 缺点:对于需要添加新路径的情况就必须对整个数组进行遍历,找到需要修改的节点,逐一修改。
    • 如果要添加的新路径的数量是 M M ,节点数量是 N N ,那么最后的时间复杂度就是 M N M*N

并查集 V 2.0 V 2.0

并查集 V 1.0 V 1.0 每个节点没有很好地被组织起来,所属的组号只能单独记录,当涉及到修改的时候,只能逐一修改。

如何将节点以更好的方式组织起来?链表,图,树 ???,但是哪种结构对于查找和修改的效率最高?毫无疑问是

我们可以这样设计: i d [ p ] id[p] 的值就是 p p 节点的父节点的序号,如果 p p 是树根的话, i d [ p ] id[p] 的值就是 p p

/**
 * @Author: Hoji(PAN先森)
 * @Date: 1/20/2020 4:27 PM
 * @个人博客:https://www.hoji.site
 */
public class UnionFind02 {
  private int[] parent;  // 存储结点的父节点 id
  private int count;     // 结点数量
  public UnionFind02(int N) {
    count = N;
    parent = new int[N];
    for(int i = 0; i < N; i++)
      parent[i] = i;
  }

  public int count()  { return count; }

  /**
   * 查找元素p对应的集合编号,O(height)
   */
  public int find(int p) {
    if(p < 0 || p >= parent.length)
      throw new IllegalArgumentException("p is out of bound");

    while(p != parent[p])
      p = parent[p];

    return p;
  }

  /**
   * 查询p与q是否属于同一个集合
   * 时间复杂度:O(height)
   */
  public boolean isConnected(int p, int q) {
	  return find(p) == find(q);
  }

  /**
   * 合并元素 p 和元素 q 所在的集合
   * 时间复杂度:O(h)
   */
  public void union(int p, int q) {
    // 获得 p 和 q 的组号(即跟根节点的组号)
    int pRootID = find(p);
    int qRootID = find(q);

    if(pRootID == qRootID)  return;

    parent[pRootID] = qRootID;
    count--;  // 连通分量减一
  }
}

优缺点

  • 优点 u n i o n f i n d i s C o n n e c t e d union、find、isConnected 方法的性能对数据的规模的敏感度比之前的版本降低了很多。
  • 缺点:树这种数据结构容易出现极端情况,因为在建树的过程中,树的最终形态严重依赖于输入数据本身的性质,比如数据是否排序,是否随机分布等等。
    • 比如在输入数据是有序的情况下,构造的 B S T BST 会退化成一个链表。

解决树的极端情况

使用 s i z e [ ] size[] 数组来记录每一棵树对应的节点数。


并查集 V 3.0 V 3.0

将并查集 V 2.0 V 2.0 版本的代码加入以下(更换)代码,其他不变,

  private int[] size;
  public UnionFind03(int N) {
    count = N;
    parent = new int[N];
    size = new int[N];
    for(int i = 0; i < N; i++) {
      parent[i] = i;
      size[i] = 1;
    }
  }
  /**
   * 将节点数少的子集合并元素到结点数多的子集
   * 时间复杂度:O(h)
   * @param p
   * @param q
   */
  public void union(int p, int q) {
    // 获得 p 和 q 的组号(即跟根节点的组号)
    int pRootID = find(p);
    int qRootID = find(q);

    if(pRootID == qRootID)  return;

    // 如果p所属的树的结点数 > q所属树的结点数,就让q指向p的树根
    if(size[pRootID] > size[qRootID]) {
      parent[qRootID] = pRootID;
      size[pRootID] += size[qRootID];
    }else {
      parent[pRootID] = qRootID;
      size[qRootID] += size[pRootID];
    }
    count--;  // 连通分量减一
  }

优缺点

  • 优点:并查集 V 3.0 V 3.0 主要是对 u n i o n union 方法进行优化,使得树型变得极端的概率 减少 ,注意只是减少。
  • 缺点:比如下图的这种情况,两棵树的深度不能作对比,导致合并后,树的整体深度变大。

解决不记录树的深度的情况

引入一个记录以某个结点为根的树的最大深度数组。


(4) 并查集 V 4.0 V 4.0

将并查集 V 3.0 V 3.0 s i z e size 数组改为 r a n k rank 数组,再修改 u n i o n union 方法即可。

  • r a n k [ i ] rank[i] 表示以 i i 为根的集合(树)的最大高度
  private int[] rank;    
  public UnionFind04(int N) {
    count = N;
    parent = new int[N];
    rank = new int[N];
    for(int i = 0; i < N; i++) {
      parent[i] = i;
      rank[i] = 1;
    }
  }
  /**
   * 将高度低的树并元素到高度高的树
   * 时间复杂度:O(h)
   */
  public void union(int p, int q) {
    // 获得 p 和 q 的组号(即跟根节点的组号)
    int pRootID = find(p);
    int qRootID = find(q);

    if(pRootID == qRootID)  return;

    // 如果p所属的树的结点数 > q所属树的结点数,就让q指向p的树根
    if(rank[pRootID] > rank[qRootID])
      parent[qRootID] = pRootID;
    else if(rank[pRootID] < rank[qRootID])
      parent[pRootID] = qRootID;
    else {
      parent[pRootID] = qRootID;
      rank[qRootID]++;
    }
    count--;  // 连通分量减一
  }

优缺点

  • 优点:并查集 V 4.0 V 4.0 通过记录以 i i 为根的树的高度,来进行 u n i o n union 两棵子树,使得树型变得极端的概率 大大减少
  • 缺点:随着树合并得越来越多,每棵树的高度都显得参差不齐。而最理想的树形是每一棵子树的结点只有一个(或者尽量让子树的高度降低),见下图。

尽量让子树的高度降低的解决方案

路径压缩( P a t h C o m p r e s s i o n Path-Compression ): p a r e n t [ p ] = p a r e n t [ p a r e n t [ p ] ] parent[p] = parent[parent[p]]


(5) 并查集 V 5.0 V 5.0

f i n d find 方法中查找结点 4 4 的根 r o o t root 时,我们在并查集 V 4.0 V 4.0 及以前的版本中,我们不断执行以下代码。其实我们可以不这样做:

while(p != parent[p])
   p = parent[p];

我们在 f i n d find 过程中将 路径压缩

while(p != parent[p]) {
   parent[p] = parent[parent[p]]; // 让p的父亲指向父亲的父亲
   p = parent[p];
}

—> —>

最后我们并查集 V 5.0 V 5.0 代码将会是 —>

public class UnionFind05 {
  private int[] parent;  // 存储结点的父节点 id
  private int count;     // 结点数量
  private int[] rank;    // rank[i]表示一i为根的集合(树)的最大高度
  public UnionFind05(int N) {
    count = N;
    parent = new int[N];
    rank = new int[N];
    for(int i = 0; i < N; i++) {
      parent[i] = i;
      rank[i] = 1;
    }
  }

  public int count()  { return count; }

  public int find(int p) {
    if(p < 0 || p >= parent.length)
      throw new IllegalArgumentException("p is out of bound");
	// 路径压缩
    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 union(int p, int q) {
    // 获得 p 和 q 的组号(即跟根节点的组号)
    int pRootID = find(p);
    int qRootID = find(q);

    if(pRootID == qRootID)  return;

    // 如果p所属的树的结点数 > q所属树的结点数,就让q指向p的树根
    if(rank[pRootID] > rank[qRootID])
      parent[qRootID] = pRootID;
    else if(rank[pRootID] < rank[qRootID])
      parent[pRootID] = qRootID;
    else {
      parent[pRootID] = qRootID;
      rank[qRootID]++;
    }
    count--;  // 连通分量减一
  }
}

优缺点

  • 优点:并查集 V 5.0 V 5.0 可说得上是最完美的了。
  • 缺点:在这个版本的结点还不是全是叶子结点。怎么让树达到只有孩子结点的状态呢?

递归实现 | 多次调用 find 方法

可将 f i n d find 方法改为一下代码,即可在查找时让树的所有结点变为叶子结点。

if(p != parent[p]) {
  p = find(parent[p]);
}

五、并查集的时间复杂度

O ( 1 ) < O ( m ) < O ( l o g n ) O(1) < O(m) < O(logn) m m 为并查集的数据规模。

发布了300 篇原创文章 · 获赞 48 · 访问量 8045

猜你喜欢

转载自blog.csdn.net/qq_43539599/article/details/104052765