有向无环图与入度算法

42 阅读5分钟

在软件开发中,有向无环图(Directed Acyclic Graph, DAG)是一种核心数据结构,广泛应用于任务调度、工作流管理、依赖解析等领域,如编译器的模块依赖、CI/CD 管道或大数据处理框架(如 Apache Airflow)。DAG 的无环特性确保了任务的有序执行,避免了循环依赖导致的死锁问题。

DAG 的基本概念

DAG 是一种由节点(vertices)和有向边(directed edges)组成的有向图,且不存在任何环路。以下是其核心组成:

  • 节点:表示任务、步骤或实体。例如,在工作流中,节点可能代表“加载数据”或“渲染 UI”。
  • :表示依赖关系,从源节点指向目标节点,意味着源任务必须在目标任务前执行。
  • 无环:图中没有路径能从一个节点返回自身,确保任务的可排序性和有限性。

DAG 的无环特性使其特别适合建模依赖关系。例如,在软件包管理(如 npm)中,DAG 用于确定包的安装顺序;在工作流引擎中,DAG 确保步骤按正确顺序执行。

为什么需要 DAG?

在复杂系统中,任务往往有依赖关系(如任务 A 依赖 B,B 依赖 C)。如果存在环(如 A 依赖 B,B 依赖 A),会导致无限循环。DAG 通过确保无环,提供了可靠的任务调度基础。常见应用场景包括:

  • 任务调度:如 Makefile 的目标依赖。
  • 工作流管理:如 GitHub Actions 的步骤执行。
  • 计算图:如 TensorFlow 的神经网络计算。

入度算法(Kahn's 算法)

拓扑排序是 DAG 的核心算法,用于生成节点的线性执行顺序,确保所有依赖都满足。Kahn's 算法是一种基于入度的经典实现,入度指指向某个节点的边数。算法步骤如下:

  1. 初始化

    • 构建邻接表(记录每个节点的出边)。
    • 计算每个节点的入度。
    • 将入度为 0 的节点(无依赖)加入队列。
  2. 处理队列

    • 从队列中取出一个节点,加入排序结果。
    • 遍历该节点的出边,减少目标节点的入度。
    • 如果目标节点入度降为 0,加入队列。
  3. 终止与检查

    • 队列为空时,若排序结果包含所有节点,则排序成功。
    • 若结果节点数少于总节点数,说明图中存在环,抛出错误。

时间与空间复杂度

  • 时间复杂度:O(V + E),其中 V 是节点数,E 是边数(遍历所有节点和边)。
  • 空间复杂度:O(V),用于存储入度、邻接表和队列。

Kahn's 算法简单高效,且能自动检测环,适合任务调度场景。它还支持并行处理,因为队列中的节点无依赖,可并发执行。

示例场景

考虑一个工作流:

  • 节点:b1(titleSlot-left)、b2(titleSlot-right)、b3(default)。
  • 边:b1 → b2,b2 → b3。
  • 初始入度:b1=0, b2=1, b3=1。

排序过程:

  1. b1 入度为 0,加入队列,排序:[b1]。
  2. 移除 b1,b2 入度减为 0,加入队列,排序:[b1, b2]。
  3. 移除 b2,b3 入度减为 0,加入队列,排序:[b1, b2, b3]。
  4. 移除 b3,队列为空,排序完成。

最终顺序:b1 → b2 → b3。

TypeScript 实现

以下是用 TypeScript 实现的 DAG 和工作流引擎,分为两个类:

  • DAG:负责图构建、拓扑排序和环检测。
  • WorkflowEngine:负责执行工作流,支持异步节点逻辑。

类型定义

interface NodeData {
  execute: () => void; // 节点执行逻辑,支持 Promise<void> 以处理异步
}

interface Node {
  id: string;
  label: string;
  data: NodeData;
}

interface Edge {
  id: string;
  source: string; // 源节点
  target: string; // 目标节点
}

DAG 类

DAG 类负责构建图结构和执行拓扑排序,使用邻接表和入度表存储图信息。

class DAG {
  private nodes: Map<string, Node>;
  private adjList: Map<string, string[]>;
  private indegree: Map<string, number>;

  constructor(nodes: Node[], edges: Edge[]) {
    this.nodes = new Map(nodes.map(node => [node.id, node]));
    this.adjList = new Map();
    this.indegree = new Map();

    nodes.forEach(node => {
      this.indegree.set(node.id, 0);
      this.adjList.set(node.id, []);
    });

    edges.forEach(edge => {
      const { source, target } = edge;
      if (this.nodes.has(source) && this.nodes.has(target)) {
        this.adjList.get(source)!.push(target);
        this.indegree.set(target, (this.indegree.get(target)! || 0) + 1);
      }
    });
  }

  public topologicalSort(): string[] {
    const order: string[] = [];
    const queue: string[] = [];

    this.indegree.forEach((degree, id) => {
      if (degree === 0) queue.push(id);
    });

    while (queue.length > 0) {
      const current = queue.shift()!;
      order.push(current);
      this.adjList.get(current)?.forEach(neighbor => {
        const newDegree = this.indegree.get(neighbor)! - 1;
        this.indegree.set(neighbor, newDegree);
        if (newDegree === 0) queue.push(neighbor);
      });
    }

    if (order.length !== this.nodes.size) {
      throw new Error('Graph contains a cycle! Cannot perform topological sort.');
    }

    return order;
  }

  public getNode(id: string): Node | undefined {
    return this.nodes.get(id);
  }
}

WorkflowEngine 类

WorkflowEngine 类负责执行工作流,通过 execute 方法接收 DAG 实例,获取拓扑顺序并执行节点逻辑。

class WorkflowEngine {
  public async execute(dag: DAG): Promise<void> {
    const order = dag.topologicalSort();
    console.log('Topological order:', order.map(id => dag.getNode(id)!.label).join(' -> '));

    for (const id of order) {
      const node = dag.getNode(id)!;
      try {
        await node.data.execute();
        console.log(`Executed node: ${node.label}`);
      } catch (error) {
        console.error(`Error executing node ${node.label}:`, error);
        throw error;
      }
    }
    console.log('Workflow execution completed.');
  }
}

示例运行

以下是示例工作流的定义和执行代码:

const nodes: Node[] = [
  { id: 'b1', label: 'titleSlot-left', data: { execute: () => console.log('Executing titleSlot-left...') } },
  { id: 'b2', label: 'titleSlot-right', data: { execute: () => console.log('Executing titleSlot-right...') } },
  { id: 'b3', label: 'default', data: { execute: () => console.log('Executing default...') } }
];

const edges: Edge[] = [
  { id: 'b12', source: 'b1', target: 'b2' },
  { id: 'b23', source: 'b2', target: 'b3' }
];

const dag = new DAG(nodes, edges);
const engine = new WorkflowEngine();
engine.execute(dag).catch(console.error);

输出

Topological order: titleSlot-left -> titleSlot-right -> default
Executing titleSlot-left...
Executed node: titleSlot-left
Executing titleSlot-right...
Executed node: titleSlot-right
Executing default...
Executed node: default
Workflow execution completed.

扩展与优化

以下是一些可能的优化方向:

  • 并行执行:对于无依赖的节点,可使用 Promise.all 并行执行,缩短总执行时间。
  • 错误处理:增强 WorkflowEngine,支持失败节点重试或跳过。
  • 可视化:结合 Graphviz 或 Cytoscape.js 展示 DAG 结构,便于调试和优化。
  • 性能优化:对于大规模图,可使用更高效的数据结构(如稀疏矩阵)存储边。

结论

有向无环图和入度算法为复杂依赖关系的建模和执行提供了高效解决方案。本文通过 TypeScript 实现了一个工作流引擎,清晰展示了 DAG 的构建、拓扑排序和任务执行过程。DAG 类专注于图结构和排序,WorkflowEngine 类专注于执行,职责分离使得代码易于维护和扩展。无论是工作流管理还是依赖解析,DAG 和 Kahn's 算法都是不可或缺的工具。