事情是这样的,本来只想摸鱼刷道中等题,结果点开一道 Hard,一坐就是一下午。
起因:一个平平无奇的下午
今天下午三点,脑子有点糊,想着刷道题清醒一下。
打开 LeetCode,随手点了一道看起来名字挺有意思的题:1606. 找到处理最多请求的服务器。
Hard 难度。
我心想,Hard 就 Hard 吧,做不出来就当学习了。
结果这一坐,就是一下午。
现在把整个过程写下来,也算给这道题一个交代。
题目长啥样
简单说就是:你有 k 个服务器,编号 0 到 k-1。一堆请求按时间顺序到达,每个请求有到达时间和工作量。
分配规则:
- 第
i个请求来了,先看第i % k个服务器; - 如果它闲着,就给它;
- 如果它忙着,就往后找下一个闲着的(环形找,找到末尾绕回 0);
- 如果所有服务器都忙,这个请求直接扔掉。
最后问你:哪个服务器处理的请求最多?
听起来不难对吧?我一开始也这么觉得。
第一版:暴力模拟,直接 TLE
我第一反应很朴素:搞一个数组 end_time,记录每个服务器什么时候空闲。每个请求来了,从 i % k 开始往后遍历,找到第一个 end_time <= arrival[i] 的服务器就分配。
代码大概长这样:
class Solution:
def busiestServers(self, k, arrival, load):
end_time = [0] * k
counts = [0] * k
for i, (arr, l) in enumerate(zip(arrival, load)):
target = i % k
assigned = -1
for offset in range(k):
s = (target + offset) % k
if end_time[s] <= arr:
assigned = s
break
if assigned == -1:
continue
end_time[assigned] = arr + l
counts[assigned] += 1
max_count = max(counts)
return [i for i, c in enumerate(counts) if c == max_count]
跑了一下示例,对了。提交。
TLE。
看了一眼数据范围,k 和 n 都是 10^5 级别,O(nk) 果然不行。
行吧,开始想优化。
想优化:先搞清楚慢在哪
我盯着代码看了一会儿,发现慢在两层循环:
for offset in range(k):
s = (target + offset) % k
if end_time[s] <= arr:
...
每个请求最坏要遍历 k 个服务器,这太蠢了。
但是等等——我需要真的遍历吗?
仔细想想,每个请求到达时,服务器其实只有两种状态:
- 忙着:还没到结束时间;
- 闲着:已经可以接活了。
对于"忙着"的服务器,我只关心一件事:谁先结束? 因为只有先结束的,才有可能在当前请求到达时已经空闲。
对于"闲着"的服务器,我只关心另一件事:按编号排好序,这样我才能快速找到"从 target 开始的下一个"。
这两件事,对应两个完全不同的数据结构。
第一个结构:最小堆,管理"谁先下班"
忙碌的服务器,本质上是按"结束时间"排队。
每次有请求到达,我就想看看:有没有人已经下班了?
如果有,就把它从"忙碌名单"里挪到"空闲名单"里。
这个"按结束时间取最小"的语义,天然就是最小堆。
堆里存 (结束时间, 服务器编号),每次请求来了:
while work_end and work_end[0][0] <= arr:
_, server_id = heappop(work_end)
# 这个服务器下班了,挪到空闲名单
注意这里我踩了一个小坑:一开始我写的是 <,结果示例 3 过不了。
后来才反应过来:如果服务器在请求到达的同一时刻完成上一个任务,它那一刻就已经空闲了。所以应该是 <=。
就差这一个符号,我 debug 了十几分钟。
第二个结构:有序列表,管理"谁来接活"
空闲服务器,关键需求是按编号有序。
为什么要有序?因为题目要求"从 i % k 开始往后找第一个空闲的"。
如果空闲服务器是乱序的,我又得遍历。
但如果它是升序的,我就可以用 二分查找 直接定位:
target = i % k
idx = bisect_left(free, target)
bisect_left(free, target) 的意思是:在有序列表 free 里,找第一个 >= target 的元素下标。
这一个函数,同时覆盖了三种情况:
情况一:target 自己就是空闲的 → 直接找到它。
情况二:target 忙着,但后面有空闲的 → 找到后面第一个空闲的。
情况三:target 后面全忙着 → 返回 len(free),说明后面没找到。
前两种情况,bisect_left 直接搞定。第三种情况,需要手动绕回:
if idx == len(free):
idx = 0
因为 free 是有序的,free[0] 就是全局编号最小的空闲服务器,也就是从 0 开始找遇到的第一个。绕回逻辑一行搞定。
这个设计我真的服气。 环形查找不一定要真的写 (target + offset) % k 的循环,用"有序数组 + 二分 + 索引回绕"三件套,代码又短又快。
最终代码
from bisect import bisect_left, insort
from heapq import heappop, heappush
class Solution:
def busiestServers(self, k: int, arrival: list[int], load: list[int]) -> list[int]:
work_end = [] # 最小堆:(结束时间, 服务器编号)
free = list(range(k)) # 有序空闲列表
counts = [0] * k # 每个服务器处理数
for i, (arr, l) in enumerate(zip(arrival, load)):
# 释放所有已完成的服务器
while work_end and work_end[0][0] <= arr:
_, sid = heappop(work_end)
insort(free, sid)
# 全忙就丢弃
if not free:
continue
# 找 target 或它后面第一个空闲的
target = i % k
idx = bisect_left(free, target)
if idx == len(free):
idx = 0
sid = free.pop(idx)
heappush(work_end, (arr + l, sid))
counts[sid] += 1
max_count = max(counts)
return [i for i, c in enumerate(counts) if c == max_count]
提交,AC。
踩过的坑,比题目本身还多
坑一:<= 写成 <
上面说过了,服务器刚好在请求到达时刻完成,也算空闲。差一个等号,示例过不了。
坑二:i 到底是谁
这是本题最大的陷阱。
i 是请求的序号,来自 enumerate,不是"成功处理了几个请求"。
就算某个请求被丢弃了,i 还是继续往上走。
我一开始脑子抽了一下,想着"被丢弃的请求不算数吧",差点改成用成功计数来算 target。后来看了一眼示例 3 的推理过程,发现 i=4 被丢弃后,下一个请求(如果有)依然要用 i=5 来算 target。
如果这个搞错,一旦有请求被丢弃,后面全错。
坑三:insort 和 pop 其实不便宜
表面上我用堆和二分,看起来是 O(n log k)。但 free 是普通 Python 列表,insort 和 pop 的最坏复杂度是 O(k)。
所以这段代码的实际最坏复杂度还是 O(nk)。
不过 LeetCode 上能过,因为:
free大部分时候没那么长;- Python 的
list.insert底层是memmove,常数很小; - 测试数据没有刻意卡这个。
如果想要严格 O(n log k),得把 free 换成有序集合(比如 SortedList),或者上线段树,每次找"从 target 开始第一个空闲"就是线段树上二分。
但说实话,面试的时候,能写出堆 + 二分这版,已经能讲清楚思路了。
做完之后的一点感想
这道题让我最有感触的,不是堆,也不是二分,而是这个点:
环形查找,不一定非要真的写一个环。
我以前遇到"环形"的题,第一反应都是 (x + offset) % n,用取模来模拟环形。但这道题告诉我,只要数据有序,二分 + 索引回绕就能实现同样的语义,而且更快、更短、更不容易写错。
这是一个挺反直觉的技巧。环形不是一种数据结构,而是一种查找语义。只要我的数据结构能支持"从某个位置往后找第一个满足条件的",那环形就只是"找不到就回到开头"这么简单。