华为OD机试经典题解:贪心算法与优先队列实现最大任务调度

📅 2026/7/27 8:12:53
华为OD机试经典题解:贪心算法与优先队列实现最大任务调度
1. 项目概述与核心需求解析最近在准备华为OD机试的同学们应该对“可以处理的最大任务”这道题不陌生了。它作为C卷的经典题目频繁出现在各种机试真题和模拟题中是检验候选人贪心算法和排序思维的一道“试金石”。这道题的核心远不止是写对一个排序和循环那么简单它背后考察的是你如何将现实中的任务调度问题抽象成一个可计算的数学模型并选择最高效的策略去求解。很多朋友第一次做可能会觉得思路有点绕或者能通过样例但总有几个测试点过不去。今天我就结合自己刷题和带新人的经验把这道题从题意理解、思路推导到代码实现的每一个细节以及那些容易踩坑的地方给大家掰开揉碎了讲清楚。无论你是刚开始准备OD机试的新手还是想巩固贪心算法基础的同学这篇文章都能让你对这类问题有一个透彻的理解。简单来说题目会给你一系列任务每个任务都有两个关键属性最晚开始时间和所需处理时长。你拥有一台机器它在时间0启动并且处理任务时是独占的即同一时间只能处理一个任务。你的目标是如何安排这些任务的执行顺序使得在任务不超时即任务的实际开始时间不晚于其最晚开始时间的前提下这台机器能够处理的任务数量最大化。这听起来就像一个高难度的“时间管理大师”挑战你需要在一堆截止日期和耗时各不相同的待办事项中做出最优的取舍。2. 问题本质与算法思路拆解2.1 问题建模从生活场景到算法抽象我们可以把这个问题映射到一个非常生活化的场景你是一名学生今天有若干门作业要写每门作业都有一个“最晚开始动笔时间”比如数学作业晚上8点前必须开始写和“需要连续写作的时长”比如需要写1小时。你从放学后时间0开始一次只能专心写一门作业。你怎么安排写作业的顺序才能完成尽可能多的作业并且保证每门作业都在它的“最晚开始时间”之前动笔理解了这个场景问题的两个核心约束就非常清晰了任务独占性机器/你同一时刻只能处理一个任务。时间约束每个任务必须在它的最晚开始时间之前开始执行。我们的目标是最大化任务数量而不是总耗时或其他指标。这直接提示我们这可能是一个贪心算法可以解决的问题。贪心算法的精髓在于每一步都做出当前看起来最优的选择并希望这种局部最优能导致全局最优。对于任务调度问题常见的贪心策略有按截止时间最早、按处理时间最短、按开始时间最早等。我们需要找到适合本题的贪心策略。2.2 思路推导为什么是“截止时间最晚开始时间 最小堆”直接思考所有任务的排列组合会非常复杂阶乘级。我们必须找到一个可以逐步构建最优解的规律。经过分析一个行之有效的策略是按最晚开始时间升序排序优先考虑那些“截止时间”更紧迫的任务。这是一个很自然的想法把最着急的事情先纳入考虑范围。使用一个“当前时间”变量和一个小顶堆优先队列current_time记录机器已经安排到的时刻。小顶堆用来动态维护当前“已选择要执行”的任务的处理时长。堆顶永远是已选任务中处理时间最长的那个。算法的核心流程如下我们按顺序遍历排序后的任务。对于每个任务我们先尝试把它加入执行计划即假设我们现在开始执行它那么current_time需要增加这个任务的耗时。我们将这个耗时放入堆中。加入后我们检查更新后的current_time是否超过了这个任务的最晚开始时间。如果没有超过太好了这个任务可以被顺利安排我们继续处理下一个任务。如果超过了说明我们当前的选择包含了这个新任务已经导致它无法在其截止时间前开始了。这时贪心的策略就体现出来了——我们丢弃掉当前已选任务中“处理时间最长”的那个任务也就是小顶堆的堆顶元素。因为丢弃最耗时的任务能为时间表腾出最多的空闲最有可能让后续更多短任务被加入。从堆中弹出堆顶最长耗时。current_time减去这个被丢弃任务的耗时。遍历完所有任务后堆的大小就是我们能处理的最大任务数量。这个思路为什么有效关键在于当我们发现加入新任务导致超时时我们选择“牺牲”掉已选任务中最“费时”的一个而不是新任务本身。因为新任务可能是短任务而我们已经安排的一个长任务虽然截止时间可能更晚但它占用了大量时间导致后续任务无法插入。通过维护一个“已选任务耗时最大堆”我们总能及时剔除那个“最不划算”的长任务从而为更多短任务腾出空间最终实现任务数量的最大化。这是一种“以数量换时间”的贪心策略。注意这里有一个非常关键的细节也是很多初次接触此算法的同学疑惑的点。我们排序的依据是任务的“最晚开始时间”但堆里维护的是“处理时长”。排序保证了我们按截止时间的紧迫性来扫描任务而堆帮助我们动态优化已选任务的集合确保总耗时尽可能小。两者结合缺一不可。3. 核心数据结构与C实现详解3.1 数据结构选择vector,pair,priority_queue在C中实现上述算法我们需要选择合适的数据结构来高效地表达和操作数据。任务表示每个任务包含两个整数最晚开始时间deadline和处理时长duration。使用std::pairint, int非常合适或者定义一个简单的struct Task。为了排序方便我们使用pair并约定first为最晚开始时间second为处理时长。任务列表使用std::vectorstd::pairint, int tasks来存储所有任务。“已选任务耗时”容器我们需要一个能快速获取最大值、插入和删除最大值的数据结构。std::priority_queue优先队列完美符合要求。默认的priority_queueint是大顶堆堆顶最大而我们需要的是能快速访问最大值的容器以便丢弃最长任务所以直接使用大顶堆即可。在代码逻辑中这个堆里存放的就是已被纳入当前计划的任务的duration。3.2 代码实现逐行解析下面给出完整的C实现并附上详细注释。#include iostream #include vector #include algorithm #include queue // 用于priority_queue using namespace std; int maxTasks(vectorpairint, int tasks) { // 1. 特殊情况处理如果任务列表为空直接返回0 if (tasks.empty()) { return 0; } // 2. 按任务的最晚开始时间deadline进行升序排序 // 使用lambda表达式定义排序规则比较pair的first元素 sort(tasks.begin(), tasks.end(), [](const pairint, int a, const pairint, int b) { return a.first b.first; // 按最晚开始时间从小到大排 }); // 3. 初始化一个最大堆优先队列用于存储已选择任务的耗时 // priority_queue默认是最大堆堆顶元素最大 priority_queueint max_heap; // current_time 记录当前时间线进展到的位置 int current_time 0; // 4. 遍历排序后的每一个任务 for (const auto task : tasks) { int deadline task.first; int duration task.second; // 4.1 尝试将当前任务加入计划 current_time duration; // 假设执行它当前时间推进 max_heap.push(duration); // 将其耗时放入堆中 // 4.2 检查加入后是否导致当前任务超时当前时间 该任务最晚开始时间 if (current_time deadline) { // 如果超时说明当前计划不可行需要“牺牲”一个任务 // 贪心策略丢弃当前已计划任务中耗时最长的那个即堆顶任务 int longest_duration max_heap.top(); // 获取最长耗时 max_heap.pop(); // 将其从计划中移除 current_time - longest_duration; // 当前时间回退这个任务的耗时 } // 如果没超时则什么也不做继续下一个任务 } // 5. 遍历结束后堆中剩余的任务数就是能处理的最大任务数量 return max_heap.size(); } int main() { // 示例输入任务列表 {最晚开始时间, 处理时长} vectorpairint, int tasks {{5, 3}, {3, 4}, {2, 1}, {10, 2}, {7, 5}}; int result maxTasks(tasks); cout 可以处理的最大任务数量是: result endl; // 对于示例最优解是执行 {2,1}, {5,3}, {10,2} 这三个任务结果为3。 return 0; }关键代码段解析sort(tasks.begin(), tasks.end(), [](...) {...}) 使用Lambda表达式进行自定义排序确保任务按最晚开始时间从小到大处理。这是贪心策略的第一步也是正确性的基础。priority_queueint max_heap 声明一个存储int类型的最大堆。这个堆是我们进行“任务替换”优化的核心工具。循环体内的if (current_time deadline) 这是算法的灵魂所在。它实现了“尝试-检查-回退”的贪心逻辑。current_time模拟了真实的时间流逝而deadline是硬性约束。一旦违反约束就立刻进行修正。max_heap.top()和max_heap.pop() 当需要修正时我们取出并移除当前计划中最耗时的任务。因为堆顶就是最大值所以这个操作是O(log n)的非常高效。return max_heap.size() 最终所有经历过“加入”和“可能被剔除”筛选后仍留在堆里的任务就是我们的最优解集合其大小即为答案。3.3 复杂度分析时间复杂度O(n log n)。排序操作sort的时间复杂度为O(n log n)。接下来对n个任务进行遍历每次遍历中堆的插入(push)和弹出(pop)操作复杂度均为O(log k)其中k是堆的大小k ≤ n。因此遍历部分的总复杂度也是O(n log n)。综合来看主导因素是排序整体为O(n log n)。这对于机试中常见的数据规模n ≤ 10^5是完全可行的。空间复杂度O(n)。主要用于存储任务列表tasks和优先队列max_heap。在最坏情况下所有任务都可能被加入堆中尽管随后可能被弹出因此空间复杂度为O(n)。4. 算法正确性证明与思维延伸4.1 贪心选择性质的简要证明为什么丢弃耗时最长的任务是最优的我们可以用交换论证的思想来理解。 假设在某个时刻我们有一个任务集合S当前时间current_time超过了新任务t_new的截止时间。为了容纳t_new我们必须从S中至少移除一个任务。设被移除的任务是t_out。 我们的目标是让current_time减少得尽可能多这样不仅能让t_new满足截止时间也为后续任务留出更多空间。current_time的减少量等于t_out.duration。 因此为了最大化减少量自然应该选择S中duration最大的任务即t_out argmax_{t in S} t.duration。这就是我们算法中选择堆顶任务的原因。这个选择保证了在移除一个任务后新的时间表是“最宽松”的从而为最大化任务总数提供了可能。4.2 与类似问题的对比这道题很容易和另一个经典贪心问题“活动选择问题”Activity Selection Problem混淆。活动选择问题是给定一系列活动的开始和结束时间求能参加的不冲突活动的最大数量。它的贪心策略是按结束时间排序每次选择结束最早且不与之前选择冲突的活动。两者的核心区别在于活动选择活动的“时长”是固定的开始到结束选择了一个活动就占用了那整段时间。冲突是指时间区间重叠。本问题最大任务处理任务有“最晚开始时间”和“处理时长”但实际开始时间可以提前。冲突不是看区间重叠而是看如果按某个顺序执行是否每个任务都能在其截止时间前开始。它更像是一个带截止时间的单机调度问题。理解这个区别有助于你在遇到新题时快速定位算法模型。5. 常见错误与实战调试技巧5.1 典型错误案例排序依据错误错误做法按处理时长(duration)排序优先做短任务。反例任务列表为[{100, 90}, {5, 4}]。按短任务优先会先做{5,4}耗时4当前时间4未超时然后做{100,90}耗时90当前时间94未超时。结果是2个任务。但最优解其实是只做{100,90}这一个任务吗不这里两个都能做。但这个策略在面对更复杂的例子时会失败因为它没有考虑截止时间的紧迫性。错误做法按最晚开始时间排序但超时后丢弃的是新任务而不是已选中最长任务。这会导致无法达到最大任务数。忽略边界条件输入任务列表为空时函数应返回0。任务的最晚开始时间可能小于其处理时长这种任务本身就不可能完成因为即使在时间0开始处理完也超过了它的最晚开始时间。我们的算法能正确处理这种情况吗答案是肯定的。例如任务{2, 5}deadline2,duration5。算法会先将其加入current_time5然后发现5 2于是从堆中弹出它duration5current_time回退到0。这个任务最终不会被计入结果。这符合逻辑。current_time初始化与更新错误current_time必须初始化为0代表机器从时间0开始可用。每次加入任务时是 duration丢弃时是- duration。顺序不能错。5.2 调试与测试策略在机试或自己练习时如何快速验证代码的正确性设计小规模测试用例不要只依赖题目给的样例。用例1tasks {} 预期结果0。用例2tasks {{1, 100}} 预期结果0因为100 1无法完成。用例3tasks {{5, 1}, {5, 1}, {5, 1}} 预期结果3三个短任务都可以在时间5之前完成。用例4tasks {{2, 3}, {4, 2}, {6, 5}}。手动推导排序后为[{2,3}, {4,2}, {6,5}]。加入{2,3}: time3, heap{3}, 32? 是丢弃3time0, heap{}。加入{4,2}: time2, heap{2}, 24? 否。加入{6,5}: time7, heap{2,5}, 76? 是丢弃5time2, heap{2}。最终heap大小1。最优解是只执行{4,2}这一个任务。你可以验证其他顺序都不如这个好。使用priority_queue的调试技巧如果你想在调试时查看堆里的内容需要注意的是priority_queue没有迭代器。一个简单的调试方法是准备一个辅助向量在每次操作堆后将堆的内容复制出来打印但这会破坏堆仅用于调试。// 非生产代码仅用于调试理解过程 void debugHeap(priority_queueint heap) { // 注意这里传值不改变原堆 vectorint v; while (!heap.empty()) { v.push_back(heap.top()); heap.pop(); } cout Heap contents: ; for (int d : v) cout d ; cout endl; }对比暴力枚举结果针对极小规模n当n很小比如10时可以写一个暴力枚举所有任务子集和排列顺序的程序计算最大可完成任务数用来验证贪心算法的结果。这是验证算法正确性的终极方法。5.3 机试实战注意事项输入输出格式华为OD机试通常是标准输入输出。确保你的main函数能正确读取数据。题目可能先给一个n然后n行每行两个整数。务必处理好输入格式。int main() { int n; cin n; vectorpairint, int tasks(n); for (int i 0; i n; i) { cin tasks[i].first tasks[i].second; // 假设输入是 deadline duration } cout maxTasks(tasks) endl; return 0; }变量命名清晰在紧张的考试环境中使用deadline和duration这样清晰的变量名远比a和b有助于你理清思路避免逻辑错误。优先队列的声明记住priority_queueint默认是最大堆。如果需要最小堆应该声明为priority_queueint, vectorint, greaterint。本题我们需要的正是最大堆。时间与空间考虑本题O(n log n)的算法足够应对大数据量。如果遇到特别大的n确认没有使用O(n^2)的算法即可。这道“可以处理的最大任务”题完美地融合了排序、贪心、优先队列这三个重要的知识点。理解其背后的“按截止时间扫描”和“替换最长任务”的贪心思想不仅是为了解这一道题更是为你解决一整类调度优化问题提供了有力的工具。在平时的练习中多问几个“为什么”多构造几个边缘用例去测试你的算法思维和代码实现能力才会得到扎实的提升。