并查集之朋友圈的个数

547. 朋友圈

班上有 N 名学生。其中有些人是朋友,有些则不是。他们的友谊具有是传递性。如果已知 A 是 B 的朋友,B 是 C 的朋友,那么我们可以认为 A 也是 C 的朋友。所谓的朋友圈,是指所有朋友的集合。

给定一个 N * N 的矩阵 M,表示班级中学生之间的朋友关系。如果M[i][j] = 1,表示已知第 i 个和 j 个学生互为朋友关系,否则为不知道。你必须输出所有学生中的已知的朋友圈总数。

示例 1:

输入:
[[1,1,0],
 [1,1,0],
 [0,0,1]]
输出:2 
解释:已知学生 0 和学生 1 互为朋友,他们在一个朋友圈。
第2个学生自己在一个朋友圈。所以返回 2 。

示例 2:

输入:
[[1,1,0],
 [1,1,1],
 [0,1,1]]
输出:1
解释:已知学生 0 和学生 1 互为朋友,学生 1 和学生 2 互为朋友,所以学生 0 和学生 2 也是朋友,所以他们三个在一个朋友圈,返回 1 。

DFS解法

遍历每一个人i,如果这个人i没有访问过,也即不属于之前的任意一个朋友圈,那么朋友圈数+1,然后遍历i的所有朋友,所有和i相关的人,以及和i相关的人的相关的人.
比如,1和2是朋友,那么就继续遍历2的朋友,然后遍历2的朋友的朋友,直到遍历完成,则1的朋友圈确定,接着继续遍历其他人,如果已经属于之前的朋友圈,那么就跳过.
注意输入是一个方阵,并且是对称的矩阵,因为1和2是互为朋友

class Solution:
    def findCircleNum(self, M: List[List[int]]) -> int:
        n = len(M)
        cnt = 0
        visited = set()

        def dfs(i): # 寻找第i个人的朋友,以及第i个人的朋友的朋友...
            for j in range(n):
                if M[i][j] and j not in visited:
                    visited.add(j)
                    dfs(j)
        
        for i in range(n): # 依次遍历第i个人
            if i not in visited:
                cnt += 1
                visited.add(i)
                dfs(i)
        return cnt

并查集介绍

并查集算法,主要是解决图论中「动态连通性」问题的。
动态连通性其实可以抽象成给一幅图连线。比如下面这幅图,总共有 10 个节点,他们互不相连,分别用 0~9 标记:
在这里插入图片描述
Union-Find 算法主要需要实现这两个 API:

class UnionFind:
	def union(self, p, q):
		# 将p和q连接
	def connected(self, p, q):
		# 判断 p 和 q 是否连通
	def count(self):
		# 返回图中有多少个连通分量

这里所说的「连通」是一种等价关系,也就是说具有如下三个性质:

  • 自反性:节点p和p是连通的。
  • 对称性:如果节点p和q连通,那么q和p也连通。
  • 传递性:如果节点p和q连通,q和r连通,那么p和r也连通。

比如说之前那幅图,0~9 任意两个不同的点都不连通,调用connected都会返回 false,连通分量为 10 个。
如果现在调用union(0, 1),那么 0 和 1 被连通,连通分量降为 9 个。
再调用union(1, 2),这时 0,1,2 都被连通,调用connected(0, 2)也会返回 true,连通分量变为 8 个。
在这里插入图片描述
判断这种「等价关系」非常实用,比如说编译器判断同一个变量的不同引用,比如社交网络中的朋友圈计算等等。
Union-Find 算法的关键就在于union和connected函数的效率。

并查集实现思路

我们使用森林(若干棵树)来表示图的动态连通性,用数组来具体实现这个森林

怎么用森林来表示连通性呢?
我们设定树的每个节点有一个指针指向其父节点,如果是根节点的话,这个指针指向自己。比如说刚才那幅 10 个节点的图,一开始的时候没有相互连通,就是这样:
在这里插入图片描述

class UnionFind:
	# 构造函数,n为图的节点总数
	def uf(self, n):
		count = n # 一开始互不连通
		parent = [i for i in range(n)] # 父节点指针初始指向自己
	# 其它函数

如果某两个节点被连通,则让其中的(任意)一个节点的根节点接到另一个节点的根节点上:
de在这里插入图片描述
图中是1和5连通,那么将0接到2上,或者2接到0上,这样,这两个节点就具有相同的根节点了

def union(self, p, q):
	rootP = find(p)
	rootQ = find(q)
	if rootP == rootQ:
		return
	# 将两棵树合并为一棵树
	parent[rootP] = rootQ # 或者 parent[rootQ] = rootP
	count -= 1 # 两个分量合二为一,连通分量减少一个
def find(self,x):
	# 根节点的 parent[x]==x
	while parent[x] != x:
		x = parent[x]
	return x
def count(self):
	return count

这样,如果节点p和q连通的话,它们一定拥有相同的根节点
在这里插入图片描述

def connected(self, p, q):
	rootP = find(p)
	rootQ = find(q)
	return rootP == rootQ

复杂度分析
主要 API connectedunion 中的复杂度都是 find 函数造成的,所以说它们的复杂度和find一样
find主要功能就是从某个节点向上遍历到树根,其时间复杂度就是树的高度。我们可能习惯性地认为树的高度就是logN,但这并不一定。logN的高度只存在于平衡二叉树,对于一般的树可能出现极端不平衡的情况,使得「树」几乎退化成「链表」,树的高度最坏情况下可能变成N
在这里插入图片描述
所以说上面这种解法,find,union,connected时间复杂度都是 O(N)。这个复杂度很不理想的,你想图论解决的都是诸如社交网络这样数据规模巨大的问题,对于unionconnected的调用非常频繁,每次调用需要线性时间完全不可忍受。

平衡性优化

union过程:我们一开始就是简单粗暴的把p所在的树接到q所在的树的根节点下面,那么这里就可能出现「头重脚轻」的不平衡状况,比如下面这种局面:
在这里插入图片描述
我们希望,小一些的树接到大一些的树下面,这样就能避免头重脚轻,更平衡一些。解决方法是额外使用一个size数组,记录每棵树包含的节点数,我们不妨称为「重量」

class UnionFind:
	# 构造函数,n为图的节点总数
	def uf(self, n):
		count = n # 一开始互不连通
		parent = [i for i in range(n)] # 父节点指针初始指向自己
		size = [1 for _ in range(n)] # 最初每棵树只有一个节点,重量初始化为1
	# 其它函数

比如说size[3] = 5表示,以节点3为根的那棵树,总共有5个节点。这样我们可以修改一下union方法:

def union(self, p, q):
	rootP = find(p)
	rootQ = find(q)
	if rootP == rootQ:
		return
	# 将两棵树合并为一棵树,小树接到大树下面,较平衡
	if size[rootP] > size[rootQ]:
		parent[rootQ] = rootP
		size[rootP] += size[rootQ]
	else:
		parent[rootP] = rootQ
		size[rootQ] += size[rootP]
	count -= 1 # 两个分量合二为一,连通分量减少一个

通过比较树的重量,就可以保证树的生长相对平衡,树的高度大致在logN这个数量级,极大提升执行效率。
此时,find,union,connected的时间复杂度都下降为 O(logN),即便数据规模上亿,所需时间也非常少。

路径压缩

进一步压缩每棵树的高度,使树高始终保持为常数
在这里插入图片描述
这样find就能以 O(1) 的时间找到某一节点的根节点,相应的,connectedunion复杂度都下降为 O(1)

def find(self,x):
	# 根节点的 parent[x]==x
	while parent[x] != x:
		parent[x] = parent[parent[x]]
		x = parent[x]
	return x

在这里插入图片描述
可见,调用find函数每次向树根遍历的同时,顺手将树高缩短了,最终所有树高都不会超过 3(union的时候树高可能达到 3)。

完整代码:

class UnionFind:
	# 构造函数,n为图的节点总数
	def __init__(self, n):
		self.count = n # 一开始互不连通
		self.parent = [i for i in range(n)] # 父节点指针初始指向自己
		self.size = [1 for _ in range(n)] # 最初每棵树只有一个节点,重量初始化为1
	# 合并连通分量函数
	def union(self, p, q):
		rootP = self.find(p)
		rootQ = self.find(q)
		if rootP == rootQ:
			return
		# 将两棵树合并为一棵树,小树接到大树下面,较平衡
		if self.size[rootP] > self.size[rootQ]:
			self.parent[rootQ] = rootP
			self.size[rootP] += self.size[rootQ]
		else:
			self.parent[rootP] = rootQ
			self.size[rootQ] += self.size[rootP]
		self.count -= 1 # 两个分量合二为一,连通分量减少一个
	# 查找根节点函数
	def find(self,x):
		# 根节点的 parent[x]==x
		while self.parent[x] != x:
			self.parent[x] = self.parent[self.parent[x]]
			x = self.parent[x]
		return x
	# 判断是否连通
	def connected(self, p, q):
		rootP = self.find(p)
		rootQ = self.find(q)
		return rootP == rootQ
	# 返回连通分量个数
	def count(self):
		return self.count

复杂度分析

Union-Find 算法的复杂度可以这样分析:构造函数初始化数据结构需要 O(N) 的时间和空间复杂度;连通两个节点union、判断两个节点的连通性connected、计算连通分量count所需的时间复杂度均为 O(1)。

朋友圈应用

class Solution:
    def findCircleNum(self, M: List[List[int]]) -> int:
        n = len(M)
        self.count = n

        def find(x, parent):
            while parent[x] != x:
                parent[x] = parent[parent[x]]
                x = parent[x]
            return x
        
        def union(x, y, parent, size):
            x_parent = find(x, parent)
            y_parent = find(y, parent)
            if x_parent == y_parent:
                return
            
            if size[x_parent] > size[y_parent]:
                parent[y_parent] = x_parent
                size[x_parent] += size[y_parent]
            else:
                parent[x_parent] = y_parent
                size[y_parent] += size[x_parent]
            self.count -= 1
        
        parent = [i for i in range(n)]
        size = [1] * n

        for i in range(n):
            for j in range(i + 1, n):
                if M[i][j] == 1:
                    union(i, j, parent, size)
        return self.count

最大朋友圈有多少个用户

有10^7个用户,编号从1开始,这些用户中有m个关系,每一对关系用两个数x,y表示,意味着用户x和用户y在同一圈子,关系具有传递性,A和B是同一个圈子,B和C是同一个圈子,则A,B,C就在同一个圈子。问最大的圈子有多少个用户。

直接利用并查集求解
注意最后输出最大圈子有多少个用户,而不是朋友圈的个数

class uf:
    def __init__(self, n):
        self.parent = [i for i in range(n)]
        self.size = [1] * n
        self.count = n

    def find(self, x):
        while self.parent[x] != x:
            self.parent[x] = self.parent[self.parent[x]]
            x = self.parent[x]
        return x

    def union(self, p, q):
        rootp = self.find(p)
        rootq = self.find(q)
        if rootp == rootq:
            return
        if self.size[rootp] > self.size[rootq]:
            self.parent[rootq] = rootp
            self.size[rootp] += self.size[rootq]
        else:
            self.parent[rootp] = rootq
            self.size[rootq] += self.size[rootp]
        self.count -= 1


nodes = [(1, 2), (3, 4), (5, 6), (7, 8)]
friends = uf(8)
for node in nodes:
    friends.union(node[0]-1, node[1]-1) # 此处由于人员编号从1开始,所以索引要减一,对应数组的索引
print(friends.count) # 4
print(max(friends.size)) # 2
  • 1
    点赞
  • 4
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值