今天刷 LeetCode 偶然写到的这道题,把我卡了整整一个下午

1 阅读7分钟

事情是这样的,本来只想摸鱼刷道中等题,结果点开一道 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 个服务器,这太蠢了。

但是等等——我需要真的遍历吗?

仔细想想,每个请求到达时,服务器其实只有两种状态:

  1. 忙着:还没到结束时间;
  2. 闲着:已经可以接活了。

对于"忙着"的服务器,我只关心一件事:谁先结束? 因为只有先结束的,才有可能在当前请求到达时已经空闲。

对于"闲着"的服务器,我只关心另一件事:按编号排好序,这样我才能快速找到"从 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,用取模来模拟环形。但这道题告诉我,只要数据有序,二分 + 索引回绕就能实现同样的语义,而且更快、更短、更不容易写错。

这是一个挺反直觉的技巧。环形不是一种数据结构,而是一种查找语义。只要我的数据结构能支持"从某个位置往后找第一个满足条件的",那环形就只是"找不到就回到开头"这么简单。