拓扑排序在前端开发中的应用场景

1、什么是拓扑排序?

拓扑排序(Topological Sorting)是一种图论算法,用于解决有向无环图(DAGDirected Acyclic Graph)中的节点排序问题。拓扑排序的目标是将图中的所有节点按照一种线性顺序排列,使得对于任何有向边 (u, v),节点 u 在排列中都出现在节点 v 的前面。

换句话说,拓扑排序能够找到一种排列,使得所有的依赖关系都能够被满足。

拓扑排序常常用于解决涉及依赖关系的问题,如编译顺序、任务调度、课程选修等。

如果有向图中存在环路(循环依赖),则无法进行拓扑排序,因为无法满足依赖关系。

从拓扑排序用途的阐述,我们可以得知,前端程序员似乎每天都在应用它,如果你还没有想到?你是不是忽略了一个非常重要的知识点,当你在命令行输入npm install之后,是不是就是经历了上述所说的过程。

2、拓扑排序解决问题的思路

对于一个有向无环图,肯定有一些顶点是没有入度的(假设图中的一些边指向某个顶点,那么指向这个顶点的边的条数就称之为这个节点的入度),也就是说这些顶点没有前驱依赖,那么这些顶点就可以直接输出来了,但是将这些顶点处理完成之后,这些顶点的后继节点的入度肯定要相应的减 1,重复这个过程,我们再看一下还有没有入度为 0 的顶点,直到处理完所有的顶点

假设图内存在环的话,那就是意味着一些顶点的入度永远无法变为 0,最终处理的结果的顶点数肯定是比图的定点数少的。

对于怎么处理入度为 0 的顶点,我们可以把它放到一个专门的地方,每次都从这个专门的地方取一个顶点出来进行处理,这样就少了再去找它们的过程;输出这个顶点之后,扫一下它的邻接点,如果发现了有入度为 0 的节点,又可以将其加到这个队列,直到没有满足条件的顶点。

这个过程有点儿像二叉树的层序遍历,当然你也可以选择别的线性数据结构来存储入度为0的节点,不过最终输出的顺序就不相同了。

3、拓扑排序的算法实现

为了方便直观,我使用以下结构来描述DAG

ts 复制代码
/**
 * 图中的顶点
 */
export interface Vertex {
  /**
   * 入度
   */
  inDegree: Edge[];
  /**
   * 出度
   */
  outDegree: Edge[];
  /**
   * 节点名称
   */
  name: string;
}

/**
 * 图中的边
 */
export interface Edge {
  /**
   * 权重
   */
  weight?: number;
  /**
   * 前驱节点
   */
  prev: Vertex;
  /**
   * 后继节点
   */
  next: Vertex;
}

/**
 * 有向无环图
 */
export interface Graph {
  /**
   * 图的节点数组
   */
  nodes: Vertex[];
  /**
   * 图的节点的个数
   */
  get count(): number;
}

以下则是基于上述的结构实现的拓扑排序:

ts 复制代码
/**
 * 拓扑排序
 */
export function topologicalSort(g: Graph) {
  // 用于记住入度数
  const inDegreeMap: Map<Vertex, number> = new Map();
  // 队列,用于处理入度是0的点
  const queue: Vertex[] = [];
  const topSortResults: Vertex[] = [];
  let count = 0;
  // 初始化的时候,用map记住每个节点的入度数
  g.nodes.forEach((v) => {
    if (v.inDegree.length === 0) {
      queue.push(v);
    } else {
      inDegreeMap.set(v, v.inDegree.length);
    }
  });
  // 开始进行拓扑排序
  while (queue.length) {
    // 出队一个节点进行处理
    const vertex = queue.shift()!;
    // 将其加入到结果里面去
    topSortResults.push(vertex);
    // 处理的个数加1
    count++;
    // 处理出度
    vertex.outDegree.forEach((edge) => {
      const nextVertex = edge.next;
      // 获取后继节点的入度
      const inDegree = inDegreeMap.get(nextVertex)!;
      // 设置节点新的入度
      inDegreeMap.set(nextVertex, inDegree - 1);
      // 如果除开这个节点的话,下个节点的入度将会是0,说明经过这个操作之后它已经没有入度了,可以进行操作了
      if (inDegree === 1) {
        queue.push(nextVertex);
      }
    });
  }
  // 如果把所有的入度为0的节点都找过了,凡是发现不够总的节点个数,于是可以得出一个结论,某些节点的入度无论如何不可能是0,于是可以推导出图中存在回路的结论。
  if (count < g.count) {
    throw new Error("图中存在回路,无法进行拓扑排序~");
  }

  return topSortResults;
}

4、拓扑排序的应用之------课程选修

假设某大学的计算机专业学生的培养计划如下:

请输出其对应的排课顺序。

对于这个问题,我们采取上文所述的方式表达图。 我们用一个 json 来描述这个培养方案:

json 复制代码
[
  {
    "name": "程序设计基础",
    "id": "c1",
    "deps": ""
  },
  {
    "name": "离散数学",
    "id": "c2",
    "deps": ""
  },
  {
    "name": "数据结构",
    "id": "c3",
    "deps": "c1,c2"
  },
  {
    "name": "微积分(上)",
    "id": "c4",
    "deps": ""
  },
  {
    "name": "微积分(下)",
    "id": "c5",
    "deps": "c4"
  },
  {
    "name": "线性代数",
    "id": "c6",
    "deps": "c5"
  },
  {
    "name": "算法分析与设计",
    "id": "c7",
    "deps": "c3"
  },
  {
    "name": "逻辑与计算机设计基础",
    "id": "c8",
    "deps": ""
  },
  {
    "name": "计算机组成",
    "id": "c9",
    "deps": "c8"
  },
  {
    "name": "操作系统",
    "id": "c10",
    "deps": "c7,c9"
  },
  {
    "name": "编译原理",
    "id": "c11",
    "deps": "c7,c9"
  },
  {
    "name": "数据库",
    "id": "c12",
    "deps": "c7"
  },
  {
    "name": "计算理论",
    "id": "c13",
    "deps": "c2"
  },
  {
    "name": "计算机网络",
    "id": "c14",
    "deps": "c10"
  },
  {
    "name": "数值分析",
    "id": "c15",
    "deps": "c6"
  }
]

id 为每个课程的唯一性标识,name 为课程的名称,deps 为课程的前置课程,多门课程以逗号分隔。 首先需要得到一个DAG,因此我们需要对这个 json 进行加工,以下是根据 json 生成DAG的算法:

ts 复制代码
export class BuildDAG {
  /**
   * 用于存储课程的信息映射
   */
  private refMap: Map<string, VertexInfo> = new Map();
  /**
   * 用于存储已经构建好的节点,防止重复构建
   */
  private builtMap: Map<string, Vertex> = new Map();
  /**
   * 存储外界传递的课程信息
   */
  private vertexInfo: VertexInfo[];

  constructor(vertexInfo: VertexInfo[]) {
    this.vertexInfo = vertexInfo;
  }

  /**
   * 链接两个节点
   * @param startVertex 开始节点
   * @param endVertex 结束节点
   */
  private link(startVertex: Vertex, endVertex: Vertex): void {
    const edge: Edge = {
      prev: startVertex,
      next: endVertex,
    };
    startVertex.outDegree.push(edge);
    endVertex.inDegree.push(edge);
  }
  /**
   * 构建顶点
   * @param name 顶点的名称
   * @param deps 顶点的依赖节点
   */
  private buildVertex(id: string) {
    const vertexInfo = this.refMap.get(id);
    // 找不到节点
    if (!vertexInfo) {
      return null;
    }
    // 如果节点已经被构建,可以直接返回已经构建的节点
    if (this.builtMap.get(id)) {
      return this.builtMap.get(id);
    }
    const { id: vertexId, deps, name } = vertexInfo;
    // 初始化节点信息
    const vertex: Vertex = {
      name,
      inDegree: [],
      outDegree: [],
    };
    // 递归的构建当前节点的前驱节点,若有的话
    const depsNodes: Vertex[] =
      deps === ""
        ? []
        : deps.split(",").map((depId) => {
            return this.buildVertex(depId) as Vertex;
          });
    // 将有依赖关系的节点建立关系
    depsNodes.forEach((pre) => {
      this.link(pre, vertex);
    });
    // 将当前已经构建的节点加入到已构建的哈希表中
    this.builtMap.set(vertexId, vertex);
    return vertex;
  }

  /**
   * 构建图
   */
  private buildGraph(): Graph {
    // 跟姐ID建立节点的映射关系
    this.vertexInfo.forEach((item) => {
      this.refMap.set(item.id, item);
    });
    // 依次构建每个节点
    const nodes = this.vertexInfo.map((v) => {
      return this.buildVertex(v.id) as Vertex;
    });
    return {
      nodes,
      get count() {
        return nodes.length;
      },
    };
  }

  build() {
    return this.buildGraph();
  }
}

对这个图进行拓扑排序得到的结果:

json 复制代码
[
  "程序设计基础",
  "离散数学",
  "微积分(上)",
  "逻辑与计算机设计基础",
  "数据结构",
  "计算理论",
  "微积分(下)",
  "计算机组成",
  "算法分析与设计",
  "线性代数",
  "操作系统",
  "编译原理",
  "数据库",
  "数值分析",
  "计算机网络"
]

有了这个结果,那么我们就可以直接根据每个学期学生需要完成的课程数进行分块,每块就是该学生对应学期需要完成的课程。

5、拓扑排序的应用之------Monorepo项目的构建顺序

好了,说了这么多,终于可以进入正题了。

现在的开源库已经逐渐采用pnpm+Monorepo的管理方式,其拥有以下优点:

  • 代码共享和重用 : 在Monorepo中,不同部分的代码可以轻松共享和重用。这有助于避免重复工作,提高代码的一致性,并使开发人员更容易找到和使用已经存在的功能模块或库。
  • 统一的构建和部署: 由于所有代码都在一个仓库中,构建和部署过程变得更加统一和协调。这有助于确保不同部分的代码之间没有不兼容性,减少构建和部署的问题。
  • 版本一致性 : 在Monorepo中,所有代码都可以使用相同的版本控制系统和工具进行管理。这有助于确保项目的各个部分保持一致的版本,减少版本冲突和依赖问题。
  • 易于跟踪更改Monorepo使得跟踪项目中的更改变得更加容易,因为所有更改都在同一个仓库中进行。这有助于开发团队更好地理解和管理代码变更。
  • 简化协作 : 当多个团队或开发者同时工作在一个项目中时,Monorepo可以简化协作过程。开发者可以更容易地查看和理解整个项目的状态,而不必在不同的仓库之间切换。
  • 提高构建性能 : 在一些情况下,Monorepo可以提高构建性能。因为代码和依赖项都在一个仓库中,可以更有效地利用缓存和并行构建,从而加快构建时间。
  • 强化代码质量控制: 通过将所有代码集中在一个仓库中,可以更容易地实施代码审查、测试和代码质量控制标准,确保高质量的代码交付。

虽然Monorepo模式拥有以上优点,但是Monorepo模式有一个比较关键的问题------>依赖的先后顺序问题。 假设 B 项目依赖 A 项目,若 A 项目没有构建成功,B 项目是肯定不会构建成功的,因此,我们就需要得到一个科学的构建关系,在项目不多的时候,我们通过肉眼还能分辨出构建关系,但是随着子项目增多,这件事将会变得越来越困难,那么怎么样自动得到这个科学的构建关系呢------>即对项目的依赖关系进行拓扑排序。

以下是我开发的一个项目中的实际场景。

首先,先将packages目录下面的所有子项目解析到,支持剔除不解析的项目。

js 复制代码
import fs from 'fs/promises'
import path from 'path'

class MonorepoProjectReader {
  // 定义Monorepo项目根目录
  get rootDir() {
    return process.cwd()
  }
  // 定义packages目录的路径
  get pkgsDir() {
    return path.join(this.rootDir, 'packages')
  }

  // 忽略的目录
  ignoreDirs = ['site']

  // 用于存储子项目的名称和依赖项
  projectInfo = []

  async checkDirectory(path) {
    try {
      const stats = await fs.stat(path)
      return stats.isDirectory()
    } catch (err) {
      console.error(`无法获取路径 ${path} 的信息:${err}`)
      return false
    }
  }
  
  // 读取项目的package.json中的关键信息
  async readPackageJson(subProject) {
    const baseDir = path.join(this.pkgsDir, subProject)
    const checkResult = await this.checkDirectory(baseDir)
    if (!checkResult) {
      return
    }
    const packageJsonPath = path.join(baseDir, 'package.json')
    try {
      const packageJsonContent = await fs.readFile(packageJsonPath, 'utf8')
      const packageJson = JSON.parse(packageJsonContent)
      const projectName = packageJson.name
      const dependencies = packageJson.dependencies || {}
      // 我的处理比较简单粗暴,只处理了dependencies的依赖,你可以根据你的实际需求酌情处理
      this.projectInfo.push({
        package: projectName,
        deps: Object.keys(dependencies).filter((pkg) => {
          return /^@funny/i.test(pkg)
        }),
      })
    } catch (err) {
      console.error(`Error reading package.json for ${subProject}: ${err.message}`)
    }
  }

  async getPackageInfo() {
    // 获取packages目录下的所有子项目
    try {
      const subProjects = await fs.readdir(this.pkgsDir)
      // 遍历每个子项目的package.json文件
      // eslint-disable-next-line no-restricted-syntax
      for (const subProject of subProjects) {
        if (!this.ignoreDirs.includes(subProject)) {
          await this.readPackageJson(subProject)
        }
      }
    } catch (err) {
      console.error(`Error reading directory: ${err.message}`)
    }
  }

  async read() {
    await this.getPackageInfo()
    return this.projectInfo
  }
}

然后,处理对依赖关系进行拓扑排序的逻辑:

js 复制代码
export class BuildDAG {
  refMap = new Map()
  // 检测是否存在循环依赖的哈希表
  detectCycleMap = new Map()

  builtMap = new Map()

  vertexInfo = []

  constructor(vertexInfo) {
    this.vertexInfo = vertexInfo
  }

  /**
   * 链接两个节点
   * @param startVertex 开始节点
   * @param endVertex 结束节点
   */
  link(startVertex, endVertex) {
    const edge = {
      prev: startVertex,
      next: endVertex,
    }
    startVertex.outDegree.push(edge)
    endVertex.inDegree.push(edge)
  }

  /**
   * 构建顶点
   * @param name 顶点的名称
   * @param deps 顶点的依赖节点
   */
  buildPkg(pkg) {
    const vertexInfo = this.refMap.get(pkg)
    if (!vertexInfo) {
      return null
    }
    if (this.builtMap.get(pkg)) {
      return this.builtMap.get(pkg)
    }
    const { package: vertexPkg, deps } = vertexInfo
    const vertex = {
      inDegree: [],
      package: vertexPkg,
      outDegree: [],
    }
    const depsNodes = deps.map((depId) => {
      // 如果发现循环依赖,则报错
      if (this.detectCycleMap.get(pkg) === depId) {
        throw new Error('项目中存在循环依赖,为了避免问题,请重构项目再构建')
      } else {
        // 将依赖的项目设置到当前项目的路径上,防止循环依赖
        this.detectCycleMap.set(pkg, depId)
        const depPkg = this.buildPkg(depId)
        return depPkg
      }
    })
    depsNodes.forEach((pre) => {
      this.link(pre, vertex)
    })
    this.builtMap.set(vertexPkg, vertex)
    return vertex
  }

  /**
   * 构建图
   */
  buildReference() {
    this.vertexInfo.forEach((item) => {
      this.refMap.set(item.package, item)
    })
    const nodes = this.vertexInfo.map((v) => {
      return this.buildPkg(v.package)
    })
    return {
      nodes,
    }
  }

  build() {
    return this.buildReference()
  }
}

/**
 * 拓扑排序
 * @param {any} g
 * @returns
 */
export function topologicalSort(g) {
  // 用于记住入度数
  const inDegreeMap = new Map()
  // 队列,用于处理入度是0的点
  const queue = []
  const topSortResults = []
  let count = 0
  // 初始化的时候,用map记住每个节点的入度数
  g.nodes.forEach((v) => {
    if (v.inDegree.length === 0) {
      queue.push(v)
    } else {
      inDegreeMap.set(v, v.inDegree.length)
    }
  })
  // 开始进行拓扑排序,一直处理所有的入度为0的节点直到没有
  while (queue.length) {
    // 出队一个节点进行处理
    const vertex = queue.shift()
    // 将其加入到结果里面去
    topSortResults.push(vertex)
    // 处理的个数加1
    count++
    // 处理出度
    vertex.outDegree.forEach((edge) => {
      const nextVertex = edge.next
      // 获取后继节点的入度
      const inDegree = inDegreeMap.get(nextVertex)
      // 设置节点新的入度
      inDegreeMap.set(nextVertex, inDegree - 1)
      // 如果除开这个节点的话,下个节点的入度将会是0,说明经过这个操作之后它已经没有入度了,可以进行操作了
      if (inDegree === 1) {
        queue.push(nextVertex)
      }
    })
  }
  // 如果把所有的入度为0的节点都找过了,凡是发现不够总的节点个数,于是可以得出一个结论,某些节点的入度无论如何不可能是0,于是可以推导出图中存在回路的结论。
  if (count < g.nodes.length) {
    throw new Error('图中存在回路,无法进行拓扑排序~')
  }
  return topSortResults
}

/**
 * 获取正确的包的依赖顺序
 */
export async function getPackageSequence() {
  const reader = new MonorepoProjectReader()
  const projectInfo = await reader.read()
  const builder = new BuildDAG(projectInfo)
  const graph = builder.build()
  const results = topologicalSort(graph)
  return results.map((v) => v.package)
}

最后,是我执行构建的脚本:

js 复制代码
import execa from 'execa'
import { createSpinner } from 'nanospinner'
import { resolve } from 'path'
import { getPackageSequence } from './reference-analysis.mjs'

const CWD = process.cwd()
const PKG_CORE_DIR = resolve(CWD, './packages/core')
const PKG_ENV_DIR = resolve(CWD, './packages/env')
const PKG_SHARE_DIR = resolve(CWD, './packages/share')
const PKG_TRACK_DIR = resolve(CWD, './packages/track')
const PKG_REQUEST_DIR = resolve(CWD, './packages/request')
const PKG_WIDGETS_DIR = resolve(CWD, './packages/widgets')
const PKG_GOTO_DIR = resolve(CWD, './packages/goto')
const PKG_BRIDGE_DIR = resolve(CWD, './packages/bridge')
const PKG_CROSS_PLATFORM_DIR = resolve(CWD, './packages/cross-platform')

const PKG_CORE = '@funnu/core'
const PKG_ENV = '@funny/env'
const PKG_SHARE = '@funny/share'
const PKG_TRACK = '@funny/track'
const PKG_REQUEST = '@funny/request'
const PKG_WIDGETS = '@funny/widgets'
const PKG_GOTO = '@funny/goto'
const PKG_BRIDGE = '@funny/bridge'
const PKG_CROSS_PLATFORM = '@funny/cross-platform'

export const buildEnv = () => execa('pnpm', ['build'], { cwd: PKG_ENV_DIR })
export const buildShare = () => execa('pnpm', ['build'], { cwd: PKG_SHARE_DIR })
export const buildGoto = () => execa('pnpm', ['build'], { cwd: PKG_GOTO_DIR })
export const buildWidgets = () => execa('pnpm', ['build'], { cwd: PKG_WIDGETS_DIR })
export const buildTrack = () => execa('pnpm', ['build'], { cwd: PKG_TRACK_DIR })
export const buildRequest = () => execa('pnpm', ['build'], { cwd: PKG_REQUEST_DIR })
export const buildBridge = () => execa('pnpm', ['build'], { cwd: PKG_BRIDGE_DIR })
export const buildCore = () => execa('pnpm', ['build'], { cwd: PKG_CORE_DIR })
export const buildCrossPlatform = () => execa('pnpm', ['build'], { cwd: PKG_CROSS_PLATFORM_DIR })

const taskBuilderRunner = {
  [PKG_ENV]: buildEnv,
  [PKG_BRIDGE]: buildBridge,
  [PKG_CROSS_PLATFORM]: buildCrossPlatform,
  [PKG_CORE]: buildCore,
  [PKG_GOTO]: buildGoto,
  [PKG_SHARE]: buildShare,
  [PKG_REQUEST]: buildRequest,
  [PKG_TRACK]: buildTrack,
  [PKG_WIDGETS]: buildWidgets,
}

export async function runTask(taskName, task) {
  const s = createSpinner(`Building ${taskName}`).start()
  try {
    await task()
    s.success({ text: `Build ${taskName} completed!` })
  } catch (e) {
    s.error({ text: `Build ${taskName} failed!` })
    console.error(e.toString())
  }
}

export async function runTaskQueue() {
  // 得到的构建顺序是无论如何都不会出现问题的顺序,若有则程序报错
  const packageInfo = await getPackageSequence()
  while (packageInfo.length) {
    const pkg = packageInfo.shift()
    const runner = taskBuilderRunner[pkg]
    await runTask(pkg, runner)
  }
}

经过这样一个处理之后,后续再增加子项目代码的核心逻辑不用做任何修改,并且,经过这个处理之后还有另外一个好处,如果一旦发现有项目之间存在循环依赖,构建脚本会报错,将会提醒你对项目进行重构避免产生不必要的问题。

6、结语

如果谁以后再说前端学习算法没用,那你又可以给他甩一个强有力的证据反驳他了,哈哈哈。

算法和数据机构是程序的灵魂,对于大多数前端程序员来说,可能非科班出身,没有系统的学习过这方面的知识,但是它将决定的是你将来的高度,如果有时间的话一定要系统的学习一下,至少要知道有些什么知识点,能解决什么问题。

如果你不知道对应问题的解决方法,实际开发中采用蛮力算法,最终程序的运行效率相当低。

积跬步以致千里,积小流而成江海,加油!每一个努力奔跑着的前端开发者。

由于笔者水平有限,写作过程中难免出现错误,若有纰漏,请各位读者指正,请联系作者本人,邮箱404189928@qq.com,你们的意见将会帮助我更好的进步。本文乃作者原创,若转载请联系作者本人。

相关推荐
kyriewen7 分钟前
我拿 4 个真实前端任务试了 GLM-5.3 Flash:代码一遍跑通,账单 4 分钱
前端·程序员·ai编程
IT_陈寒7 分钟前
Vue的响应式更新有时候真的不听话
前端·人工智能·后端
风骏时光牛马24 分钟前
AI编程常见问题梳理与要点解析
前端
前端snow27 分钟前
ai agent -- LCEL 汇总
前端
玖玥拾30 分钟前
LeetCode 392 判断子序列
笔记·算法·leetcode
计算机魔术师1 小时前
NVIDIA 季度营收指引达 1080 亿美元,首次突破单季千亿大关
前端
数据狐(Datafox)1 小时前
京东商品列表API技术解析与落地应用(含标准 JSON 示例)
java·大数据·前端·人工智能·python·数据分析·json
风萧何1 小时前
架构艺术,是权衡的艺术。
前端·javascript·架构
YIAN1 小时前
ESLint 新版 Flat Config 实战:团队代码风格统一,从这一份配置开始
前端·团队管理
计算机魔术师1 小时前
英伟达单季营收逼近千亿美元,黄仁勋放话:真实需求远超想象
前端