Python `disjoint-set` (并查集):解决连通性问题的算法

好的,各位观众老爷,今天咱们来聊聊一个听起来高大上,其实贼实用的算法——“并查集”(Disjoint-Set Union),也叫“不相交集合数据结构”。这玩意儿专门用来解决连通性问题,说白了,就是判断两个东西是不是一伙儿的,或者把两伙东西合并成一伙儿。

一、啥是连通性?这玩意儿有啥用?

连通性,简单来说,就是两个东西之间有没有路可以走通。比如,在一张地图上,两个城市之间有没有公路连接;在一个社交网络里,两个人之间有没有共同好友。

这玩意儿用处可大了,你想啊:

  • 社交网络分析: 看看哪些人属于同一个社交圈子,挖掘潜在的群组关系。
  • 网络路由: 确定网络中的节点是否连通,选择最佳的路由路径。
  • 图像处理: 识别图像中的连通区域,进行图像分割。
  • 迷宫生成与求解: 生成随机迷宫,或者找到迷宫的出口。
  • 最小生成树(Kruskal 算法): 在图中找到连接所有节点的最小代价的树。

二、并查集:你的连通性好帮手

并查集是一种数据结构,它维护着若干个不相交的集合。每个集合都有一个“代表元素”,可以理解为这个集合的“老大”。并查集主要提供两个操作:

  • Find(x): 找到元素 x 所在的集合的代表元素,也就是找到 x 的“老大”。
  • Union(x, y): 将元素 x 所在的集合和元素 y 所在的集合合并成一个集合,也就是让 x 和 y 的“老大”变成同一个。

三、并查集的实现:从青铜到王者

并查集的实现方式有很多种,咱们从最简单的开始,一步步进化。

1. 青铜:朴素实现

最简单的实现方式,就是用一个数组 parent 来记录每个元素的“爸爸”(也就是它的上级),根节点的“爸爸”指向自己。

class DisjointSet:
    def __init__(self, n):
        self.parent = list(range(n))  # 初始化,每个元素都是自己的老大

    def find(self, x):
        """找到元素 x 的老大"""
        if self.parent[x] == x:
            return x  # x 自己就是老大
        return self.find(self.parent[x])  # 递归向上找

    def union(self, x, y):
        """合并 x 和 y 所在的集合"""
        root_x = self.find(x)
        root_y = self.find(y)
        if root_x != root_y:
            self.parent[root_x] = root_y  # 让 x 的老大变成 y 的小弟

这个实现很简单,但是效率很低。find 操作最坏情况下需要遍历整个树,时间复杂度是 O(n)。

2. 白银:路径压缩

路径压缩是一种优化 find 操作的方法。在 find 的过程中,我们将每个访问过的节点都直接指向根节点,这样下次再查找这个节点时,就可以直接找到根节点,而不需要再向上遍历。

class DisjointSet:
    def __init__(self, n):
        self.parent = list(range(n))

    def find(self, x):
        """找到元素 x 的老大,并进行路径压缩"""
        if self.parent[x] == x:
            return x
        self.parent[x] = self.find(self.parent[x])  # 路径压缩,让 x 直接指向老大
        return self.parent[x]

    def union(self, x, y):
        root_x = self.find(x)
        root_y = self.find(y)
        if root_x != root_y:
            self.parent[root_x] = root_y

路径压缩后的 find 操作,平均时间复杂度接近 O(α(n)),其中 α(n) 是反阿克曼函数,增长速度非常慢,可以认为是一个常数。

3. 黄金:按秩合并

按秩合并是一种优化 union 操作的方法。我们用一个数组 rank 来记录每个集合的“秩”,秩可以理解为集合的高度(实际上并不完全是高度,而是一个估计值)。在合并两个集合时,我们将秩较小的集合合并到秩较大的集合上,这样可以尽量保持树的平衡,避免出现退化成链表的情况。

class DisjointSet:
    def __init__(self, n):
        self.parent = list(range(n))
        self.rank = [0] * n  # 初始化,每个集合的秩都是 0

    def find(self, x):
        """找到元素 x 的老大,并进行路径压缩"""
        if self.parent[x] == x:
            return x
        self.parent[x] = self.find(self.parent[x])
        return self.parent[x]

    def union(self, x, y):
        """合并 x 和 y 所在的集合,并进行按秩合并"""
        root_x = self.find(x)
        root_y = self.find(y)
        if root_x != root_y:
            if self.rank[root_x] < self.rank[root_y]:
                self.parent[root_x] = root_y  # 让秩小的集合合并到秩大的集合上
            elif self.rank[root_x] > self.rank[root_y]:
                self.parent[root_y] = root_x
            else:
                self.parent[root_y] = root_x  # 秩相等时,随便合并一个
                self.rank[root_x] += 1  # 被合并的集合的秩加 1

按秩合并后的 union 操作,时间复杂度也是接近 O(α(n))。

4. 钻石:路径压缩 + 按秩合并

将路径压缩和按秩合并结合起来,可以得到性能最好的并查集实现。

class DisjointSet:
    def __init__(self, n):
        self.parent = list(range(n))
        self.rank = [0] * n

    def find(self, x):
        """找到元素 x 的老大,并进行路径压缩"""
        if self.parent[x] == x:
            return x
        self.parent[x] = self.find(self.parent[x])
        return self.parent[x]

    def union(self, x, y):
        """合并 x 和 y 所在的集合,并进行按秩合并"""
        root_x = self.find(x)
        root_y = self.find(y)
        if root_x != root_y:
            if self.rank[root_x] < self.rank[root_y]:
                self.parent[root_x] = root_y
            elif self.rank[root_x] > self.rank[root_y]:
                self.parent[root_y] = root_x
            else:
                self.parent[root_y] = root_x
                self.rank[root_x] += 1

这种实现的 findunion 操作的时间复杂度都接近 O(α(n)),可以认为是常数时间。

5. 王者:带权并查集

带权并查集是指在并查集的基础上,给每个节点增加一个“权值”,用来表示节点与其父节点之间的某种关系。例如,可以用来表示节点之间的距离、方向等等。

带权并查集在 findunion 操作中,需要维护节点的权值,使得权值能够反映节点之间的关系。

class WeightedDisjointSet:
    def __init__(self, n):
        self.parent = list(range(n))
        self.weight = [0] * n  # 初始权重为 0

    def find(self, x):
        """查找根节点,并更新权重"""
        if self.parent[x] == x:
            return x
        root = self.find(self.parent[x])
        self.weight[x] += self.weight[self.parent[x]] # 更新权重
        self.parent[x] = root
        return root

    def union(self, x, y, w):
        """合并集合,w 是 x 到 y 的权重"""
        root_x = self.find(x)
        root_y = self.find(y)
        if root_x != root_y:
            self.parent[root_x] = root_y
            self.weight[root_x] = self.weight[y] - self.weight[x] - w

四、代码示例:解决实际问题

光说不练假把式,咱们来几个实际的例子。

例 1:判断图中的连通分量个数

给你一个无向图,判断图中共有多少个连通分量。

def count_connected_components(n, edges):
    """
    计算无向图中的连通分量个数
    n: 节点个数
    edges: 边列表,例如 [[0, 1], [1, 2], [3, 4]]
    """
    ds = DisjointSet(n)
    for u, v in edges:
        ds.union(u, v)

    count = 0
    for i in range(n):
        if ds.parent[i] == i:
            count += 1
    return count

# 示例
n = 5
edges = [[0, 1], [1, 2], [3, 4]]
components = count_connected_components(n, edges)
print(f"图中有 {components} 个连通分量")  # 输出:图中有 2 个连通分量

例 2:判断两个节点是否连通

给你一个无向图,判断两个节点是否连通。

def is_connected(n, edges, u, v):
    """
    判断无向图中两个节点是否连通
    n: 节点个数
    edges: 边列表
    u, v: 要判断的两个节点
    """
    ds = DisjointSet(n)
    for x, y in edges:
        ds.union(x, y)

    return ds.find(u) == ds.find(v)

# 示例
n = 5
edges = [[0, 1], [1, 2], [3, 4]]
u = 0
v = 2
connected = is_connected(n, edges, u, v)
print(f"节点 {u} 和节点 {v} 是否连通:{connected}")  # 输出:节点 0 和节点 2 是否连通:True

u = 0
v = 3
connected = is_connected(n, edges, u, v)
print(f"节点 {u} 和节点 {v} 是否连通:{connected}")  # 输出:节点 0 和节点 3 是否连通:False

例 3: 求解等式方程的可满足性

给定一个由表示变量之间关系的字符串方程组成的数组,每个字符串方程 equations[i] 的长度为 4,并采用两种不同的形式之一:"a==b" 或 "a!=b"。在这里,a 和 b 是小写字母(不一定不同),返回 True,如果可以赋值变量,使得给定的等式方程为真;否则返回 False。

def equationsPossible(equations):
    """
    判断等式方程是否可满足
    equations: 等式方程列表,例如 ["a==b", "b!=c", "a==c"]
    """
    ds = DisjointSet(26)  # 26个字母
    equalities = []
    inequalities = []

    for equation in equations:
        if equation[1] == '=':
            equalities.append((ord(equation[0]) - ord('a'), ord(equation[3]) - ord('a')))
        else:
            inequalities.append((ord(equation[0]) - ord('a'), ord(equation[3]) - ord('a')))

    # 处理等式
    for a, b in equalities:
        ds.union(a, b)

    # 处理不等式
    for a, b in inequalities:
        if ds.find(a) == ds.find(b):
            return False  # 如果相等,则方程不可满足

    return True
# 示例
equations1 = ["a==b", "b!=c", "a==c"]
equations2 = ["a==b", "b==c", "a!=c"]
equations3 = ["a==b", "b==a"]
print(equationsPossible(equations1)) # False
print(equationsPossible(equations2)) # False
print(equationsPossible(equations3)) # True

五、总结:并查集的武功秘籍

并查集是一种非常实用的数据结构,可以用来解决各种连通性问题。它的核心思想是:

  • Find: 找到元素的“老大”,也就是它所在的集合的代表元素。
  • Union: 合并两个集合,让它们的“老大”变成同一个。

通过路径压缩和按秩合并等优化技巧,可以将并查集的时间复杂度降低到接近常数级别。

六、并查集的适用场景

场景 并查集是否适用 备注
判断连通性 适用 快速判断两个元素是否属于同一个连通分量。
求解最小生成树 适用 Kruskal 算法使用并查集来判断两个节点是否属于同一个集合,避免形成环路。
社交网络分析 适用 发现社交网络中的社群,分析用户之间的关系。
网络路由 适用 确定网络中的节点是否连通,选择最佳的路由路径。
动态连通性 适用 随着时间推移,元素之间的连通关系发生变化,并查集可以动态维护连通性信息。
元素数量不变 适用 并查集适合于元素数量固定的场景。如果元素数量动态变化,可能需要考虑其他数据结构。

七、一些需要注意的地方

  • 初始化: 初始化时,每个元素都应该属于一个独立的集合,也就是让每个元素的“老大”指向自己。
  • 路径压缩的副作用: 路径压缩会改变树的结构,但是不会影响并查集的正确性。
  • 按秩合并的秩: 秩只是一个估计值,并不一定是树的实际高度。

好了,今天的并查集讲座就到这里。希望各位观众老爷能够掌握这门武功,在编程的道路上越走越远!下次再见!

发表回复

您的邮箱地址不会被公开。 必填项已用 * 标注