控制面板

算法控制

入度为 0 的节点队列

拓扑排序结果

算法原理详解

Kahn 算法 (基于入度)
DFS 算法 (基于搜索)
复杂度分析

Kahn 算法核心思想

Kahn 算法是一种直观的拓扑排序方法,它模拟了任务执行的过程:总是先执行那些没有前置依赖的任务。 算法的关键在于维护每个节点的 入度 (In-degree),即有多少条边指向该节点。

  • 入度为 0 的节点表示它没有任何前置依赖,可以作为当前执行(或排序)的起点。
  • 算法维护一个队列(或集合),专门存放当前所有入度为 0 的节点。
  • 每次从队列中取出一个节点,将其加入拓扑排序结果。这个过程相当于“完成”了一个任务。
  • 当一个节点(任务)被完成后,它指向的所有后续节点(依赖它的任务)的前置依赖就解除了一部分。这体现在将这些后续节点的入度减 1。
  • 如果某个后续节点的入度在减 1 后变为 0,意味着它的所有前置依赖都已完成,于是将它也加入队列,等待后续处理。
  • 重复此过程,直到队列为空。

算法流程图

初始化:
  计算所有节点的入度 in_degree[v]
  创建一个队列 Q
  将所有 in_degree[v] == 0 的节点 v 加入 Q
  创建一个列表 L 用于存储结果

循环:
  while Q 不为空:
    从 Q 中取出一个节点 u
    将 u 加入 L
    for 每个 u 的邻居 v:
      in_degree[v] = in_degree[v] - 1
      if in_degree[v] == 0:
        将 v 加入 Q

检查:
  if L 中的节点数 == 图中总节点数:
    返回 L (拓扑排序结果)
  else:
    图中存在环,无法拓扑排序
                                

伪代码


function KahnTopologicalSort(Graph G):
  L = [] // 存储排序结果的列表
  in_degree = map() // 存储每个节点的入度
  queue = Queue() // 存储入度为 0 的节点

  // 1. 计算所有节点的入度
  for each node u in G:
    in_degree[u] = 0
  for each node u in G:
    for each neighbor v of u:
      in_degree[v] = in_degree[v] + 1

  // 2. 将所有入度为 0 的节点加入队列
  for each node u in G:
    if in_degree[u] == 0:
      queue.enqueue(u)

  // 3. 处理队列中的节点
  while not queue.isEmpty():
    u = queue.dequeue()
    L.append(u)

    // 4. 更新邻居的入度
    for each neighbor v of u:
      in_degree[v] = in_degree[v] - 1
      if in_degree[v] == 0:
        queue.enqueue(v)

  // 5. 检查是否存在环
  if len(L) == G.numberOfNodes():
    return L // 成功找到拓扑排序
  else:
    return Error("Graph contains a cycle") // 图中有环
                                

优点: 实现相对简单,容易理解。可以同时检测图中是否存在环。

缺点: 需要额外空间存储入度和队列。

DFS 算法核心思想

基于深度优先搜索 (DFS) 的拓扑排序利用了 DFS 完成节点访问的顺序特性。其核心思想是:一个节点必须在其所有后续依赖节点都被访问(并加入结果)之后,才能被加入最终的拓扑序列

具体来说,当 DFS 访问到一个节点 `u` 时:

  • 标记 `u` 为“正在访问”(visiting)。这用于检测环:如果在访问 `u` 的后代时又遇到了 `u`,则说明存在环。
  • 递归地访问 `u` 的所有未访问过的邻居 `v`。
  • 当从 `u` 出发的所有路径都探索完毕(即所有邻居的递归调用都已返回)时,说明所有依赖 `u` 的节点都已处理完成。此时,将 `u` 标记为“已访问”(visited),并将 `u` 添加到结果列表的头部
  • 对图中所有未访问的节点重复此 DFS 过程。

因为节点是在其所有后继节点被处理完后才加入结果列表的头部,所以最终列表的顺序就是拓扑排序的一个有效序列。

递归示意

Graph: A -> B, A -> C, B -> D, C -> D

DFS(A):
  Mark A as visiting
  DFS(B):
    Mark B as visiting
    DFS(D):
      Mark D as visiting
      (D has no unvisited neighbors)
      Mark D as visited
      Add D to front of result: [D]
    Mark B as visited
    Add B to front of result: [B, D]
  DFS(C):
    Mark C as visiting
    DFS(D): (D is already visited, skip)
    Mark C as visited
    Add C to front of result: [C, B, D]
  Mark A as visited
  Add A to front of result: [A, C, B, D]

Final Result: A, C, B, D (One possible topological sort)
                                

伪代码


function DFSTopologicalSort(Graph G):
  L = [] // 存储排序结果的列表 (最终需要反转或头部插入)
  visited = set() // 记录已完全访问的节点
  visiting = set() // 记录当前递归栈中的节点 (用于检测环)
  has_cycle = false

  function dfs_visit(node u):
    if u in visiting: // 检测到环
      has_cycle = true
      return
    if u in visited: // 已访问过,直接返回
      return

    visiting.add(u) // 标记为正在访问

    for each neighbor v of u:
      if not has_cycle:
        dfs_visit(v)
      else:
        return // 如果已检测到环,提前退出

    visiting.remove(u) // 从递归栈中移除
    visited.add(u) // 标记为已访问
    L.insert(0, u) // 将节点插入结果列表的头部

  // 对图中每个节点启动 DFS
  for each node u in G:
    if u not in visited and not has_cycle:
      dfs_visit(u)

  if has_cycle:
    return Error("Graph contains a cycle")
  else:
    return L // 返回拓扑排序结果
                                

优点: 通常比 Kahn 算法需要更少的额外空间(如果使用递归栈),代码可能更简洁。也能检测环。

缺点: 理解上可能比 Kahn 算法稍复杂,递归深度可能受限。

时间复杂度

假设图有 V 个顶点和 E 条边。

  • Kahn 算法:
    • 计算所有节点的入度:需要遍历所有边,时间复杂度为 O(E)。如果使用邻接表,访问所有邻居也是 O(V+E)。总计 O(V+E)。
    • 初始化队列:最多将 V 个节点入队,O(V)。
    • 主循环:每个节点最多入队和出队一次 O(V)。对于每个出队的节点 u,遍历其所有出边(邻居 v)并更新 v 的入度。总的来说,每条边最多被访问一次 O(E)。
    • 因此,Kahn 算法的总时间复杂度为 O(V + E)
  • DFS 算法:
    • 标准的深度优先搜索遍历图的时间复杂度是 O(V + E)(使用邻接表)。
    • 在 DFS 过程中,访问每个节点和每条边正好一次。记录访问状态和添加到结果列表的操作都是 O(1)。
    • 因此,DFS 算法的总时间复杂度也是 O(V + E)

结论:两种主要的拓扑排序算法都具有线性时间复杂度 O(V + E),效率都很高。

空间复杂度

  • Kahn 算法:
    • 需要存储每个节点的入度:O(V)。
    • 需要一个队列来存储入度为 0 的节点,最坏情况下可能存储 V 个节点:O(V)。
    • 需要存储结果列表:O(V)。
    • 总空间复杂度为 O(V)
  • DFS 算法:
    • 需要存储访问状态(visited 和 visiting/recursion stack):O(V)。
    • 递归调用栈的深度在最坏情况下(链状图)可能达到 O(V)。
    • 需要存储结果列表:O(V)。
    • 总空间复杂度为 O(V)(包括递归栈)。

结论:两种算法的空间复杂度也都是线性的 O(V)。

复杂度比较图示

时间复杂度 (Time Complexity): O(V + E)

Kahn: O(V+E)
DFS: O(V+E)


空间复杂度 (Space Complexity): O(V)

Kahn: O(V)
DFS: O(V)

(V = 顶点数, E = 边数)

探索更多拓扑排序知识

拓扑排序是图论中的基础算法,但它还有一些有趣的变种和更广泛的应用。

知识延伸

多源拓扑排序 & 字典序最小

在标准的 Kahn 算法中,我们将所有入度为 0 的节点放入队列。如果使用优先队列(最小堆)来代替普通队列,每次取出当前入度为 0 的节点中字典序(或编号)最小的那个,就可以得到所有可能的拓扑序列中字典序最小的那一个。

示例: 图 A->C, B->C。初始入度为 0 的节点有 A 和 B。

  • 普通队列可能先处理 A 或 B,得到序列 A, B, C 或 B, A, C。
  • 使用最小堆,会先取出 A,得到唯一的字典序最小序列 A, B, C。

在线拓扑排序

有时,图的边是动态添加的。在线拓扑排序算法能够在每次添加新边后,快速判断是否仍然是 DAG,并维护(或更新)拓扑序。这通常比每次重新计算要高效,但算法也更复杂,可能需要维护更复杂的数据结构来跟踪节点间的可达性或环。

与关键路径结合

在项目管理(如 PERT 图)中,任务不仅有依赖关系,还有持续时间。拓扑排序可以用来确定任务的执行顺序。结合动态规划思想,可以在拓扑排序的过程中计算每个任务的最早开始时间 (EST) 和最晚完成时间 (LFT),从而找出影响项目总工期的关键路径

S A(3) B(2) C(4) E

示意:关键路径(实线)决定最短工期

实际项目应用案例

  • 构建系统 (Make, Bazel): 根据文件依赖关系确定编译顺序。
  • 数据库事务调度: 保证事务执行满足依赖约束。
  • 并行计算任务调度: 在分布式系统中安排无依赖的任务并行执行。
  • UI 框架更新: 如 React 或 Vue 中组件的渲染和更新可能依赖父组件或数据流,拓扑排序思想有助于确定更新顺序。

了解更多?可以搜索 "Topological Sort Applications" 或查阅相关算法书籍和论文。