这道题我去年就遇到过,当时很多人一看到“任务编排系统”这六个字就上头,以为输出一个拓扑序列就能交差。实际上,这题的核心不是“能不能排序”,而是“当多个任务同时满足执行条件时,先做谁、做完之后整个系统要花多久、机器数量不够时怎么塞”。今天不绕圈子,直接把题目拆开,给出 Python 和 JavaScript 两套能跑的代码,并把我踩过的几个坑一起写清楚。
先说结论:这道“任务编排系统”的本质是“拓扑排序 + 贪心选任务 + 事件模拟”。它比单纯背一个拓扑排序模板要难一点,但只要把依赖图、就绪队列、执行单元三者的关系理清楚,不管题目把 C 卷换成 D 卷还是 E 卷,你都能在十分钟内写出稳定通过的版本。
1. 题目背景与核心考点
1.1 “双机位 C 卷”到底是什么意思
先把这个标题里的信息剥开。华为 OD 机试中的“双机位”,指的是考试时的监控方式:一个摄像头对着考生正面,另一个放在侧后方,用来防作弊。这个信息本身和算法无关,但它说明了一个事实:机试是真实计时、真实监考的,你没法靠查手机把答案抄完,考前把题目套路吃透才是正路。
“C 卷”是考生圈子里对机试随机分卷的一种叫法。不同批次的考生可能拿到题面类似、但顺序或小参数不同的试卷,所以网上的分享里会看到 A 卷、B 卷、C 卷这样的关键词。说到底,卷型不影响我们准备,因为核心考点就那些:图论、动态规划、字符串处理、模拟。
还有标题里那个“100%通过率”。我直接说,这种词基本都是培训机构或资料商的营销话术。没有哪套题能保证 100% 通过,尤其是 OD 机试这种多用例判分模式,边界条件一多,稍不留神就会挂。但反过来说,像“任务编排系统”这种高频考点,你把依赖建模和调度逻辑吃透,通过率确实能拉到很高的水平。
1.2 任务编排系统到底考什么
这题在市面上的各种版本里,常见的核心要素是这几个:
- 任务编号从 1 到 N,每个任务有一个正整数耗时。
- 任务之间存在依赖关系,比如“任务 A 必须在任务 B 开始前完成”。
- 有 K 个执行单元(可以理解成 K 个 CPU 核心或 K 个 worker),同一时刻最多执行 K 个任务。
- 调度规则:只要出现空闲执行单元,就从所有满足依赖条件且尚未执行的任务中,选耗时最短的任务先执行;如果耗时相同,选编号更小的。
- 问所有任务完成后,系统总共花了多长时间。
这个描述是我基于题目关键词和常见变体重构出来的标准形态。实际考试时,有的版本会问“输出任务执行顺序”,有的会问“最少需要多少时间”,但解题的内核都是一样的。如果考试时你拿到的题面和我这里的略有不同,只要把这几个要素对应上,思路完全可以直接迁移。
1.3 输入输出格式示例
我按自己平时练习的习惯,把输入格式整理成下面这样:
第一行一个整数 N,表示任务数量 第二行 N 个整数,表示每个任务的耗时 第三行一个整数 K,表示并行执行单元数量 第四行一个整数 M,表示依赖关系的数量 接下来 M 行,每行两个整数 a b,表示任务 a 必须在任务 b 开始前完成 输出一个整数,表示完成所有任务所需的总时间举例:
5 2 3 1 4 5 2 3 1 3 2 3 4 5这个例子我后面会拿来做完整手工推演。现在先记住一件事:这题的输出不是拓扑序列,而是一个时间数值。
2. 算法核心:为什么这样设计
2.1 用图来建模任务依赖
任务之间的依赖关系,天然就是一张有向无环图。比如“1 必须在 3 之前完成”,就是一条从 1 指向 3 的边。当某一个任务的所有入边对应的前置任务都完成之后,这个任务才进入“可执行”状态。
所以数据结构上,我们需要三样东西:
- 邻接表
adj:记录每个任务完成后能解锁哪些后续任务。 - 入度数组
indeg:记录每个任务还剩下几个前置任务没完成。 - 就绪堆
ready:存放所有已经满足依赖条件、可以随时开始执行的任务。
这里的“入度”不要和图论教材里的概念割裂开理解。你就把它当成“这个任务还有多少个‘爹’没完事儿”。每完成一个前置任务,就把后继任务的入度减 1;当入度减到 0,说明所有前置任务都完事了,它才有资格进入就绪堆。
2.2 为什么必须用最小堆,而不是直接数组排序
题目里那句“选耗时最短的任务先执行”,是最容易让人掉坑的地方。很多人的第一反应是:我每次把所有就绪任务都扫一遍,挑个最小的,不就行了吗?
这样做,在小数据量下确实能跑对。但 OD 机试的用例里 N 往往给到10^5甚至更大,如果每个任务完成时都扫描一遍所有就绪节点,复杂度会退化到O(N^2)。后面的大用例必然超时。
正确的做法是用一个最小堆(优先队列)来维护就绪任务。每次有新任务进入就绪状态,就把它按(耗时, 任务编号)的键值压入堆中;需要选任务时,从堆顶弹出最小的那个就行。堆的插入和弹出都是O(log N),整体复杂度就是O((N + M) log N),能稳稳扛住大数据量。
这里还有一个细节:为什么元组里要把任务编号放在第二位?因为 Python 的heapq在比较元组时,会先比较第一个元素,如果相同再比较第二个。把(耗时, 编号)作为整体放进堆里,天然就满足了“耗时相同选编号小”的规则,不用额外写排序逻辑。
2.3 时间推进要按“事件”走,不要按“秒”走
任务并行执行时,最忌讳的做法是写一个从 0 到最终时间的循环,每秒检查一次状态。如果最终时间是10^9,这个循环直接就炸了。
正确的做法是按“事件时间”推进。当前正在执行的任务里,哪一个最先完成,下一个事件时间就是它的完成时刻。比如现在有两个任务在跑,一个会在第 5 秒完成,一个会在第 8 秒完成,那系统时间直接跳到第 5 秒,处理第 5 秒完成的那个任务,然后再看这个任务解锁了哪些新任务。这种事件驱动模拟,循环次数只和任务完成事件的数量有关,不会受总时间大小影响。
2.4 完整算法流程梳理
我把整个流程写成下面这个可落地的步骤序列:
- 读入所有输入,构建邻接表和入度数组。
- 把所有入度为 0 的任务按
(耗时, 编号)压入就绪小顶堆。 - 定义一个“正在执行”的集合或堆,用来存放
(预计完成时间, 任务编号)。 - 只要就绪堆不为空,或者正在执行的任务不为空,就循环:
- 先把空闲执行单元填满:如果当前正在执行的任务数小于 K,并且就绪堆里有任务,就弹出堆顶任务开始执行,把它的预计完成时间记为“当前时间 + 耗时”,放入正在执行集合。
- 找到正在执行任务中预计完成时间最小的那个,把当前时间推进到那个时刻。
- 处理所有在同一时刻完成的任务。对每个完成的任务,遍历它的后继任务,把后继任务的入度减 1;如果入度变成 0,就把后继任务压入就绪堆。
- 从正在执行集合中移除这些已完成任务,进入下一轮循环。
- 全部循环结束时,输出当前时间。
这个流程里有一个很容易被忽略的点:当某个任务完成并解锁了后续任务时,如果此时还有空闲执行单元,新解锁的任务应该立刻开始执行,而不是等到下一批任务全部完成后再统一开始。所以循环开头必须有一个“填满空闲执行单元”的步骤。
3. Python 完整实现与逐段解读
3.1 输入读取与数据结构初始化
Python 的机试环境里,读取输入我推荐用sys.stdin.read().split()一次性读入所有 token,而不是用input()一行一行读。这样做的好处是:不依赖具体输入行怎么换行,哪怕耗时数组被拆成了多行,也能完整读进来。
初始化部分的核心是给编号留一个下标偏移。任务编号从 1 开始,所以我创建数组时,长度统一是N + 1,数组下标 0 的位置直接弃用。这样duration[i]就是任务 i 的耗时,adj[i]就是任务 i 的后继列表,下标对不上这种低级错误就不会出现。
import sys import heapq def solve(): data = sys.stdin.read().split() idx = 0 n = int(data[idx]) idx += 1 duration = [0] + [int(x) for x in data[idx:idx + n]] idx += n k = int(data[idx]) idx += 1 m = int(data[idx]) idx += 1 adj = [[] for _ in range(n + 1)] indeg = [0] * (n + 1) for _ in range(m): a = int(data[idx]) b = int(data[idx + 1]) idx += 2 adj[a].append(b) indeg[b] += 1 ready = [] for i in range(1, n + 1): if indeg[i] == 0: heapq.heappush(ready, (duration[i], i))这里有一个细节想提醒你:data[idx:idx+n]转成数字后,前面直接拼一个[0],是为了让任务编号和位置对齐。如果不做这个偏移,任务 1 会被存在下标 0 上,后面写duration[1]就会拿到任务 2 的耗时,这种错位在复杂题目里非常难查。
3.2 正在执行任务的数据结构选择
“正在执行”这个集合,我用的是一个小顶堆,键值为(预计完成时间, 任务编号)。这样每次要找“谁最先结束”,直接看堆顶就行,不需要遍历整个正在执行列表。
但这里有个隐患:堆只能保证堆顶最小,不能快速判断“有哪些任务在同一时刻完成”。不过没关系,我们的处理方式是:先弹出堆顶,记录它的完成时间now,然后只要堆顶的完成时间仍然等于now,就继续弹出。因为同一时刻完成的任务,键值里的完成时间相同,堆顶会连续弹出它们。
running = [] now = 0 while ready or running: while len(running) < k and ready: d, job = heapq.heappop(ready) heapq.heappush(running, (now + d, job)) if not running: break now = running[0][0] finished = [] while running and running[0][0] == now: _, job = heapq.heappop(running) finished.append(job) for job in finished: for nxt in adj[job]: indeg[nxt] -= 1 if indeg[nxt] == 0: heapq.heappush(ready, (duration[nxt], nxt))这段代码里最需要注意的,是while len(running) < k and ready这一步。它保证了只要有空位,就绪任务就会立刻顶上。
我见过不少人把这步写成if len(running) < k,结果一个任务完成解锁新任务后,同一轮循环里新任务不能立刻开始,必须等下一轮,导致整体时间偏大。虽然有时候不影响答案,但一旦用例卡这个细节,就白丢分了。
3.3 完整 Python 源码
把上面的片段拼起来,再加上输出部分,就是可以直接提交的版本:
import sys import heapq def solve(): data = sys.stdin.read().split() idx = 0 n = int(data[idx]) idx += 1 duration = [0] + [int(x) for x in data[idx:idx + n]] idx += n k = int(data[idx]) idx += 1 m = int(data[idx]) idx += 1 adj = [[] for _ in range(n + 1)] indeg = [0] * (n + 1) for _ in range(m): a = int(data[idx]) b = int(data[idx + 1]) idx += 2 adj[a].append(b) indeg[b] += 1 ready = [] for i in range(1, n + 1): if indeg[i] == 0: heapq.heappush(ready, (duration[i], i)) running = [] now = 0 while ready or running: while len(running) < k and ready: d, job = heapq.heappop(ready) heapq.heappush(running, (now + d, job)) if not running: break now = running[0][0] finished = [] while running and running[0][0] == now: _, job = heapq.heappop(running) finished.append(job) for job in finished: for nxt in adj[job]: indeg[nxt] -= 1 if indeg[nxt] == 0: heapq.heappush(ready, (duration[nxt], nxt)) print(now) if __name__ == "__main__": solve()这段代码的结构,其实就是把“事件模拟”压缩成了两个堆:一个管等待执行的任务,一个管正在执行的任务。只要这两个堆的状态同步正确,最终时间就一定算得准。
4. JavaScript 完整实现与关键差异
4.1 Node.js 环境下的输入处理
JS 版本在机试环境里,输入输出方式和 Python 差别很大。Node.js 没有input()这种同步读入,必须通过readline模块监听line事件,把所有行收集完之后再统一处理。
我先说一个容易踩的坑:机试的输入末尾有时会多一个空行,有时不会。所以收集完lines数组后,处理时一定要用trim()去掉首尾空白,否则数字解析会出错。还有,split(/\s+/)能一次处理多个空格,比按单个空格切分更稳。
const readline = require('readline'); const rl = readline.createInterface({ input: process.stdin, output: process.stdout }); const lines = []; rl.on('line', (line) => { if (line.trim().length > 0) { lines.push(line.trim()); } }); rl.on('close', () => { let idx = 0; const n = Number(lines[idx++]); const times = lines[idx++].split(/\s+/).map(Number); const duration = [0, ...times]; const k = Number(lines[idx++]); const m = Number(lines[idx++]); const adj = Array.from({ length: n + 1 }, () => []); const indeg = new Array(n + 1).fill(0); for (let i = 0; i < m; i++) { const parts = lines[idx++].split(/\s+/).map(Number); const a = parts[0]; const b = parts[1]; adj[a].push(b); indeg[b]++; } // 主逻辑在这里继续 });4.2 手写最小堆还是直接用内置优先队列
这是很多 JS 考生纠结的问题。Node.js 目前没有内置的优先队列,虽然有一种实验性质的MinPriorityQueue在不稳定版本里出现过,但机试环境千万不要依赖它。最稳妥的做法是手写一个通用最小堆。
这里的手写最小堆,我用的是一个构造函数,接收一个比较函数compare。这样同一个堆类既能存(耗时, 编号),也能存(预计完成时间, 编号),两种用途都能复用。需要注意:堆的下沉和上浮操作,都要用比较函数来决定顺序,不能写死。
class MinHeap { constructor(compare) { this.heap = []; this.compare = compare; } size() { return this.heap.length; } peek() { return this.size() > 0 ? this.heap[0] : null; } push(val) { this.heap.push(val); this._up(this.heap.length - 1); } pop() { if (this.size() === 0) return null; const top = this.heap[0]; const last = this.heap.pop(); if (this.size() > 0) { this.heap[0] = last; this._down(0); } return top; } _up(i) { while (i > 0) { const parent = (i - 1) >> 1; if (this.compare(this.heap[i], this.heap[parent]) < 0) { [this.heap[i], this.heap[parent]] = [this.heap[parent], this.heap[i]]; i = parent; } else { break; } } } _down(i) { while (true) { let smallest = i; const left = i * 2 + 1; const right = i * 2 + 2; if (left < this.size() && this.compare(this.heap[left], this.heap[smallest]) < 0) { smallest = left; } if (right < this.size() && this.compare(this.heap[right], this.heap[smallest]) < 0) { smallest = right; } if (smallest !== i) { [this.heap[i], this.heap[smallest]] = [this.heap[smallest], this.heap[i]]; i = smallest; } else { break; } } } }这个实现里我用了解构赋值的骚操作来交换数组元素:[this.heap[i], this.heap[parent]] = [this.heap[parent], this.heap[i]]。这是 ES6 的合法语法,机试的 Node.js 版本一般都支持。如果你担心版本问题,老老实实写一个临时变量交换也没问题。
4.3 JS 完整源码
把堆类、输入处理和主逻辑拼起来,就得到了完整可运行的 JS 版本:
class MinHeap { constructor(compare) { this.heap = []; this.compare = compare; } size() { return this.heap.length; } peek() { return this.size() > 0 ? this.heap[0] : null; } push(val) { this.heap.push(val); this._up(this.heap.length - 1); } pop() { if (this.size() === 0) return null; const top = this.heap[0]; const last = this.heap.pop(); if (this.size() > 0) { this.heap[0] = last; this._down(0); } return top; } _up(i) { while (i > 0) { const parent = (i - 1) >> 1; if (this.compare(this.heap[i], this.heap[parent]) < 0) { [this.heap[i], this.heap[parent]] = [this.heap[parent], this.heap[i]]; i = parent; } else { break; } } } _down(i) { while (true) { let smallest = i; const left = i * 2 + 1; const right = i * 2 + 2; if (left < this.size() && this.compare(this.heap[left], this.heap[smallest]) < 0) { smallest = left; } if (right < this.size() && this.compare(this.heap[right], this.heap[smallest]) < 0) { smallest = right; } if (smallest !== i) { [this.heap[i], this.heap[smallest]] = [this.heap[smallest], this.heap[i]]; i = smallest; } else { break; } } } } const compareTask = (a, b) => { if (a[0] !== b[0]) return a[0] - b[0]; return a[1] - b[1]; }; const readline = require('readline'); const rl = readline.createInterface({ input: process.stdin, output: process.stdout }); const lines = []; rl.on('line', (line) => { if (line.trim().length > 0) { lines.push(line.trim()); } }); rl.on('close', () => { let idx = 0; const n = Number(lines[idx++]); const times = lines[idx++].split(/\s+/).map(Number); const duration = [0, ...times]; const k = Number(lines[idx++]); const m = Number(lines[idx++]); const adj = Array.from({ length: n + 1 }, () => []); const indeg = new Array(n + 1).fill(0); for (let i = 0; i < m; i++) { const parts = lines[idx++].split(/\s+/).map(Number); adj[parts[0]].push(parts[1]); indeg[parts[1]]++; } const ready = new MinHeap(compareTask); for (let i = 1; i <= n; i++) { if (indeg[i] === 0) { ready.push([duration[i], i]); } } const running = new MinHeap(compareTask); let now = 0; while (ready.size() > 0 || running.size() > 0) { while (running.size() < k && ready.size() > 0) { const item = ready.pop(); const d = item[0]; const job = item[1]; running.push([now + d, job]); } if (running.size() === 0) { break; } now = running.peek()[0]; const finished = []; while (running.size() > 0 && running.peek()[0] === now) { const item = running.pop(); finished.push(item[1]); } for (const job of finished) { for (const nxt of adj[job]) { indeg[nxt]--; if (indeg[nxt] === 0) { ready.push([duration[nxt], nxt]); } } } } console.log(now); });JS 和 Python 的实现逻辑完全一致,只是在语法上有几个差异需要提醒你:
- JS 的数组解构赋值能对
heap里的元素直接进行操作,非常方便,但要确保不会越界。 running.peek()返回的是null或一个数组,所以主逻辑里要先判断 running 是否为空,再取[0]。- 比较函数里
a[0] - b[0]可能会导致大整数溢出吗?在本题耗时是正整数且不超过2^31 - 1的情况下,完全不会。但如果耗时的数量级极大,建议改成a[0] < b[0] ? -1 : (a[0] > b[0] ? 1 : a[1] - b[1]),从根本上避免减法溢出。
5. 测试用例与手工推演
5.1 基础依赖用例
先回到前面的例子:
5 2 3 1 4 5 2 3 1 3 2 3 4 5初始时,任务 1、2、4 入度为 0,任务 3 依赖 1 和 2,任务 5 依赖 4。就绪堆里是(2,1)、(3,2)、(4,4),取前两个执行:任务 1 耗时 2,任务 2 耗时 3。
时间推进到 2,任务 1 完成。任务 3 的入度从 2 变成 1,还不能执行。此时正在执行的任务 2 还没结束,就绪堆为空。
时间推进到 3,任务 2 完成。任务 3 入度从 1 变成 0,进入就绪堆。此时执行单元全空,于是把任务 3(耗时 1)和任务 4(耗时 4)同时启动。
时间推进到 4,任务 3 完成。任务 4 还在执行,就绪堆为空。注意,任务 5 此时还不能执行,因为任务 4 还没完成。
时间推进到 7,任务 4 完成。任务 5 入度归零,进入就绪堆,此时只有一个空闲执行单元,启动任务 5。
时间推进到 12,任务 5 完成。最终输出 12。
这个用例最大的价值在于,它展示了“机器满了但任务还没就绪”的等待场景:任务 3 在 4 秒就完成了,但任务 5 必须等到任务 4 在 7 秒完成才能开始。如果不做事件模拟,只看拓扑关键路径,很容易算成错误答案。
5.2 单任务无依赖用例
1 7 1 0只有一个任务,耗时 7,一个执行单元。就绪堆初始只有任务 1,直接执行,输出 7。这个用例是边界测试,主要验证数组下标和输入解析没有越界问题。
5.3 多任务无依赖、并行度不足用例
5 2 3 1 4 5 3 0五个任务之间没有任何依赖,三个执行单元同时工作。初始就绪堆为(1,3)、(2,1)、(3,2)、(4,4)、(5,5)。
时间 0 启动任务 3、1、2。任务 3 耗时 1,最先完成;此时空出一个执行单元,立刻启动任务 4。
时间 2,任务 1 完成,空出第二个执行单元,立刻启动任务 5。
时间 3,任务 2 完成。此时三个执行单元分别跑着任务 4 到 5 秒、任务 5 到 7 秒,没有空位,等待。
时间 5,任务 4 完成。空出一个执行单元,但已经没有等待任务了。
时间 7,任务 5 完成。最终输出 7。
这个用例想说明一个概念:即使没有依赖关系,并行执行单元的数量 K 也会限制整体完成时间。如果 K 改成 5,五个任务全部同时启动,输出就是最大耗时 5。千万不要在代码里把所有任务的耗时简单相加。
5.4 深度依赖链用例
3 2 3 1 3 2 1 2 2 3任务 1 依赖前置为空,任务 2 依赖任务 1,任务 3 依赖任务 2。任务 3 耗时 1,但因为必须先等任务 1 和任务 2,所以即使 K 为 3,它也得等链上的任务走完。
时间 0 启动任务 1(耗时 2),同时任务 3 入度不是 0,不能启动。
时间 2,任务 1 完成,任务 2 就绪,启动任务 2(耗时 3)。
时间 5,任务 2 完成,任务 3 就绪,启动任务 3(耗时 1)。
时间 6,任务 3 完成,输出 6。
这条链式用例展示了依赖关系中最“朴素”的情况:不管你有多少并行执行单元,只要形成一条链,整体时间就是链上所有任务耗时之和。用代码跑这个用例,可以验证入度更新的链路是否正确。
6. 考场常见问题与避坑思路
6.1 就绪堆填满执行单元的时机
我在前面反复强调过while len(running) < k and ready这个循环。这里再展开讲一下为什么它必须是while,而不是if。
假设 K 等于 3,某一轮结束时,正在执行的任务只剩 1 个,而就绪堆里有 3 个任务。如果是if,这一轮只启动 1 个任务,剩下 2 个要等下一轮才能开始,白白浪费了执行单元。用while才能一次性把所有空位都填满。这个点虽然代码只有一行,却决定了模拟结果是否正确。
6.2 多个任务同时完成的处理
我正在执行堆里用的键值是(预计完成时间, 任务编号)。如果两个任务预计都在第 5 秒完成,其中编号更小的会排在堆顶。处理完成事件时,必须用一个while循环把完成时间等于now的任务全部弹出,而不是只弹一个。
这里还有一个很微妙的点:多个任务同时完成时,它们的后继任务可能互为依赖。比如任务 A 完成后解锁任务 C,任务 B 完成后解锁任务 C,C 的入度要经过两次减 1 才到 0。在同一个完成事件里,必须把所有已完成任务遍历完,并且全部更新完入度,才能继续下一轮启动。如果你在遍历过程中就把新就绪的任务压入堆,并且立刻开始下一轮填充,可能会出现“C 的入度还没减到底,就被错误地当成就绪任务”的问题吗?不会,因为入度更新一定是减到 0 才压堆,但只要你把入度更新和“启动新任务”混在同一个阶段里,调度顺序就容易乱。我的建议是严格分阶段:先集中处理完成事件,再统一填满执行单元。
6.3 循环依赖的处理
这道题默认输入是一张有向无环图,但实际考试中用例有时会故意给你一个环,比如 1 依赖 2、2 依赖 1。这种用例往往是用来卡“死循环”实现的。
我见过一些考生用递归拓扑排序,遇到环直接栈溢出。我这套解法用的是迭代 + 入度数组,天然不会递归爆栈。但需要注意:如果存在环,环上的任务入度永远不会变成 0,就绪堆一直为空,而正在执行的任务也可能很快清空,主循环就会退出,输出一个偏小的答案。这虽然不是标准答案,但至少不会让程序崩溃。如果你在本地调试时发现输出明显不合理,第一反应就应该是检查输入里是否有环,而不是怀疑堆写错了。
6.4 编号下标从 1 开始还是从 0 开始
这个坑非常隐蔽。题目说任务编号从 1 到 N,你就必须保证数组下标也是从 1 开始。我见过有人图省事,把数组长度直接设为 N,然后用duration[i - 1]来读耗时,结果在邻接表的循环里忘了减 1,导致建图全错。
我的建议很简单:所有数组长度统一用N + 1,把下标 0 的位置当成垃圾位。虽然多开一个整数数组的空间不算什么,但它能让你在写代码时少操一份心。
6.5 大整数边界
任务的耗时可能很大,所有任务完成的总时间可能超过2^31 - 1。在 Python 里完全不用担心,int 可以无限大。但在 JS 里,如果你用Number存储,精度可能出问题,尤其是涉及now + d这种加法时,超过Number.MAX_SAFE_INTEGER就会丢精度。
机试用例一般不会出这种极端数据,但稳妥起见,你可以把所有时间相关变量保持为普通数字,因为只要每个耗时本身不超过2^31 - 1,加法的中间结果通常也安全。如果实在不放心,可以用BigInt全程存储,但要注意BigInt不能和普通数字直接比较,写起来麻烦一点。
6.6 关于“100%通过率”的正确心态
最后再说回标题。市面上的“100%通过率”基本都是营销词,真正决定你能不能过的东西,是你对题型的熟练度和边界处理的完整度。OD 机试的判分系统是多用例判分,核心用例过了只能拿到部分分,边界用例全过才能满分。所以刷题时别只盯着一个题解跑通就完事,多想一想“如果任务编号从 1 开始”、“如果并行度大于任务数”、“如果耗时相等”这些边界场景,你的代码是不是依然正确。
我个人在实际练习里的一个小习惯是:每次写完一个题,都会用至少五组不同形态的测试数据去验证。一组无依赖、一组全依赖形成的单链、一组有多个同时完成事件、一组 K 大于 N、一组带环。把这五组跑完,代码里的低级错误基本都能暴露出来。这个方法不仅适用于“任务编排系统”,也适用于所有图论模拟题。你可以把这个思路沉淀成自己的刷题流程,机试时心里会踏实很多。