ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

贪心算法与最小堆:从接水问题到多线程调度优化

贪心算法与最小堆:从接水问题到多线程调度优化 1. 问题引入从“排队打水”到“多线程调度”如果你参加过蓝桥杯或者刷过一些算法题大概率会遇到一类关于“调度”或“资源分配”的问题。ALGO-664 接水问题就是其中非常经典的一道。它描述的场景极其生活化有n个同学排队接水只有m个水龙头可用每个同学接水需要的时间已知。问如何安排才能使所有同学都接完水的总时间最短初看之下这像是一个简单的排队问题。但当你真正动手去解尤其是用代码去模拟这个过程时你会发现它背后隐藏的模型和计算机科学中“操作系统进程调度”、“多线程任务分配”甚至“生产线流水作业”的核心思想如出一辙。它考察的绝不仅仅是循环和数组而是对“时间”和“并行”这两个概念的深刻理解。很多初学者会想当然地用一个复杂的排序加模拟结果要么逻辑漏洞百出要么代码冗长低效。今天我们就来彻底拆解这道题不仅给出能AC通过的代码更重要的是理清其背后的数学模型和贪心策略让你下次遇到同类问题能一眼看穿本质。2. 问题建模将生活场景抽象为计算模型在动手写代码前我们必须先把模糊的“接水问题”翻译成精确的、可计算的数学模型。这是解决任何算法问题的第一步也是最关键的一步。2.1 核心参数定义首先我们明确题目给出的所有条件n 接水同学的总人数。这是一个整数。m 水龙头的数量。这也是一个整数且通常m n如果水龙头比人多问题就太简单了。t[i] 一个长度为n的数组t[i]表示第i个同学接满水所需要的时间单位通常是秒或分钟。这是问题的输入数据。我们需要输出的结果是所有同学都接完水所需的最短总时间。2.2 关键约束与规则模型的核心在于规则我们必须严格定义“接水”这个过程是如何进行的并行性m个水龙头可以同时使用即同一时刻最多可以有m个同学在接水。不可抢占 一个同学一旦开始在一个水龙头接水就必须一直占用这个水龙头直到他接完期间不能换人也不能被其他同学打断。顺序性 同学们是排好队的。当某个水龙头空闲时队首的下一个同学会立刻上前使用它。这是一个“先到先服务”FCFS的队列。目标 在遵守以上所有规则的前提下找到一种安排方式使得从第一个同学开始接水时间点0到最后一个同学接完水所经过的时间最短。2.3 一个简单的思维实验为了直观理解我们举个小例子。假设n5,m2, 接水时间t [3, 5, 2, 1, 4]。一种最直观但错误的想法是让时间短的人先接。如果我们先排序[1, 2, 3, 4, 5]然后两个水龙头分配为[1, 2]-[3, 4]-[5]总时间会是1359吗不对因为水龙头是并行的。让我们用时间线来模拟正确过程按原排队顺序时间0 水龙头1分配给同学1(3)水龙头2分配给同学2(5)。时间3 同学1接完水龙头1空闲。队首下一个是同学3(2)开始接水。时间5 同学2接完水龙头2空闲。此时同学3还在接水还剩0秒不对同学3从时间3开始需要2分钟应在时间5结束。所以时间5时两个水龙头同时空闲。队首下一个是同学4(1)使用水龙头2同学5(4)使用水龙头1。时间6 同学4接完。时间9 同学5接完。所以总时间是9。这个过程中我们没有改变同学的顺序只是模拟了水龙头的占用和释放。那么有没有更优的安排如果我们能改变顺序让时间短的先接呢这就是贪心策略需要探讨的。3. 算法核心贪心策略与最小堆模拟经过分析我们会发现最优解其实蕴含着一个朴素的贪心思想总是让最先空闲出来的水龙头去服务下一个等待的同学。更进一步如果我们能优先安排接水时间短的同学是否能让水龙头更快地空闲出来从而服务更多人呢对于单个水龙头m1总时间就是所有人时间之和顺序无关。但对于多个水龙头顺序就至关重要了。3.1 贪心策略的正确性分析对于m 1的情况最优策略是将接水时间最短的 m 个人先安排到 m 个水龙头上。为什么 想象一下在开始的瞬间我们有m个资源。如果我们不把最短的m个人放上去而是放了一个时间长的人那么这个人就会长时间霸占一个资源导致其他本可以快速完成、释放资源的人等待。从全局完成时间的角度看这个“长时间任务”的结束时间会拖累整个流程的结束时间。因此在初始分配时优先分配短任务可以最小化第一批任务的完成时间从而让资源更早地被释放。接下来怎么办当第一个水龙头空闲时我们应该从剩余的人中选择下一个接水时间最短的人吗是的这就是标准的贪心选择每次总是从等待队列中选择接水时间最短的同学分配给最早空闲的水龙头。这个策略可以保证全局总时间最短其思想类似于操作系统的“短作业优先SJF”调度算法在并行环境下的应用。3.2 数据结构的选择为什么是最小堆我们如何高效地实现“每次找到最早空闲的水龙头”和“每次找到剩余人中时间最短的”这两个操作暴力模拟 用一个数组finish_time[m]记录每个水龙头当前的“任务结束时间”。每一轮扫描整个数组找到最小值最早空闲的水龙头然后再扫描剩余同学找到时间最小值。时间复杂度是 O(n * m)在 n 和 m 较大时比如都是10^5会超时。高效算法 我们需要一种能快速获取最小值、并插入新值的数据结构。最小堆优先队列完美符合要求。我们可以维护一个大小为m的最小堆堆中的每个元素代表一个水龙头下一次空闲的时间点。初始时堆中有m个0表示所有水龙头在时间0都空闲。然后我们将n个同学的接水时间按从小到大排序。遍历排序后的同学时间time从堆中弹出最小值current_time这就是当前最早空闲的水龙头。这个水龙头开始为当前同学接水它的新空闲时间变为current_time time。将current_time time这个新时间点压回堆中。遍历完成后堆中最大的那个时间点或者最后弹出的那个时间点就是所有同学都接完水的总时间。这个算法的时间复杂度是 O(n log m)主要消耗在堆的pop和push操作上远比暴力法高效。3.3 算法步骤详解与手动演算让我们用之前的例子t [3, 5, 2, 1, 4],m2来手动演算这个堆算法。初始化 将接水时间排序[1, 2, 3, 4, 5]。初始化一个最小堆heap [0, 0]两个水龙头初始空闲时间为0。处理同学1时间1弹出堆顶current_time 0水龙头A空闲。该水龙头新空闲时间 0 1 1。将1压入堆。堆变为[0, 1]水龙头B空闲时间为0水龙头A变为1。处理同学2时间2弹出堆顶current_time 0水龙头B空闲。新空闲时间 0 2 2。将2压入堆。堆变为[1, 2]。处理同学3时间3弹出堆顶current_time 1水龙头A在时间1空闲。新空闲时间 1 3 4。将4压入堆。堆变为[2, 4]。处理同学4时间4弹出堆顶current_time 2水龙头B在时间2空闲。新空闲时间 2 4 6。将6压入堆。堆变为[4, 6]。处理同学5时间5弹出堆顶current_time 4水龙头A在时间4空闲。新空闲时间 4 5 9。将9压入堆。堆变为[6, 9]。所有同学处理完毕。堆中最大的时间是9这就是总耗时。这个结果和我们之前按顺序模拟的结果一致但注意我们的输入顺序是[3,5,2,1,4]排序后变成了[1,2,3,4,5]。算法实际上改变了服务的顺序但这个顺序是在全局贪心策略下最优的。你可以尝试其他顺序总时间都不会短于9。4. 代码实现C语言版本详解理解了算法代码实现就清晰了。C语言标准库没有内置堆我们需要自己实现或者巧妙利用数组和排序来模拟。这里给出两种常见的实现方法。4.1 方法一排序后模拟水龙头队列直观法这种方法不显式建堆而是利用了“每次找最早空闲的水龙头”等价于“维护一个有序的水龙头空闲时间列表”。#include stdio.h #include stdlib.h // 比较函数用于qsort排序升序 int compare(const void *a, const void *b) { return (*(int*)a - *(int*)b); } int main() { int n, m; scanf(%d %d, n, m); int *times (int*)malloc(n * sizeof(int)); for(int i 0; i n; i) { scanf(%d, ×[i]); } // 关键步骤1将接水时间按升序排序贪心基础 qsort(times, n, sizeof(int), compare); // 如果水龙头数多于人数总时间就是最长的个人时间 if(m n) { int max 0; for(int i 0; i n; i) { if(times[i] max) max times[i]; } printf(%d\n, max); free(times); return 0; } // 初始化水龙头队列前m个同学先接水每个水龙头的空闲时间就是该同学的接水时间 int *faucets (int*)malloc(m * sizeof(int)); for(int i 0; i m; i) { faucets[i] times[i]; } // 关键步骤2模拟剩余同学接水 for(int i m; i n; i) { // 找到当前最早空闲的水龙头即faucets数组中的最小值 int min_index 0; for(int j 1; j m; j) { if(faucets[j] faucets[min_index]) { min_index j; } } // 该水龙头为下一个同学服务其新的空闲时间增加 faucets[min_index] times[i]; } // 关键步骤3所有水龙头都空闲时最晚的那个时间就是总时间 int total_time 0; for(int i 0; i m; i) { if(faucets[i] total_time) { total_time faucets[i]; } } printf(%d\n, total_time); free(times); free(faucets); return 0; }代码解读与注意事项排序qsort(times, n, sizeof(int), compare);这行是贪心策略的体现。必须排序。边界处理if(m n)的情况必须单独处理。否则在初始化faucets数组时会访问times越界。模拟核心for(int i m; i n; i)这个循环处理第m个及之后的同学。每次循环都需要扫描faucets数组找到最小值最早空闲的水龙头。这是一个O(m)的操作嵌套在O(n)的循环里总复杂度是O(n*m)。在蓝桥杯OJ上如果n和m不超过10^4这个方法通常可以AC。但如果数据量更大就需要下面更高效的方法。结果计算 最后遍历faucets数组找最大值即为总耗时。4.2 方法二使用最小堆优先队列高效实现为了应对更大数据量我们需要实现一个最小堆。这里给出一个简易版的数组实现。#include stdio.h #include stdlib.h // 最小堆结构体 typedef struct { int *data; // 堆数组 int size; // 当前堆大小 int capacity; // 堆容量 } MinHeap; // 交换两个整数 void swap(int *a, int *b) { int temp *a; *a *b; *b temp; } // 上浮调整用于插入 void heapify_up(MinHeap *heap, int index) { while (index 0) { int parent (index - 1) / 2; if (heap-data[parent] heap-data[index]) break; swap(heap-data[parent], heap-data[index]); index parent; } } // 下沉调整用于删除堆顶 void heapify_down(MinHeap *heap, int index) { int smallest index; int left 2 * index 1; int right 2 * index 2; if (left heap-size heap-data[left] heap-data[smallest]) smallest left; if (right heap-size heap-data[right] heap-data[smallest]) smallest right; if (smallest ! index) { swap(heap-data[index], heap-data[smallest]); heapify_down(heap, smallest); } } // 初始化堆 MinHeap* create_heap(int capacity) { MinHeap *heap (MinHeap*)malloc(sizeof(MinHeap)); heap-data (int*)malloc(capacity * sizeof(int)); heap-size 0; heap-capacity capacity; return heap; } // 插入元素 void heap_push(MinHeap *heap, int value) { if (heap-size heap-capacity) return; // 简单处理实际可扩容 heap-data[heap-size] value; heapify_up(heap, heap-size); heap-size; } // 弹出堆顶元素 int heap_pop(MinHeap *heap) { if (heap-size 0) return -1; // 错误处理 int top heap-data[0]; heap-data[0] heap-data[heap-size - 1]; heap-size--; heapify_down(heap, 0); return top; } // 获取堆顶元素不弹出 int heap_peek(MinHeap *heap) { if (heap-size 0) return -1; return heap-data[0]; } // 比较函数用于qsort int compare(const void *a, const void *b) { return (*(int*)a - *(int*)b); } int main() { int n, m; scanf(%d %d, n, m); int *times (int*)malloc(n * sizeof(int)); for(int i 0; i n; i) { scanf(%d, ×[i]); } // 排序 qsort(times, n, sizeof(int), compare); // 创建最小堆容量设为m MinHeap *heap create_heap(m); // 初始化堆将m个0表示水龙头初始空闲入堆 // 注意如果mn我们只需要初始化n个水龙头有效 int init_size (m n) ? m : n; for(int i 0; i init_size; i) { heap_push(heap, 0); } // 模拟接水过程 for(int i 0; i n; i) { // 取出最早空闲的水龙头时间 int earliest heap_pop(heap); // 该水龙头服务当前同学更新其空闲时间 int new_finish earliest times[i]; // 将新时间放回堆中 heap_push(heap, new_finish); } // 找出堆中最大的时间即为总时间 // 简单方法不断弹出直到最后一个 int total_time 0; while (heap-size 0) { total_time heap_pop(heap); } // 或者在模拟过程中记录一个最大值变量每次push后比较 printf(%d\n, total_time); // 释放内存 free(times); free(heap-data); free(heap); return 0; }代码解读与对比堆的实现 我们手动实现了最小堆的基本操作push,pop,peek。核心是heapify_up插入时从下往上调整和heapify_down删除堆顶时从上往下调整。算法流程 与章节3.3的描述完全一致。初始化堆为m个0然后遍历排序后的时间列表每次弹出堆顶最早空闲时间加上当前任务时间再压回堆中。效率 每次pop和push的复杂度是 O(log m)遍历n次总复杂度 O(n log m)。即使 n 和 m 达到 10^5 级别也游刃有余。边界处理init_size (m n) ? m : n;这行代码优雅地处理了m n的情况。如果水龙头多我们只需要初始化和人数一样多的“虚拟水龙头”即可。注意在竞赛中如果使用C可以直接使用priority_queue代码会简洁很多。但蓝桥杯的ALGO系列有时限制语言为C因此掌握C语言的手动实现是必要的。5. 常见错误与调试技巧即便理解了算法实现时也容易掉进一些坑。这里总结几个常见的错误点和调试方法。5.1 错误1未对接水时间排序这是最致命的错误。如果直接按输入顺序分配得到的结果只是“按排队顺序接水的时间”而不是“最短总时间”。例如输入[5, 1, 2],m2不排序的结果是时间0 水龙头1接5水龙头2接1。时间1 水龙头2空闲接2。总时间 max(5, 12) 5。 而排序后[1, 2, 5]时间0 水龙头1接1水龙头2接2。时间1 水龙头1空闲接5。总时间 max(15, 2) 6等等这里计算有误。按堆算法初始堆[0,0] - 处理1 - 堆[0,1] - 处理2 - 堆[1,2] - 处理5 - 弹出1 - 压入6 - 堆[2,6] - 总时间6。 实际上不排序的5反而是更优的让我们仔细分析场景两个水龙头三个人时间5,1,2。不排序顺序分配 A(5)和B(1)先开始。时间1B完成C(2)开始。时间3C完成。时间5A完成。总时间为5。排序后短优先 B(1)和C(2)先开始。时间1B完成A(5)开始。时间3C完成。时间6A完成。总时间为6。啊哈这是一个非常重要的反直觉点对于m2任务[5,1,2]最优解并不是让最短的1和2先做。因为如果让5和1先做5虽然长但它和1并行在时间1时1做完2可以立刻开始最终在时间5结束。如果让1和2先做时间1时空闲一个水龙头立刻开始做5但5需要做到时间6。这说明我们之前的贪心策略叙述不够精确。正确的贪心策略是每次总是将当前任务分配给“目前累计负载最小”的水龙头即最早空闲的水龙头而任务的顺序先分配哪个任务会影响结果吗对于给定的任务顺序我们通过“每次找最早空闲的水龙头”来分配得到的是一个确定的总时间。那么为了最小化这个总时间我们应该以何种顺序来分配任务呢这实际上等价于一个离线调度问题。对于“接水问题”或“多机调度问题”Minimum Makespan当m2且任务可任意排序时找到最优解是一个NP难问题。但是题目ALGO-664通常的数据规模下使用“按时间从大到小排序然后每次分配给当前总时间最小的水龙头”这个贪心策略可以得到一个非常近似最优的解这是一个经典的多机调度近似算法称为LPT - Longest Processing Time first。而在蓝桥杯的评测数据中这个策略往往能通过。核心修正 对于追求绝对最优解的“多机调度”是NP难的。但在算法竞赛中ALGO-664接水问题通常的设定是同学顺序不可改变即必须按照输入的顺序依次接水不能重新排序。这时我们的策略就是简单的“哪个水龙头先空下一个同学就上”。这种情况下排序就是错误的我们必须严格按照输入顺序处理。让我们重新审视题目描述虽然正文未提供但根据蓝桥杯常规“第i个同学接水需ti秒且同学们按顺序接水”。这意味着顺序是固定的。那么正确的算法是不需要对t[i]排序。维护一个大小为m的最小堆初始为m个0。依次遍历t[0]到t[n-1]原始顺序earliest pop(heap)push(heap, earliest t[i])堆中最大值即为答案。用这个算法再算[5,1,2],m2堆[0,0] - 处理5 - 堆[0,5] - 处理1 - 弹出0压入1 - 堆[1,5] - 处理2 - 弹出1压入3 - 堆[3,5] - 总时间5。正确。所以务必仔细读题如果题目说“按顺序”就不能排序。如果题目说“可以任意安排顺序”则可以用LPT策略从大到小排序。ALGO-664在蓝桥杯练习系统中通常是“按顺序”的版本。这是一个极易混淆的点也是很多同学失分的地方。5.2 错误2忽略 m n 的边界情况如果水龙头数量多于人数那么总时间应该是所有人中接水时间最长的那个而不是所有时间之和。因为所有人在时间0可以同时开始接水。处理方法很简单在初始化或计算前加一个判断if(m n) { int max_time 0; for(int i 0; i n; i) { if(times[i] max_time) max_time times[i]; } printf(%d\n, max_time); return 0; // 或 continue }5.3 错误3堆实现中的索引错误自己实现堆时容易在计算父节点、子节点索引时出错。记住对于下标从0开始的数组节点i的父节点下标(i - 1) / 2左孩子下标2*i 1右孩子下标2*i 2heapify_down时要比较左右孩子和当前节点三者的大小找到最小的进行交换。5.4 调试技巧小数据手工模拟 像我们上面做的那样用纸笔或注释一步步跟踪程序状态堆的内容、循环变量、中间结果与手工计算对比。打印中间变量 在关键步骤后如每次pop/push后打印出堆的当前状态观察其变化是否符合预期。测试边界数据n1, m1 时间5。 答案应为5。n5, m1 时间任意。 答案应为所有时间之和。n5, m5(或 mn) 时间任意。 答案应为最大时间。n3, m2 时间[3, 3, 3]。 答案应为6顺序分配3,3先做时间3空一个做第三个3总时间6。使用在线OJ的样例 蓝桥杯题目通常会提供样例输入输出。确保你的程序能通过样例。6. 举一反三同类问题与扩展思考掌握了“接水问题”你就掌握了“多资源顺序调度”的一类基础模型。下面看看它可以如何变种和扩展。6.1 变种1任务可并行且可抢占如果任务可以拆分比如接水接到一半可以让给另一个人或者水龙头可以随意开关问题就变成了更简单的“总工作量除以资源数”的上取整问题。但“接水问题”的核心在于任务不可拆分、不可抢占。6.2 变种2每个水龙头效率不同如果每个水龙头单位时间出水量不同即第j个水龙头为同学i服务所需时间为t[i] / speed[j]。这时我们的堆元素就不再是简单的“空闲时间点”而是(当前水龙头空闲时间点, 水龙头效率)。分配策略会变得更复杂可能需要动态规划或更复杂的贪心。6.3 变种3求所有同学的平均等待时间最短“接水问题”目标是总完工时间最短Makespan。如果目标是所有同学的等待时间之和最短包括接水时间那么策略就完全不同了。对于单水龙头按时间升序排列可使平均等待时间最短。对于多水龙头则是一个更复杂的调度问题。6.4 扩展思考从“接水”到“线程池”这在编程中非常实用。想象一个服务器有m个线程水龙头来了n个网络请求同学每个请求处理时间为t[i]。线程池的任务调度器正是要解决这个问题如何分配任务给线程使得所有请求被处理完的总时间最短通常现代线程池如Java的ThreadPoolExecutor采用一个任务队列排队的学生线程空闲时就从队列头部取任务执行FCFS。这正好对应了我们“顺序接水”的模型。如果你自己实现一个简单的线程池这个模拟算法就是其核心调度逻辑的简化版。理解了这个模型你就能更好地配置和使用线程池参数如核心线程数、最大线程数、队列容量因为你可以预估在给定任务量和处理时间分布下系统的最大吞吐量或最短响应时间。
返回列表