1. 从项目排期说起:为什么需要 AOV 网和 AOE 网
做研发管理或者构建系统时,你一定遇到过这类问题:十几个任务互相依赖,谁先谁后、哪条链路最耗时、能不能并行,靠脑子排很容易出错。有向无环图(DAG)就是解决这类问题的数学骨架,而 AOV 网和 AOE 网是它最常用的两种落地形态。
AOV 网(Activity On Vertex Network)用顶点表示活动,边表示活动之间的先后约束。比如「编译 A 模块」必须在「打包」之前完成,这条依赖就是一条有向边。AOV 网的核心用途是拓扑排序:把所有活动排成一个线性序列,保证任何一条依赖边都从前指向后。只要图里没有环,就一定能排出这样的顺序;如果排不出来,说明依赖里存在循环,工程配置写错了。
AOE 网(Activity On Edge Network)则把活动放到边上,顶点表示「事件」——也就是指向它的所有活动都已完成这个状态。边上的权值代表活动耗时。AOE 网通常只有一个源点(入度为 0)和一个汇点(出度为 0),用来回答「整个工程最短需要多久」以及「哪些活动一旦延迟就会拖慢整体」。后者就是关键路径。
这篇文章面向的是想把图论真正落到排期工具、CI 构建编排、任务调度器里的开发者。我会用邻接表 + 入度数组的骨架,给出可复制的拓扑排序和关键路径代码,再带你手工验证每一步的输出。读完你应该能直接把这套逻辑塞进自己的调度模块。
2. 前置准备:用 TaoToken 快速搭一个可调试的环境
写图算法最烦的不是逻辑,而是环境。我习惯先用 TaoToken 把模型对话和 API 调通,让 AI 帮我检查邻接表构造和边界条件,省掉大量手工推演时间。
TaoToken 是一个聚合多家大模型能力的平台,适合做代码生成、算法讲解和调试辅助。你可以先到模型对话页面直接问「帮我检查这段拓扑排序的入度更新有没有漏」,也可以申请 API Key 后在自己的脚本里调用。
具体入口:
- 模型对话(适合边写边问):https://taotoken.net/?utm_source=taotoken_aicg_blog_end&utm_medium=csdn&utm_campaign=rewrite&utm_content=model-chat
- 申请 API Key:https://taotoken.net/api-keys?utm_source=taotoken_aicg_blog_end&utm_medium=csdn&utm_campaign=rewrite&utm_content=api-keys
- 接入文档(看请求格式和参数):https://taotoken.net/doc?utm_source=taotoken_aicg_blog_end&utm_medium=csdn&utm_campaign=rewrite&utm_content=doc
- 控制台(管理额度和调用记录):https://taotoken.net/console?utm_source=taotoken_aicg_blog_end&utm_medium=csdn&utm_campaign=rewrite&utm_content=console
API 基础地址是 https://taotoken.net/api ,注意这个地址不带 UTM 参数,直接用于代码里的 base_url。
如果你打算长期做编码类任务,比如让模型持续帮你重构调度器、生成测试用例,可以看 Coding Plan:https://taotoken.net/coding-plan?utm_source=taotoken_aicg_blog_end&utm_medium=csdn&utm_campaign=rewrite&utm_content=coding-plan 。用 Claude Code 这类工具接入的话,参考:https://taotoken.net/claude-code-anthropic?utm_source=taotoken_aicg_blog_end&utm_medium=csdn&utm_campaign=rewrite&utm_content=claudecode 。
提示:环境准备只是辅助,核心算法还是得自己跑一遍。下面所有代码都可以脱离网络独立运行,TaoToken 用来加速你理解和排错。
3. 可复制配置:邻接表 + 入度数组骨架
拓扑排序和关键路径都依赖同一套图存储结构。用邻接矩阵在节点多的时候会爆内存,所以这里统一用邻接表。下面这份骨架你可以直接抄进项目。
#include <bits/stdc++.h> using namespace std; const int MAXN = 100005; struct Edge { int to; // 目标顶点 int w; // 权值,AOV 网可忽略,AOE 网表示活动耗时 }; vector<Edge> adj[MAXN]; // 邻接表 int indeg[MAXN]; // 入度数组 int n, m; // n 个顶点,m 条边 // 加一条 a -> b 的边,权值 w void addEdge(int a, int b, int w = 1) { adj[a].push_back({b, w}); indeg[b]++; }这份骨架的关键点有三个。第一,adj用vector动态存边,稀疏图下空间是 O(n+m),比矩阵的 O(n²) 友好得多。第二,indeg在加边时同步维护,避免后面重复统计。第三,Edge里带w,这样同一套结构既能跑 AOV 的拓扑排序,也能跑 AOE 的关键路径,不用写两份。
读入数据的部分:
int main() { cin >> n >> m; for (int i = 0; i < m; i++) { int a, b, w; cin >> a >> b >> w; addEdge(a, b, w); } // 后续调用拓扑排序或关键路径 return 0; }注意:顶点编号从 1 开始还是从 0 开始要统一。上面默认从 1 开始,如果你用 0 起始,记得把数组下标和循环边界一起改,否则会出现越界或漏算入度。
4. 拓扑排序落地:AOV 网定执行顺序
拓扑排序的算法思路很直白:每次找一个入度为 0 的顶点输出,然后删掉它所有的出边,对应终点的入度减一。重复直到所有顶点输出完。如果中途找不到入度为 0 的顶点但还有剩余顶点,说明图里有环。
用队列实现(Kahn 算法):
vector<int> topoSort() { vector<int> order; queue<int> q; // 初始把所有入度为 0 的顶点入队 for (int i = 1; i <= n; i++) { if (indeg[i] == 0) q.push(i); } while (!q.empty()) { int u = q.front(); q.pop(); order.push_back(u); for (auto &e : adj[u]) { int v = e.to; indeg[v]--; if (indeg[v] == 0) q.push(v); } } // 输出顶点数小于 n,说明有环 if ((int)order.size() < n) return {}; return order; }这里有个容易踩的坑:indeg在排序过程中被修改了。如果你后面还要用原始入度(比如再跑一次关键路径),要么提前备份,要么把拓扑排序写成不破坏原数组的版本。我一般直接备份一份indegCopy。
手工验证一下。假设有 6 个任务,依赖关系是 1→2、1→3、2→4、3→4、4→5、4→6,权值先都当 1。初始入度:1 是 0,2 是 1,3 是 1,4 是 2,5 是 1,6 是 1。
第一轮队列里只有 1,输出 1,把 2 和 3 的入度减到 0,入队。第二轮输出 2,4 的入度减到 1;输出 3,4 的入度减到 0,入队。第三轮输出 4,5 和 6 入度减到 0,入队。最后输出 5、6。得到序列 1 2 3 4 5 6,顶点数等于 6,无环。
预期输出:
1 2 3 4 5 6如果依赖里出现 4→1 这样的回边,队列会在输出 4 个顶点后空掉,order.size()小于 6,函数返回空,你就能立刻定位到循环依赖。
5. 关键路径落地:AOE 网算最短工期
AOE 网要算两件事:每个事件的最早发生时间ve和最晚发生时间vl。ve通过正向拓扑序递推,vl通过逆向拓扑序递推。两者相等的顶点就是关键节点,连接关键节点的边就是关键路径,路径总权值就是最短工期。
先正向求ve:
vector<int> ve(MAXN, 0), vl(MAXN, 0); void calcVE(const vector<int> &order) { for (int u : order) { for (auto &e : adj[u]) { int v = e.to; ve[v] = max(ve[v], ve[u] + e.w); } } }再逆向求vl,汇点的vl等于它的ve,其余顶点取所有出边终点的vl减去边权的最小值:
void calcVL(const vector<int> &order) { // 汇点初始化:所有出度为 0 的顶点 for (int i = 1; i <= n; i++) { bool isSink = true; for (auto &e : adj[i]) { isSink = false; break; } if (isSink) vl[i] = ve[i]; } // 逆序遍历拓扑序 for (int idx = order.size() - 1; idx >= 0; idx--) { int u = order[idx]; for (auto &e : adj[u]) { int v = e.to; vl[u] = min(vl[u], vl[v] - e.w); } } }最后找关键活动:对每条边 u→v,如果ve[u] == vl[v] - e.w,这条边就在关键路径上。
void printCriticalPath() { for (int u = 1; u <= n; u++) { for (auto &e : adj[u]) { int v = e.to; if (ve[u] == vl[v] - e.w) { cout << u << " -> " << v << " (w=" << e.w << ")\n"; } } } }手工验证一个经典例子。顶点 1 到 6,边和权值:1→2(3)、1→3(2)、2→4(2)、3→4(4)、4→5(3)、4→6(2)。源点 1,汇点 5 和 6。
正向算ve:ve[1]=0,ve[2]=3,ve[3]=2,ve[4]=max(3+2, 2+4)=6,ve[5]=6+3=9,ve[6]=6+2=8。
逆向算vl:汇点 5 的 vl=9,6 的 vl=8。vl[4]=min(9-3, 8-2)=6。vl[2]=6-2=4,vl[3]=6-4=2,vl[1]=min(4-3, 2-2)=0。
关键边判断:1→2 满足 ve[1]=0 且 vl[2]-3=1,不相等,不是关键边。1→3 满足 0 == 2-2,是关键边。3→4 满足 2 == 6-4,关键边。4→5 满足 6 == 9-3,关键边。所以关键路径是 1→3→4→5,总工期 9。
预期输出:
1 -> 3 (w=2) 3 -> 4 (w=4) 4 -> 5 (w=3)这条路径上任何一个活动延迟,整个工程就会延迟。排期时应该优先保障这些任务的资源。
6. 本篇常见错排查
入度数组被污染。拓扑排序会修改indeg,如果后面还要用原始入度,必须先备份。我试过在同一个函数里先跑拓扑再跑关键路径,结果ve全算错,排查半天才发现是入度被改了。
汇点判断写错。AOE 网理论上只有一个汇点,但实际工程里可能有多个终点。上面的代码用「出度为 0」来识别汇点,能兼容多汇点场景。如果你硬编码vl[n] = ve[n],遇到汇点不是 n 的情况就会出错。
有环图直接跑关键路径。关键路径的前提是 DAG。如果拓扑排序返回空(有环),order是空的,calcVE和calcVL都不会执行,ve和vl全是初始值,输出会误导你。正确做法是先检查拓扑排序结果,有环就报错退出。
权值类型溢出。ve[u] + e.w在节点多、权值大的时候可能超过int范围。工期动辄几十万秒的场景,建议把ve、vl和w都换成long long。
顶点编号不连续。如果顶点编号是 100、200、300 这种,用数组下标直接映射会浪费大量空间。要么做离散化,要么改用unordered_map存邻接表。
队列实现漏掉初始入度为 0 的多个顶点。有些图有多个源点,初始入队时要把所有入度为 0 的都放进去,不能只放一个。
7. 把图论接进你的排期工具
到这里,AOV 网的拓扑排序和 AOE 网的关键路径都能跑通了。落地到排期工具时,我的建议是:任务依赖用 AOV 网建模,先跑拓扑排序检测循环依赖并生成执行顺序;如果任务带耗时,再叠加 AOE 网算关键路径,把关键任务标红优先调度。
调试阶段可以借助 TaoToken 的模型对话快速验证算法逻辑,遇到边界条件不确定时直接问,比翻文档快。需要批量生成测试用例或者把算法封装成服务时,用 API Key 接入更顺手:
- 模型对话:https://taotoken.net/?utm_source=taotoken_aicg_blog_end&utm_medium=csdn&utm_campaign=rewrite&utm_content=model-chat
- API Keys:https://taotoken.net/api-keys?utm_source=taotoken_aicg_blog_end&utm_medium=csdn&utm_campaign=rewrite&utm_content=api-keys
- 接入文档:https://taotoken.net/doc?utm_source=taotoken_aicg_blog_end&utm_medium=csdn&utm_campaign=rewrite&utm_content=doc
长期做编码和 Agent 调度的,Coding Plan 会更划算:https://taotoken.net/coding-plan?utm_source=taotoken_aicg_blog_end&utm_medium=csdn&utm_campaign=rewrite&utm_content=coding-plan
最后留一个实用技巧:把拓扑排序的order数组打印出来存日志,线上出现循环依赖时直接看哪个顶点没进序列,比读堆栈快得多。关键路径的结果也建议缓存,任务图不变时不用重复计算。