1、什么是拓扑排序?
拓扑排序(Topological Sorting
)是一种图论算法,用于解决有向无环图(DAG
,Directed 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,你们的意见将会帮助我更好的进步。本文乃作者原创,若转载请联系作者本人。