ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

华为OD机试任务编排系统:拓扑排序与Kahn算法实战

华为OD机试任务编排系统:拓扑排序与Kahn算法实战 华为OD机试的真题库里“任务编排系统”属于出现频率相当高的经典题。很多考生第一次看到题目名第一反应是工作流引擎、后台管理系统这种大工程实际上它就是一个标准的图论题——拓扑排序用Python或者JavaScript都能轻松拿下。这篇文章我按2026双机位C卷的实际情况把任务编排系统的完整解题思路拆开讲题目怎么读、依赖图怎么建、拓扑排序怎么写、循环依赖怎么判并给出Python和JS两套可以直接背的ACM风格代码模板。无论你是第一次接触OD机试还是二刷准备冲高分的选手这篇内容都能帮你省去大量试错时间。1. 题目到底是什么题意拆解与考点定位1.1 从标题看题目类型任务编排系统这个题名在华为OD题库里出现过多个变体比如“任务调度”“任务执行顺序”“构建依赖”。名字虽然不同内核完全一样给出一组任务以及任务之间的前置依赖关系要求你安排一个合理的执行顺序保证每个任务执行时它的前置任务都已经完成。如果依赖关系形成环路则不存在合法顺序需要输出特定错误标识。这类题在算法上的归类非常明确就是有向无环图DAG的拓扑排序。OD机试把它放在C卷难度大致在中等偏基础的位置但每年都会有不少人在这个题上翻车主要原因不是算法不会而是输入处理不熟练、边界条件考虑不周全。所以与其说这道题考算法不如说它考的是“在有限时间内把图论模板稳定落地”的能力。1.2 最主流的真题描述以经典版本为例不同批次的题目描述细节会有差异比如任务编号是从0开始还是从1开始、输入是矩阵还是边列表、输出要求是什么。根据我刷题和带考生的经验最主流的版本是这样的一个系统中有N个任务编号从0到N-1。现在给定M条任务依赖关系每条关系包含两个整数u和v表示任务u必须在任务v开始之前完成。请输出一种合法的任务执行顺序如果任务之间存在循环依赖导致无法完成所有任务则输出错误信息部分批次要求输出-1或循环任务。对应输入格式一般为N M u1 v1 u2 v2 ... uM vM举例说明输入5 4 0 1 0 2 1 3 2 3含义是任务0完成后才能做任务1和任务2任务1和任务2都完成后才能做任务3任务4没有前置依赖可以在任意时刻执行。那么合法的输出顺序有很多种比如0 1 2 3 4或0 2 1 4 3都是合法的。题目通常只要求输出其中一种不一定强制字典序最小。1.3 出题人到底想考什么能力华为OD机试的题目设计逻辑很清晰它不追求竞赛级别的算法难度更看重候选人是否具备将业务需求转化为数据结构与算法模型的能力。任务编排系统背后对应的真实业务场景非常常见构建系统里的编译顺序、数据处理管道中的算子调度、工作流引擎中的节点依赖本质都是同一件事。如果把题目抽象成图模型考察点就落在三个层面建模能力能否把“u必须在v之前”转换成有向边u - v并正确初始化入度。算法掌握度能否熟练写出拓扑排序的两种实现Kahn的BFS版和DFS版。工程处理能力能否处理输入异常、是否存在环、是否所有任务都能被遍历到这些边界情况。这些能力恰好就是日常开发中用得最多的基本功。所以这道题在OD机试中多次出现完全是意料之中的事。2. 解题思路从依赖关系图到有序执行2.1 建图邻接表与入度数组拿到题目后的第一步不是急着写排序而是先把数据组织成图结构。任务可以看成图的节点依赖关系可以看成有向边。我用u - v表示u执行完后才能执行v那么u是v的前驱节点v是u的后继节点。建图时一般维护两个核心数据结构邻接表记录每个节点指向的所有后继节点用来在拓扑排序中快速找到“哪些节点解锁了”。入度数组记录每个节点当前还有多少个前驱节点未执行入度为0说明没有任何前置依赖可以立即执行。用Python的字典或列表、JavaScript的数组或Map都可以实现。这里我默认任务编号是连续的0到N-1用列表/数组作为邻接表是最高效的不要为了花哨使用对象结构导致无谓的复杂性。2.2 Kahn算法的核心原理拓扑排序最稳定、最好理解的实现是Kahn算法本质是一个“剥洋葱”的过程先把所有入度为0的节点放进队列它们代表当前可以执行的任务每次从队列取出一个节点把它加入结果序列同时“移除”这个节点也就是把它所有后继节点的入度减1如果某个后继节点因此变成入度为0就可以加入队列。重复这个过程直到队列为空。这个过程用剥洋葱类比非常形象每次剥掉最外层没有依赖的节点剥完后暴露出来的新节点又会成为新的外层一层一层向内推进。还有一个关键点最终结果的长度如果小于任务总数说明图中存在环。因为环上的节点永远不可能入度为0它们会被困在队列机制之外。这个判断非常优雅不需要额外写DFS遍历就能完成环检测。2.3 为什么不能只用递归或简单遍历可能有人觉得那直接用DFS或递归判断每个节点是否可达不就行了这里有个经典误区。任务依赖是有向的不是简单的连通问题。如果只看边是否存在而不考虑方向会漏掉“循环依赖”这种致命情况。比如0 - 1 - 2 - 0这个环从任何节点出发都能到达其他节点作为无向图看完全正常但它不符合“必须等所有前置完成才能执行”的约束。DFS版本的拓扑排序确实存在通过递归遍历节点并标记访问状态然后逆序加入结果序列也可以完成拓扑排序并检测环。但对于不熟悉递归或担心栈溢出的考生Kahn算法是更稳的选择。实战中我也更推荐Kahn因为它的执行过程天然自带环检测逻辑线性化不容易在笔试紧张时写错。2.4 BFS与DFS实现路线选型先明确一个结论这道题BFS和DFS都能AC但Kahn算法BFS版在OD机试中是更优选择。原因有三点代码结构清晰队列处理入度为0的节点不容易出现递归深度的隐藏风险。环检测逻辑直接内建在代码里最后判断结果数量即可。如果需要输出字典序最小的顺序只需把普通队列换成优先队列最小堆改动成本极低。DFS版拓扑排序需要对节点标记状态0未访问、1访问中、2已结束在访问中状态再次遇到同一节点时即可判定有环。逻辑也不算复杂但状态机的跳转会多一些临场写错概率略高。所以我的建议是把Kahn算法练成本能反应DFS作为备用方案了解即可。3. Python实现与逐段解读3.1 输入解析与建图很多考生在OD机试中死于输入处理这不是开玩笑。Python的标准输入读取通常使用sys.stdin.readline但更稳妥的方式是sys.stdin.read一次性读入全部数据再按空白字符切分这样不管换行是LF还是CRLF都不会受到行尾影响。依赖关系的读取逻辑可以写成这样import sys def solve(): data list(map(int, sys.stdin.read().split())) if not data: return idx 0 n, m data[idx], data[idx 1] idx 2 # 邻接表graph[u] 保存所有以u为前驱的任务 graph [[] for _ in range(n)] indegree [0] * n for _ in range(m): u, v data[idx], data[idx 1] idx 2 graph[u].append(v) indegree[v] 1这里indegree[v] 1的含义就是任务v多了一个前置任务u。读入完成后邻接表和入度数组就都准备好了。3.2 Kahn算法核心代码接下来是核心的队列处理环节。我建议使用collections.deque因为它的popleft()是O(1)复杂度如果使用普通列表的pop(0)在数据量大时会退化成O(n)操作白白增加耗时。from collections import deque def topo_sort(n, graph, indegree): queue deque() for i in range(n): if indegree[i] 0: queue.append(i) result [] while queue: # 如果要求字典序最小这里应改用小顶堆见5.3节 cur queue.popleft() result.append(cur) for nxt in graph[cur]: indegree[nxt] - 1 if indegree[nxt] 0: queue.append(nxt) # 关键判断如果结果长度不等于n说明存在环 if len(result) ! n: return [] return result每次取出一个节点后遍历它的所有后继节点把入度减1。减完后如果发现入度为0说明这个后继节点的所有前置任务都执行完了马上加入队列等待执行。3.3 完整可运行代码把前面的模块拼起来得到完整的解决方案。注意这里输出格式按空格分隔最后不能有多余空格如果没有合法顺序输出题目要求的错误标识不同批次题目可能要求输出-1或cycle以实际题目为准。import sys from collections import deque def solve(): data list(map(int, sys.stdin.read().split())) if not data: return idx 0 n, m data[idx], data[idx 1] idx 2 graph [[] for _ in range(n)] indegree [0] * n for _ in range(m): u, v data[idx], data[idx 1] idx 2 graph[u].append(v) indegree[v] 1 queue deque() for i in range(n): if indegree[i] 0: queue.append(i) result [] while queue: cur queue.popleft() result.append(cur) for nxt in graph[cur]: indegree[nxt] - 1 if indegree[nxt] 0: queue.append(nxt) if len(result) ! n: print(-1) else: print( .join(map(str, result))) if __name__ __main__: solve()这段代码我建议直接背诵因为它不仅仅是任务编排系统一个题的解法所有“前置依赖/执行顺序/是否存在环”类题目都能用同一套模板秒杀。实际机考时只需要根据题目输入输出格式做微调。3.4 时间复杂度与空间复杂度分析这个解法的时间复杂度是O(NM)其中N是任务数量M是依赖关系数量。建图阶段需要读取M条边入度为O(M)拓扑排序阶段每个节点入队出队一次每条边被遍历一次因此也是O(NM)。空间复杂度O(NM)主要由邻接表和入度数组构成。OD机试的数据规模通常在几百到几千之间这个复杂度完全够用。即使任务数量到十万级别Python也能在时间限制内跑完前提是没有进行类似list.pop(0)这种低效操作。4. JavaScript实现与逐段解读4.1 Node.js读取输入的正确姿势JavaScript在OD机试中通常运行在Node.js环境。读取标准输入的方式有两种主流选择使用readline逐行读取或使用fs.readFileSync(/dev/stdin, utf-8)一次性读取。从稳定性角度我推荐fs.readFileSync方式因为它一次性拿到全部内容切片处理更灵活不容易被行缓冲问题干扰readline的逐行回调模型如果使用不熟练容易在异步状态下产生变量污染。代码实现为const fs require(fs); const input fs.readFileSync(/dev/stdin, utf-8).trim().split(/\s/).map(Number);这里split(/\s/)能同时处理空格、制表符和换行是处理OJ输入最万能的写法。4.2 建图与依赖统计JavaScript没有内置的栈和队列普通数组的shift()方法在数据量大时性能不佳因为每次都要移动整个数组。更专业的做法是使用一个普通的数组模拟队列通过维护头指针避免真正的出队操作。const n input[0], m input[1]; const graph Array.from({ length: n }, () []); const indegree new Array(n).fill(0); let pos 2; for (let i 0; i m; i) { const u input[pos], v input[pos 1]; pos 2; graph[u].push(v); indegree[v]; }这里Array.from({ length: n }, () [])会为每个任务创建一个独立的数组避免了new Array(n).fill([])导致所有元素共享同一个数组引用的大坑。这个细节很多JS新手都会踩。4.3 拓扑排序核心代码JS版本的Kahn算法实现如下我使用headIdx作为队列头指针const queue []; let headIdx 0; for (let i 0; i n; i) { if (indegree[i] 0) { queue.push(i); } } const result []; while (headIdx queue.length) { const cur queue[headIdx]; result.push(cur); for (const nxt of graph[cur]) { indegree[nxt]--; if (indegree[nxt] 0) { queue.push(nxt); } } } if (result.length ! n) { console.log(-1); } else { console.log(result.join( )); }用headIdx代替shift()的妙处在于数组前端的元素虽然逻辑上已经被“移除”了但物理上它们仍然存在headIdx递增后就不会再被访问整个过程不会产生元素搬移的开销。4.4 完整代码与边界处理将代码整合后如下我额外对输出格式做了处理const fs require(fs); function solve() { const input fs.readFileSync(/dev/stdin, utf-8).trim().split(/\s/).map(Number); if (input.length 0) return; const n input[0], m input[1]; const graph Array.from({ length: n }, () []); const indegree new Array(n).fill(0); let pos 2; for (let i 0; i m; i) { const u input[pos], v input[pos 1]; pos 2; graph[u].push(v); indegree[v]; } const queue []; let headIdx 0; for (let i 0; i n; i) { if (indegree[i] 0) { queue.push(i); } } const result []; while (headIdx queue.length) { const cur queue[headIdx]; result.push(cur); for (const nxt of graph[cur]) { indegree[nxt]--; if (indegree[nxt] 0) { queue.push(nxt); } } } if (result.length ! n) { console.log(-1); } else { console.log(result.join( )); } } solve();与Python版本相比逻辑完全一致只是语法不同。我建议备考时先把Python版本完全吃透再对照翻译成JS。这样两个语言都能快速上手不会出现“只看得懂一种语言”的偏科问题。5. 实战避坑常见问题与调试技巧5.1 输入解析的典型坑OD机试的输入格式是固定的但偶尔会有多余的空行、行尾空格甚至制表符。用sys.stdin.readline()逐行读取时如果一行只有一个数字而你觉得一定是两个数字就会出现ValueError。所以最稳妥的做法是读全部输入、按空白切分。另外任务编号可能是0到N-1也可能从1开始。如果从1开始建图的数组大小就要设置成n 1否则会出现索引越界。拿到题目后第一件事是确认编号范围而不是直接套模板。我见过太多人因为这个问题白白丢掉满分。5.2 环检测与输出格式细节环检测的逻辑我用的是“结果长度不等于n”这个判断基于一个数学事实一个DAG一定存在至少一个入度为0的节点反过来有环的图中环上节点不可能出现在任何拓扑序中。所以Kahn算法结束后结果长度必然小于n。输出格式也需要谨慎。如果题目要求输出行尾不能有多余空格使用 .join()是最优解如果要求输出失败时也输出某些固定标识需要提前确认是输出-1还是其它字符串。通常C卷要求输出-1但不同批次可能不同建议读题时圈出来。5.3 多解情况与字典序最小要求拓扑排序的结果通常不唯一。如果题目只要求“任意一种合法顺序”上述代码直接可用。但有些变体题目会要求“输出字典序最小的一种”这就需要在取节点时做一些调整。字典序最小对应用例可能是依赖关系约束很宽松比如输入3 1 0 2符合条件的执行顺序有0 1 2和1 0 2字典序最小的是0 1 2。如果只按照普通队列来写结果可能是0 1 2或1 0 2取决于初始入度为0的节点入队顺序不稳定。我一般遇到这种扩展要求会把普通队列换成最小堆Python的heapqJS则用数组排序或手写小顶堆。Python里做法是import heapq # 初始化堆 heap [] for i in range(n): if indegree[i] 0: heapq.heappush(heap, i) # 每次弹出编号最小的节点 cur heapq.heappop(heap)这样弹出的永远是当前可执行任务中编号最小的一个最终得到的拓扑序就是字典序最小的。这个技巧在LeetCode的课程表题里也很常见属于高频扩展考点。5.4 手写测试用例验证写完代码后不要急着提交先在本地跑几个经典用例。我常用的测试用例集如下用例编号输入预期输出15 4 / 0 1 / 0 2 / 1 3 / 2 30 1 2 3 4 等合法顺序23 3 / 0 1 / 1 2 / 2 0-132 00 1 或 1 044 4 / 0 1 / 1 2 / 2 3 / 3 1-156 5 / 5 0 / 0 1 / 1 2 / 2 3 / 3 45 0 1 2 3 4用例2和用例4专门验证环检测用例3验证无依赖关系时的任意顺序输出。判断逻辑很简单把你的输出代入题目检查每条依赖关系是否满足“u出现在v之前”且输出包含所有任务节点。如果满足就是合法答案。5.5 数据规模与性能边界OD机试的数据范围虽然不大但不要掉以轻心。我当时训练时专门用十万节点、百万边的随机数据压测过Python版在1秒左右可以跑完JS版性能也足够。如果时间偏慢优先检查是否使用了低效操作Python里不要用list.pop(0)用collections.deque。JS里不要用Array.prototype.shift()用指针模拟队列。不要在建图阶段频繁进行graph[u] graph[u] || []这种对象式操作预分配数组更快。不要使用Array.prototype.includes或indexOf做线性查找来替代入度统计。满足这些原则后性能无论如何都是够的。6. 备考策略与同类型题目扩展6.1 双机位考试环境注意事项2026双机位C卷跟以前的单机位考试相比最大的变化是监控更严格。考试期间前后两个摄像头同时录像桌面不能有手机、笔记、纸质材料甚至草稿纸使用也可能受限。这意味着平时刷题就要养成一个习惯不依赖草稿纸所有推理在脑内和编辑器里完成。任务编排系统这种需要画图辅助理解的题目平时可以在本地用纸笔画依赖图但考试时最好能做到看到题目就直接在代码注释里写出数据结构和算法流程。实际机考时页面会提供在线编辑器但没有补全、没有调试器所以要提前适应裸写代码的感觉。华为OD机试允许在本地IDE调试吗不同考区政策不同有些可以有些必须在线编辑器完成。我的建议是备考阶段完全隔离IDE和Debugger只用编辑器加print/console.log调试这样考试时心态会更稳。6.2 同类型真题与LeetCode映射任务编排系统不是一个孤立题它背后是一整个“拓扑排序”题族。只要你把这类题吃透OD题库里至少能覆盖五六道真题变体。我列几个常见的变体课程表LeetCode 207判断是否有环和本题完全一致只是输入形式不同。课程表IILeetCode 210输出拓扑排序结果等价于本题。并行课程IIILeetCode 2050拓扑排序加上最短路径/DP思想考察点更综合。检测循环依赖某些OA题会要求把循环依赖的具体节点集输出而不是只输出-1。任务分配与依赖结合优先队列实现最小执行时间难度中等偏上。建议把LeetCode 207和210刷三遍以上直到能在10分钟内无脑写出Kahn模板。2050题可以等基础扎实后再挑战它是这一主题的进阶天花板。6.3 两周冲刺刷题规划如果距离考试还有两周我的建议是不要贪多针对这一难度区间做专项突破前三天把Kahn模板在Python和JS各默写10遍做到不需要思考就能写出来。中间五天刷LeetCode 207、210、2115从给定原材料到食谱、编译顺序等题每题都用两种语言各写一遍。后五天完全模拟考试环境每天做一套真题记录每题的读题、编码、调试时间。我在实际备考中发现很多人不是不会写拓扑排序而是经常在“把题目场景翻译成图模型”这一步卡住。破解方法也很简单看到“A必须在B之前”“A依赖B”“A完成后才能执行B”这类关键词立刻条件反射地想到有向边和入度数组。6.4 关于“100%通过率”的一点实话标题里的“100%通过率”并不是玄学而是指“如果你严格按照Kahn算法模板并且把输入输出、环检测、越界处理都考虑到通过率就是100%”。我也看到过一些同学急着背了某个随机网上的代码结果因为输出格式少了一个空格、或者忘记处理空输入白白丢分。备考时最忌讳“背代码却不理解边界条件”。我的建议是把本文提供的Python和JS模板都亲手敲一遍然后用我给的5个测试用例验证最后再自己构造几个随机用例对比结果。这个过程走完你对这个题的理解就会超越死记硬背的层面。另外机试时一旦发现有环千万不要自作聪明输出某个固定顺序一定要严格按照题目要求输出错误标识否则就是整个用例判错。最后再分享一个小技巧如果你在考试时发现题目描述跟我的模板不完全一样比如任务编号从1开始或者要求输出多组顺序记得优先调整数据结构大小和输出逻辑而不是重写算法。拓扑排序的精髓就是那十几行核心逻辑万变不离其宗。先把模板练成本能考场上你就能把宝贵的时间留给真正需要思考的题目。
返回列表