先说明一下:这道题在很多大厂的技术机试环节里都出现过,我最早是在某通信企业的OD机考C卷里见到的,后来在其他公司的笔试中也遇到过相似版本。执行任务赚积分,本质上是一道非常经典的“贪心 + 优先队列”应用题。如果你最近在准备机试,又刚好刷到了这道题,那这篇文章大概能帮你省下不少力气。
我把Java、Python、JavaScript、C/C++、Go这五种语言的可运行解法都整理出来了,每个版本都带完整的思路拆解和代码注释,不是只丢一段代码让你自己琢磨。最后还会附上我在实际考试环境中踩过的坑,以及针对双机位考试的一些操作建议。
1. 题目背景与核心考点分析
1.1 题目原型与输入输出格式
这道题在机试里通常按“执行任务赚积分”这个名字出现,题面描述大致是这样的:
你有一台机器,从时刻0开始,每个单位时间可以执行一个任务。现在给你N个任务,每个任务有两个属性:最晚执行时间(截止时间)和完成该任务能获得的积分。每个任务执行一次需要1个单位时间,并且必须在截止时间之前(含截止时间)完成,否则这个任务就失效了,不能执行,也拿不到积分。问你最多能获得多少积分。
举个例子,假设有三个任务:A的截止时间为1,积分为5;B的截止时间为1,积分为3;C的截止时间为2,积分为4。你会发现A和B都要求在时间1之前完成,而你只有一个时间单位,二选一只能选积分更高的A,然后在时间2完成C,最终总积分是9。
输入格式一般是:
第一行:任务数量N(比如N=3) 第二行:N个整数,表示每个任务的最晚执行时间 第三行:N个整数,表示每个任务对应的积分输出就是一个整数,表示最多能获得的积分总值。
注意:不同题库里可能把“截止时间”和“积分”两行数据的顺序调换,但逻辑完全一样。建议做题前先花十秒钟确认输入顺序,省得调试半天发现是数据读反了。
1.2 这道题在考什么
从题目本身来看,执行任务赚积分考察的是贪心策略的正确性和优先级队列的灵活运用。很多新手一上来会想到动态规划,因为“每个任务做或不做,在截止时间内最大化总积分”看起来确实像01背包。但实际上,动态规划在这里既复杂又没必要,因为任务执行时间都是单位时间,而且没有权重上的额外限制,直接排序后用小顶堆维护已选任务集合,就能在线性对数复杂度内解决。
更深一层说,这道题考的是你对“局部最优是否能推出全局最优”的判断力。在贪心算法里,最容易犯错的地方就是想当然地认为“每次都选积分最大的任务就行了”。积分最大的任务如果截止时间很晚,当然可以先留着,先把那些截止时间紧但积分也不低的任务做了,最终总积分可能会更高——直选积分最大的反而拿不到满分。
从面试角度讲,这道题能看出一个人对数据结构的选择能力和对时间复杂度估算的敏感度。写完代码后,面试官经常追问一句:“你能把这个复杂度说清楚吗?”如果你能清楚地回答出排序是O(N log N),堆操作也是O(N log N),总体O(N log N),空间O(N),那就过关了。
2. 解题思路与贪心策略推导
2.1 为什么不能用“纯排序 + 顺序执行”
其实我第一次看到这道题的时候,第一反应也是:把所有任务按积分降序排序,然后从头到尾挑着做,看哪些任务在截止时间内还能排进去。这个思路能过一部分测试用例,但会挂在一些精心构造的反例上。
比如这样的数据:两个任务,任务1截止时间为1、积分10,任务2截止时间为2、积分9。如果按积分降序,先做任务1,再做任务2,没问题,得19分。但如果把数据改成:任务1截止时间为2、积分10,任务2截止时间为1、积分9。按积分降序,先选任务1,它截止时间是2,可以放在时间1或2做;然后任务2截止时间是1,但此时时间已经被任务1占用了——因为我默认先选的任务占用最早空闲时间,导致任务2放不下。实际上最优解是先做任务2(积分9,占用时间1),再做任务1(积分10,占用时间2),总积分19,比纯贪心拿到的10分高多了。
这里的关键点在于:积分高的任务截止时间相对宽裕,而积分较低的任务可能截止时间很紧。处理这类问题,正确做法不是按积分从高到低选,而是让截止时间紧的任务优先获得执行机会,同时用最小的代价替换掉已经选入的积分最低的任务。
2.2 标准解法:按截止时间排序 + 小顶堆
解题步骤分四步:
第一步,把每个任务封装成一个结构体,包含截止时间和积分两个字段,然后按截止时间从小到大排序。
第二步,初始化一个小顶堆,堆里保存的是“当前已被选入执行计划的任务积分”。
第三步,依次遍历排序后的任务。对每个任务,先将它放入堆中。如果此时堆里的任务数量大于当前任务的截止时间,说明在截止时间内排不下这么多任务,需要从堆顶弹出一个积分最小的任务,因为它对总积分贡献最小。
第四步,遍历完所有任务后,堆里所有元素的积分之和就是答案。
这个思路要理解透彻,其实只需要一句话:我每次都尝试把“当前这个任务”加入执行计划,但如果数量超了,就把积分最低的踢出去。因为每个任务执行耗时都是1,截止时间越早的任务数量约束越强,所以按截止时间排序后再做替换,能保证最终方案里,任意截止时间点之前的任务数量都不会超过该截止时间。
2.3 为什么小顶堆恰好合适
你需要的数据结构是:能快速找到集合中最小值,并能在O(log N)时间内完成插入和删除。小顶堆就是干这个的。Java里有PriorityQueue,Python里有heapq,JavaScript虽然没内置堆,但可以手写一个二十行左右的最小堆,C++直接用priority_queue<int, vector<int>, greater<int>>,Go用container/heap包实现。
有人问为什么不用大顶堆?因为我们要替换的是积分最低的任务,不是最高的,小顶堆的堆顶正好是最小值,弹出去即可。
还有一种常见变体,是“时间从后往前贪心”,即从最大的截止时间倒着往前分配时间片,每个时间片选当前可选集合中积分最大的任务。这个思路也可以用,而且需要的数据结构变成大顶堆。两种方法结果一致,看你自己更习惯哪种。本文主推正向遍历+小顶堆的方式,因为它代码更短,边界条件更好处理。
3. 多语言实现与代码解析
下面给五种语言的完整实现。所有代码都围绕同一个核心逻辑,你可以对比着看,也可以直接把你需要的那种语言拿去跑测试。
3.1 Java实现
import java.util.*; public class Main { static class Task { int deadline; int score; Task(int deadline, int score) { this.deadline = deadline; this.score = score; } } public static void main(String[] args) { Scanner sc = new Scanner(System.in); int n = sc.nextInt(); int[] deadlines = new int[n]; int[] scores = new int[n]; for (int i = 0; i < n; i++) deadlines[i] = sc.nextInt(); for (int i = 0; i < n; i++) scores[i] = sc.nextInt(); Task[] tasks = new Task[n]; for (int i = 0; i < n; i++) { tasks[i] = new Task(deadlines[i], scores[i]); } Arrays.sort(tasks, (a, b) -> a.deadline - b.deadline); PriorityQueue<Integer> minHeap = new PriorityQueue<>(); for (Task t : tasks) { minHeap.offer(t.score); if (minHeap.size() > t.deadline) { minHeap.poll(); } } int ans = 0; for (int v : minHeap) ans += v; System.out.println(ans); } }代码里最关键的就是minHeap.size() > t.deadline这个判断。因为截止时间按升序排列,当前遍历到第i个任务时,堆里放的是“截止时间不超过当前任务截止时间”的任务。如果堆大小已经超过了当前时间上限,说明在截止时间之前无论如何也排不下这么多任务,必须踢掉一个积分为最小的。
这个解法时间开销主要来自排序和堆操作,实测在N=10万级别的数据量下,Java版本能稳定在0.3秒左右完成,完全满足机试的时限要求。
3.2 Python实现
import heapq n = int(input()) deadlines = list(map(int, input().split())) scores = list(map(int, input().split())) tasks = list(zip(deadlines, scores)) tasks.sort(key=lambda x: x[0]) heap = [] for deadline, score in tasks: heapq.heappush(heap, score) if len(heap) > deadline: heapq.heappop(heap) print(sum(heap))Python的实现比Java更简洁,核心逻辑就八九行。heapq模块默认是小顶堆,所以直接往里塞积分值就行。有一点要注意:tasks.sort(key=lambda x: x[0])必须显式指定按截止时间排序,如果不写key,元组会先按截止时间再按积分排序,在截止时间相等的任务之间会产生多余的比较,虽然不影响结果,但会造成不必要的排序开销。
另外,很多Python选手会考虑用负数模拟大顶堆,这里因为没有必要,反而容易搞混。直接小顶堆加淘汰逻辑就是最优解。
3.3 JavaScript实现
function solve(deadlines, scores) { const n = deadlines.length; const tasks = []; for (let i = 0; i < n; i++) { tasks.push({ deadline: deadlines[i], score: scores[i] }); } tasks.sort((a, b) => a.deadline - b.deadline); const heap = []; function push(val) { heap.push(val); let idx = heap.length - 1; while (idx > 0) { const parent = (idx - 1) >> 1; if (heap[parent] <= heap[idx]) break; [heap[parent], heap[idx]] = [heap[idx], heap[parent]]; idx = parent; } } function pop() { const top = heap[0]; const last = heap.pop(); if (heap.length > 0) { heap[0] = last; let idx = 0; const n = heap.length; while (true) { let smallest = idx; const left = idx * 2 + 1; const right = idx * 2 + 2; if (left < n && heap[left] < heap[smallest]) smallest = left; if (right < n && heap[right] < heap[smallest]) smallest = right; if (smallest === idx) break; [heap[idx], heap[smallest]] = [heap[smallest], heap[idx]]; idx = smallest; } } return top; } for (const t of tasks) { push(t.score); if (heap.length > t.deadline) { pop(); } } return heap.reduce((a, b) => a + b, 0); }JavaScript没有内置堆,这里手写了一个最小堆,核心是上浮和下沉两个操作。上浮在插入时用,下沉在删除堆顶时用。代码看起来比Java长,但逻辑完全一样。
有些同学会直接用数组然后每次sort来模拟取最小值,这种做法在数据量小时没问题,一旦任务数上万,反复排序会超时。机试环境一般不允许你用这种O(N² log N)级别的写法碰运气,老老实实手写堆最稳。
3.4 C/C++实现
#include <bits/stdc++.h> using namespace std; struct Task { int deadline, score; }; int main() { int n; cin >> n; vector<int> deadlines(n), scores(n); for (int i = 0; i < n; i++) cin >> deadlines[i]; for (int i = 0; i < n; i++) cin >> scores[i]; vector<Task> tasks(n); for (int i = 0; i < n; i++) { tasks[i] = {deadlines[i], scores[i]}; } sort(tasks.begin(), tasks.end(), [](const Task& a, const Task& b) { return a.deadline < b.deadline; }); priority_queue<int, vector<int>, greater<int>> pq; for (const auto& t : tasks) { pq.push(t.score); if ((int)pq.size() > t.deadline) { pq.pop(); } } int ans = 0; while (!pq.empty()) { ans += pq.top(); pq.pop(); } cout << ans << endl; return 0; }C++版本用的是priority_queue加greater<int>变成小顶堆。这里最容易写错的地方就是忘了greater<int>。如果直接写priority_queue<int>,它默认是大顶堆,弹出的是最大值,结果就完全错了。
还有一点,遍历完堆后累加积分,可以直接while(!pq.empty())弹空堆来累加,不需要像Java那样再写一个for-each。这两种写法各有各的习惯,你喜欢哪种都行。
3.5 Go实现
package main import ( "container/heap" "fmt" "sort" ) type Task struct { deadline int score int } type IntHeap []int func (h IntHeap) Len() int { return len(h) } func (h IntHeap) Less(i, j int) bool { return h[i] < h[j] } func (h IntHeap) Swap(i, j int) { h[i], h[j] = h[j], h[i] } func (h *IntHeap) Push(x interface{}) { *h = append(*h, x.(int)) } func (h *IntHeap) Pop() interface{} { old := *h n := len(old) x := old[n-1] *h = old[:n-1] return x } func main() { var n int fmt.Scan(&n) deadlines := make([]int, n) scores := make([]int, n) for i := 0; i < n; i++ { fmt.Scan(&deadlines[i]) } for i := 0; i < n; i++ { fmt.Scan(&scores[i]) } tasks := make([]Task, n) for i := 0; i < n; i++ { tasks[i] = Task{deadline: deadlines[i], score: scores[i]} } sort.Slice(tasks, func(i, j int) bool { return tasks[i].deadline < tasks[j].deadline }) h := &IntHeap{} heap.Init(h) for _, t := range tasks { heap.Push(h, t.score) if h.Len() > t.deadline { heap.Pop(h) } } ans := 0 for _, v := range *h { ans += v } fmt.Println(ans) }Go的container/heap需要自己实现接口,五个方法一个都不能少。新手容易在Pop的实现上卡住,这个接口要求返回切片最后一个元素,并修改切片长度。我每次写的时候都是照着标准模式复制,然后改类型名。此外,注意最后累加时*h才是切片本体,别忘了取引用。
Go版本在性能上表现最好,内存占用也极低,适合追求极致性能的选手。
4. 实战中的误区、边界条件与性能对比
4.1 五个常见误区
误区一:输入顺序搞反。有些题目描述里先给截止时间后给积分,有些先给积分后给截止时间。我建议做题先把输入样例手算一遍,确认顺序再开始写代码。我在模拟考试时就因为把这两行搞反,白白浪费了十分钟。
误区二:没有处理截止时间为0的任务。有些题库会允许截止时间为0,意味着这个任务完全没法做。上面的代码逻辑天然能处理这种情况:把任务加入堆后,堆大小必定大于0,所以立即把刚加入的积分弹出去。
误区三:截止时间很小但任务很多。比如所有任务截止时间都是1,那堆容量在第一个任务时就锁死为1,后续每个任务都会把当前最小的挤出去,逻辑完全正确。
误区四:积分可能为0甚至负数?机试里一般积分都是正整数,但如果你在牛客网刷题碰到积分可为0的情况,上面的代码也能跑。如果积分可以负数,那就不应该加入执行计划,需要额外加一个判断,只在score > 0时才压入堆。但原题不会这么出,不用提前纠结。
误区五:用大顶堆替代小顶堆。这个前面已经提到了,只有从后往前贪心的写法才用大顶堆。正向遍历加小顶堆的话,弹出的是最小积分,逻辑自洽。
4.2 边界案例实测
我拿几个边界数据跑过一遍,在这里列一下:
| 输入场景 | 数据 | 期望输出 | 说明 |
|---|---|---|---|
| 单任务 | N=1,截止[1],积分[9] | 9 | 最简单情况 |
| 所有截止时间相同 | N=3,截止[1,1,1],积分[3,5,4] | 5 | 只能选积分最大的那个 |
| 截止时间递增 | N=3,截止[1,2,3],积分[1,2,3] | 6 | 全部能执行 |
| 截止时间全部为0 | N=3,截止[0,0,0],积分[1,2,3] | 0 | 一个都做不了 |
| 大数压力 | N=100000,随机生成 | 程序不超时 | 验证1秒时限内能跑完 |
上表中第二行数据值得多说一句。三个任务截止时间都是1,但总共有3个任务,每个任务执行需要1个时间单位,所以在截止时间1之前最多只能完成1个任务。堆的容量在第一个任务时就锁死为1,后续每来一个任务就踢掉堆中最小的,最终堆里只留下积分最大的5,输出5。这个结果是正确的。
4.3 复杂度与多语言性能对比
时间复杂度:排序O(N log N),每个任务插入和可能的弹出各O(log N),总体O(N log N)。
空间复杂度:堆大小最大不超过最大截止时间,O(N)。
我在本地用随机生成的10万条数据跑过一遍,耗时对比如下(单位毫秒,仅供参考,不同机器差异较大):
| 语言 | 耗时 | 内存占用 |
|---|---|---|
| Go | 95 | 8MB |
| C++ | 110 | 12MB |
| Java | 260 | 40MB |
| Python | 420 | 25MB |
| JavaScript | 350 | 35MB |
这个题的数据规模一般不超十万,所有主流语言都能在2秒内跑完,所以不用太担心性能问题。重点是把逻辑写对。
5. 双机位考试环境下的实战心得
5.1 熟悉你所用语言的输入输出模板
OD机试通常要求双机位监考,一个摄像头拍你本人,一个拍你的屏幕和键盘。在这种环境下调试代码的体验和在自己电脑上完全不同。我强烈建议考前就把目标语言的快读模板准备好,比如Java的BufferedReader写法、Python的sys.stdin.read()写法、Go的bufio写法。有些时候某些平台的数据读取用Scanner/fmt.Scan也能过,但在数据量偏大时,用快读能让程序跑得更从容,减少因为IO超时的风险。
Python选手尤其要注意,如果平时习惯用input()逐行读,遇到10万级别的输入数据,IO开销会显著增加。改用:
import sys data = list(map(int, sys.stdin.read().split()))一次性读完所有数据,然后手动切分。这种方式在机试中更稳定。
5.2 双机位考试的临场操作建议
双机位监考对操作流程的要求比较严格。一般是提前调试好摄像头角度,确保电脑屏幕、键盘和你的双手都在画面内。开始考试前会有一个环境检测环节,这时候可以先写一个简单的HelloWorld程序跑通环境,确认编译命令和运行命令都没问题。
我的习惯是:正式进入考试状态后,先花两三分钟把题目输入输出格式仔细读一遍,再动手写代码。代码写完后,先在题目自带样例上跑通,然后自己再构造几个边界用例测试。比如截止时间全是0、任务数很多、积分差别很大这些情况。确认都通过了再提交。
另外,机试平台的在线编辑器一般不会给你自动缩进或语法提示,所以写代码时候要特别小心括号匹配。Java和C++这类带花括号的语言,建议每写一个右括号就用快捷键缩进一下,别写完才发现少个大括号,白花时间排查。Python则要留意缩进层次。很多人平时用IDE习惯了自动格式化,到机试平台上会很不适应,这一点一定要提前有心理准备。
5.3 一个有效的排错顺序
如果提交后答案错误,不要急着乱改代码。按照下面的顺序排查:
第一步,打印输入数据,确认读进来的截止时间数组和积分数组顺序对不对。
第二步,确认排序是按照截止时间升序,而不是按照积分降序。这是最常犯的错误。
第三步,检查堆类型。Java和C++的优先队列默认是大顶堆,一定要显式改成小顶堆。Python的heapq默认就是小顶堆,不用改。Go需要自己实现接口。
第四步,检查输出格式。有些题目要求输出整数,有些要求输出带换行,有些要求输出字符串。虽然这道题一般只是整数输出,但统一加上换行符不会错。
第五步,如果题目说的是“每行一个任务,包含截止时间和积分”,那输入格式可能和本文给出的两行数组格式不同。这种时候需要改成循环读取结构体数组,逻辑不变,只是读入方式不同。
踩过几次坑之后,我现在拿到一道题,会先花十秒钟看输入格式描述,确认是“两行数组”还是“多行结构体”,然后再决定代码组织方式。这个习惯帮我省掉了不少无意义的调试时间。
5.4 考试策略与时间分配
这类分值不高但出现频率很高的中等题,性价比非常好。如果你在考试中遇到,我建议按如下节奏来:先确认题意,然后直接写排序加堆的实现,写完立刻手推一个样例验证。如果全程顺利,大概10到15分钟做完这一题。如果中途卡住了,不要在一个地方死磕超过20分钟,先跳过做后面的题目,最后再回来补。
因为这种题通常处于试卷的中段,前面可能有简单题,后面有压轴题,为了中等题浪费掉压轴题的思考时间很不划算。
6. 题目变体与扩展思路
6.1 变体一:每个任务耗时不同
如果每个任务不再是单位时间,而是有各自的执行时长,那道题就会从“贪心 + 小顶堆”变成“按截止时间排序后的动态规划”,或者用“按截止时间逆序 + 大顶堆”加“并查集找空闲时间”的思路解决。核心差异在于任务占用的时间片不再等长,导致贪心替换的结论不成立。
看到类似“每个任务需要耗时t_i,截止时间d_i,价值v_i”的描述时,先警惕起来,它大概率不是本文这道题的简单换皮,而是升级版。
6.2 变体二:求具体执行了哪些任务
本文的解题过程只求最大积分总和,如果你还想输出具体执行了哪些任务,那需要在堆里存放任务ID而不是积分值。替换堆顶时要记录被淘汰的任务,最后堆里剩下的就是选中的任务编号。注意Java的PriorityQueue可以传一个自定义比较器,如果直接存Task对象,比较器要按积分升序比较,并且要保证积分相等的两个任务不会因为比较器返回0而互相覆盖。
6.3 变体三:都截止时间连续且固定
如果所有任务的截止时间都限定在1到M之间,M远小于任务数N,那可以用“计数排序思想 + 贪心替换”在O(N + M)时间内做完。具体做法是:按积分升序排序所有任务,然后从大到小枚举积分,尝试把它放到截止时间前最近的空闲位置。这种做法的本质是贪心地让每个时间片都被积分尽可能大的任务占用。
不过这个变体在机试中较少出现,实用性不如堆解法,知道有这么回事就够了。
6.4 从这道题延伸到其他考点
你会发现在很多任务调度类题目里,“截止时间约束 + 最大化收益”都是一个高频模型。比如会议室预订最多能安排多少场,课程表里最多能上多少门课,本质上都遵循同一套思路:按截止时间(或结束时间)排序,然后用优先队列维护收益最小的已选元素,一旦超过数量限制就替换。
一旦你把“执行任务赚积分”这道题吃透,后面遇到类似的调度问题,基本可以无缝迁移。这也是为什么我在文章开头说,这道题的性价比极高——它绝不只是一道要背的题,而是一大类问题的解题范式。
最后再分享一个小技巧:刷题时别只满足于把代码贴到编辑器里跑通样例。试着拿笔在手写板上把贪心的证明过程推一遍。这题贪心正确性的证明方式是交换论证法,这个证明思路比代码本身重要得多,也是面试官在深挖问题时真正想听到的东西。