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