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