Langchain系列文章目录
01-玩转LangChain:从模型调用到Prompt模板与输出解析的完整指南
02-玩转 LangChain Memory 模块:四种记忆类型详解及应用场景全覆盖
03-全面掌握 LangChain:从核心链条构建到动态任务分配的实战指南
04-玩转 LangChain:从文档加载到高效问答系统构建的全程实战
05-玩转 LangChain:深度评估问答系统的三种高效方法(示例生成、手动评估与LLM辅助评估)
06-从 0 到 1 掌握 LangChain Agents:自定义工具 + LLM 打造智能工作流!
07-【深度解析】从GPT-1到GPT-4:ChatGPT背后的核心原理全揭秘
08-【万字长文】MCP深度解析:打通AI与世界的“USB-C”,模型上下文协议原理、实践与未来
Python系列文章目录
PyTorch系列文章目录
机器学习系列文章目录
深度学习系列文章目录
Java系列文章目录
JavaScript系列文章目录
Python系列文章目录
Go语言系列文章目录
Docker系列文章目录
数据结构与算法系列文章目录
01-【数据结构与算法-Day 1】程序世界的基石:到底什么是数据结构与算法?
02-【数据结构与算法-Day 2】衡量代码的标尺:时间复杂度与大O表示法入门
03-【数据结构与算法-Day 3】揭秘算法效率的真相:全面解析O(n^2), O(2^n)及最好/最坏/平均复杂度
04-【数据结构与算法-Day 4】从O(1)到O(n²),全面掌握空间复杂度分析
05-【数据结构与算法-Day 5】实战演练:轻松看懂代码的时间与空间复杂度
06-【数据结构与算法-Day 6】最朴素的容器 - 数组(Array)深度解析
07-【数据结构与算法-Day 7】告别数组束缚,初识灵活的链表 (Linked List)
08-【数据结构与算法-Day 8】手把手带你拿捏单向链表:增、删、改核心操作详解
09-【数据结构与算法-Day 9】图解单向链表:从基础遍历到面试必考的链表反转
10-【数据结构与算法-Day 10】双向奔赴:深入解析双向链表(含图解与代码)
11-【数据结构与算法-Day 11】从循环链表到约瑟夫环,一文搞定链表的终极形态
12-【数据结构与算法-Day 12】深入浅出栈:从“后进先出”原理到数组与链表双实现
13-【数据结构与算法-Day 13】栈的应用:从括号匹配到逆波兰表达式求值,面试高频考点全解析
14-【数据结构与算法-Day 14】先进先出的公平:深入解析队列(Queue)的核心原理与数组实现
15-【数据结构与算法-Day 15】告别“假溢出”:深入解析循环队列与双端队列
16-【数据结构与算法-Day 16】队列的应用:广度优先搜索(BFS)的基石与迷宫寻路实战
17-【数据结构与算法-Day 17】揭秘哈希表:O(1)查找速度背后的魔法
18-【数据结构与算法-Day 18】面试必考!一文彻底搞懂哈希冲突四大解决方案:开放寻址、拉链法、再哈希
19-【数据结构与算法-Day 19】告别线性世界,一文掌握树(Tree)的核心概念与表示法
20-【数据结构与算法-Day 20】从零到一掌握二叉树:定义、性质、特殊形态与存储结构全解析
21-【数据结构与算法-Day 21】精通二叉树遍历(上):前序、中序、后序的递归与迭代实现
22-【数据结构与算法-Day 22】玩转二叉树遍历(下):广度优先搜索(BFS)与层序遍历的奥秘
23-【数据结构与算法-Day 23】为搜索而生:一文彻底搞懂二叉搜索树 (BST) 的奥秘
24-【数据结构与算法-Day 24】平衡的艺术:图解AVL树,彻底告别“瘸腿”二叉搜索树
25-【数据结构与算法-Day 25】工程中的王者:深入解析红黑树 (Red-Black Tree)
26-【数据结构与算法-Day 26】堆:揭秘优先队列背后的“特殊”完全二叉树
27-【数据结构与算法-Day 27】堆的应用:从堆排序到 Top K 问题,一文彻底搞定!
28-【数据结构与算法-Day 28】字符串查找的终极利器:深入解析字典树 (Trie / 前缀树)
29-【数据结构与算法-Day 29】从社交网络到地图导航,一文带你入门终极数据结构:图
30-【数据结构与算法-Day 30】图的存储:邻接矩阵 vs 邻接表,哪种才是最优选?
31-【数据结构与算法-Day 31】图的遍历:深度优先搜索 (DFS) 详解,一条路走到黑的智慧
32-【数据结构与算法-Day 32】掌握广度优先搜索 (BFS),轻松解决无权图最短路径问题
33-【数据结构与算法-Day 33】最小生成树之 Prim 算法:从零构建通信网络
34-【数据结构与算法-Day 34】最小生成树之 Kruskal 算法:从边的视角构建最小网络
文章目录
摘要
在图论的广阔天地中,最小生成树(Minimum Spanning Tree, MST)问题是构建网络、规划路线等场景下的核心模型。继上一篇我们学习了从顶点出发的 Prim 算法后,今天我们将探索另一种截然不同却同样精妙的解决方案——Kruskal 算法。Kruskal 算法以“边”为核心,采用贪心策略,在纷繁复杂的图中挑选出总权重最小的边集来连通所有顶点。本文将深入剖析 Kruskal 算法的贪心思想,并详细讲解其成功的关键——一种名为“并查集”的高效数据结构,它巧妙地解决了算法中最棘手的“判环”问题。通过图文并茂的讲解、代码实战和复杂度分析,你将彻底掌握 Kruskal 算法的原理与应用。
一、回顾:什么是最小生成树 (MST)?
在深入 Kruskal 算法之前,让我们快速回顾一下最小生成树的核心概念。
对于一个连通的、带权的无向图 G = ( V , E ) G=(V, E) G=(V,E),其中 V V V 是顶点集,E 是边集,每条边 e ∈ E e \in E e∈E 都有一个权重 w ( e ) w(e) w(e)。
生成树 (Spanning Tree) 是图 G 的一个子图,它需满足:
- 包含图中所有的 V V V 个顶点。
- 恰好有 V − 1 V-1 V−1 条边。
- 是连通的且无环的。
最小生成树 (Minimum Spanning Tree, MST) 是指在所有可能的生成树中,边的权重之和最小的那一棵。
MST 在现实世界中有广泛应用,例如:
- 通信网络建设:如何在多个城市间铺设光缆,使得总长度最短且所有城市都能互联互通。
- 电路设计:如何连接电路板上的所有引脚,使得使用的导线总长度最短。
- 交通网络规划:建设一个连接所有村庄的公路网,如何使总造价最低。
上一篇我们学习的 Prim 算法,其策略是从一个顶点开始,像“滚雪球”一样,每一步都选择一个离当前已构建部分最近的新顶点加入,直到覆盖所有顶点。而 Kruskal 算法则提供了一个全新的视角。
二、Kruskal 算法的核心思想——“边的贪心”
Kruskal 算法的思路非常直接且符合直觉:要想总权重最小,我何不每次都选择当前能选的、权重最小的边呢?
2.1.1 算法概述
Kruskal 算法完全基于边的权重进行贪心选择。其基本步骤如下:
- 初始化:创建一个空的集合,用于存放构成最小生成树的边。
- 排序:将图中所有的边按照权重从小到大进行排序。
- 遍历与选择:从权重最小的边开始,依次遍历所有排好序的边。对于当前考察的边
(u, v):- 判断:如果将这条边加入到当前的生成树集合中,不会形成环路。
- 接纳:则将这条边加入到集合中。
- 舍弃:否则,舍弃这条边,继续考察下一条。
- 终止:当集合中的边数达到
顶点数 - 1时,算法结束。此时集合中的所有边就构成了该图的最小生成树。
这个过程就像是在一片散落的珍珠(顶点)中,用最短的线(边)将它们串起来,每次都选择最短的那段线,但要确保不会把已经串好的两端再次连接,从而形成一个圈。
2.1.2 核心挑战:如何避免形成环路?
贪心策略听起来很简单,但其成功的关键在于如何高效且准确地判断“加入一条边是否会形成环路”。
设想我们已经选择了一些边,构成了一个或多个连通的子图。现在我们考虑一条新的边 (u, v)。
- 不成环的情况:如果顶点
u和v分别属于两个不同的连通子图,那么连接它们,只会将这两个子图合并成一个更大的连通子图,而不会产生环路。 - 成环的情况:如果顶点
u和v已经位于同一个连通子图中,那么它们之间本身就已经存在一条路径。此时再加入边(u, v),必然会形成一条新的路径,与原有路径构成一个环路。
如下图所示:
那么,问题就转化为:如何快速判断两个顶点是否已经连通? 这就是 Kruskal 算法的灵魂伴侣——并查集大显身手的地方。
三、Kruskal 的灵魂伴侣——并查集 (Disjoint Set Union)
并查集(Disjoint Set Union, DSU),也常被称为“不相交集联合”或“查找联合”,是一种专门用来处理集合合并与查询问题的数据结构。它能以近乎常数时间 O ( α ( n ) ) O(\alpha(n)) O(α(n)) 的效率回答“两个元素是否属于同一个集合”的问题,完美契合了 Kruskal 算法的需求。
3.1.1 什么是并查集?
想象一下,你有很多朋友,一开始每个人都自成一派。后来,一些朋友互相结盟,形成小团体。并查集就是管理这些“小团体”的工具。它主要提供两个核心操作:
Find(查找):确定一个元素属于哪个集合(团体)。通常,每个集合都有一个“代表”或“老大”,Find(x)就是找出元素x的老大是谁。Union(合并):将两个元素所在的集合合并成一个。比如,Union(x, y)就是让x的老大和y的老大结盟,从此他们就属于同一个大团体了。
在 Kruskal 算法中,每个顶点最初都是一个独立的集合。每当我们选择一条边 (u, v) 并加入到 MST 中,就相当于将 u 和 v 所在的集合进行合并(Union)。在选择边之前,我们通过 Find 操作来判断 u 和 v 是否已经属于同一个集合。
3.1.2 并查集的实现
并查集通常用一个数组(我们称之为 parent 数组)来实现,该数组将每个元素映射到其父节点。
parent[i] = j表示元素i的父节点是j。- 一个集合的“代表”(根节点)的特征是它的父节点是它自己,即
parent[i] = i。
(1) 初始化
开始时,每个顶点都是一个独立的集合,其父节点就是它自己。
parent[i] = i for all i in 0...V-1
(2) Find 操作 (查找代表元)
Find(i) 操作就是从 i 开始,沿着 parent 数组不断向上查找,直到找到根节点(即 parent[x] == x 的那个节点)。
# 基础版的 Find 操作
def find(parent, i):
if parent[i] == i:
return i
return find(parent, parent[i])
(3) Union 操作 (合并集合)
Union(i, j) 操作首先分别找到 i 和 j 的代表元 root_i 和 root_j。
- 如果
root_i和root_j相同,说明i和j已经在同一个集合,无需操作。 - 如果不同,则将一个集合的根指向另一个集合的根,从而合并它们。例如,可以令
parent[root_i] = root_j。
# 基础版的 Union 操作
def union(parent, i, j):
root_i = find(parent, i)
root_j = find(parent, j)
if root_i != root_j:
parent[root_i] = root_j
3.1.3 如何用并查集判环?
逻辑非常清晰:对于待考察的边 (u, v):
- 调用
find(u)得到u的代表元root_u。 - 调用
find(v)得到v的代表元root_v。 - 如果
root_u == root_v,说明u和v早已连通(在同一个集合里),加入边(u, v)必将形成环路。因此,舍弃此边。 - 如果
root_u != root_v,说明u和v分属不同连通分量,加入此边不会形成环路。于是,接纳此边,并执行union(u, v),将两个集合合并。
3.1.4 并查集的优化(进阶内容)
基础版的并查集在极端情况下(如合并成一条链)Find 操作的复杂度会退化到
O
(
N
)
O(N)
O(N)。为了提高效率,通常会引入两种优化:
- 路径压缩 (Path Compression):在
Find的过程中,将查找路径上的所有节点直接指向根节点。这能极大地扁平化树形结构。 - 按秩合并 (Union by Rank/Size):在
Union时,总是将较“小”的树(按深度或大小)合并到较“大”的树上,以保持树的平衡。
经过优化的并查集,其单次操作的均摊时间复杂度可以达到 O ( α ( n ) ) O(\alpha(n)) O(α(n)),其中 α ( n ) \alpha(n) α(n) 是阿克曼函数的反函数,其增长极其缓慢,对于所有实际问题,可以认为它是一个小于 5 的常数。
四、Kruskal 算法的实现与分析
现在,我们将所有部分组合起来,实现完整的 Kruskal 算法。
4.1.1 算法流程拆解
下面是 Kruskal 算法的完整流程图:
graph TD
A[开始] --> B{初始化: MST为空集, 边计数器edges_count=0};
B --> C{将图中所有边存入列表并按权重升序排序};
C --> D{初始化并查集: 每个顶点自成一个集合};
D --> E{遍历排序后的边 (u, v, weight)};
E --> F{u 和 v 是否在同一集合? (find(u) == find(v))};
F -- 是/形成环路 --> G[舍弃该边, 继续下一条];
F -- 否/不形成环路 --> H{将该边加入MST};
H --> I{合并u和v的集合 (union(u, v))};
I --> J{edges_count++};
J --> K{edges_count == V-1 ?};
G --> E;
K -- 否 --> E;
K -- 是 --> L[结束, MST构建完成];
4.1.2 代码实战 (Python)
我们使用 Python 来实现 Kruskal 算法,其中包含一个简单的并查集类。
# 并查集 (Disjoint Set Union) 类
class DSU:
def __init__(self, n):
# 初始化 parent 数组,每个元素的父节点是自己
self.parent = list(range(n))
def find(self, i):
# 路径压缩:在查找的同时,将路径上的节点直接指向根节点
if self.parent[i] == i:
return i
self.parent[i] = self.find(self.parent[i])
return self.parent[i]
def union(self, i, j):
# 合并两个元素所在的集合
root_i = self.find(i)
root_j = self.find(j)
if root_i != root_j:
self.parent[root_i] = root_j
return True # 返回 True 表示成功合并
return False # 返回 False 表示本就在同一集合
# Kruskal 算法主函数
def kruskal(vertices_count, edges):
"""
vertices_count: 顶点数量
edges: 边的列表,每条边格式为 (weight, u, v)
"""
# 1. 将所有边按权重从小到大排序
# edges 列表中的元组格式为 (权重, 顶点1, 顶点2)
edges.sort()
# 2. 初始化并查集
dsu = DSU(vertices_count)
mst = [] # 存储最小生成树的边
mst_weight = 0 # 记录总权重
# 3. 遍历排序后的边
for weight, u, v in edges:
# 4. 如果两个顶点不在同一个集合中(不会形成环)
if dsu.union(u, v):
# 将这条边加入最小生成树
mst.append((u, v, weight))
mst_weight += weight
# 优化:当 MST 的边数达到 V-1 时,即可提前结束
if len(mst) == vertices_count - 1:
break
if len(mst) < vertices_count - 1:
return None, -1 # 如果无法连通所有顶点(非连通图)
return mst, mst_weight
# --- 示例 ---
if __name__ == '__main__':
# 顶点数
V = 4
# 边列表: (权重, 顶点1, 顶点2)
# 顶点编号为 0, 1, 2, 3
graph_edges = [
(10, 0, 1),
(6, 0, 2),
(5, 0, 3),
(15, 1, 3),
(4, 2, 3)
]
print("图的边(权重, u, v):", graph_edges)
minimum_spanning_tree, total_weight = kruskal(V, graph_edges)
if minimum_spanning_tree:
print("\nKruskal 算法找到的最小生成树边集为:")
for u, v, weight in minimum_spanning_tree:
print(f"边({u}, {v}) - 权重: {weight}")
print(f"\n最小生成树的总权重为: {total_weight}")
else:
print("该图为非连通图,无法生成最小生成树。")
4.1.3 复杂度分析
(1) 时间复杂度
Kruskal 算法的时间复杂度主要由两个部分决定:
- 对边进行排序:图中有 E E E 条边,使用高效的排序算法(如快速排序、归并排序),时间复杂度为 O ( E log E ) O(E \log E) O(ElogE)。
- 遍历边并执行并查集操作:需要遍历最多
E
E
E 条边。对于每条边,执行两次
find和一次union操作。在使用路径压缩和按秩合并优化的并查集中,这些操作的均摊时间复杂度为 O ( α ( V ) ) O(\alpha(V)) O(α(V))。总共是 O ( E ⋅ α ( V ) ) O(E \cdot \alpha(V)) O(E⋅α(V))。
由于 E log E E \log E ElogE 通常远大于 E ⋅ α ( V ) E \cdot \alpha(V) E⋅α(V),所以 Kruskal 算法的总时间复杂度由排序决定,为 O ( E log E ) O(E \log E) O(ElogE)。
(2) 空间复杂度
空间复杂度主要来自:
- 存储边的列表:需要 O ( E ) O(E) O(E) 的空间。
- 并查集的
parent数组:需要 O ( V ) O(V) O(V) 的空间。
因此,总空间复杂度为 O ( E + V ) O(E + V) O(E+V)。
五、Kruskal vs. Prim:两种思想的碰撞
Kruskal 和 Prim 是解决 MST 问题的两大经典算法,它们基于不同的贪心策略,各有千秋。
| 特性 | Kruskal 算法 | Prim 算法 |
|---|---|---|
| 核心思想 | 边的贪心:从全局选择权重最小且不构成环的边。 | 点的贪心:从一个点开始,不断扩展集合,选择离当前集合最近的点。 |
| 数据结构 | 并查集 (用于判环) | 优先队列 (堆) (用于找到最近的点) |
| 适用图类型 | 特别适合稀疏图 (边数 E E E 远小于 V 2 V^2 V2)。因为其复杂度与 E E E 关系更密切。 | 特别适合稠密图 (边数 E E E 接近 V 2 V^2 V2)。其基于堆优化的版本复杂度为 O ( E log V ) O(E \log V) O(ElogV)。 |
| 过程产物 | 在过程中会形成一个森林,最后才连接成一棵树。 | 始终保持一个连通块 (一棵树)。 |
| 实现复杂度 | 相对简单,主要在于排序和并查集。 | 涉及优先队列的操作,实现稍复杂。 |
选择建议:
- 如果图是稀疏的,边很少,Kruskal 的 O ( E log E ) O(E \log E) O(ElogE) 通常会比 Prim 的 O ( E log V ) O(E \log V) O(ElogV) 更快。
- 如果图是稠密的,边很多,Prim 算法可能更具优势。
六、总结
本文系统地学习了构建最小生成树的 Kruskal 算法,其精髓可以概括为以下几点:
- 核心策略:Kruskal 算法采用了一种基于边的贪心策略。它将所有边按权重排序,然后从最小的边开始,只要新边不与已选边构成环路,就将其采纳,直到选中 V − 1 V-1 V−1 条边为止。
- 关键工具:算法的成败在于如何高效地“判环”。并查集(DSU)是解决此问题的完美工具,它通过
find和union操作,能以近乎常数的时间复杂度判断两个顶点是否已连通。 - 时间复杂度:算法的主要性能瓶颈在于对所有 E E E 条边进行排序,因此其时间复杂度为 O ( E log E ) O(E \log E) O(ElogE)。
- 适用场景:由于其复杂度与边数 E E E 密切相关,Kruskal 算法尤其适用于边数较少的稀疏图。
- 与 Prim 的对比:Kruskal 从“边”的全局视角出发,而 Prim 从“点”的局部视角出发,两者殊途同归,共同构成了最小生成树算法的基石。

212

被折叠的 条评论
为什么被折叠?



