C++高级数据结构算法 | 并查集(Union Find、Disjoint Sets)

知识共享许可协议 版权声明:署名,允许他人基于本文进行创作,且必须基于与原先许可协议相同的许可协议分发本文 (Creative Commons

关于并查集,推荐一篇非常好的博文《超有爱的并查集》,不足的是只使用了并查集两个主要优化中的"路径压缩"优化,其中代码主要采用迭代的方式,其实很多情况下采用递归的写法要易懂很多。本文将基于C++实现并查集并使用“按秩合并”和”路径压缩“优化并查集。



并查集的基本概念

并查集(Union Find),在一些有N个元素的集合应用问题中,我们通常是在开始时让每个元素构成一个单元素的集合,然后按一定顺序将属于同一组的元素所在的集合合并,其间要反复查找一个元素在哪个集合中。

对于一个集合 S = { a 1 , a 2 , . . . , a n 1 , a n } S=\{a_1, a_2, ..., a_{n-1}, a_n\} ,我们还可以对集合 S S 进一步划分: S 1 , S 2 , . . . , S m 1 , S m S_1,S_2,...,S_{m-1},S_m ,我们希望能够快速确定 S S 中的两两元素是否属于 S S 的同一子集

举个栗子, S = { 0 , 1 , 2 , 3 , 4 , 5 , 6 } S=\{0,1, 2, 3, 4, 5, 6\} ,如果我们按照一定的规则对集合 S S 进行划分,假设划分后为 S 1 = { 1 , 2 , 4 } S1=\{1, 2, 4\} S 2 = { 3 , 6 } S2=\{3, 6\} S 3 = { 0 , 5 } S3=\{0, 5\} ,任意给定两个元素,我们如何确定它们是否属于同一子集?某些合并子集后,又如何确定两两关系?基于此类问题便出现了并查集这种数据结构。


并查集的基本结构

并查集是一种树型的数据结构,用于处理一些不相交集合(Disjoint Sets)的合并及查询问题。常常在使用中以森林来表示

我们可以使用这种数据结构来表示集合,不同的树就是不同的集合,并查集中包含了多棵树,表示并查集中不同的子集,树的集合是森林,所以并查集属于森林


有一个联合-查找算法(union-find algorithm)定义了两个用于此数据结构的操作:

  • Find确定元素属于哪一个子集。这个确定方法就是不断向上查找找到它的根节点,它可以被用来确定两个元素是否属于同一子集。
  • Union将两个子集合并成同一个集合

由于支持这两种操作,一个不相交集也常被称为联合-查找数据结构(union-find data structure)或合并-查找集合(merge-find set)。其他的重要方法,MakeSet,用于建立单元素集合。有了这些方法,许多经典的划分问题可以被解决。

为了更加精确的定义这些方法,需要定义如何表示集合。一种常用的策略是为每个集合选定一个固定的元素,称为代表,以表示整个集合。接着,Find(x) 返回 x 所属集合的代表,而 Union 使用两个集合的代表作为参数

上图中简单演示了并查集的两个重要操作,一个是FIND,一个UNION


下面我们看一个简单的实例:

若集合 S = { 0 , 1 , 2 , 3 , 4 , 5 , 6 } S=\{0, 1, 2, 3, 4, 5, 6\} ,最初每一个元素都是一棵树。

对于 U n i o n Union 操作,我们只需要将两棵树合并,例如合并 0 1 2 0、1、2 得到
S 1 = { 0 , 1 , 2 } S1=\{0, 1, 2\} ,合并3和4得到 S 2 = { 3 , 4 } S2=\{3, 4\}

对于 F i n d Find 操作,我们只需要返回该元素所在树的根节点。所以,如果我们想要比较判断 1 1 2 2 是否在一个集合,只需要通过 F i n d ( 1 ) Find(1) F i n d ( 2 ) Find(2) 返回各自的根节点比较是否相等便可。已知树中的一个节点,找到其根节点的时间复杂度为 O ( D ) O(D) D D 为节点的深度。

我们可以使用数组来表示树,数组下标表示树的一个节点,下标所对应的值表示树的父节点。例如 P [ i ] P[i] 表示元素 i i 的父节点。对于的集合,我们可以存储在下面的数组中(第二行为数组下标),对于树的根节点,我们规定其元素值为其本身(即父节点为自己)


并查集森林及基本操作实现

并查集森林是一种将每一个集合以树表示的数据结构其中每一个节点保存着到它的父节点的引用

在并查集森林中,每个集合的代表即是集合的根节点。“查找”根据其父节点的引用向根行进直到到底树根。“联合”将两棵树合并到一起,这通过将一棵树的根连接到另一棵树的根。


再来回顾一下基本操作:

1、初始化 : 把每个点所在集合初始化为其自身。

通常来说,这个步骤在每次使用该数据结构时只需要执行一次,无论何种实现方式,时间复杂度均为 O ( N ) O(N)

2、查找 : 查找元素所在的集合,即根节点。

3、合并 : 将两个元素所在的集合合并为一个集合。

通常来说,合并之前,应先判断两个元素是否属于同一集合,这可用上面的“查找”操作实现。

class Disjoint_set {
private:
	/* 数组模拟树,其下标所对应的值为相应的父节点 */
	vector<int> parent;
public:
	/* 初始化 */
	Disjoint_set(vector<int> vec);
	
	/* 查找 */
	int Find(int item);
	
	/* 合并 */
	void Union(int node1, int node2);
};

/* 初始化:初始化每一个元素的根节点都为自身 */
Disjoint_set::Disjoint_set(vector<int> vec)
{
	int length = vec.size();
	for (int i = 0; i < length; i++)
	{
		parent[vec[i]] = vec[i];
	}
}

/* 查找:查找元素所在的集合,即根节点 */
int Disjoint_set::Find(int item)
{
	/* 根节点的父节点就是根节点,因此查询到即返回根节点 */
	if (parent[item] == item)
	{
		return item;
	}
	else
	{
		/* 向根节点递归,直到查找到根返回 */
		return Find(parent[item]);
	}
}

/* 合并: 将两个元素所在的集合合并为一个集合,前提是它们不属于同一个集合 */
void Disjoint_set::Union(int node1, int node2)
{
	int set1 = Find(node1);
	int set2 = Find(node2);
	if(set1 != set2)
	{
		parent[set1] = set2;
	}
}

/* 查找的迭代版本:一个while循环即可解决 */
int Disjoint_set::Find(int item)
{
	while (parent[item] != item)
	{
		item = parent[item];
	}

	return item;
}

这是并查集森林的最基础的表示方法,这个方法不会比链表法好,这是因为创建的树可能会严重不平衡

通过上面的实现,可以看出每一次Find操作的时间复杂度为O(N),N为树的高度,由于我们没有对树做特殊处理,所以树的不断合并可能会使树严重不平衡,最坏情况每个节点都只有一个子节点,如下图(第一个点为根节点)

此时Find操作的时间复杂度为O(n),这显然不是我们想要的。针对树的不平衡问题,下面我们引入两种优化。


并查集的两种优化

按秩合并

第一种方法,称为“按秩合并”,即总是将更小的树连接至更大的树上。因为影响运行时间的是树的深度,更小的树添加到更深的树的根上将不会增加秩除非它们的秩相同。在这个算法中,术语“秩”替代了“深度”,因为同时应用了路径压缩时秩将不会与高度相同。单元素的树的秩定义为0,当两棵秩同为 r r 的树联合时,它们的秩 r + 1 r+1

void Disjoint_set::Union(int node1, int node2)
{
	int set1 = Find(node1);
	int set2 = Find(node2);

	/* node1 与 node2 属于同一个集合 */
	if (set1 == set2)
		return;

	/* set1的秩大于set2的秩,那么set2合并到set1 */
	if (rank[set1] > rank[set2])
	{
		parent[set2] = set1;
	}
	/* set1的秩小于set2的秩,那么set1合并到set2 */
	else if (rank[set1] < rank[set2])
	{
		parent[set1] = set2;
	}
	else
	{
		parent[set1] = set2;
		/* 两个秩相等,可任意合并到一个上,但是需要更新相应的rank */
		rank[set2] += 1;
	}
}

路径压缩

第二个优化,称为“路径压缩”,是一种在执行“查找”时扁平化树结构的方法。关键在于在路径上的每个节点都可以直接连接到根上;他们都有同样的表示方法。为了达到这样的效果,Find递归地经过树,改变每一个节点的引用到根节点。得到的树将更加扁平,为以后直接或者间接引用节点的操作加速

int Disjoint_set::Find(int item)
{
	if (parent[item] == item)
	{
		return item;
	}
	else
	{
		/* 递归回溯时写入item对应的父节点 */
		return parent[item] = Find(parent[item]);
	}
}

下面看一下迭代版本,便于理解:

int Disjoint_set::Find(int item)
{
	/* 保存item结点,最后更新父节点要使用 */
	int child = item;

	/* 找到根item */
	while (parent[item] != item)
	{
		item = parent[item];
	}

	while (child != item)
	{
		/* 先保存父节点 */
		int tmp = parent[child];
		/* 父节点指向根item */
		parent[child] = item;

		child = tmp;
	}

	return item;
}

下面是路径压缩与按秩合并的完整代码:

class Disjoint_set {
private:
	/* 数组模拟树,其下标所对应的值为相应的父节点 */
	vector<int> parent;
	/* 存储树的秩 */
	vector<int> rank;
public:
	/* 初始化 */
	Disjoint_set(vector<int> vec);
	
	/* 查找 */
	int Find(int item);
	
	/* 合并 */
	void Union(int node1, int node2);
};

Disjoint_set::Disjoint_set(vector<int> vec)
{
	int length = vec.size();
	for (int i = 0; i < length; i++)
	{
		parent[vec[i]] = vec[i];
		/* 初始化秩 */
		rank[vec[i]] = vec[i];
	}
}

int Disjoint_set::Find(int item)
{
	if (parent[item] == item)
	{
		return item;
	}
	else
	{
		/* 递归回溯时写入item对应的父节点 */
		return parent[item] = Find(parent[item]);
	}
}

void Disjoint_set::Union(int node1, int node2)
{
	int set1 = Find(node1);
	int set2 = Find(node2);

	/* node1 与 node2 属于同一个集合 */
	if (set1 == set2)
		return;

	/* set1的秩大于set2的秩,那么set2合并到set1 */
	if (rank[set1] > rank[set2])
	{
		parent[set2] = set1;
	}
	/* set1的秩小于set2的秩,那么set1合并到set2 */
	else if (rank[set1] < rank[set2])
	{
		parent[set1] = set2;
	}
	else
	{
		parent[set1] = set2;
		/* 两个秩相等,可任意合并到一个上,但是需要更新相应的rank */
		rank[set2] += 1;
	}
}

/* 查找的迭代实现 */
int Disjoint_set::Find(int item)
{
	/* 保存item结点,最后更新父节点要使用 */
	int child = item;

	/* 找到根item */
	while (parent[item] != item)
	{
		item = parent[item];
	}

	while (child != item)
	{
		/* 先保存父节点 */
		int tmp = parent[child];
		/* 父节点指向根item */
		parent[child] = item;

		child = tmp;
	}

	return item;
}

题目示例

题目:假如已知有 n n 个人和 m m 对好友关系 (存于数字 r r ) 。 如果两个人是直接或间接的好友 , 则认为他们属于同一个朋友圈,请写程序求出这 n 个人里一共有多少个朋友圈。 假如: n = 5 n = 5 m = 3 m = 3 r = { { 1 , 2 } , { 2 , 3 } , { 4 , 5 } } r = \{\{1 , 2\} , \{2 , 3\} , \{4 , 5\}\} ,表示有 5 个人,1 和 2 是好友,2 和 3 是好友,4 和 5 是好友,则 1、2、3 属于一个朋友圈,4、5 属于另一个朋友圈,结果为 2 个朋友圈。

算法分析:本题属于典型的并查集分析问题,较为简单,使用并查集的基本代码框架,将初始数组合并构建树,然后根据测试数组中的元素使用Find来进行查找匹配(这里我们封装了一个API:isSameSet() 测试是否处于同一集合)

define N 100
int parent[N + 1] = { 0 };
unordered_map<int, set<int>> map;

void initialize()
{
	for (int i = 1; i <= N; i++)
	{
		parent[i] = i;
	}
}

int Find(int item)
{
	/* 保存item结点,最后更新父节点要使用 */
	int child = item;

	/* 找到根item */
	while (parent[item] != item)
	{
		item = parent[item];
	}
	/* 根 - 集合的映射 */
	if(map.count(item) == 0)
	{
		set<int> set;
		set.insert(child);
		map[item] = set;
	}
	else
	{
		auto it = map.find(item);
		it->second.insert(child);
	}

#if 0
	while (child != item)
	{
		/* 先保存父节点 */
		int tmp = parent[child];
		/* 父节点指向根item */
		parent[child] = item;

		child = tmp;
	}
#endif
	return item;
}

/* 合并 */
void Union(int node1, int node2)
{
	int set1 = Find(node1);
	int set2 = Find(node2);

	if (set1 != set2)
		parent[set2] = set1;
}

void isSameSet(int node1, int node2)
{
	int m = Find(node1);
	int n = Find(node2);

	cout << "{" << node1 << ", " << node2 << "} : ";
	if (m == n)
	{
		 cout << "YES" << endl;
	}
	else
	{
		cout << "NO" << endl;
	}
}

/* 初始数组 */
int input[] =
{
	3, 4,
	4, 2,
	7, 6,
	5, 1,
	3, 9,
	11, 8,
	6, 10,
	9, 13,
	11, 12,
};

/* 测试数组 */
int test[] =
{
	3, 2,
	9, 4,
	7, 10,
	6, 7,
	13, 4,
	8, 12,

	6, 9,
	4, 7,
	11, 10,
	1, 2,
	12, 13,
	7, 13,
};

int main()
{
	int numSum = 13;

	int n = sizeof(input) / sizeof(input[0]) / 2;

	/* 初始化 */
	initialize();

	int i = 0;
	int j = 0;
	
	/* 合并同属 */
	for (j = 0; j < n; ++j)
	{
		int m = input[i++];
		int n = input[i++];

		Union(m, n);
	}

cout << "----------------Parent-------------------" << endl;
	for (int i = 1; i < numSum; i++)
	{
		cout << "parent[" << i << "] = " << parent[i] << endl;
	}

	i = 0;
	n = sizeof(test) / sizeof(test[0]) / 2;
	cout << "----------------Result-------------------" << endl;
	for (j = 0; j < n; j++)
	{
		int m = test[i++];
		int n = test[i++];

		isSameSet(m, n);
	}

	cout << "------------------Set--------------------" << endl;
	auto it = map.begin();
	while(it != map.end())
	{
		set<int> set;
		set = it->second;
		if(set.size() != 1)
		{
			cout << it->first << " : ";
			for(int val : set)
				cout << val << " ";
			cout << endl;
		}
		++it;
	}
}

运行上述程序后,我们可以清晰的看到parent数组、测试集的结果情况以及分组情况:


注:本文基础知识部分、配图部分参考网上其他优秀博文。

猜你喜欢

转载自blog.csdn.net/ZYZMZM_/article/details/93678420