每天都刷朋友圈,那你知道并查集吗?

微信大概是我们每天必须接触的一个APP之一,公交上、地铁上,异或工作休息时,我们都忍不住刷刷朋友圈,看看一些点赞之交的朋友当天又经历了什么。相较于QQ,微信的一个特点之一就是:除非好友的好友也是你的好友,否则你在朋友圈里看不到好友的好友对好友朋友圈的点赞和评论

今天刷LeetCode,发现了一道名为“朋友圈”的题目:

不过题目的要求和微信朋友圈不一样。题目说明,如果A和B是朋友,B和C是朋友,那么A和C也是朋友,即朋友圈中的友谊具有传递性。这里的朋友圈也就是朋友的集合。

如何来求解这个题目呢?那就要用到一个用于表示集合内元素关系的数据结构——并查集

1.并查集与并查集算法

1.1. 并查集

并查集是一种处理不相交集合的合并及查询问题的数据结构,主要用于处理一些不相交集合的合并问题。一些常见的用途有求连通子图、求最小生成树的 Kruskal 算法和求最近公共祖先(Least Common Ancestors, LCA)等。换言之,并查集是一种树形结构,可以用来回答两个元素是否连接的问题。即,通过并查集算法,可以将两个不相连的元素连接起来,也可以查询两个元素是否已连接。这里的“连接”的含义是,两个元素是否具有同一个“根”(从这个角度可以理解,为什么是树形结构)。

1.2.并查集算法

并查集算法通常有以下几个接口:

  • 查询两个元素是否连通:connected(int p, int q)
  • 将给定的两个元素连通:unionElement(int p, int q)
  • 返回给定集合中有多少个连通分量:count()

判断两个元素p和q是否连通,即判断元素p和q是否拥有同一个根root,这里我们需要实现一个辅助函数find(int p),用于查找元素p的根。同理,将两个元素连通,只需要保证两个元素的根是同一个元素即可。

2.并查集算法代码实现

代码实现中,我们使用一个int型的数组parent来表示每一个元素的前驱元素是谁,即它的父节点是谁。如果将元素p和q连接起来了,可以说p是q的父节点,因此parent[q] = p。此时函数find(q)的结果也就是p了,也就是说,此时去寻找q元素的根节点,也就是p了。当然,也有可能p并不是根节点,因为p的根节点可能是m,即find(p)=m​。所以find(q)的最终结果是m​。(可以看到这里有迭代关系)​。下列是并查集算法的C++实现:

class UnionFind{
private:
	// 元素个数
	int n;

	// 每个元素的父节点
	int* parent;
	
	// 连通分量个数
	int ccount;
public:
	UnionFind(int n){
		this->n = n;

		// 初始时,每个元素都独立,所以有n个连通分量
		ccount = n;
		this->parent = new int[n];

		// 初始化每个元素的根节点是自己
		for (int i = 0; i < n; i++){
			parent[i] = i;
		}
	}
	~UnionFind(){
		// 注意释放空间
		delete[]parent;
	}

	// 查找元素p的根
	int find(int p){
		while (p != parent[p]){
			p = parent[p];
		}
		return p;
	}
	// 判断两个元素是否连通,即是否具有同一个根
	bool connected(int p, int q){
		return find(p) == find(q);
	}

	// 将两个元素连通
	void unionElement(int p, int q){
		int rootP = find(p);
		int rootQ = find(q);
		if (rootP == rootQ)return;
		parent[rootQ] = rootP;

		// 连通后,连通分量将减少1
		ccount--;
	}

	// 返回连通分量个数
	int count(){
		return ccount;
	}
};

可以看到,并查集这个高级的数据结构,其实代码实现很简单。不过,这部分代码有优化的空间,比如查找函数中,可以使用路径压缩等方法。

2.1. 路径压缩

假设有如下一个连通图,我们要查找元素7的根,最终要经过6次迭代查找​,这样的查找效率是很低的(类似于退化为链表的二叉树)​。

扫描二维码关注公众号,回复: 13121787 查看本文章

 ​我们可以做一些优化:如果经过一次查找发现7的根并不是7,那么可以将7的父节点指向其父节点的父节点​,如下图:

这样只需要经过3次迭代就可以找到最终的root了​。这就是路径压缩。

路径压缩的代码如下:

// 查找元素p的根:路径压缩
  int find(int p){
    while (p != parent[p]){
      parent[p] = parent[parent[p];
      p = parent[p];
    }
    return p;
  }

3.LeetCode相关题目

547. 朋友圈

这是本文刚开始时提到的朋友圈问题。从示例给出的矩阵可以看到,这是一个沿对角对称的矩阵,因此我们可以只考察左下或右上部分即可。如何考察呢?如果某个元素M[i][j]==1,说明i和j是好友,这时候只需要使用union将二者连接起来即可。该题目求最后有几个朋友圈,其实也就是求有几个连通分量。由并查集的上述代码实现可知,初始化时,连通分量个数被初始化为元素个数,每当成功执行一次合并操作后,连通分量个数减1。所以最后我们直接返回count函数即可。

int findCircleNum(vector<vector<int>>& M) {
        int num = M.size();
        UnionFind uf(num);
        for(int i=0;i<num;i++){
            for(int j = i+1;j<num;j++){
                if(M[i][j] == 1 && !uf.connected(i,j)){
                    uf.unionElement(i,j);
                }
            }
        }
        return uf.count();
    }

130. 被围绕的区域

此题目与朋友圈一题类似,不同之处在于,如果边界上存在'O'并且矩阵内部有与边界'O'相邻的'O',应该如何表示其连通?这里我们可以设定一个假想的'O',不妨将它的索引设置为元素个数+1。将边界上的'O'直接与整个假想'O'连通,矩阵内部与边界'O'相邻的'O'也与这个假想'O'连通。最后,我们遍历整个矩阵,只需要判断每个遍历的元素是否与假想‘O’相连通即可

void solve(vector<vector<char>>& board) {
	if (board.empty()){
		return;
	}
	int row = board.size();
	int col = board[0].size();

	// 多一个元素是因为有一个假想的'O',并且假设
	// 这个'O'在并查集中的序号为最后,即row*col(索引从0开始)
	UnionFind uf(row*col + 1);
	for (int i = 0; i < row; i++){
		for (int j = 0; j<col; j++){
			if (board[i][j] == 'O'){
				// 在边界上的'O'直接与假想的'O'连通
				if (i == 0 || i == row - 1 || j == 0 || j == col - 1){
					uf.unionElement(i*col + j, row*col);
				}
				// 内部的'O'与上下左右的'O'连通
				else{
					if (i>0 && board[i - 1][j] == 'O'){
						uf.unionElement(i*col + j, (i - 1)*col + j);
					}
					if (i < row - 1 && board[i + 1][j] == 'O'){
						uf.unionElement(i*col + j, (i + 1)*col + j);
					}
					if (j>0 && board[i][j - 1] == 'O'){
						uf.unionElement(i*col + j, i*col + j - 1);
					}
					if (j < col - 1 && board[i][j + 1] == 'O'){
						uf.unionElement(i*col + j, i*col + j + 1);
					}
				}
			}
		}
	}
	for (int i = 1; i < row; i++){
		for (int j = 1; j < col; j++){
			// 凡是与假想'O'没有连通的元素,都赋值为'X'
			if (!uf.connected(i*col + j, row*col)){
				board[i][j] = 'X';
			}
		}
	}
}

200. 岛屿数量

这道题目可以说与上一道题目一模一样。不同之处在于,最后返回连通数目时需要减1,因为题目只需求出岛屿数量,我们需要减去1个水的连通分量

int numIslands(vector<vector<char>>& grid) {
	if (grid.empty()){
		return 0;
	}
	int row = grid.size();
	int col = grid[0].size();
	UnionFind uf(row*col + 1);
	int c = 0;
	for (int i = 0; i<grid.size(); i++){
		for (int j = 0; j<grid[i].size(); j++){
			if (grid[i][j] == '0'){
				uf.unionElement(i*col + j, row*col);
			}
			else if (grid[i][j] == '1'){
				// 尝试与左边和上边合并
				if (i == 0 && j == 0){
					uf.unionElement(0, 0);
				}
				else if (i == 0){// 第一行,只考虑与左边合并
					if (grid[i][j - 1] == '1'){
						uf.unionElement(i*col + j, i*col + j - 1);
					}
				}
				else if (j == 0){// 第一列,只考虑与上边合并
					if (grid[i - 1][j] == '1'){
						uf.unionElement((i - 1)*col + j, i*col + j);
					}
				}
				else{
					if (grid[i - 1][j] == '1'){
						uf.unionElement((i - 1)*col + j, i*col + j);
					}
					if (grid[i][j - 1] == '1'){
						uf.unionElement(i*col + j, i*col + j - 1);
					}
				}
			}
		}
	}
	return uf.count() - 1;
}

猜你喜欢

转载自blog.csdn.net/sinat_21107433/article/details/105906791