贪心法实战:如何用C++解决流水作业调度问题(附PTA真题解析)

📅 发布时间:2026/7/7 6:29:20 👁️ 浏览次数:
贪心法实战:如何用C++解决流水作业调度问题(附PTA真题解析)
贪心算法在流水线调度中的实战从Johnson法则到C高效实现流水线作业调度这个听起来颇具工业时代气息的问题实际上在计算机科学领域有着深厚的理论基础和广泛的应用场景。想象一下你面前有n个任务每个任务都需要经过两道工序——先由机器M1处理再由机器M2完成。每项任务在两台机器上的处理时间各不相同而你的目标是为这些任务找到一个最优的排列顺序使得从第一个任务在M1上启动到最后一个任务在M2上完成所花费的总时间最短。这不仅仅是工厂生产线上的优化问题更是算法设计中一个经典的调度难题。对于正在准备PTA程序设计类实验辅助教学平台考试或深入研习贪心算法的学习者而言掌握流水作业调度问题的解法具有双重价值一方面它能够帮助你理解贪心策略如何在实际约束条件下做出局部最优选择最终导向全局最优解另一方面这类问题在各类算法竞赛和面试中频繁出现是检验你算法设计与分析能力的绝佳试金石。本文将带你深入剖析Johnson法则的核心思想并用C一步步实现高效的解决方案同时结合PTA真题进行实战演练让你不仅知其然更知其所以然。1. 问题本质与Johnson法则的直觉流水作业调度问题之所以不简单根源在于两道工序间的耦合关系。一个任务在M1上完成后并不能立刻在M2上开始它必须等待M2空闲出来。而M2何时空闲又取决于前一个任务在它上面的处理时间。这种前后依赖使得简单的“先来先服务”或“最短处理时间优先”策略往往失效。贪心算法在此处的切入点是寻找一种排序规则能够巧妙地平衡两道工序的负载减少M2机器的空闲等待时间。这就是著名的Johnson法则。其核心直觉可以这样理解我们需要关注的是那些在M1上处理很快a[i]小或在M2上处理很慢b[i]大的任务。前者应尽早开始以便尽快释放M1给后续任务后者应尽早开始其在M1上的工序因为它在M2上耗时很长早点开始M1部分可以为它漫长的M2处理争取更早的启动时间。反之在M1上很慢a[i]大或在M2上很快b[i]小的任务可以适当靠后安排。Johnson法则将这种直觉形式化为一个清晰的两步排序规则将所有作业分成两个集合集合N1包含所有在M1上的处理时间小于等于在M2上处理时间的作业即a[i] b[i]。集合N2包含所有在M1上的处理时间大于在M2上处理时间的作业即a[i] b[i]。对两个集合分别排序将N1中的作业按照其在M1上的处理时间a[i]升序排列。将N2中的作业按照其在M2上的处理时间b[i]降序排列。最终的最优调度序列是N1的排序结果 N2的排序结果。为什么这样有效我们可以通过一个简单的对比来感受调度策略核心思想在流水线调度中的潜在缺陷完全按M1时间排序让M1尽快处理完所有任务可能导致一些M2处理时间极长的任务被排在后面造成M2长时间等待。完全按M2时间排序优先处理M2上耗时的任务可能让M1先处理了本身耗时很长的任务阻塞了后续任务进入流水线。Johnson法则动态平衡M1和M2的负载优先安排a[i]小的和b[i]大的。从数学上被证明对于两机器流水线调度是最优的。注意Johnson法则仅对两台机器的流水线调度问题保证最优性。当机器数量增加到三台或以上时该问题就变成了NP-hard问题通常需要借助更复杂的启发式算法或动态规划来求解。2. 算法实现的关键步骤与数据结构设计理解了Johnson法则的思想后我们需要用程序语言将其精确地表达出来。整个算法的实现可以分解为几个清晰的步骤而选择合适的数据结构是高效实现的第一步。首先我们需要一种方式来同时存储作业的原始序号、它所属的组别N1或N2以及用于排序的关键时间min(a[i], b[i])。这里定义一个结构体struct是再合适不过的选择。它就像一个“作业信息卡”方便我们进行统一处理和排序。struct Job { int id; // 作业原始编号0-based 或 1-based bool group; // true 表示属于N1组 (a b), false 表示属于N2组 (a b) int minTime; // 用于排序的时间取a和b中的较小值 // 重载小于运算符用于sort函数的升序排序 bool operator(const Job other) const { return minTime other.minTime; } };接下来算法的核心流程可以概括为以下四步数据初始化与分组读入每个作业的a[i]和b[i]并创建Job对象根据a[i]和b[i]的大小关系确定其group并计算minTime。基于minTime排序将所有Job对象按minTime升序排列。这一步是Johnson法则的体现它保证了a[i]小的N1作业和b[i]小的N2作业会排在序列的前端。构造最优序列遍历排序后的Job数组。如果作业属于N1组就将其id从最优序列的前端开始放入如果属于N2组则从其后端开始放入。这巧妙地实现了“N1升序在前N2降序在后”的拼接效果。模拟计算总时间按照生成的最优序列模拟作业在两条流水线上的加工过程计算出最终的总完工时间。第三步是算法中最精妙的部分它用一个双指针或说一前一后填充的技巧在一次遍历中完成了两个集合的归位。我们来看一下这段核心代码vectorint optimalOrder(n); // 最优调度序列 int front 0, back n - 1; // 前向指针和后向指针 for (const auto job : jobs) { if (job.group) { // N1组放前面 optimalOrder[front] job.id; } else { // N2组放后面 optimalOrder[back--] job.id; } }3. 时间计算模拟与过程可视化得到了最优调度序列我们还需要一个方法来计算按照这个顺序执行后的总时间。这个过程是一个简单的离散事件模拟但其中包含的状态转移是理解问题动态的关键。我们定义两个状态变量timeOnM1表示机器M1完成当前序列中已处理作业的累计时间。注意M1是连续工作的只要序列确定它的完成时间就是固定的累加。timeOnM2表示机器M2完成当前已处理作业的累计时间。M2的开始时间取决于两个时刻的最大值上一个作业在M2上完成的时间以及当前作业在M1上完成的时间。计算过程可以用下面的伪代码表示初始化 timeOnM1 0, timeOnM2 0 对于最优序列中的每一个作业j timeOnM1 timeOnM1 a[j] // 作业j在M1上加工 // M2开始加工j的时间是M1加工完j的时刻和M2加工完上一个作业的时刻的较晚者 timeOnM2 max(timeOnM1, timeOnM2) b[j] 最终timeOnM2的值就是总加工时间让我们用一个具体的例子来可视化这个过程。假设有三个作业加工时间分别为作业1 (a3, b2) 作业2 (a1, b4) 作业3 (a5, b3)。首先应用Johnson法则作业1: a(3) b(2) - 属于N2组作业2: a(1) b(4) - 属于N1组作业3: a(5) b(3) - 属于N2组排序键作业1 min2 作业2 min1 作业3 min3。排序后作业2 (N1), 作业1 (N2), 作业3 (N2)。构造序列N1的作业2放前面N2的作业1和作业3从后往前放。得到最优序列[2, 3, 1]。现在模拟执行序列[2, 3, 1]时间线M1 (状态)M2 (状态)说明0-1加工作业2 (a1)空闲M1开始工作。1-2空闲加工作业2 (b4)作业2在t1时离开M1立即进入M2。M1因无后续作业就绪而空闲实际上等M2释放作业3。2-6加工作业3 (a5)加工作业2 (进行中)在t2时M1开始加工作业3。M2仍在加工作业2。6-9空闲加工作业3 (b3)作业3在t6时离开M1但M2要到t6才空闲刚做完作业2所以作业3立即进入M2。9-11加工作业1 (a3)加工作业3 (进行中)在t9时M1开始加工作业1。M2仍在加工作业3。11-13空闲加工作业1 (b2)作业1在t11时离开M1M2在t11时空闲作业1立即进入M2。13-完成所有作业在M2上加工完毕。总时间 13。通过这个甘特图式的分析可以清晰地看到M2的等待时间如最初空闲的1个单位时间是如何被优化的调度顺序所减少的。4. 完整的C代码实现与PTA真题适配将上述所有部分整合我们便得到了一份完整、清晰且高效的C解决方案。这份代码严格遵循了Johnson法则并包含了健壮的输入输出处理可以直接用于解决PTA平台上的对应题目。#include iostream #include vector #include algorithm using namespace std; struct Job { int id; bool group; // true for N1 (a b), false for N2 (a b) int minTime; bool operator(const Job other) const { return minTime other.minTime; } }; int calculateTotalTime(const vectorint a, const vectorint b, const vectorint order) { int timeM1 0; int totalTime 0; for (int jobId : order) { timeM1 a[jobId]; // 作业在M1上完成 // M2的开始时间是max(M1完成该作业的时刻, M2完成上一作业的时刻) totalTime max(totalTime, timeM1) b[jobId]; } return totalTime; } int main() { int n; cin n; vectorint a(n), b(n); for (int i 0; i n; i) { cin a[i] b[i]; } // 1. 创建Job信息数组 vectorJob jobs(n); for (int i 0; i n; i) { jobs[i].id i; jobs[i].group (a[i] b[i]); jobs[i].minTime min(a[i], b[i]); } // 2. 按minTime升序排序 sort(jobs.begin(), jobs.end()); // 3. 构建最优调度序列N1组从前放N2组从后放 vectorint optimalOrder(n); int frontIdx 0, backIdx n - 1; for (const auto job : jobs) { if (job.group) { optimalOrder[frontIdx] job.id; } else { optimalOrder[backIdx--] job.id; } } // 4. 计算并输出总时间 int result calculateTotalTime(a, b, optimalOrder); cout result endl; return 0; }针对PTA平台常见的输入格式如样例145 612 24 148 7这段代码能够正确读取并处理。你可以直接复制到本地编译器或OJ平台进行测试。运行样例1程序会输出33与题目要求一致。提示在调试自己的代码时如果结果不对可以尝试打印出optimalOrder序列看看排序和分组逻辑是否正确。有时候索引从0开始还是从1开始的细微差别会导致错误。5. 算法变体、常见陷阱与性能分析掌握了基础解法后我们来看看一些可能的变化和需要注意的细节。变体需要输出作业顺序有些题目不仅要求输出最短时间还要求输出最优的作业加工顺序通常是作业编号。我们的代码已经得到了optimalOrder向量其中存储的就是作业的原始索引假设输入时作业编号隐含为1,2,3...对应索引0,1,2...。输出时只需遍历这个向量并将每个元素加1即可如果题目要求1-based编号。常见陷阱输入格式陷阱PTA题目有时会在一行内给出所有数据有时是分多行。我们的代码使用cin a[i] b[i]可以自动处理空格和换行分隔适应性较好。但务必先确认n的值是否正确读入。等号归属问题在Johnson法则分组时a[i] b[i]的作业属于N1组还是N2组根据定义a[i] b[i]属于N1组因此相等情况应归入N1组。我们的代码中(a[i] b[i])的判断正确处理了这一点。稳定排序我们使用std::sort它不一定稳定。但对于这个问题minTime相同的作业无论其相对顺序如何最终的总时间是否相同理论上对于minTime相同的作业交换它们可能不影响最终的最优性但为了严谨如果题目要求字典序最小的最优序列可能需要更复杂的比较逻辑。性能分析时间复杂度算法的瓶颈在于排序操作。std::sort的平均时间复杂度为O(n log n)。数据准备O(n)和模拟计算O(n)都是线性时间因此总时间复杂度为O(n log n)对于n达到10^5数量级的数据也完全可行。空间复杂度我们使用了额外的jobs数组O(n)和optimalOrder数组O(n)因此空间复杂度为O(n)。这是非常经济的。在实际的PTA考试或算法竞赛中遇到流水作业调度问题你可以自信地直接套用Johnson法则的模板。它思路清晰代码简洁且效率极高。理解其背后的平衡思想比死记硬背代码更有价值因为这能帮助你在遇到类似的双资源调度、任务安排问题时触类旁通设计出合理的贪心策略。