高级数据结构并查集详解

并查集(Union Find)

定义

孩子节点指向父亲节点的一种很不一样的树形结构,对于一组数据,主要支持两个动作:

  • u n i o n ( p , q ) union(p, q) union(p,q):把元素 p p p和元素 q q q所在的两个不相交的集合合并为一个集合
  • i s C o n n e c t e d ( p , q ) isConnected(p,q) isConnected(p,q):查询元素 p p p和元素 q q q是否在同一个集合中

基本的数据表示

在这里插入图片描述

0 / 1 0/1 0/1表示不同的集合,上图中元素 [ 0 , 2 , 4 , 6 , 8 ] [0,2,4,6,8] [0,2,4,6,8]属于同一个集合,元素 [ 1 , 3 , 5 , 7 , 9 ] [1,3,5,7,9] [1,3,5,7,9]的属于同一个集合

接口定义

/**
 * 并查集支持的操作
 */
public interface UF {
    boolean isConnected(int p, int q);
    void unionElements(int p, int q);
    int getSize();
}

版本一:Quick Find

初始化

public class UnionFind1 implements UF{
    private int[] id;                                       // 节点x的父节点是id[x]
    public UnionFind1(int size) {
        id = new int[size];

        for (int i = 0; i < id.length; i++) {
            id[i] = i;                                    // 此时 每个元素都所属不同的集合
        }
    }
    
    @Override
    public int getSize() {
        return id.length;
    }
}

isConnected

在这里插入图片描述

/**
  * 查找元素p所对应的集合编号
  * O(1)
  * @param p
  * @return
  */
private int find(int p) {
    if (p < 0 || p >= id.length) {
        throw new IllegalArgumentException("p is out of bound.");
    }
    return id[p];
}

/**
  * 元素p和元素q是否属于同一个集合
  * O(1)
  * @param p
  * @param q
  * @return
  */
@Override
public boolean isConnected(int p, int q) {
    return find(p)==find(q);
}

Union

在这里插入图片描述
可以将4所属集合中的元素都合并到1所属集合中,也可以将1所属集合中的元素都合并到2所属集合中:

在这里插入图片描述

/**
 * 合并元素pq所属的集合
 * O(n)
 * @param p
 * @param q
 */
@Override
public void unionElements(int p, int 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;
        }
    }
}

时间复杂度分析

Quick Find本质上属于用数组模拟并查集的操作

  • unionElements(p,q)​时间复杂度为 O ( n ) O(n) O(n)
  • isConnected(p,q)​时间复杂度为 O ( 1 ) O(1) O(1)

版本二:Quick Union

基本思想

将每一个元素,看做是一个节点,节点之间相连接形成树形结构,该树形结构中是孩子节点指向父亲节点:
在这里插入图片描述
上图的数据表示如下:
在这里插入图片描述

Quick Union下的数据表示

在这里插入图片描述
在这里插入图片描述
初始化时,相当于有10颗树

public class UnionFind2 implements UF{
    private int[] parent;

    public UnionFind2(int size) {
        parent = new int[size];

        for (int i = 0; i < size; i++) {
            parent[i] = i;                 // 初始化:每个节点指向本身,即每个节点都是一棵独立的树
        }
    }

    @Override
    public int getSize() {
        return parent.length;
    }
}

Union

U n i o n ( 4 , 3 ) Union(4,3) Union(4,3):让4节点指向3节点,数组中表示即为parent[4]=3
在这里插入图片描述
在这里插入图片描述
U n i o n ( 3 , 8 ) Union(3,8) Union(3,8):让3节点指向8节点,数组中表示即为parent[3]=8​

在这里插入图片描述
在这里插入图片描述
U n i o n ( 6 , 5 ) Union(6,5) Union(6,5):让6节点指向5节点,数组中表示即为parent[6]=5
在这里插入图片描述
在这里插入图片描述
U n i o n ( 9 , 4 ) Union(9,4) Union(9,4):让9节点指向4节点所在树的根节点,涉及查询操作,查询4所在树的根节点,如果让9直接指向4就形成了链表,体现不出树的优势,数组中表示即为parent[9]=8
在这里插入图片描述
在这里插入图片描述
U n i o n ( 6 , 2 ) Union(6,2) Union(6,2):让6节点所在树的根节点指向2节点所在树的根节点
在这里插入图片描述

/**
 * 查找元素p所对应的集合编号
 * O(h),h为树的高度
 * @param p
 * @return
 */
private 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;
}
/**
 * 合并元素pq所属的集合
 * O(h),h为树的高度
 * @param p
 * @param q
 */
@Override
public void unionElements(int p, int q) {
    int pRoot = find(p);
    int qRoot = find(q);

    if (pRoot == qRoot) {
        return ;
    }

    parent[pRoot] = qRoot;
}

isConnected

/**
 * 元素p和元素q是否属于同一个集合
 * O(h),h为树的高度
 * @param p
 * @param q
 * @return
 */
@Override
public boolean isConnected(int p, int q) {
    return find(p) == find(q);
}

时间复杂度分析

  • unionElements(p,q)isConnected(p,q)的时间复杂度均为 O ( h ) O(h) O(h) h h h为树的高度,通常树高远远小于数据总量 n n n

版本三:Quick Union基于size的优化

版本二中由于在合并两个树时不对两个树的形状做判断,因此有可能形成链表,例如下图经由 U n i o n ( 0 , 1 ) Union(0,1) Union(0,1) U n i o n ( 0 , 2 ) Union(0,2) Union(0,2) U n i o n ( 0 , 3 ) Union(0,3) Union(0,3)形成:
在这里插入图片描述
一个简单的解决方案是考虑树的节点数目size

在这里插入图片描述
例如上图考虑 U n i o n ( 9 , 4 ) Union(9,4) Union(9,4),如果让8节点直接指向9,那么新的树高到达了4,但如果让9节点指向8,那么新的树高仍然为3

在这里插入图片描述
因此让节点数目少的那颗树的根节点指向节点数目多的那颗树的根节点,这样有更高的概率让形成的新树的高度比较低

/**
 * 优化第二版的unionElements方法
 * 基于size的优化
 */
public class UnionFind3 implements UF {
	private int[] parent;
	private int[] sz;         // sz[i]表示以i为根的集合中元素个数

	public UnionFind3(int size) {
		parent = new int[size];
		sz = new int[size];
		for (int i = 0; i < size; i++) {
			parent[i] = i;
			sz[i] = 1;
		}
	}

	@Override
	public int getSize() {
		return parent.length;
	}

	/**
	 * 查找元素p所对应的集合编号
	 * O(h),h为树的高度
	 * @param p
	 * @return
	 */
	private 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(h)
	 * @param p
	 * @param q
	 * @return
	 */
	@Override
	public boolean isConnected(int p, int q) {
		return find(p) == find(q);
	}

	/**
	 * 合并元素pq所属的集合
	 * O(h)
	 * @param p
	 * @param q
	 */
	@Override
	public void unionElements(int p, int q) {
		int pRoot = find(p);
		int qRoot = find(q);

		if (pRoot == qRoot) {
			return ;
		}

		/*
		 * 让元素个数比较少的根节点指向元素个数比较多的根节点
		 * 避免形成链表
		 */
		if (sz[pRoot] < sz[qRoot]) {
			parent[pRoot] = qRoot;   // pRoot指向qRoot
			sz[qRoot] += sz[pRoot];
		} else {
			parent[qRoot] = pRoot;
			sz[pRoot] += sz[qRoot];
		}
	}
}

版本四:Quick Union基于rank的优化

版本三的优化思路是在每次合并两颗树时,尽量保证形成的新树的高度不会增加
在这里插入图片描述
考虑对上图所示的并查集进行 U n i o n ( 4 , 2 ) Union(4,2) Union(4,2)操作,根据版本三的执行逻辑,应该是节点8指向节点7,但是明显树高增加了
在这里插入图片描述
所以更加合理的合并方案应该是让树高比较低的树的根节点指向树高比较高的根节点:
在这里插入图片描述
因此合并时应该让深度比较低的那颗树向深度比较高的那颗树合并,使用rank[i]记录根节点为i的树的高度

/**
 * 基于rank的优化
 */
public class UnionFind4 implements UF {
    private int[] parent;
    private int[] rank;         // rank[i]表示以i为根的集合所表示的树的层数

    public UnionFind4(int size) {
        parent = new int[size];
        rank = new int[size];
        for (int i = 0; i < size; i++) {
            parent[i] = i;
            rank[i] = 1;
        }
    }

    /**
		 * 查找元素p所对应的集合编号
		 * O(h),h为树的高度
		 *
		 * @param p
		 * @return
		 */
    private 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;
    }

    @Override
    public int getSize() {
        return parent.length;
    }

    /**
		 * 元素p和元素q是否属于同一个集合
		 * O(h)
		 *
		 * @param p
		 * @param q
		 * @return
		 */
    @Override
    public boolean isConnected(int p, int q) {
        return find(p) == find(q);
    }

    /**
		 * 合并元素pq所属的集合
		 * O(h)
		 *
		 * @param p
		 * @param q
		 */
    @Override
    public void unionElements(int p, int q) {
        int pRoot = find(p);
        int qRoot = find(q);

        if (pRoot == qRoot) {
            return;
        }

        /*
		 * 根据两个元素所在树的rank不同判断合并方向
		 * 将rank低的集合合并到rank高的集合上
		 */
        if (rank[pRoot] < rank[qRoot]) {
            parent[pRoot] = qRoot;   // pRoot指向qRoot
        } else if (rank[qRoot] < rank[pRoot]) {
            parent[qRoot] = pRoot;
        } else {
            parent[qRoot] = pRoot;
            rank[pRoot] += 1;       
        }
    }
}

版本五:路径压缩Path Compression

在这里插入图片描述
对于如上的三棵树,虽然表示的是同一个集合,但是由于树高不同,因此执行find操作的效率也不同,最理想的情况当然是最下面那种树,树高为2,路径压缩就是在并查集中将高树压缩为矮树,路径压缩发生在执行find操作时,在查找根节点的过程中,顺便让树高降低:
在这里插入图片描述
parent[p]=parent[parent[p]]表示让p节点指向其父节点的父节点

例如查找4节点的父节点,那么执行完parent[p]=parent[parent[p]]后,4节点的父节点为2节点:
在这里插入图片描述
此时2节点仍然不是根节点,继续向上遍历:

在这里插入图片描述
至此就找到了4的根节点0,同时在查找的过程中将树的高度由5降为3,这个过程就叫做路径压缩

在这里插入图片描述

/**
 * 路径压缩
 * 优化find方法
 */
public class UnionFind5 implements UF{
    private int[] parent;
    private int[] rank;         // rank[i]表示以i为根的集合所表示的树的层数
                                // 不反应高度/深度

    public UnionFind5(int size) {
        parent = new int[size];
        rank = new int[size];
        for (int i = 0; i < size; i++) {
            parent[i] = i;
            rank[i] = 1;
        }
    }

    @Override
    public int getSize() {
        return parent.length;
    }

    /**
     * 查找元素p所对应的集合编号
     * O(h),h为树的高度
     * @param p
     * @return
     */
    private 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;
    }

    /**
     * 元素p和元素q是否属于同一个集合
     * O(h)
     * @param p
     * @param q
     * @return
     */
    @Override
    public boolean isConnected(int p, int q) {
        return find(p) == find(q);
    }

    /**
     * 合并元素pq所属的集合
     * O(h)
     * @param p
     * @param q
     */
    @Override
    public void unionElements(int p, int q) {
        int pRoot = find(p);
        int qRoot = find(q);

        if (pRoot == qRoot) {
            return ;
        }

        /*
         * 根据两个元素所在树的rank不同判断合并方向
         * 将rank低的集合合并到rank高的集合上
         * 并不实际反应节点的深度/高度值
         */
        if (rank[pRoot] < rank[qRoot]) {
            parent[pRoot] = qRoot;   // pRoot指向qRoot

        } else if (rank[qRoot] < rank[pRoot]) {
            parent[qRoot] = pRoot;
        } else {
            parent[qRoot] = pRoot;
            rank[pRoot] += 1;
        }
    }
}

版本六:利用递归优化路径压缩

在上一版本中,利用路径压缩将下图中左边的树压缩成右边的形状,性能已经有了较大提升
在这里插入图片描述
但是在最理想的情况下, 希望将上图左边的树直接压缩成高度为2的树:
在这里插入图片描述
这样一种路径压缩可以利用递归来实现,在查找4的根节点过程中,将4以及之前的节点全部指向根节点

/**
 * 路径压缩
 * 利用递归 优化find方法
 */
public class UnionFind6 implements UF{
    private int[] parent;
    private int[] rank;         // rank[i]表示以i为根的集合所表示的树的层数
                                // 不反应高度/深度

    public UnionFind6(int size) {
        parent = new int[size];
        rank = new int[size];
        for (int i = 0; i < size; i++) {
            parent[i] = i;
            rank[i] = 1;
        }
    }

    @Override
    public int getSize() {
        return parent.length;
    }

    /**
     * 查找元素p所对应的集合编号
     * O(h),h为树的高度
     * @param p
     * @return 根节点
     */
    private int find(int p) {

        if (p < 0 || p >= parent.length) {
            throw new IllegalArgumentException("p is out of bound.");
        }
		
        // p 节点以及p节点之前的节点都将指向根节点
        if (p != parent[p]) {
            parent[p] = find(parent[p]);
        }
        return parent[p];
    }

    /**
     * 元素p和元素q是否属于同一个集合
     * O(h)
     * @param p
     * @param q
     * @return
     */
    @Override
    public boolean isConnected(int p, int q) {
        return find(p) == find(q);
    }

    /**
     * 合并元素pq所属的集合
     * O(h)
     * @param p
     * @param q
     */
    @Override
    public void unionElements(int p, int q) {
        int pRoot = find(p);
        int qRoot = find(q);

        if (pRoot == qRoot) {
            return ;
        }

        /*
         * 根据两个元素所在树的rank不同判断合并方向
         * 将rank低的集合合并到rank高的集合上
         * 并不实际反应节点的深度/高度值
         */
        if (rank[pRoot] < rank[qRoot]) {
            parent[pRoot] = qRoot;   // pRoot指向qRoot

        } else if (rank[qRoot] < rank[pRoot]) {
            parent[qRoot] = pRoot;
        } else {
            parent[qRoot] = pRoot;
            rank[pRoot] += 1;
        }
    }
}

版本六的性能不一定高于版本五,递归是有一定的性能开销的
在版本五中,多执行几次find操作也是可以将树的高度压缩为2的,例如再一次执行find(4)+find(3)
在这里插入图片描述
在这里插入图片描述

时间复杂度分析

加入路径压缩后并查集的时间复杂度严格意义上为: O ( l o g ∗ n ) − > i t e r a t e d    l o g a r i t h m O(log^*n)->iterated\,\,logarithm O(logn)>iteratedlogarithm
l o g ∗ n = { 0  if  n ≤ 1 1 + l o g ∗ ( l o g n )  if  n > 1 log^*n=\begin{cases} 0 & \text{ if } n \le 1 \\ 1+log^*(logn) & \text{ if } n > 1 \end{cases} logn={01+log(logn) if n1 if n>1
近乎是 O ( 1 ) O(1) O(1)级别的。

应用

  • 连接问题 Connectivity Problem
    • 网络中节点间的连接状态
    • 数学中的集合类实现

Reference

  • 1
    点赞
  • 2
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 1
    评论
数据结构中的线性结构是指元素之间存在一对一的关系,形成一个有序的序列。常见的线性结构有数组和链表两种形式。 数组是一种连续存储的线性结构,元素在内存中按照顺序排列。数组具有随机访问的特点,即可以通过索引直接访问任意位置的元素。然而,数组的插入和删除操作需要移动大量元素,因此时间复杂度是O(N),其中N是数组的长度。 链表是一种离散存储的线性结构,元素在内存中通过指针连接起来。链表的插入和删除操作只需要修改指针指向,因此时间复杂度是O(1)。然而,链表的随机访问需要遍历整个链表,时间复杂度是O(N)。 为了综合数组和链表的优势,我们可以使用哈希表这种数据结构。哈希表通过哈希函数将元素映射到数组中的一个位置,每个位置对应一个链表。当插入或查找元素时,先通过哈希函数计算出元素在数组中的位置,然后在相应的链表中进行操作。这样,哈希表既可以快速定位元素,又可以高效地进行插入和删除操作。 拉链法是哈希表最常用的一种实现方法。它将哈希表中的每个位置都看作一个链表,当多个元素通过哈希函数映射到同一个位置时,它们会被依次连接成一个链表。这样,哈希表中的每个位置都可以容纳多个元素,解决了冲突的问题。 综上所述,线性结构包括数组和链表,而哈希表则是一种综合了数组和链表优势的数据结构。而拉链法是哈希表最常用的实现方法之一,通过将哈希表中的每个位置看作一个链表,解决了元素冲突的问题。<span class="em">1</span><span class="em">2</span><span class="em">3</span> #### 引用[.reference_title] - *1* *3* [数据结构讲解 ---- 线性结构详解](https://blog.csdn.net/fengyuyeguirenenen/article/details/122675420)[target="_blank" data-report-click={"spm":"1018.2226.3001.9630","extra":{"utm_source":"vip_chatgpt_common_search_pc_result","utm_medium":"distribute.pc_search_result.none-task-cask-2~all~insert_cask~default-1-null.142^v93^chatsearchT3_2"}}] [.reference_item style="max-width: 50%"] - *2* [详解Redis数据结构之跳跃表](https://download.csdn.net/download/weixin_38617604/14831886)[target="_blank" data-report-click={"spm":"1018.2226.3001.9630","extra":{"utm_source":"vip_chatgpt_common_search_pc_result","utm_medium":"distribute.pc_search_result.none-task-cask-2~all~insert_cask~default-1-null.142^v93^chatsearchT3_2"}}] [.reference_item style="max-width: 50%"] [ .reference_list ]

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

xylitolz

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值