1. 接水问题到底在问什么:先把题面翻译成人话
接水问题和贪心算法这两个词凑在一起,是很多人学算法时第一次真正体会"局部最优推导全局最优"的场景。题面通常长这样:一个水龙头前有 n 个人排队,第 i 个人接满自己那桶水需要 t[i] 秒,问怎么安排接水顺序,能让所有人的平均等待时间最短。看起来是个生活问题,实际上它是一道结构非常干净的最优化问题,干净到你可以用一张纸把最优解证明出来,同时它又足够典型,典型到大量后续的调度类题目都能套用同一个分析框架。
我第一次做这道题的时候,本能地觉得"先来后到"最公平,按输入顺序排下去就行。结果一算总等待时间,发现把接水快的人放到前面,总时间能直接砍掉一大截。这个直觉上的落差,就是这道题最有价值的地方——它逼着你去追问一句话:凭什么?
这道题适合三类人看。第一类是刚接触贪心算法、想做第一道有完整证明的例题的人;第二类是在准备笔试面试、被"短作业优先"这个结论砸过但没真正理解的人;第三类是写业务代码时经常要处理排队、任务调度、线程池分配,想搞清楚背后分配逻辑的人。如果你属于其中任何一类,下面这些内容都值得从头看到尾。
另外要提前说清楚一件事:标题里的"接水问题"在实际语境中至少有三个不同变体,它们的解法完全不同,混淆了就会写出看似正确但实际错误的代码。我在后面会用一整节把这三个变体拆开讲,这也是很多人做题时最容易踩的坑。
1.1 三个变体:别把三道题当成一道题
最常见的变体是单水龙头 + 顺序可自由安排,目标是最小化所有人的总等待时间(或平均等待时间)。这一版就是纯粹的排序贪心,把接水时间从小到大排一遍,答案就出来了。它的难点不在代码,在于证明。
第二个变体是多水龙头 + 顺序固定不可更改,目标是最小化全部接完所需的总时间。这一版经常出现在信息学竞赛里,队伍顺序是输入给定的,你只能老老实实模拟,谁接完谁走,下一个人补上。它是模拟题,不是排序题,你就算把数组排了序也是错的,因为顺序不允许你改。
第三个变体是多水龙头 + 顺序可自由安排,目标是最小化最后一个接完水的人的时间(也就是最大完成时间)。这一版一旦放开顺序,问题立刻从多项式时间跳到 NP-hard,除了暴力枚举没有精确解法,工程上只能用 LPT 这类近似算法。很多人没意识到这一点,傻乎乎地去写排序贪心,结果样例过不了还找不到原因。
提示:拿到任何一道"接水/排队"类题目,先问自己三个问题——水龙头几个?顺序能不能改?优化目标是最小化总和还是最小化最大值?这三个答案决定了你写的是排序、模拟还是近似算法。
1.2 为什么这道题值得反复琢磨
理由很简单,因为它是"贪心算法有效性"这个抽象命题的最小可验证样本。你在这道题上学到的交换论证法,后面可以用在任务调度、磁盘寻道、缓存淘汰、压缩编码等一大堆场景里。你在这道题上咂摸出来的"为什么排序就对了",本质上是在训练一种能力:看到一个问题,能判断它的贪心结构是否成立,而不是靠感觉去猜。
而且这道题的边界情况特别多。接水时间为 0 的人怎么处理?数据规模大到什么程度会溢出?排序稳定性会影响结果吗?多水龙头下如果有人接水时间特别长会怎样?这些问题在写完第一版代码之后会一个个冒出来,每一个都能延伸出一小段值得记录的调试经验。
2. 贪心思路的来历:短作业优先为什么是对的
先说结论:让接水时间短的人先接,总等待时间最小。这个策略在操作系统调度里叫 SJF(Shortest Job First,短作业优先),在排队论里有对应的理论支撑。但结论本身没有意义,真正有意义的是你怎么让别人相信它是对的。算法面试里如果只写一句"显然接水快的先上",大概率会被追问到答不上来。
2.1 交换论证法:把证明写在纸上
假设我们已经有了一个最优的接水顺序,记作序列 a[1], a[2], ..., a[n]。现在来看序列里任意一对相邻的人,设他们分别是第 k 个和第 k+1 个,接水时间分别为 x 和 y,并且假设 x > y,也就是出现了"慢的人排在快的人前面"这种逆序情况。
设排在他们前面所有人的接水时间总和为 S。那么在交换之前,这两个人的等待时间分别是 S 和 S + x,两个人加起来的等待时间贡献是 2S + x。
现在把这两个人交换一下位置,其他人都不动。交换之后,原位置 k 上的人变成接水时间为 y 的那位,原位置 k+1 上的人变成接水时间为 x 的那位。此时两人的等待时间分别是 S 和 S + y,加起来是 2S + y。
因为 x > y,所以 2S + y < 2S + x。也就是说,只要序列里存在一对相邻的逆序对,把它们交换就能让总等待时间变小。这个结论和"当前序列已经是最优"矛盾。因此,最优序列里不可能存在任何逆序对,也就是说最优序列一定是按接水时间非递减排列的。
整个证明只用了两步:构造一个相邻交换、比较交换前后的代价。这个方法就是交换论证法,是贪心算法证明里最常用也最扎实的一套工具。我强烈建议你把这段推导亲手写一遍,比看十遍讲解管用得多。
2.2 另一种视角:重排不等式
如果你学过一点竞赛数学,还有一个更简洁的证明角度。设最终排序后的接水时间序列为 t[1] ≤ t[2] ≤ ... ≤ t[n]。每个人的等待时间等于他前面所有人的接水时间之和,所以总等待时间可以写成:
总等待时间 = (n-1)·t[1] + (n-2)·t[2] + ... + 1·t[n-1] + 0·t[n]
仔细观察这个式子:它是一组"时间 × 系数"的和,系数分别是 n-1, n-2, ..., 0,是一个严格递减的正数序列。而根据重排不等式,当两个序列一个递增、一个递减时,它们的乘积和取到最小值。系数序列递减,所以时间序列递增时,乘积和最小。
这个视角比交换论证更"一眼看出答案",但它的前提是你得先想到把总等待时间展开成这个形式。第一次做这道题的人通常想不到,我是做完之后回头复盘才发现的。
从这两个证明里能提炼出一个通用判据:当目标函数可以写成"某组数据的加权和,且权重随位置单调变化"时,通常就可以用排序贪心,把数据按和权重相反的方向排。
2.3 一个容易被忽略的细节:总和与平均值等价吗
有人会问:题目要的是平均等待时间,我算的是总等待时间,这两个能一样吗?答案是能,因为平均等待时间等于总等待时间除以人数 n,而 n 是固定的常数,所以最小化总等待时间和最小化平均等待时间是同一件事。这一点在数学上是平凡的,但在写代码的时候会影响你的取整策略,我们后面会专门讲。
还有一个细节:如果目标改成"最小化最后一个人接完的时刻",那答案就变了。因为所有人的接完时刻等于"当前人的等待时间 + 自己的接水时间",总和是总等待时间加上所有人的接水时间之和,后者是固定值。也就是说最小化总等待时间和最小化最后完成时刻,在单水龙头场景下其实是等价的。这个等价关系在多水龙头场景下会失效,这是后话。
3. 手把手实现:从暴力到排序加前缀和
理论讲完了,落到代码上就三行核心逻辑。但我想带你走一遍完整的思路演化过程,因为从暴力到优化的这一步,比背下最终代码重要得多。
3.1 数据建模:把现实问题变成数组
第一步永远是建模。把 n 个人抽象成一个整数数组 t,t[i] 表示第 i 个人接水需要多少秒。等待时间的定义是"从开始排队到轮到自己接水"这段时间,所以如果一个人排在第一个,他的等待时间是 0;排在第二个,等待时间就是第一个人的接水时间;依此类推。
这里有个极易搞错的点:有些题目定义的"等待时间"包含自己接水的时间(也就是"完成时间"),有些题目定义的是纯粹的"排队等待时间"。这两者相差一个常量总和,最小化顺序是一样的,但输出结果不同。我做题时栽过一次,写完一版答案发现和样例差了一个定值,查了半天才发现是定义理解错了。
提示:把等待时间和完成时间分开记。等待时间不包含自己接水那段,完成时间包含。改题的时候先确认题目用的是哪一个。
3.2 暴力版本:先写对,再写快
最直白的做法是枚举所有排列,算每种排列的总等待时间,取最小值。n 个人有 n! 种排列,n 稍微大一点就炸了。但这个版本的价值在于它是"正确答案的参照物",可以用它来验证你的贪心解在小数据上是否一致。
from itertools import permutations def brute_force(times): best = float('inf') for perm in permutations(times): prefix = 0 total = 0 for t in perm: total += prefix prefix += t best = min(best, total) return best写完之后你可以自己造几组小数据,把暴力解和贪心的结果对拍,如果完全一致,说明你的贪心策略至少在随机数据上是靠谱的。这个"对拍"习惯我从刚开始学算法一直保留到现在,它能帮你省下大量盲猜的时间。
3.3 核心实现:排序加前缀和,O(n log n)
def min_total_wait(times): times.sort() # 升序,接水快的排前面 total = 0 prefix = 0 for t in times: total += prefix # 这个人前面所有人接水时间之和,就是他的等待时间 prefix += t # 更新前缀和 return total这个过程就是一次线性扫描,边走边累加前缀和。为什么用前缀和而不是每次重新求和?因为重新求和会让复杂度退化到 O(n²)。当 n 是一万的时候,O(n²) 是一亿次操作,勉强能跑;当 n 是十万的时候就是一百亿次,直接超时。前缀和把这个内层循环压掉了,整体复杂度由排序决定,是 O(n log n)。
C++ 版本写出来是这样的:
#include <algorithm> #include <vector> using namespace std; long long minTotalWait(vector<int>& t) { sort(t.begin(), t.end()); long long prefix = 0, total = 0; for (int x : t) { total += prefix; prefix += x; } return total; }注意这里的 total 和 prefix 都用了 long long。原因很简单:当 n = 10^5、每个人的接水时间是 10^4 时,总等待时间的量级大约是 n² × t / 2 = 5×10^13,这已经远超 int 的表示范围了。用 int 接结果会直接溢出成负数,而且不会报错,你会得到一个莫名其妙的答案然后怀疑人生。我自己在这类题上被坑过至少三次。
3.4 多水龙头版本:优先队列模拟
现在换成 m 个水龙头,队伍顺序固定不能改。这时候思路就完全变了,不再是排序,而是模拟:前 m 个人各自占一个水龙头开始接水,剩下的人在后面等着。每当有一个水龙头空出来,队列里最前面的人就走过去接。问所有人接完是第几秒。
这个场景天然适配小顶堆:堆里存每个水龙头"当前任务完成"的时刻,每次取出最早完成的那个,把下一个人接到它后面。
import heapq def finish_time(times, m): n = len(times) if n == 0: return 0 if m == 0: raise ValueError("水龙头数量必须大于 0") m = min(m, n) heap = times[:m] heapq.heapify(heap) for t in times[m:]: earliest = heapq.heappop(heap) heapq.heappush(heap, earliest + t) return max(heap)这里的逻辑是:堆顶是"最早空出来的水龙头",它的完成时刻是 earliest。新来的人从 earliest 这个时刻开始接水,所以他的完成时刻是 earliest + t。把他压回堆里,继续等下一次有人结束。
C++ 里用priority_queue要记得它是默认大顶堆,得加greater<int>才是小顶堆:
#include <queue> #include <vector> #include <algorithm> using namespace std; int finishTime(vector<int>& t, int m) { int n = t.size(); if (n == 0) return 0; priority_queue<int, vector<int>, greater<int>> pq; m = min(m, n); for (int i = 0; i < m; ++i) pq.push(t[i]); for (int i = m; i < n; ++i) { int cur = pq.top(); pq.pop(); pq.push(cur + t[i]); } int ans = 0; while (!pq.empty()) { ans = max(ans, pq.top()); pq.pop(); } return ans; }一个小细节:min(m, n)这一步不能省。如果水龙头数量比人数还多,直接把所有人塞进堆里然后取最大值就行,前面那个循环根本不会执行。如果不做这个限制,times[:m]在 Python 里不会报错(切片越界会截断),但在 C++ 里用下标访问就会越界,属于典型的"Python 能过、C++ 崩掉"的坑。
4. 参数选择与规模账本:什么数据量配什么算法
写算法题最容易犯的错不是想不出思路,而是想对了思路但选错了数据结构或者数据类型,导致在大数据上崩掉。这一节专门算这笔账。
4.1 数据规模和算法的对应关系
下面这张表是我自己总结的速查表,遇到题目先看数据范围再决定写什么。
| 数据规模 n | 可选算法 | 复杂度 | 典型场景 |
|---|---|---|---|
| n ≤ 8 | 全排列暴力 | O(n!) | 对拍验证、教学演示 |
| n ≤ 5000 | 冒泡排序 + 二维求和 | O(n²) | 早期 OJ 老题 |
| n ≤ 10^5 | 快排 + 前缀和 | O(n log n) | 主流在线评测规模 |
| n ≤ 10^6 | 快排 + 前缀和 + 快速 IO | O(n log n) | 需要关闭流同步 |
| 多水龙头且顺序固定 | 小顶堆模拟 | O(n log m) | 排队调度类模拟题 |
| 多水龙头且顺序自由 | LPT 近似 | O(n log n + n log m) | 负载均衡,只能近似 |
这里特别要说明最后一行。如果允许自由安排顺序,同时有多个水龙头,目标是最小化最后一个完成的时间,这个问题在理论上是 NP-hard 的,没有任何多项式时间的精确算法(除非某些复杂度猜想被推翻)。工程上的做法是用 LPT:按接水时间从大到小排序,每次把当前这个人分配给当前总负载最小的水龙头。这个策略的近似比有明确上界,最坏情况不会超过最优解的 4/3 倍减去一个和机器数相关的小量。
import heapq def lpt_makespan(times, m): heap = [(0, i) for i in range(m)] heapq.heapify(heap) for t in sorted(times, reverse=True): load, idx = heapq.heappop(heap) heapq.heappush(heap, (load + t, idx)) return max(load for load, _ in heap)先处理大的任务,是因为大任务一旦放晚了,很难找到足够长的空闲窗口去容纳它,最后会导致某个水龙头特别闲而另一个特别忙。这个"大块先安排"的直觉,在装箱问题、内存分配、批处理调度里都通用。
4.2 数据类型的账要提前算
再强调一次溢出问题,因为它太常见了。判断是否需要 long long 的方法很简单:估算最大可能值。总等待时间的上界大约是 n² × maxT / 2。取 n = 10^5,maxT = 10^4,得到 5×10^13,而 int 的常见上限是 2.1×10^9,差了四个数量级。
所以在写这类题的时候,我的习惯是一律用 64 位整数接总和,只有在明确知道数据很小时才用 int。多占那点内存完全不心疼,但少一次溢出调试能省半小时。
还有一些题会要求输出平均值并保留小数。这时候要注意浮点精度:先算整数总和再除以 n,比一边加一边除要稳。Python 里直接用/得到 float,C++ 里要记得把其中一个操作数转成 double,否则会做整数除法。
5. 贪心的通用判别法:从接水问题到跳跃游戏
接水问题只是贪心算法的一个入口。真正让能力上台阶的,是你能从这一道题里提炼出一套判断"什么时候能贪"的方法,并且能横向对比不同贪心题之间结构上的差异。
5.1 跳跃游戏:另一种完全不同的贪心结构
拿"跳跃游戏 II"来说,题目给定一个非负整数数组 nums,nums[i] 表示从位置 i 最多能往前跳多少步,问从下标 0 跳到最后一个下标最少需要几步。这道题的贪心和接水问题看上去毫无关系,但它的解法同样是贪心,只是结构完全不同。
做法是维护三个变量:当前这一步能覆盖的最远边界 end、在走到 end 之前扫描到的能跳最远的下一站 farthest、已经跳的步数 steps。
def jump(nums): n = len(nums) if n <= 1: return 0 steps = 0 end = 0 farthest = 0 for i in range(n - 1): farthest = max(farthest, i + nums[i]) if i == end: # 已经走到当前这一步的边界,必须再跳一次 steps += 1 end = farthest return steps这道题的贪心逻辑是"在必须跳的时候,跳到能覆盖最远的那个位置"。它和接水问题的共同点是都做了"局部最优选择",但局部最优的定义完全不同:接水问题是"选当前最小的元素放前面",跳跃游戏是"在当前可达区间内选能让边界推最远的落点"。
5.2 两类贪心的结构差异
我把这两个放在一起对比,是因为它们代表了贪心算法的两种典型形态。
接水问题属于排序型贪心:候选集合是固定的,你要做的是决定处理顺序。这类问题的通用套路是先假设一个顺序,用交换论证证明"任何逆序对都可以消掉",然后直接排序。同类型的还有任务调度、区间覆盖、最小生成树的 Kruskal 算法。
跳跃游戏属于扩张型贪心:你从一个起点出发,每一步要决定往哪走,目标是在最少步数内覆盖到终点。这类问题的通用套路是维护一个"当前可达范围",在范围内扫描出"下一步可达的更大范围",一旦当前范围走完就推进一步。同类型的还有视频拼接、区间跳跃、最远可达距离。
两者都能用"局部最优推出全局最优",但证明手法不同。排序型靠交换论证,扩张型靠"不可能用更少步数覆盖更多距离"的反证。做题的时候先识别它属于哪一型,再去想对应的套路,效率会高很多。
5.3 什么时候贪心会失效
贪心失效的经典反例是背包问题。假设有一个容量为 10 的背包,物品分别是重量 6 价值 6、重量 5 价值 5、重量 5 价值 5。如果按单位价值贪心,第一个物品的单位价值最高,先拿它,剩下容量 4,其他两个都放不进去,总价值 6。但如果拿后两个,总价值 10。贪心输了。
判断的关键在于:贪心选择之后,剩余子问题是否还是同一个问题的最优子结构。接水问题里,选完第一个人之后,剩下的 n-1 个人仍然构成一个同类型的接水问题,只是人数少了一个,所以贪心安全。背包问题里,选完第一个物品之后,剩下的容量和物品组合变成了一个不同结构的子问题,贪心就不安全了。
这个判据不是万能的,但足以让你在面对新问题时先有个方向。真正严格的判断还是得靠交换论证或者反例。
6. 常见问题与排查技巧实录
理论讲完,来点实战。下面这些问题都是我在做题和实际业务代码里真实踩过的坑。
6.1 排序方向搞反
这是最高频的错误。接水问题是升序排,让快的先上;但如果你把优化目标改成"最小化最大完成时间"(在单水龙头下等价,多水龙头下不等价),或者改成其他目标,排序方向可能会反过来。我在一处批处理任务调度的代码里就抄错了方向,把耗时最长的任务排在前面,结果整批任务的平均完成时间涨了将近一倍,还好上线前做了压测。
排查方法:拿两三个人的最小用例手算一遍。三个人接水时间分别是 1、2、3。升序排是 1、2、3,等待时间是 0+1+3=4。降序排是 3、2、1,等待时间是 0+3+5=8。差一倍,一眼能看出哪个对。
6.2 等待时间到底算谁
前面提过一次,这里再展开。定义一:等待时间 = 从开始排队到开始接水。定义二:等待时间 = 从开始排队到接完水(也就是完成时间)。这两种定义算出来的顺序一样,但数值差一个人均接水时间。
还有一种更绕的定义:题目问的是"平均等待时间"还是"等待时间之和"?如果是平均值,要注意输出格式,比如保留两位小数。Python 的round是银行家舍入,某些题目会卡精度,稳妥做法是用格式化字符串f"{x:.2f}"。
6.3 排序稳定性会不会影响结果
对于纯数值数组,排序稳定性不影响总等待时间,因为接水时间相同的人互相交换位置不改变任何东西。但如果题目里每个人还带了编号,要求输出的是具体的排队顺序,那就要注意了:时间相同的人之间,通常要求按原始编号从小到大排,这时候就需要自定义比较器。
people = [(times[i], i) for i in range(n)] people.sort(key=lambda x: (x[0], x[1]))Python 的sort是稳定排序,所以sort(key=lambda x: x[0])也能保证相同时间的人保持原顺序。但显式写出第二个键更清晰,也更不容易在换语言时出问题。C++ 的std::sort不稳定,std::stable_sort稳定,如果题目要求输出顺序,必须用后者或者在比较函数里加上编号。
6.4 多水龙头模拟的三个边界
第一个边界是水龙头数量大于人数。这时候每个人都能立刻开始接水,答案就是所有接水时间的最大值。代码里不做min(m, n)处理,C++ 会数组越界。
第二个边界是接水时间为 0 的人。这在现实中没什么意义,但在题目里可能出现。时间 0 的人不会占用任何时间,堆里会出现多个相同值,逻辑上没问题,但如果你手写模拟循环而没有用堆,可能会陷入"这个人一直没结束"的死循环。
第三个边界是只有一个人或者一个水龙头。单水龙头模拟时,堆里永远只有一个元素,答案就是所有人的接水时间之和。这个情况用贪心排序的答案也一样,可以互相验证。
6.5 常见问题速查表
| 现象 | 可能原因 | 排查方向 |
|---|---|---|
| 样例过、大数据错 | 整数溢出 | 检查总和变量是否为 64 位 |
| 答案差一个定值 | 等待时间定义理解错 | 区分等待时间和完成时间 |
| C++ 崩溃、Python 正常 | 数组越界 | 检查水龙头数量是否超过人数 |
| 输出顺序不对 | 排序不稳定 | 改用稳定排序或加次关键字 |
| 时间超限 | 内层循环重复求和 | 改用前缀和 |
| 多水龙头结果偏大 | 误用了排序贪心 | 确认队伍顺序是否允许改动 |
| 浮点结果有偏差 | 累积误差 | 先整数求和再除,或指定精度格式 |
7. 一些实操心得
上面这些内容如果压缩成一句话,就是:先把问题的三个维度问清楚(几个水龙头、顺序能否改、优化目标是什么),再决定用排序、模拟还是近似算法。这三步走对了,代码基本不会跑偏。
我在实际写业务代码的时候,遇到过一个很有意思的场景:一个线程池里有一批异步任务要执行,任务耗时差异很大,业务方希望整体完成时间尽可能短。这不就是多水龙头问题吗?线程数就是水龙头数量,任务是排队的人。顺序可以自由安排,目标是整体完成时间最短,也就是最小化最大完成时间。按前面的分析,这属于 NP-hard,所以我没去追求精确最优,而是用了 LPT 的思路,按任务预估耗时降序提交进线程池。上线后整体耗时确实比原来的先进先出降了差不多三成。这个收益不是靠什么高级技巧,纯粹是把贪心的直觉用在了正确的地方。
另外一个经验是,遇到"看起来像排序贪心"的题目,先别急着敲代码,花两分钟在纸上写一个三四个元素的小用例,手动算一遍贪心解和暴力解。如果两个结果对得上,再动手写。如果对不上,那说明你对题目的理解有偏差,或者贪心策略本身不成立。这两分钟的投入,往往能省掉后面二十分钟的调试。
还有个容易忽略的点是输入规模对代码写法的影响。如果 n 到了 10^6 级别,Python 的input()和print()会成为瓶颈,需要用sys.stdin.read().split()一次性读入,C++ 则要加ios::sync_with_stdio(false); cin.tie(nullptr);。这类优化不难,但很多人第一次遇到大数据超时的时候会以为是算法错了,反复检查逻辑,白白浪费时间。
最后分享一个我用来验证排序贪心是否写对的小技巧:把排序后的数组和排序前的数组各算一遍总等待时间,正常情况下排序后的结果一定小于等于排序前的。如果反过来了,那说明你的排序方向或者前缀和的累加逻辑有 bug。这个自检只需两行代码,我在写任何排序型贪心的时候都会顺手加上。