Lec9 Graph Algorithms(4)
Lec9 Graph Algorithms(4)
图论的世界,探索!!!😎解决了最短路之后,接下来该你了:最小生成树问题!🤔
这一节注重算法的原理,只要给你一张图,你能够用这两种方法找到最小生成树,这一节你就已经过关了。
再立一个flag,弄一个跳转目录,这样子就可以方便别人去寻找各个方面的知识了。🎈
生成树 (Spanning Tree) 和最小生成树 (Minimum Spanning Tree)概述
生成树 (Spanning Tree)
对于一个 连接图 $ G(V, E) $,其中 $ V $ 表示图的节点集合,$ E $ 表示图的边集合:
- 生成树 $ T(V', E') $ 是图的一个子图,满足以下条件:
- 覆盖所有节点:$ V' = V $,即生成树包含图中的所有节点。
- 无环结构:生成树是一个树结构,因此它没有环(即它是一个无环连通图)。
- 边数:生成树中的边数为 $ |V| - 1 $,即生成树的边数等于节点数减去 1。
简单来说,生成树是一个包含图中所有节点的子图,且无环且连通。
最小生成树 (Minimum Spanning Tree)
- 加权边:在加权图中,每条边都有一个权重(即成本)。
- 最小生成树的目标是:找到一个生成树,使得这棵树的边的总权重最小。
最小生成树的应用场景很多,常见的有:
- 寻找最便宜的电线铺设方式:比如在某个区域内布置电线时,我们希望使用最少的成本连接所有房屋。
- 寻找最小成本的互联网信息传递路径:例如,找到在网络中传递数据时最便宜的路径。
常见算法:
- Prim 算法:通过逐步选择最短的边来构建生成树。
- Kruskal 算法:通过排序所有边,逐步选择不形成环的最短边来构建生成树。
最小生成树构建策略
生成树的基本性质
- 对于任何生成树 $ T $,若在树中插入一条新边 $ e_{new} $,则会形成一个环。
- 然而,如果从这个环中删除一条边 $ e_{old} $,则可以恢复成一个生成树。
- 如果新边 $ e_{new} $ 的权重比旧边 $ e_{old} $ 小,那么我们已经取得了进展,权重更小的生成树。
构建最小生成树的策略
- 贪心算法:每次选择一条不形成环且代价最小的边添加到生成树中。
- 通过逐步添加最小权重的边,保证每一步都使当前生成树的总权重最小。
- 重复操作:执行 $ |V| - 1 $ 次,直到生成树包含所有节点。因为生成树中必须有 $ |V| - 1 $ 条边。
- 正确性证明:
- 如果在某一步中,存在一条边 $ e $ 比已选边 $ e_{old} $ 权重更小,而该边没有被选中,那么算法一定会在适当的时机选择它。
- 因为贪心算法每次选择的是当前最优解,而如果有更优的边,算法会选择它。
总结
最小生成树的构建可以通过贪心策略,每次选择代价最小的边来扩展生成树,确保最终生成的树的权重最小。这种策略通过不断添加边并检查环来避免冗余的路径,从而逐步构建最优解。
Prim 算法 (增量构建生成树)
算法概述
Prim 算法通过逐步扩展生成树,始终选择连接当前树的最小代价边,将其加入到生成树中,直到所有的节点都包含在内。其核心思想是:每次从已选的节点中选择一条代价最小的边,并将该边连接的节点加入到生成树中。
算法步骤
- 初始化:
- 为每个节点设置初始连接成本为 "无穷大"(表示不可达)。
- 将每个节点标记为“未访问”。
- 选择起始节点:
- 选择一个节点 $ v $,并将其代价设为 0,表示从该节点开始构建生成树。
- 将该节点的前驱节点设为 0(起始节点无前驱)。
- 循环直到所有节点都标记为已访问:
- 选择代价最小的未访问节点:从所有未访问节点中选择一个代价最小的节点 $ u $,并将其标记为已访问。
- 更新相邻节点的代价:对于与 $ u $
相邻的每个未访问节点 $ w $,如果通过 $ u $ 到达 $ w $ 的代价小于当前 $ w
$ 的代价,则更新 $ w $ 的代价为通过 $ u $ 的代价。
- 代价更新公式:
$ (w) := (u, w) $ - 同时,更新 $ w $ 的前驱节点为 $ u $。
- 记录最短路径:$ [w] = u $
- 代价更新公式:
- 重复:重复步骤 3,直到所有节点都被加入生成树中。
与 Dijkstra 算法的相似性
- 相同点:
- 两个算法都使用了贪心策略:每一步选择代价最小的节点。
- 都采用了“标记节点”和“更新代价”的策略,确保每个节点的最短代价被逐步计算出来。
- 不同点:
- Dijkstra 算法是单源最短路径算法,只考虑从一个源节点到其他所有节点的最短路径。
- Prim 算法是构建最小生成树的算法,每次选择一个边将节点加入树中,最终得到覆盖所有节点的最小代价树。
示例
算法实现
以下是使用邻接表表示图的 Prim 算法实现:
1 | void Prim(Graph* G, int* D, int s) { |
复杂度分析
- 时间复杂度:如果通过二叉堆来选择最小代价的节点,时间复杂度为 $ O((n + m) n) $,其中 $ n $ 是节点数,$ m $ 是边数。
- 空间复杂度:主要取决于存储图结构的空间,通常为 $ O(n + m) $。
贪心策略
Prim 算法是一种贪心算法,在每一步中,始终选择连接已选节点和未选节点的代价最小的边。通过不断扩展生成树,最终得到最小代价的生成树。
正确性证明
- 反证法:假设有一个最小生成树(MST),在某一步选择的边不是最小代价的边,那么一定存在一条更优的边,而贪心算法会选择这个边。因此,Prim 算法能够保证每次选择的是最优边,最终构建的生成树一定是最小代价的生成树。
Prim 算法的正确性证明
我们通过反证法来证明 Prim 算法 总是能找到最小生成树 (MST),即最小代价的生成树。
证明步骤:
- 定义顶点的顺序:
- 假设图中的顶点 $ v_0, v_1, , v_{n-1} $ 按照它们被算法添加到生成树 (MST) 中的顺序排列。换句话说,顶点的顺序是根据它们被选择进入 MST 的时间。
- 假设算法出现错误:
- 假设在算法的执行过程中,存在第一个边 $ e_j = (v_p, v_j) $(其中 $ p < j $)是算法选择的错误边,这条边与 “真实” 最小生成树 (MST) 中的边不同。
- 构造真实 MST 中的路径:
- 在真实的最小生成树中,必然存在一条路径从 $ v_j $ 到 $ v_p $,这条路径经过某些顶点 $ v_u $ 和 $ v_w $,其中 $ u < j $ 且 $ w > j $,连接了顶点 $ v_p $ 和 $ v_j $。
- 这条路径 $ v_j v_w v_u v_p $ 包含了在真实 MST 中的边,而 $ e_j = (v_p, v_j) $ 是不在真实 MST 中的。
- 比较边的代价:
- 假设在这条路径中,真实的边 $ e' = (v_u, v_w) $ 的代价小于边 $ e_j = (v_p, v_j) $ 的代价。
- 由于 Prim 算法是贪心算法,在每次选择边时都会选择当前可用的、代价最小的边。如果存在一条边 $ e' $ 的代价比 $ e_j $ 小,那么根据算法的贪心性质,Prim 算法一定会选择边 $ e' $,而不是边 $ e_j $。
- 产生矛盾:
- 这与我们假设的“$ e_j $ 是算法第一次错误选择的边”相矛盾。
- 因此,我们的假设是错误的,算法不会出错,Prim 算法能够正确地选择最小生成树的边。
Kruskal 算法
算法描述:
Kruskal 算法 是一种构造最小生成树(MST)的方法。它采用贪心策略,按照边的权重从小到大选择边,逐步构建最小生成树。具体步骤如下:
- 初始化:
- 初始化一个森林,其中每棵树包含一个单独的节点。
- 建立一个优先队列,存储所有边,边的优先级按边的权重从小到大排序。
- 算法步骤:
- 重复以下操作,直到生成树包含 $ |V| - 1 $ 条边:
- 从优先队列中删除最小的边(边 $ e = (u, v) $)。
- 如果这条边连接的两个顶点 $ u $ 和 $ v $ 在同一棵树中,则加入这条边会形成一个环,因此丢弃该边。
- 如果 $ u $ 和 $ v $ 不在同一棵树中,则接受该边,它将连接这两棵树并缩小森林的规模。
- 重复以下操作,直到生成树包含 $ |V| - 1 $ 条边:
- 最终结果:
- 被接受的边形成了最小生成树(MST)。
环路检测:
为了避免生成环路,我们需要判断添加的边是否会形成环路。具体方法如下:
- 如果边 $ (u, v) $ 连接的顶点 $ u $ 和 $ v $ 已经在同一棵树中,则加入这条边会形成环路。因此,我们需要检查 $ u $ 和 $ v $ 是否在同一棵树中。
- 使用并查集(Disjoint Set)结构来完成这一操作:
- 调用
Find(u)
和Find(v)
判断 $ u $ 和 $ v $ 是否在同一棵树中。 - 如果 $ Find(u) = Find(v) $,则丢弃该边。
- 如果 $ Find(u) Find(v) $,则接受该边,并通过
Union(Find(u), Find(v))
合并两棵树。
- 调用
并查集的性质:
- 在初始化时,每个节点各自是一个独立的集合(即一棵独立的树)。
- 并查集的数据结构支持两个主要操作:
- Find:查找节点所在的树(或集合)。
- Union:将两个不同的集合合并为一个集合。
- 集合的合并操作(Union)
确保了每棵树的顶点是连通的,并且操作后的树结构满足以下的性质:
- 自反性:每个节点都与自己连通($ u $ 与 $ u $ 连接)。
- 对称性:如果 $ u $ 与 $ v $ 连接,则 $ v $ 与 $ u $ 也连接。
- 传递性:如果 $ u $ 与 $ v $ 连接,且 $ v $ 与 $ w $ 连接,则 $ u $ 与 $ w $ 也连接。
Kruskal 算法的实现:
1 | vector<Edge> kruskal(vector<Edge> edges, int numVertices) { |
例子
Kruskal 算法分析
时间复杂度分析:
初始化森林:
- 初始化森林需要为每个节点建立一棵单独的树,时间复杂度为 $ O(n) $,其中 $ n $ 是图中的顶点数。
初始化堆:
- 将所有的边放入优先队列(堆)中,并按权重排序。假设图中有 $ m $ 条边,时间复杂度为 $ O(m) $,其中 $ m $ 是图中边的数量。
循环执行:
- 主循环会执行 $ m $ 次,每次从堆中删除最小边(
Deletemin
操作),并执行查找和合并操作:- Deletemin 操作: 从堆中删除权重最小的边,时间复杂度为 $ O(m) $。
- Find 操作: 查找两个端点所在的集合(树),每次操作的时间复杂度为 $ O(n) $。
- Union 操作: 合并两个不同的集合,时间复杂度为 $ O(1) $,如果使用路径压缩和按秩合并等优化方法。
- 主循环会执行 $ m $ 次,每次从堆中删除最小边(
总时间复杂度:
- 主循环执行 $ m $ 次,每次执行的操作包括:
- 删除最小边的操作:$ O(m) $
- 两次 Find 操作:$ 2 O(n) = O(n) $
- 一次 Union 操作:$ O(1) $
因此,总的时间复杂度为: $ O(m m + 2m n) = O(m m) = O(m n) $ 其中,$ m = |E| $ 是图中的边数,$ n = |V| $ 是图中的顶点数。
- 主循环执行 $ m $ 次,每次执行的操作包括:
与其他算法的比较:
Prim 算法:
Prim 算法的时间复杂度为 $ O((n + m) n) $,其中 $ n $ 是顶点数,$ m $ 是边数。Kruskal 算法:
Kruskal 算法的时间复杂度为 $ O(m m) = O(m n) $,也就是边数 $ m $ 和 $ n $ 的对数关系。
实际应用中的表现:
- 在实际应用中,Kruskal 算法通常比 Prim
算法更快,尤其是在图的边数较少时。因为在稀疏图中,Kruskal
算法可以避免查看所有的边(即无需对所有边执行
Deletemin
操作),而只关注较少的边。 - 由于 Kruskal 算法通过使用优先队列来排序边,并利用并查集来避免环的生成,因此它的运行时间受限于边的数量,而不是顶点的数量。
总结:
- Kruskal 算法的时间复杂度是 $ O(m m) $,其中 $ m $ 是边的数量,通常比 Prim 算法快,尤其适用于稀疏图。
- 在密集图中,Kruskal 算法可能会略慢,因为其操作会涉及所有边,而 Prim 算法则可能在特定情况下通过优化(如使用二叉堆)获得更好的性能。