并查集详解 原理+实现

  • Post author:
  • Post category:其他




数据结构 —- 并查集(Union Find)


并查集有两个核心操作,查找和合并,O(1) 的时间复杂度查询两个元素是否在同一个集合,和将两个不同元素所在集合合并,时间复杂度也为O(1)。





一、并查集

并查集有两种实现思路


  • Quick Find


    查找(Find)的时间复杂度:0(1)

    合并(Union)的时间复杂度:0(n)


  • Quick Union


    查找(Find)的时间复杂度: O(logn)

    合并(Union))的时间复杂度: O(logn)


Quick Union还可以继续优化,均摊复杂度到O(1)。




并查集的数据存储形式


数组的下标为数据,而对应存储的值为根节点。

  • 0,1,3 为一个集合
  • 2 为一个集合
  • 4,5,6,7 为一个集合

    在这里插入图片描述


存储元素为整数,可以使用数组来存储。和堆有点类似。




并查集的接口

	int find(int e);	//查询一个元素所在集合。返回根节点
	bool isSameSet(int e1, int e2);//查询两个元素是否在同一集合
	void _union(int e1, int e2);//合并两个集合



并查集的初始化

初始化时,认为每个元素都为单独的一个集合,根节点默认为自己。

在这里插入图片描述




Quick Find 实现




Quick Find 的合并(Union)


代码示例:

	void _union(int e1, int e2) {
		assert(e1 >= 0 && e1 < parent.size());
		assert(e2 >= 0 && e2 < parent.size());
		if (isSameSet(e1, e2))	return;//已经在同一个集合

		int p1 = find(e1);//返回根节点。
		int p2 = find(e2);
		for (int i = 0; i < i < parent.size(); i++) {
			if (parent[i] == p1) {
				parent[i] = p2;
			}
		}
	}

此时进行1 和 0 合并操作,(默认左边参数挂在右边)。

将下标为1的位置存储0的根节点。

在这里插入图片描述

在这里插入图片描述


很明显能看出Quick Find的合并操作时间复杂度为O(1)。




Quick Find 的查找(Find)


代码示例:

	int find(int e) {
		check(e);
		return parent[e];
	}
	bool isSameSet(int e1, int e2) {
		check(e1);
		check(e2);
		return find(e1) == find(e1);
	}

由于合并操作,这颗树的高度始终不会超过2。实际存储数据的数组内存储的就为下标(元素)对应的根节点.


时间复杂度为O(1)。




Quick Find 完整代码

class union_find
{
public:
	// 简易实现,样本量为 [0,capacity - 1]
	union_find(int capacity) {
		assert(capacity > 0);
		for (int i = 0; i < capacity; i++) {
			parent.push_back(i);
		}

	}

	int find(int e) {
		assert(e >= 0 && e < parent.size());
		return parent[e];
	}


	bool isSameSet(int e1, int e2) {
		assert(e1 >= 0 && e1 < parent.size());
		assert(e2 >= 0 && e2 < parent.size());
		return find(e1) == find(e2);
	}


	void _union(int e1, int e2) {
		assert(e1 >= 0 && e1 < parent.size());
		assert(e2 >= 0 && e2 < parent.size());
		if (isSameSet(e1, e2))	return;//已经在同一个集合

		int p1 = find(e1);
		int p2 = find(e2);
		for (int i = 0; i < i < parent.size(); i++) {
			if (parent[i] == p1) {
				parent[i] = p2;
			}
		}
	}

private:
	std::vector<int> parent;
};



小结

由于Quick Find实现方式的 Union操作 导致这颗逻辑上的树的高度不超过2,查找直接通过下标直接定位根节点,时间复杂度可以达到O(1)。但合并的复杂度却需要遍历整个数组,时间复杂度达到了O(N)。这种实现并不实用。




Quick Union 的实现



Quick Union 的查找(Find)

两个元素的向上找,最顶部的根节点相同,就认为在同一个集合内

	int find(int e) {
		assert(e >= 0 && e < parent.size());
		while (e != parent[e])
			e = parent[e];
		return e;
	}
	bool isSameSet(int e1, int e2) {
		assert(e1 >= 0 && e1 < parent.size());
		assert(e2 >= 0 && e2 < parent.size());
		return find(e1) == find(e1);
	}




Quick Union 的合并(Union)


将一个集合的根节点连接在另一个集合的根节点下,就完成了合并。

在这里插入图片描述

	void _union(int e1, int e2) {
		assert(e1 >= 0 && e1 < parent.size());
		if (isSameSet(e1, e2))	return;//已经在同一个集合

		int p1 = find(e1);
		int p2 = find(e2);
		parent[p1] = p2;
	}



小结:

Quick Union 的查找和合并都要从底部向上查找,距离为树的高度,时间复杂度均为O(logN),但是在最坏情况下,并查集退化成链表,性能就非常低下

在这里插入图片描述




并查集的优化


有2种常见的优化方式


基于size的优化:

  • 节点少的树连接到节点多的树上


基于高度的优化:

  • 矮的树嫁接到高的树



基于size

原先都是默认左树接在右树上,这会导致某些情况出现链表情况,现在通过树的大小让小的树接在大的树下。

在这里插入图片描述


代码示例:

需要添加一个字段保存每个节点对应的大小。

	union_find(int capacity) {
		assert(capacity > 0);
		for (int i = 0; i < capacity; i++) {
			_parent.push_back(i);
			_size.push_back(1);	//默认每个节点size为1
		}

	}
	
	void _union(int e1, int e2) {
		assert(e1 >= 0 && e1 < _parent.size());
		if (isSameSet(e1, e2))	return;//已经在同一个集合

		int p1 = find(e1);
		int p2 = find(e2);

		//默认p1 size小
		if (_size[p1] > _size[p2])
			std::swap(p1, p2);

		_parent[p1] = p2;
		_size[p2] += _size[p1];
	} 



基于hight


基于size的优化,出现以下情况也会导致性能下降。

在这里插入图片描述



代码示例:

也需要一个字段保存高度信息,初始化高度为1

	void _union(int e1, int e2) {
		assert(e1 >= 0 && e1 < _parent.size());
		if (isSameSet(e1, e2))	return;//已经在同一个集合

		int p1 = find(e1);
		int p2 = find(e2);

		if (_hight[p1] < _hight[p2]) {
			_parent[p1] = p2;
		}
		else if (_hight[p1] > _hight[p2]) {
			_parent[p2] = p1;
		}
		else {
			_parent[p1] = p2;
			_hight[p2]++;
		}
	} 



路径压缩 (Path Compression)

基于rank的优化,树会相对平衡很多,但是随着Union次数的增多,树的高度依然会越来越高。

导致find操作变慢,尤其是底层节点

在这里插入图片描述


什么是路径压缩?

在find的时候,使路径上的所有节点都指向根节点,从而降低树的高度。

在这里插入图片描述



代码示例:

	int find(int e) {
		assert(e >= 0 && e < _parent.size());
		//递归调用,一路向上所有节点都指向根节点
		while (e != _parent[e])
			_parent[e] = find(_parent[e]);

		return _parent[e];
	}



路径分裂 (Path Spliting)

路径压缩将每个节点都平铺开,这样优化的性能反而可能下降。


路径分裂: 使路径上的每个节点都指向其祖父节点(parent的parent)

在这里插入图片描述

	int find(int e) {
		assert(e >= 0 && e < _parent.size());
		while (e != _parent[e]) {
			int p = _parent[e];
			_parent[e] = _parent[_parent[e]];
			e = p;
		}

		return _parent[e];
	}




版权声明:本文为juggte原创文章,遵循 CC 4.0 BY-SA 版权协议,转载请附上原文出处链接和本声明。