【数据结构】并查集(Union Find)

本文详细介绍了并查集的数据结构及其两种常见实现:QuickFind和QuickUnion,包括它们的核心操作和时间复杂度。接着讨论了如何通过基于size和rank的优化以及路径压缩(PathCompression)、路径分裂(PathSplitting)和路径减半(PathHalving)来提升性能。并查集在处理连接相关问题时表现出优越的效率,特别适合解决村庄连接等场景。

摘要生成于 C知道 ,由 DeepSeek-R1 满血版支持, 前往体验 >

需求分析

  • 假设有n个村庄,有些村庄之间有连接的路,有些村庄之间并没有连接的路
    在这里插入图片描述

  • 设计一个数据结构,能够快速执行两个操作

    • 查询2个村庄之间是否有连接的路
    • 连接2个村庄
  • 数组、链表、平衡二叉树、集合(Set)?

    • 查询、连接的时间复杂度都是: O(n)
  • 并查集能够办到查询、连接的均摊复杂度都是O(α(n)), α(n) < 5

  • 并查集非常适合解决这类"连接"相关的问题

概述

在这里插入图片描述

  • 并查集也叫作不相交集合

  • 并查集有2个核心操作

    • 查找(Find): 查找元素所在的集合(这里的集合并不是特指Set这种数据结构,是指广义的数据集合)
    • 合并(Union): 将两个元素所在的集合合并为一个集合
  • 有两种常见的实现思路

    • Quick Find
      • 查找(Find)的时间复杂度: O(1)
      • 合并(Union)的时间复杂度: O(n)
    • Quick Union
      • 查找(Find)的时间复杂度: O(log(n)),可以优化至O(α(n)), α(n) < 5
      • 合并(Union)的时间复杂度: O(log(n)),可以优化至O(α(n)), α(n) < 5

如何存储数据

  • 假设并查集处理的数据都是整型,那么可以用整型数组来存储数据
    在这里插入图片描述

在这里插入图片描述

  • 索引i表示值,nums[i]表示所处的集合

    • 0,1,3属于同一集合
    • 2单独属于一个集合
    • 4,5,6,7属于同一集合
  • 因此,并查集是可以用数组实现的树形结构(二叉堆、优先队列也是可以用数组实现的树形结构)

接口定义

在这里插入图片描述
在这里插入图片描述

初始化

  • 初始化时,每个元素各自属于一个单元素集合
    在这里插入图片描述
    在这里插入图片描述
    在这里插入图片描述

为Quick Find和Quick Union两种具体实现定义抽象父类

public abstract class UnionFind {
	protected int[] parents;
	
	public UnionFind(int capacity) {
		if (capacity < 0) {
			throw new IllegalArgumentException("capacity must be >= 1");
		}
		
		parents = new int[capacity];
		for (int i = 0; i < parents.length; i++) {
			parents[i] = i;
		}
	}
	
	/**
	 * 查找v所属的集合(根节点)
	 * @param v
	 * @return
	 */
	public abstract int find(int v);

	/**
	 * 合并v1、v2所在的集合
	 */
	public abstract void union(int v1, int v2);
	
	/**
	 * 检查v1、v2是否属于同一个集合
	 */
	public boolean isSame(int v1, int v2) {
		return find(v1) == find(v2);
	}
	
	protected void rangeCheck(int v) {
		if (v < 0 || v >= parents.length) {
			throw new IllegalArgumentException("v is out of bounds");
		}
	}
}

Quick Find

示例

在这里插入图片描述

  • union(1,0):将所有父节点为1的父节点的元素的父节点改为0的父节点
    在这里插入图片描述

  • union(1,2):将所有父节点为1的父节点的元素的父节点改为2的父节点
    在这里插入图片描述

  • union(4,0):将所有父节点为4的父节点的元素的父节点改为0的父节点
    在这里插入图片描述

  • union(3,2):将所有父节点为3的父节点的元素的父节点改为2的父节点
    在这里插入图片描述

find方法

在这里插入图片描述

在这里插入图片描述

  • find(0) = 2;
  • find(1) = 2;
  • find(3) = 3;
  • 时间复杂度: O(1)

union方法

在这里插入图片描述

  • 时间复杂度: O(n)

具体实现

public class UnionFind_QF extends UnionFind {

	public UnionFind_QF(int capacity) {
		super(capacity);
	}

	/*
	 * 父节点就是根节点
	 */
	@Override
	public int find(int v) {
		rangeCheck(v);
		return parents[v];
	}

	/**
	 * 将v1所在集合的所有元素,都嫁接到v2的父节点上
	 */
	@Override
	public void union(int v1, int v2) {
		int p1 = find(v1);
		int p2 = find(v2);
		if (p1 == p2) {
			return;
		}
		
		for (int i = 0; i < parents.length; i++) {
			if (parents[i] == p1) {
				parents[i] = p2;
			}
		}
	}
}

测试

在这里插入图片描述

  • 使用并查集构建上图
public class Main {

	public static void main(String[] args) {
		UnionFind uf = new UnionFind_QF(12);
		uf.union(0, 1);
		uf.union(0, 3);
		uf.union(0, 4);
		uf.union(2, 3);
		uf.union(2, 5);

		uf.union(6, 7);

		uf.union(8, 10);
		uf.union(9, 10);
		uf.union(9, 11);

		//判断0的父节点和5的父节点是否相同
		System.out.println(uf.isSame(0, 5));
		System.out.println(uf.isSame(2, 7));
	}
	

}

在这里插入图片描述

Quick Union

示例

在这里插入图片描述

  • union(1,0),将1的根节点指向0的根节点
    在这里插入图片描述
  • union(1,2),将1的根节点指向2的根节点
    在这里插入图片描述
  • union(4,1),将4的根节点指向1的根节点
    在这里插入图片描述

find方法

在这里插入图片描述

  • 如果当前节点值不等于父节点的值,就继续往上找
    在这里插入图片描述
  • find(0) = 2;
  • find(1) = 2;
  • find(3) = 3;
  • 时间复杂度: O(log(n))

union方法

  • 找到一个节点的根节点,将其根节点指向另一个根节点
    在这里插入图片描述
  • 时间复杂度:O(log(n))

具体实现

public class UnionFind_QU extends UnionFind {

	public UnionFind_QU(int capacity) {
		super(capacity);
	}

	/**
	 * 通过parent链条不断地向上找,直到找到根节点
	 */
	@Override
	public int find(int v) {
		rangeCheck(v);
		while (v != parents[v]) {
			v = parents[v];
		}
		return v;
	}

	/**
	 * 将v1的根节点嫁接到v2的根节点上
	 */
	@Override
	public void union(int v1, int v2) {
		int p1 = find(v1);
		int p2 = find(v2);
		if (p1 == p2) {
			return;
		}
		parents[p1] = p2;
	}

}

Quick Union - 优化

  • 在Union的过程中,可能会出现树不平衡的情况,甚至退化成链表
    在这里插入图片描述
  • 有2种常见的优化方案
    • 基于size的优化: 元素少的树 嫁接到 元素多的树
      - 基于rank的优化: 矮的树 嫁接到 高的树

基于size的优化

代码

public class UnionFind_QU_S extends UnionFind_QU {
	private int[] sizes;

	public UnionFind_QU_S(int capacity) {
		super(capacity);
		
		sizes = new int[capacity];
		Arrays.fill(sizes, 1);
	}

	/**
	 * 将v1的根节点嫁接到v2的根节点上
	 */
	@Override
	public void union(int v1, int v2) {
		int p1 = find(v1);
		int p2 = find(v2);
		if (p1 == p2) {
			return;
		}
		
		if (sizes[p1] < sizes[p2]) {
			parents[p1] = p2;
			sizes[p2] += sizes[p1];
		} else {
			parents[p2] = p1;
			sizes[p1] += sizes[p2];
		}
	}

}

测试

	public static void main(String[] args) {
		UnionFind uf = new UnionFind_QU_S(12);
		uf.union(0, 1);
		uf.union(0, 3);
		uf.union(0, 4);
		uf.union(2, 3);
		uf.union(2, 5);

		uf.union(6, 7);

		uf.union(8, 10);
		uf.union(9, 10);
		uf.union(9, 11);

		//判断0的父节点和5的父节点是否相同
		System.out.println(uf.isSame(0, 5));
		System.out.println(uf.isSame(2, 7));
	}

在这里插入图片描述

小结

  • 基于size的优化,也可能会存在树不平衡的问题
    在这里插入图片描述

在这里插入图片描述

基于rank的优化

  • 基于高度,每次把高度低的嫁接到高度高的
    在这里插入图片描述

代码

public class UnionFind_QU_R extends UnionFind_QU {
	private int[] ranks;

	public UnionFind_QU_R(int capacity) {
		super(capacity);

		ranks = new int[capacity];
		for (int i = 0; i < ranks.length; i++) {
			ranks[i] = 1;
		}
	}
	
	@Override
	public void union(int v1, int v2) {
		int p1 = find(v1);
		int p2 = find(v2);
		if (p1 == p2) {
			return;
		}
		
		if (ranks[p1] < ranks[p2]) {
			parents[p1] = p2;
		} else if (ranks[p1] > ranks[p2]) {
			parents[p2] = p1;
		} else {
			parents[p1] = p2;
			ranks[p2] += 1;
		}
	}
}

测试

public static void main(String[] args) {
		UnionFind uf = new UnionFind_QU_R(12);
		uf.union(0, 1);
		uf.union(0, 3);
		uf.union(0, 4);
		uf.union(2, 3);
		uf.union(2, 5);

		uf.union(6, 7);

		uf.union(8, 10);
		uf.union(9, 10);
		uf.union(9, 11);

		//判断0的父节点和5的父节点是否相同
		System.out.println(uf.isSame(0, 5));
		System.out.println(uf.isSame(2, 7));
	}

在这里插入图片描述

路径压缩(Path Compression) (优化)

在这里插入图片描述

  • 虽然有了基于rank的优化,树会相对平衡一点
  • 但是随着Union次数的增多,树的高度依然会越来越高
    • 导致find操作变慢,尤其是底层节点(因为find是不断向上找到根节点)
  • 什么是路径压缩?
    • 在find时使路径上的所有节点都指向根节点,从而降低树的高度
      在这里插入图片描述

代码

/**
 * Quick Union - 基于rank的优化 - 路径压缩(Path Compression)
 */
public class UnionFind_QU_R_PC extends UnionFind_QU_R {

	public UnionFind_QU_R_PC(int capacity) {
		super(capacity);
	}
	
	@Override
	public int find(int v) { // v == 1, parents[v] == 2
		rangeCheck(v);
		if (parents[v] != v) {
			parents[v] = find(parents[v]);
		}
		return parents[v];
	}
}

小结

  • 路径压缩使路径上的所有节点都指向根节点,所以实现成本稍高
  • 还有2中更优的做法,不但能降低树高,实现成本也比路径压缩低
    • 路径分裂(Path Spliting)
    • 路径减半(Path Halving)
  • 路径分裂、路径减半的效率差不多,但都比路径压缩要好

路径分裂(Path Spliting)

  • 路径分裂: 使路径上的每个节点都指向其祖父节点(parent的parent)
    在这里插入图片描述

代码

/**
 * Quick Union - 基于rank的优化 - 路径分裂(Path Spliting)
 */
public class UnionFind_QU_R_PS extends UnionFind_QU_R {

	public UnionFind_QU_R_PS(int capacity) {
		super(capacity);
	}
	
	@Override
	public int find(int v) { 
		rangeCheck(v);
		while (v != parents[v]) {
			int p = parents[v];
			parents[v] = parents[parents[v]];
			v = p;
		}
		return v;
	}
}

路径减半(Path Halving)

  • 路径减半: 使路径上每隔一个节点就指向其祖父节点(parent的parent)
    在这里插入图片描述

代码

/**
 * Quick Union - 基于rank的优化 - 路径减半(Path Halving)
 */
public class UnionFind_QU_R_PH extends UnionFind_QU_R {

	public UnionFind_QU_R_PH(int capacity) {
		super(capacity);
	}
	
	@Override
	public int find(int v) { 
		rangeCheck(v);
		while (v != parents[v]) {
			parents[v] = parents[parents[v]];
			v = parents[v];
		}
		return v;
	}
}

总结

  • 摘自《维基百科》: https://en.wikipedia.org/wiki/Disjoint-set_data_structure#Time_complexity
    在这里插入图片描述

  • 大致意思是

    • 使用路径压缩、分裂或减半 + 基于rank或者size的优化,可以确保每个操作的均摊复杂度为O(α(n)),α(n)<5
  • 大佬建议的搭配

    • Quick Union
    • 基于rank的优化
    • Path Halving 或 PathSpliting

自定义类型

  • 前面使用的都是基于整型数据,如果其它自定义类型也想使用并查集呢?
    • 方案1: 通过一些方法将自定义类型转为整型后使用并查集(比如生成哈希值)
    • 方案2: 使用链表+映射(Map)
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值