2. DAG 基础理论:有向无环图定义、拓扑排序原理、关键路径算法

在分布式任务调度系统中,DAG(有向无环图)是描述任务依赖关系的核心数学模型。理解 DAG 的定义、拓扑排序原理以及关键路径算法,是设计高效调度引擎的理论基石。本节将系统性地阐述这三个核心概念,并辅以图示与代码示例,帮助您建立扎实的理论框架。

2.1 有向无环图(DAG)定义与核心要素

DAG 是一种特殊的有向图,其中不存在任何有向环路。在任务调度场景中,每个节点代表一个任务单元,每条有向边表示任务之间的依赖关系(即前驱任务完成后,后继任务才能开始执行)。

核心定义: 一个 DAG 由顶点集合 V 和有向边集合 E 组成,且对于任意顶点 v ∈ V,不存在一条从 v 出发经过若干条有向边后回到 v 的路径。
要素 符号 调度含义 示例
顶点(Vertex) V = {v₁, v₂, …, vₙ} 任务单元(如数据清洗、模型训练) Task_A, Task_B
有向边(Directed Edge) E ⊆ V × V 依赖关系(前驱→后继) Task_A → Task_B
入度(In-degree) deg⁻(v) 指向该任务的直接前驱数量 入度为 0 表示无依赖
出度(Out-degree) deg⁺(v) 该任务触发的直接后继数量 出度为 0 表示终端任务
源点(Source) deg⁻(v) = 0 可立即启动的起始任务 数据采集任务
汇点(Sink) deg⁺(v) = 0 无后继的最终任务 结果输出任务
实践要点: 在构建任务 DAG 时,务必确保图中不存在环路。常见的环路检测方法包括拓扑排序(若无法完成所有节点排序,则存在环)和 DFS 回溯标记法。
DAG 基本结构示例:6个任务节点与依赖关系 DAG 基本结构示例 v₁ (源点) v₂ v₃ v₄ v₅ v₆ (汇点) 入度=0: 源点 出度=0: 汇点

2.2 拓扑排序原理与实现

拓扑排序是对 DAG 中所有顶点的一种线性排序,使得对于每一条有向边 u → v,顶点 u 在排序中都出现在顶点 v 之前。在任务调度中,拓扑排序直接决定了任务的执行顺序。

2.2.1 Kahn 算法(基于入度)

Kahn 算法通过维护一个入度为 0 的顶点队列,逐步移除顶点及其出边,直到所有顶点都被处理。该算法的时间复杂度为 O(V + E),空间复杂度为 O(V)。

// Kahn 算法伪代码
function topologicalSortKahn(graph):
    inDegree = 计算每个顶点的入度
    queue = 所有入度为 0 的顶点
    result = []

    while queue 不为空:
        vertex = queue.dequeue()
        result.append(vertex)

        for each neighbor in graph[vertex]:
            inDegree[neighbor] -= 1
            if inDegree[neighbor] == 0:
                queue.enqueue(neighbor)

    if result.length != graph.vertexCount:
        throw "图中存在环路,无法完成拓扑排序"
    return result

2.2.2 DFS 后序法

基于深度优先搜索(DFS)的拓扑排序利用递归后序遍历:当某个顶点的所有后继顶点都被访问完毕后,将该顶点压入栈中。最终栈的逆序即为拓扑排序结果。

// DFS 后序法伪代码
function topologicalSortDFS(graph):
    visited = 空集合
    stack = []

    function dfs(vertex):
        visited.add(vertex)
        for each neighbor in graph[vertex]:
            if neighbor not in visited:
                dfs(neighbor)
        stack.push(vertex)  // 后序入栈

    for each vertex in graph:
        if vertex not in visited:
            dfs(vertex)

    return stack.reverse()  // 栈的逆序
对比维度 Kahn 算法 DFS 后序法
核心思想 贪心移除入度为 0 的顶点 递归后序压栈
数据结构 队列 + 入度数组 栈 + 访问标记
环路检测 结果长度 < 顶点总数时存在环 递归过程中遇到灰色节点(正在访问)时存在环
适用场景 需要实时输出排序结果、动态 DAG 需要同时进行其他图遍历操作
稳定性 稳定,入度队列可控制同层顺序 依赖递归深度,大图可能栈溢出
常见陷阱: 拓扑排序仅适用于 DAG。如果图中存在环路,Kahn 算法会在队列为空时仍有未处理顶点;DFS 法会在递归中检测到回边。生产环境中,建议在构建 DAG 后立即执行拓扑排序以验证无环。
Kahn 算法拓扑排序过程:入度递减与队列变化 Kahn 算法拓扑排序过程 A (0) B (1) C (1) D (2) 算法步骤 ① 入度队列: [A] ② 出队 A, B/C 入度减 1 ③ 入度队列: [B, C] ④ 出队 B/C, D 入度减为 0 拓扑排序结果 A → B → C → D 入度: 0 入度: 1 入度: 1 入度: 2

2.3 关键路径算法(CPM)

关键路径(Critical Path)是 DAG 中从源点到汇点的最长路径,决定了整个任务调度的最短完成时间。关键路径上的任务称为关键任务,任何关键任务的延迟都会直接导致整体工期延长。

2.3.1 核心概念

  • 最早开始时间(ES, Earliest Start): 任务可以开始的最早时刻,等于所有前驱任务最早完成时间的最大值。
  • 最早完成时间(EF, Earliest Finish): EF = ES + 任务持续时间。
  • 最晚开始时间(LS, Latest Start): 在不影响整体工期的前提下,任务必须开始的最晚时刻。
  • 最晚完成时间(LF, Latest Finish): LF = LS + 任务持续时间,或等于所有后继任务最晚开始时间的最小值。
  • 总时差(TF, Total Float): TF = LS - ES = LF - EF。总时差为 0 的任务即为关键任务。

2.3.2 算法步骤

  1. 正向遍历(Forward Pass): 从源点开始,按拓扑排序顺序计算每个任务的 ES 和 EF。源点的 ES = 0。
  2. 反向遍历(Backward Pass): 从汇点开始,按拓扑排序的逆序计算每个任务的 LS 和 LF。汇点的 LF = 其 EF。
  3. 识别关键路径: 找出所有 TF = 0 的任务,这些任务构成的路径即为关键路径。
// 关键路径算法核心逻辑(伪代码)
function criticalPath(graph, durations):
    topoOrder = topologicalSortKahn(graph)
    // 正向遍历
    for each v in topoOrder:
        ES[v] = max(EF[u] for u in predecessors[v])  // 默认为 0
        EF[v] = ES[v] + durations[v]
    // 反向遍历
    for each v in reverse(topoOrder):
        LF[v] = min(LS[w] for w in successors[v])    // 默认为 EF[v]
        LS[v] = LF[v] - durations[v]
    // 计算总时差
    for each v:
        TF[v] = LS[v] - ES[v]
    // 提取关键路径
    criticalTasks = [v for v in graph if TF[v] == 0]
    return criticalTasks
工程意义: 在分布式任务调度中,关键路径算法用于识别瓶颈任务。调度引擎可以优先保障关键任务的资源分配,并在关键任务发生延迟时触发告警或动态调整策略。

2.3.3 关键路径示例

假设一个包含 5 个任务的 DAG,各任务持续时间如下:A(3), B(2), C(4), D(1), E(5)。依赖关系为:A→B, A→C, B→D, C→D, D→E。

任务 持续时间 ES EF LS LF TF 是否关键
A 3 0 3 0 3 0 是
B 2 3 5 4 6 1 否
C 4 3 7 3 7 0 是
D 1 7 8 7 8 0 是
E 5 8 13 8 13 0 是

关键路径为:A → C → D → E,总工期为 13 个时间单位。任务 B 有 1 个时间单位的浮动,不属于关键路径。