算法设计与分析实验:并查集与生成树

目录

一、情侣牵手

1.1 采用并查集的思想

1.2 采用动态规划的思想

二、账户合并

2.1 具体思路

2.2 思路呈现

2.3 代码实现

2.4 复杂度分析

三、连接所有点的最小费用

3.1 思路一:最小生成树

3.2 思路二:并查集

鸡汤


一、情侣牵手

力扣第765题

1.1 采用并查集的思想

(1)具体思路

问题是给定一组情侣的座位,其中有些座位配对正确,有些座位需要进行交换来使情侣并肩坐在一起。我们思路一使用并查集来解决这个问题。

首先,我们需要初始化一个并查集数据结构,并将每一对情侣视为一个节点。然后,我们遍历座位数组,检查相邻两个座位是否属于同一个情侣。如果不是同一个情侣,说明它们之间需要进行一次交换。

在遍历过程中,我们还需要将属于同一个情侣的座位进行合并操作,将它们标记为已经配对成功。

最后,我们统计并返回需要进行交换的次数,即未配对情侣的数量的一半。因为每两个情侣只需要进行一次交换,就能让他们并肩坐在一起。

(2)代码实现

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

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

    def union(self, p, q):
        root_p, root_q = self.find(p), self.find(q)
        if root_p == root_q:
            return
        self.parent[root_p] = root_q
        self.count -= 1


def minSwapsCouples(row):
    n = len(row) // 2
    uf = UnionFind(n)

    for i in range(0, len(row), 2):
        uf.union(row[i] // 2, row[i + 1] // 2)

    return n - uf.count


# 示例使用
row = [3, 2, 0, 1]
result = minSwapsCouples(row)
print(result)

(3)复杂度分析

该算法使用了并查集数据结构来解决问题。下面对算法的复杂度进行分析:

初始化并查集对象:时间复杂度为O(n),其中n是座位数,需要遍历n个节点进行初始化。

find操作:在并查集中进行路径压缩,时间复杂度接近O(1)。

union操作:在并查集中进行合并操作,时间复杂度接近O(1)。

主循环:遍历row数组,每次处理两个元素,最多需要遍历n/2次。在每次循环中,进行一次并查集的合并操作,时间复杂度接近O(1)。

因此,总体上算法的时间复杂度为O(n)。空间复杂度为O(n),主要由并查集对象的大小决定。

1.2 采用动态规划的思想

(1)具体思路

考虑将这些情侣拆分为两个人,我们需要找到一种最小的方法,使得拆分后的所有人坐在相邻位置上。

对于每个情侣(a,b),如果他们当前坐在相邻位置上,则不需要进行任何操作。如果他们当前坐在不相邻的位置上,则必须进行调整,将a或b中的一个人与他们的伴侣交换位置,使得他们坐在相邻位置上。假设a被交换到了第i个位置上,则第i+1个位置必须留空,下一个考虑从i+2位置开始。

因此,我们可以定义状态dp[i][j]表示第i个位置到第n个位置已经安排好,第j个位置是否必须留空时的最小操作次数。每次安排之后,我们需要根据情况判断第i和第i+1个位置是否相邻,然后根据情况进行操作,转移方程如下:

如果第i和第i+1个位置已经相邻,不需要进行任何操作:dp[i][j] = dp[i+2][j+1]

如果第i和第i+1个位置没有相邻,需要进行一次交换操作:dp[i][j] = min(dp[i+2][j+1]+1, dp[k][j+1]+k-i-1),其中k表示a的伴侣当前所在的位置。

最终结果为dp[0][0],因为我们需要找到第一个位置是否必须留空的最小操作次数

(2)代码实现

def minSwapsCouples(row):
    n = len(row)  # 座位数组的长度
    couple = [0] * n  # 每对情侣的另一个人的编号
    seat = [0] * n  # 当前座位数组中第i到第n个人的编号

    # 初始化couple数组
    for i in range(0, n, 2):
        couple[row[i]] = row[i+1]
        couple[row[i+1]] = row[i]

    # 初始化seat数组
    for i in range(n):
        seat[i] = row[i]

    # 定义dp数组
    dp = [[0] * n for _ in range(n)]

    # 边界条件
    for i in range(0, n-1, 2):
        if couple[seat[i]] == seat[i+1]:
            dp[i][0] = dp[i+2][1]
        else:
            k = i + 2
            while seat[k] != couple[seat[i]]:
                k += 1
            dp[i][0] = min(dp[i+2][1] + 1, dp[k][1] + k - i - 1)

    # 状态转移
    for j in range(1, n//2):
        for i in range(0, n-2*j-1, 2):
            a, b = seat[i], seat[i+1]

            if couple[a] == b:
                dp[i][j] = dp[i+2][j+1]
            else:
                k = i + 2
                while seat[k] != couple[a]:
                    k += 1
                dp[i][j] = min(dp[i+2][j+1] + 1, dp[k][j+1] + k - i - 1)

    # 统计最小交换次数
    res = 0
    for i in range(1, n, 2):
        if seat[i] != couple[seat[i+1]]:
            res += 1
    return res

row = [0, 2, 1, 3]
result = minSwapsCouples(row)
print(result)  # 输出 1

row = [3, 2, 0, 1]
result = minSwapsCouples(row)
print(result)  # 输出 0

在这段代码中,我们首先定义了一个变量cnt来记录交换次数,然后使用for循环遍历座位数组,每次取出当前情侣的位置和ID编号,并判断一下他们是否坐在一起。如果没有坐在一起,我们就需要寻找另一半的位置,并进行交换操作。这里我们使用了两个for循环来寻找另一半的位置,可以保证找到的位置一定是最靠前的。

(3)复杂度分析

首先,代码中有两个循环用于初始化couple和seat数组,时间复杂度为O(n)。

然后是定义和计算dp数组的部分。外层循环是一个从1到n/2的循环,内层循环是一个从0到n-2*j-1的循环,时间复杂度为O(n^2)。在内层循环中,有一个while循环用于找到与当前情侣a配对的人的位置k,其时间复杂度的上限是O(n),但由于每次循环k都会增加,因此总的时间复杂度仍然是O(n)。

最后是统计最小交换次数的部分,需要遍历一次seat数组,时间复杂度为O(n)。

综上所述,代码的总时间复杂度为O(n^2)。

由于代码中使用了额外的空间来存储couple、seat和dp数组,其空间复杂度为O(n)。

(4)运行结果

示例 1:

输入: row = [0,2,1,3]

输出: 1

解释: 只需要交换row[1]和row[2]的位置即可。

示例 2:

输入: row = [3,2,0,1]

输出: 0

解释: 无需交换座位,所有的情侣都已经可以手牵手了。

二、账户合并

力扣第721题

本题采用哈希表的思想解决

2.1 具体思路

首先,我们需要创建一个哈希表emailToName,将每个邮箱地址映射到对应的名称。其次,我们需要创建一个哈希表graph,构建账户之间的关系图。键为邮箱地址,值为与该邮箱地址属于同一人的其他所有邮箱地址。

接下来,遍历账户列表accounts,对于每个账户account,遍历邮箱地址emails,如果email在graph中不存在,则将其加入graph,并将对应的账户名称作为值;如果email在graph中存在,则将当前账户名称和email的值一起加入graph中。  

之后,我们需要创建一个集合visited,用于记录已经访问过的邮箱地址。同时,创建一个列表merged,用于存储合并后的账户信息。接下来,遍历账户列表accounts,对于每个账户account,初始化一个临时列表temp,用于存储与该账户关联的所有邮箱地址。如果该账户的任意邮箱地址在visited中不存在,则从该邮箱地址开始进行深度优先搜索,将与该邮箱地址属于同一人的所有邮箱地址加入temp中,并将这些邮箱地址标记为visited。

最后,将temp排序,并将账户名称和temp合并成一个账户信息,并加入merged列表中。最终,返回merged列表作为结果。

2.2 思路呈现

假设有以下账户列表accounts:

[

  ["John", "[email protected]", "[email protected]"],

  ["John", "[email protected]"],

  ["John", "[email protected]", "[email protected]"],

  ["Mary", "[email protected]"]

]

创建emailToName和graph哈希表。

emailToName = {}

graph = defaultdict(set)

遍历账户列表,对于每个账户account:

遍历该账户的所有邮箱地址emails。

"John" -> {"[email protected]", "[email protected]"}

"John" -> {"[email protected]"}

"John" -> {"[email protected]", "[email protected]"}

"Mary" -> {"[email protected]"}

创建集合visited和列表merged。

visited = set()

merged = []

遍历账户列表,对于每个账户account:

初始化临时列表temp,并将该账户的所有邮箱地址加入其中。

temp = ["[email protected]", "[email protected]", "[email protected]"]

temp = ["[email protected]", "[email protected]"]

temp = ["[email protected]"]

对于temp中的每个邮箱地址email:

如果该邮箱地址不在visited集合中,则将该邮箱的所有关联邮箱地址加入temp中,并将它们标记为visited。

temp = ["[email protected]", "[email protected]", "[email protected]", "[email protected]"]

将temp排序,并将账户名称和temp合并成一个账户信息,并将其添加到merged列表中。

merged = [["John", "[email protected]", "[email protected]","[email protected]","[email protected]"], 

          ["Mary", "[email protected]"]]

2.3 代码实现

from collections import defaultdict


def accountsMerge(accounts):
    emailToName = {}
    graph = defaultdict(set)

    # 构建哈希表 emailToName 和 graph
    for account in accounts:
        name = account[0]
        emails = account[1:]
        for email in emails:
            graph[email].add(email)  # 将每个邮箱地址自己加入到其对应的集合中
            emailToName[email] = name

    visited = set()
    merged = []

    # 遍历账户列表
    for account in accounts:
        name = account[0]
        emails = account[1:]

        temp = []
        for email in emails:
            if email not in visited:
                temp.extend(dfs(graph, email, visited))  # 利用深度优先搜索找出所有关联邮箱地址
        temp.sort()

        merged.append([name] + temp)

    return merged


def dfs(graph, email, visited):
    if email in visited:
        return []

    visited.add(email)
    neighbors = graph[email]
    result = [email]

    for neighbor in neighbors:
        result.extend(dfs(graph, neighbor, visited))

    return result


# 示例输入
accounts = [
    ["John", "[email protected]", "[email protected]"],
    ["John", "[email protected]"],
    ["John", "[email protected]", "[email protected]"],
    ["Mary", "[email protected]"]
]

result = accountsMerge(accounts)

# 示例输出
for account in result:
    print(account)

2.4 复杂度分析

代码看起来已经很完整了,以下是对代码的复杂度分析:

构建哈希表 emailToName 和图 graph 的复杂度为 O(N*M),其中 N 是账户列表中的账户数目,M 是每个账户中的邮箱数目。

深度优先搜索的复杂度为 O(E),其中 E 是邮箱地址的总数。

综合起来,代码的总体时间复杂度为 O(NM + E)。空间复杂度为 O(NM),用于存储哈希表和图的数据结构。

在示例输入中,账户列表中的账户数目是 4,邮箱地址的总数是 7。因此,在这种规模下,代码的时间复杂度和空间复杂度都是可以接受的。

2.5 运行结果

输入:accounts = [["John", "[email protected]", "[email protected]"], ["John", "[email protected]"], ["John", "[email protected]", "[email protected]"], ["Mary", "[email protected]"]]

预计输出:[["John", '[email protected]', '[email protected]', '[email protected]'], ["Mary", "[email protected]"],["John", "[email protected]"]

输出于预计一致

三、连接所有点的最小费用

力扣第1584题

3.1 思路一:最小生成树

(1)具体思路

首先,可以使用Prim算法或Kruskal算法来构建最小生成树。这里我们使用Prim算法来进行说明。

随机选择一个点作为起始点,将其加入最小生成树的顶点集合,并初始化一个边集合为空。

在每一轮循环中,从当前的最小生成树顶点集合中选择一个顶点 u,遍历所有不在最小生成树中的点 v,计算顶点 u 到顶点 v 的距离,并将该距离与顶点 v 相关联。

选取当前边集合中距离最小的边,将该边的头或尾(不在最小生成树中的顶点)添加到最小生成树中,并将该边加入边集合。

重复步骤2和步骤3,直到最小生成树中的顶点数达到原始点集的大小-1。

计算并返回所有边的总权值,即为将所有点连接的最小总费用。

(2)流程展示

假设有以下四个点的坐标:

A(0, 0)

B(1, 2)

C(3, 1)

D(2, 4)

我们可以按照上述步骤来构建最小生成树。首先,随机选择一个起始点,比如选择点 A。然后,计算起点 A 到其他点的距离并将其关联起来:

dist = {

    A: 0,

    B: 3,

    C: 4,

    D: 4

}

现在从 A 出发,选择距离最小的点 B,并将其加入最小生成树中。此时,边集合为 {(A, B)}。

接下来,我们更新 dist 字典,计算其他点到最小生成树的距离并更新关联:

dist = {

    A: 0,

    B: 3,

    C: 2,

    D: 4

}

继续选择距离最小的点 C,并将其加入最小生成树中。此时,边集合为 {(A, B), (A, C)}。

再次更新 dist 字典:

dist = {

    A: 0,

    B: 3,

    C: 2,

    D: 4

}

最后,选择剩余的唯一一个点 D,并将其加入最小生成树中。此时,边集合为 {(A, B), (A, C), (C, D)}。

计算边集合中所有边的总权值,即为将所有点连接的最小总费用。在这个例子中,总费用为 3 + 2 + 3 = 8。

因此,通过以上步骤,我们可以得到如下图所示的最小生成树:

算法设计与分析实验:并查集与生成树_第1张图片

                        

(3)代码实现

from typing import List
import heapq

def minCostConnectPoints(points: List[List[int]]) -> int:
    n = len(points)
    dist = [float('inf')] * n
    visited = set()
    min_cost = 0

    # 初始化起点
    dist[0] = 0
    heap = [(0, 0)]  # (距离, 顶点)

    while heap:
        d, u = heapq.heappop(heap)
        if u in visited:
            continue
        visited.add(u)
        min_cost += d

        for v in range(n):
            if v in visited:
                continue
            cost = abs(points[u][0] - points[v][0]) + abs(points[u][1] - points[v][1])
            if cost < dist[v]:
                dist[v] = cost
                heapq.heappush(heap, (cost, v))

    return min_cost

# 示例测试
points1 = [[0, 0], [2, 2], [3, 10], [5, 2], [7, 0]]
result1 = minCostConnectPoints(points1)
print(f"输入:points = {points1}")
print(f"输出:{result1}")

points2 = [[3, 12], [-2, 5], [-4, 1]]
result2 = minCostConnectPoints(points2)
print(f"输入:points = {points2}")
print(f"输出:{result2}")

(3)复杂度分析

这段代码的复杂度分析如下:

初始化部分:时间复杂度为 O(n)。其中 n 是 points 列表的长度,需要对 dist、visited 进行初始化。

主循环部分:时间复杂度为 O(n^2 log n)。主循环中,通过最小堆 heap 来选取当前距离最小的点 u,然后更新与 u 相邻未访问的点 v 的距离。由于每次插入或删除元素都需要 O(log n) 的时间,对于 n 个节点,总共需要进行 n 次插入和删除操作,所以时间复杂度为 O(n^2 log n)

总体复杂度:因此,总体的时间复杂度为 O(n^2 log n)。

代码的空间复杂度为 O(n),主要用于存储 dist 数组和 visited 集合。

需要注意的是,在示例测试中,points1 和 points2 中的点都是二维平面上的点,根据题目要求计算连接所有点的最小总费用。

3.2 思路二:并查集

(1)具体思路

由于只有点的个数不超过 1000,因此也可以采用并查集的思想来解决。

并查集是一种数据结构,它维护一个由若干个不相交集合组成的集合族,支持以下操作:

初始化:对于每个元素,初始化一个单元素集合;

合并:将两个集合合并为一个集合;

查找:确定一个元素属于哪一个子集。它可以被用于确定两个元素是否属于同一子集。

在本题中,我们可以先计算任意两点间的曼哈顿距离,并将其存储在一个边列表 edges 内,edges 的每个元素为 (dist, u, v) 表示点 u 和点 v 之间的曼哈顿距离为 dist。然后,按照边的权重(即曼哈顿距离)从小到大排序,依次选择边加入最小生成树,如果两个端点已经在同一连通块中,则跳过该边。

按照上述思路,具体实现步骤如下:

对所有边进行计算和排序;

初始化并查集,每个点初始为一个独立的集合;

遍历所有边,如果边的两个端点不在同一集合中,则将其合并,并将该边的权重加入最小总费用中。当最小生成树中边数为 n - 1 时,即所有点都已连接,退出算法;

返回最小总费用。

(2)代码实现

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

    def find(self, x):
        if self.parent[x] != x:
            self.parent[x] = self.find(self.parent[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


def minCostConnectPoints(points):
    n = len(points)
    edges = []
    for i in range(n):
        for j in range(i + 1, n):
            dist = abs(points[i][0] - points[j][0]) + abs(points[i][1] - points[j][1])
            edges.append((dist, i, j))
    edges.sort()

    uf = UnionFind(n)
    cost = 0
    num_edges = 0
    for dist, x, y in edges:
        if uf.find(x) != uf.find(y):
            uf.union(x, y)
            cost += dist
            num_edges += 1
            if num_edges == n - 1:
                break

    return cost

(3)复杂度分析

这段代码使用了并查集实现 Kruskal 算法,求解连接所有点的最小代价。

时间复杂度分析:

初始化并查集需要 O(n) 的时间。

计算和排序所有边的花费需要 O(n^2 log n) 的时间。

在遍历边列表时,最坏情况下需要遍历所有边,每次执行 union 操作的时间复杂度是 O(α(n)),其中 α 是阿克曼函数反演的某个函数,通常认为它是常数级别的。故 Kruskal 算法的时间复杂度为 O(n^2 α(n))。 总时间复杂度为 O(n^2 α(n)),其中 α(n) 为阿克曼函数反演的某个函数,通常认为它是常数级别的。

空间复杂度分析:

该算法只需要用到一个大小为 n 的并查集数组,故空间复杂度为 O(n)。

综上所述,该算法的时间复杂度为 O(n^2 α(n)),空间复杂度为 O(n)。

(4)运行结果

示例1输入及预计输出

points1 = [[00], [22], [310], [52], [70]]

算法设计与分析实验:并查集与生成树_第2张图片

示例2

points2 = [[312], [-25], [-41]]

输出均与预期结果一致

鸡汤

“数理统计告诉我可以允许自己犯错,只是要尽量控制犯第一类错误的概率。我看到围城,一座座围城。有太多的声音和道理,我们是浅薄无知的。我想起来徐涛老师讲矛盾的同一性和斗争性,生活确实是缓慢受锤和螺旋上升的过程。这个世界就是矛盾所以不必害怕失去,依旧存在可能,没有标准答案,开心即是正确。在我们的黄金时代,我们应该可以肆意地,疯狂地,尽情浪漫,尽情享受。”

记得天天开心!

你可能感兴趣的:(算法分析与设计,python,算法,数据结构,编程,并查集,哈希表,动态规划)