☰
华为OD机试任务编排系统:拓扑排序与Kahn算法实战
2026/9/30 15:59:39 网站建设 项目流程

华为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(N+M),其中N是任务数量,M是依赖关系数量。建图阶段需要读取M条边,入度为O(M);拓扑排序阶段每个节点入队出队一次,每条边被遍历一次,因此也是O(N+M)。空间复杂度O(N+M),主要由邻接表和入度数组构成。

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的heapq,JS则用数组排序或手写小顶堆)。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-1
32 00 1 或 1 0
44 4 / 0 1 / 1 2 / 2 3 / 3 1-1
56 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):判断是否有环,和本题完全一致,只是输入形式不同。
  • 课程表II(LeetCode 210):输出拓扑排序结果,等价于本题。
  • 并行课程III(LeetCode 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开始或者要求输出多组顺序,记得优先调整数据结构大小和输出逻辑,而不是重写算法。拓扑排序的精髓就是那十几行核心逻辑,万变不离其宗。先把模板练成本能,考场上你就能把宝贵的时间留给真正需要思考的题目。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询