@@ -7,130 +7,130 @@ latex: true
77pdf : true
88---
99
10- ## 引言
11- 在计算机科学中,拓扑排序是一种解决依赖关系问题的关键算法。想象这样一个场景:大学选课时,某些课程需要先修课程。例如,学习「数据结构」前必须先修「程序设计基础」,这种依赖关系构成一个有向无环图(DAG)。拓扑排序的作用正是为这类依赖关系找到一种合理的执行顺序。本文将深入解析拓扑排序的核心原理,并通过 Python 代码实现两种经典算法。
10+ ## 引言
11+ 在计算机科学中,拓扑排序是一种解决依赖关系问题的关键算法。想象这样一个场景:大学选课时,某些课程需要先修课程。例如,学习「数据结构」前必须先修「程序设计基础」,这种依赖关系构成一个有向无环图(DAG)。拓扑排序的作用正是为这类依赖关系找到一种合理的执行顺序。本文将深入解析拓扑排序的核心原理,并通过 Python 代码实现两种经典算法。
1212
13- ## 拓扑排序基础概念
14- 拓扑排序的定义是:对 DAG 的顶点进行线性排序,使得对于任意有向边 $u \to v$,顶点 $u$ 在排序中都出现在顶点 $v$ 之前。例如,若图中存在边 $A \to B$ 和 $B \to C$,则可能的排序之一是 $[ A, B, C] $。
13+ ## 拓扑排序基础概念
14+ 拓扑排序的定义是:对 DAG 的顶点进行线性排序,使得对于任意有向边 $u \to v$,顶点 $u$ 在排序中都出现在顶点 $v$ 之前。例如,若图中存在边 $A \to B$ 和 $B \to C$,则可能的排序之一是 $[ A, B, C] $。
1515
16- 拓扑排序有两个关键特性:
17- 1 . ** 无环性** :若图中存在环(例如 $A \to B \to C \to A$),则无法进行拓扑排序。可通过深度优先搜索(DFS)检测环的存在。
18- 2 . ** 不唯一性** :同一 DAG 可能有多种有效排序。例如,若图中有两个无依赖关系的节点 $A$ 和 $B$,则 $[ A, B] $ 和 $[ B, A] $ 均为合法结果。
16+ 拓扑排序有两个关键特性:
17+ 1 . ** 无环性** :若图中存在环(例如 $A \to B \to C \to A$),则无法进行拓扑排序。可通过深度优先搜索(DFS)检测环的存在。
18+ 2 . ** 不唯一性** :同一 DAG 可能有多种有效排序。例如,若图中有两个无依赖关系的节点 $A$ 和 $B$,则 $[ A, B] $ 和 $[ B, A] $ 均为合法结果。
1919
20- ## 拓扑排序算法原理
20+ ## 拓扑排序算法原理
2121
22- ### Kahn 算法(基于入度)
23- Kahn 算法的核心思想是不断移除入度为 0 的节点,直到所有节点被处理。具体步骤如下:
24- 1 . 初始化所有节点的入度表。
25- 2 . 将入度为 0 的节点加入队列。
26- 3 . 依次处理队列中的节点,将其邻接节点的入度减 1。若邻接节点入度变为 0,则加入队列。
27- 4 . 若最终处理的节点数等于总节点数,则排序成功;否则说明图中存在环。
22+ ### Kahn 算法(基于入度)
23+ Kahn 算法的核心思想是不断移除入度为 0 的节点,直到所有节点被处理。具体步骤如下:
24+ 1 . 初始化所有节点的入度表。
25+ 2 . 将入度为 0 的节点加入队列。
26+ 3 . 依次处理队列中的节点,将其邻接节点的入度减 1。若邻接节点入度变为 0,则加入队列。
27+ 4 . 若最终处理的节点数等于总节点数,则排序成功;否则说明图中存在环。
2828
29- 该算法依赖队列数据结构,时间复杂度为 $O(V + E)$,其中 $V$ 是节点数,$E$ 是边数。
29+ 该算法依赖队列数据结构,时间复杂度为 $O(V + E)$,其中 $V$ 是节点数,$E$ 是边数。
3030
31- ### DFS 后序遍历法
32- DFS 算法通过深度优先遍历图,并按递归完成时间的逆序得到拓扑排序。具体步骤如下:
33- 1 . 从任意未访问节点开始递归 DFS。
34- 2 . 将当前节点标记为已访问。
35- 3 . 递归处理所有邻接节点。
36- 4 . 递归结束后将当前节点压入栈中。
37- 5 . 最终栈顶到栈底的顺序即为拓扑排序结果。
31+ ### DFS 后序遍历法
32+ DFS 算法通过深度优先遍历图,并按递归完成时间的逆序得到拓扑排序。具体步骤如下:
33+ 1 . 从任意未访问节点开始递归 DFS。
34+ 2 . 将当前节点标记为已访问。
35+ 3 . 递归处理所有邻接节点。
36+ 4 . 递归结束后将当前节点压入栈中。
37+ 5 . 最终栈顶到栈底的顺序即为拓扑排序结果。
3838
39- DFS 算法同样具有 $O(V + E)$ 的时间复杂度,但需要额外的栈空间存储结果。
39+ DFS 算法同样具有 $O(V + E)$ 的时间复杂度,但需要额外的栈空间存储结果。
4040
41- ### 算法对比
42- - ** Kahn 算法** :显式利用入度信息,适合动态调整入度的场景(如动态图)。
43- - ** DFS 算法** :代码简洁,但难以处理动态变化的图。
41+ ### 算法对比
42+ - ** Kahn 算法** :显式利用入度信息,适合动态调整入度的场景(如动态图)。
43+ - ** DFS 算法** :代码简洁,但难以处理动态变化的图。
4444
45- ## 代码实现(以 Python 为例)
45+ ## 代码实现(以 Python 为例)
4646
47- ### 图的表示
48- 使用邻接表表示图,例如节点 0 的邻接节点为 [ 1, 2] :
49- ``` python
47+ ### 图的表示
48+ 使用邻接表表示图,例如节点 0 的邻接节点为 [ 1, 2] :
49+ ``` python
5050graph = {
5151 0 : [1 , 2 ],
5252 1 : [3 ],
5353 2 : [3 ],
5454 3 : []
5555}
56- ```
57-
58- ### Kahn 算法实现
59- ``` python
60- from collections import deque
61-
62- def topological_sort_kahn (graph , n ):
63- # 初始化入度表
64- in_degree = {i: 0 for i in range (n)}
65- for u in graph:
66- for v in graph[u]:
67- in_degree[v] += 1
68-
69- # 将入度为 0 的节点加入队列
70- queue = deque([u for u in in_degree if in_degree[u] == 0 ])
71- result = []
72-
73- while queue:
74- u = queue.popleft()
75- result.append(u)
76- # 更新邻接节点的入度
77- for v in graph.get(u, []):
78- in_degree[v] -= 1
79- if in_degree[v] == 0 :
80- queue.append(v)
81-
82- # 检查是否存在环
83- if len (result) != n:
84- return [] # 存在环
85- return result
86- ```
87-
88- ** 代码解读** :
89- - ` in_degree ` 字典记录每个节点的入度。
90- - 队列 ` queue ` 维护当前入度为 0 的节点。
91- - 每次从队列取出节点后,将其邻接节点的入度减 1。若邻接节点入度变为 0,则加入队列。
92- - 最终若结果列表长度不等于节点总数,则说明存在环。
93-
94- ### DFS 算法实现
95- ``` python
96- def topological_sort_dfs (graph ):
97- visited = set ()
98- stack = []
99-
100- def dfs (u ):
101- if u in visited:
102- return
103- visited.add(u)
104- # 递归访问所有邻接节点
105- for v in graph.get(u, []):
106- dfs(v)
107- # 递归结束后压入栈
108- stack.append(u)
109-
110- for u in graph:
111- if u not in visited:
112- dfs(u)
113- # 逆序输出栈
114- return stack[::- 1 ]
115- ```
116-
117- ** 代码解读** :
118- - ` visited ` 集合记录已访问的节点。
119- - ` dfs ` 函数递归访问邻接节点,完成后将当前节点压入栈。
120- - 最终栈的逆序即为拓扑排序结果(后进先出的栈结构需要反转)。
121-
122- ## 实例演示与测试
123- 假设有以下 DAG:
124- ```
125- 5 → 0 ← 4
126- ↓ ↓ ↓
127- 2 → 3 → 1
128- ```
129-
130- ** 手动推导** :可能的拓扑排序为 ` [5, 4, 2, 0, 3, 1] ` 。
131- ** 代码测试** :
132- - 输入图的邻接表表示:
133- ``` python
56+ ```
57+
58+ ### Kahn 算法实现
59+ ``` python
60+ from collections import deque
61+
62+ def topological_sort_kahn (graph , n ):
63+ # 初始化入度表
64+ in_degree = {i: 0 for i in range (n)}
65+ for u in graph:
66+ for v in graph[u]:
67+ in_degree[v] += 1
68+
69+ # 将入度为 0 的节点加入队列
70+ queue = deque([u for u in in_degree if in_degree[u] == 0 ])
71+ result = []
72+
73+ while queue:
74+ u = queue.popleft()
75+ result.append(u)
76+ # 更新邻接节点的入度
77+ for v in graph.get(u, []):
78+ in_degree[v] -= 1
79+ if in_degree[v] == 0 :
80+ queue.append(v)
81+
82+ # 检查是否存在环
83+ if len (result) != n:
84+ return [] # 存在环
85+ return result
86+ ```
87+
88+ ** 代码解读** :
89+ - ` in_degree ` 字典记录每个节点的入度。
90+ - 队列 ` queue ` 维护当前入度为 0 的节点。
91+ - 每次从队列取出节点后,将其邻接节点的入度减 1。若邻接节点入度变为 0,则加入队列。
92+ - 最终若结果列表长度不等于节点总数,则说明存在环。
93+
94+ ### DFS 算法实现
95+ ``` python
96+ def topological_sort_dfs (graph ):
97+ visited = set ()
98+ stack = []
99+
100+ def dfs (u ):
101+ if u in visited:
102+ return
103+ visited.add(u)
104+ # 递归访问所有邻接节点
105+ for v in graph.get(u, []):
106+ dfs(v)
107+ # 递归结束后压入栈
108+ stack.append(u)
109+
110+ for u in graph:
111+ if u not in visited:
112+ dfs(u)
113+ # 逆序输出栈
114+ return stack[::- 1 ]
115+ ```
116+
117+ ** 代码解读** :
118+ - ` visited ` 集合记录已访问的节点。
119+ - ` dfs ` 函数递归访问邻接节点,完成后将当前节点压入栈。
120+ - 最终栈的逆序即为拓扑排序结果(后进先出的栈结构需要反转)。
121+
122+ ## 实例演示与测试
123+ 假设有以下 DAG:
124+ ```
125+ 5 → 0 ← 4
126+ ↓ ↓ ↓
127+ 2 → 3 → 1
128+ ```
129+
130+ ** 手动推导** :可能的拓扑排序为 ` [5, 4, 2, 0, 3, 1] ` 。
131+ ** 代码测试** :
132+ - 输入图的邻接表表示:
133+ ``` python
134134graph = {
135135 5 : [0 , 2 ],
136136 4 : [0 , 1 ],
@@ -139,26 +139,26 @@ graph = {
139139 3 : [1 ],
140140 1 : []
141141}
142- n = 6
143- ```
144- - 运行 ` topological_sort_kahn(graph, 6) ` 应返回长度为 6 的合法排序。
145- - 若图中存在环(例如添加边 ` 1 → 5 ` ),两种算法均返回空列表。
146-
147- ## 复杂度与优化
148- 两种算法的时间复杂度均为 $O(V + E)$,空间复杂度为 $O(V)$。
149- ** 优化技巧** :若需要字典序最小的排序,可将 Kahn 算法中的队列替换为优先队列(最小堆)。
150-
151- ## 实际应用场景
152- 1 . ** 编译器构建** :确定源代码文件的编译顺序。
153- 2 . ** 课程安排** :解决 LeetCode 210 题「课程表 II」的依赖问题。
154- 3 . ** 任务调度** :管理具有前后依赖关系的任务执行顺序。
155-
156- ## 总结与扩展
157- 拓扑排序是处理依赖关系的核心算法。通过 Kahn 算法和 DFS 算法的对比,可根据实际需求选择实现方式。进一步学习可探索:
158- - ** 强连通分量** :使用 Tarjan 算法识别图中的环。
159- - ** 动态拓扑排序** :在频繁增删边的场景下维护排序结果。
160- - ** 练习题** :LeetCode 207(判断能否完成课程)、310(最小高度树)等。
161-
162- ## 参考资源
163- - 《算法导论》第 22.4 章「拓扑排序」。
164- - VisuAlgo 的可视化工具:https://visualgo.net/zh/graphds。
142+ n = 6
143+ ```
144+ - 运行 ` topological_sort_kahn(graph, 6) ` 应返回长度为 6 的合法排序。
145+ - 若图中存在环(例如添加边 ` 1 → 5 ` ),两种算法均返回空列表。
146+
147+ ## 复杂度与优化
148+ 两种算法的时间复杂度均为 $O(V + E)$,空间复杂度为 $O(V)$。
149+ ** 优化技巧** :若需要字典序最小的排序,可将 Kahn 算法中的队列替换为优先队列(最小堆)。
150+
151+ ## 实际应用场景
152+ 1 . ** 编译器构建** :确定源代码文件的编译顺序。
153+ 2 . ** 课程安排** :解决 LeetCode 210 题「课程表 II」的依赖问题。
154+ 3 . ** 任务调度** :管理具有前后依赖关系的任务执行顺序。
155+
156+ ## 总结与扩展
157+ 拓扑排序是处理依赖关系的核心算法。通过 Kahn 算法和 DFS 算法的对比,可根据实际需求选择实现方式。进一步学习可探索:
158+ - ** 强连通分量** :使用 Tarjan 算法识别图中的环。
159+ - ** 动态拓扑排序** :在频繁增删边的场景下维护排序结果。
160+ - ** 练习题** :LeetCode 207(判断能否完成课程)、310(最小高度树)等。
161+
162+ ## 参考资源
163+ - 《算法导论》第 22.4 章「拓扑排序」。
164+ - VisuAlgo 的可视化工具:https://visualgo.net/zh/graphds
0 commit comments