Loading

并查集

本文参考了【算法】并查集(Disjoint Set)并查集详解并查集

并查集原理

并查集是一种用于处理不相交集合之间合并问题的数据结构,例如求连通子图、判断是否存在环、求最小生成树等。

以判断图中是否有环为例,下图是一个无向图。

graph LR; A---B A---C A---D C---D C---E

首先把每一个顶点都看作是一个集合,逐一地考察每一条边。

  • 考察A---B,则把 A 和 B 放入同一个集合,有{A, B}, {C}, {D}, {E}
  • 考察A---C,则把 A 和 C 放入同一个集合,有{A, B, C}, {D}, {E}
  • 考察A---D,则把 A 和 D 放入同一个集合,有{A, B, C, D}, {E}
  • 考察C---D,发现 C 和 D 已经在同一个集合里了,这就说明图中存在环。

由这个例子可以看出,并查集的主要操作就是判断两个元素是否在同一个集合中,若不在同一个集合中,则将他们合并。实际上,并查集用一个”代表“来表示一个集合,对于给定的任意一个元素,都可以在常数级别的时间复杂度内找到这个元素的”代表“,即所在的集合。

可见,并查集主要包含两种操作:

  1. find(x):找到元素 x 所在集合的代表;
  2. unionSet(x, y): 若 x 和 y 不在同一个集合,则将其合并。

实现(Python)

并查集用树来表示集合,树中的每一个结点就代表集合中的一个元素,树的根结点就是这个集合的”代表“。要注意的是,树中的父子关系并不能代表元素之间的关系。而树是用数组来表示的,索引 i 处存储的是该元素的父结点的索引,而树根结点的父节点就是它本身。首先初始化一个并查集。

def init(n: int) -> List[int]:  # 假设有 n 个元素,即开始时每个元素自成一派
    return [i for i in range(n)]

接下来写find操作。只要每次沿着父节点向上,最终就一定能够找到根结点,即对应集合的”代表“。

def find(x: int, path: List[int]) -> int:
    root = x
    while path[root] != root:
        root = path[root]
    return root

最后是合并操作。需要判断两个结点的根结点是否相同,如果不同,则将他们合并。

def unionSet(x: int, y: int, path: List[int]) -> bool:
    x_root = find(x, path)
    y_root = find(y, path)
    if x_root == y_root:  # 未发生合并,返回False
        return False
    else:  # 需要合并,返回True
        path[y_root] = x_root
            return True

这时候会发现一个问题,就是之前说的find操作是在常数时间内的,如果构造树的时候构造出一个一字长蛇阵,那么这个操作显然不是常数级别的。因此需要用到路径压缩。

路径压缩

每次查找时,将结点的父节点改为对应集合的根结点。这样每条经过查找的路径高度都会降为 1。但是由于只在查找时进行路径压缩,因此树的结构仍然有可能是奇形怪状的。

只要修改find操作的代码就行了。

def find(x: int, path: List[int]) -> int:
    if x != path[x]:
        path[x] = find(path[x], path)

    return path[x]

按秩合并

另开一个跟path等长的数组,用来存树的高度。每次合并操作时,总是将较矮树的根结点指向较高树的根结点。

def init(n: int) -> List[int]:  # 假设有 n 个元素,即开始时每个元素自成一派
    path = [i for i in range(n)]
    rank = [0 for i in range(n)]
    return path, rank

同时,需要修改合并操作的代码。当两棵树的高度一样时,我们把 y 的根结点指向 x 的根结点。

def unionSet(x: int, y: int, path: List[int]) -> bool:
    x_root = find(x, path)
    y_root = find(y, path)
    if x_root == y_root:  # 未发生合并,返回False
        return False
    else:  # 需要合并,返回True
        if rank[x_root] > rank[y_root]:
            path[y_root] = x_root
        elif rank[x_root] < rank[y_root]:
	    path[x_root] = y_root
        else:
	    path[y_root] = x_root
	    rank[x_root] += 1
    return True

例题

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

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

思路:
只要直接套用以上的并查集模板即可,最后统计其中树根的个数即可。由上面的假设可知,树根结点满足path[i]=i。感觉这种不那么复杂的题也不需要按秩合并或者路径压缩了,直接来还快一些。

class Solution:
    def findCircleNum(self, M: List[List[int]]) -> int:
        def find(x: int, path: List[int]) -> int:
            root = x
            while path[root] != root:
                root = path[root]
            return root
        
        def unionSet(x: int, y: int, path: List[int], rank: List[int]) -> bool:
            x_root = find(x, path)
            y_root = find(y, path)
            if x_root == y_root:  # 未发生合并,返回False
                return False
            else:  # 需要合并,返回True
                if rank[x_root] > rank[y_root]:
                    path[y_root] = x_root
                elif rank[x_root] < rank[y_root]:
                    path[x_root] = y_root
                else:
                    path[y_root] = x_root
                    rank[x_root] += 1
            return True
        
        N = len(M)
        path = [i for i in range(N)]
        rank = [0] * N
        for i in range(N):
            for j in range(i, N):
                if M[i][j] == 1 and i != j:
                    unionSet(i, j, path, rank)
        
        cnt = 0
        for i in range(N):
            if path[i] == i:
                cnt += 1
        
        return cnt
posted @ 2020-06-17 19:41  达芬骑驴  阅读(171)  评论(1编辑  收藏  举报