0Pricing
Coding Interview Prep · 课时

任务调度器与加油站

将贪心思路应用于 CPU 任务调度器的冷却时间问题和环形加油站的可行性问题。

任务调度器与加油站 是 CoddyKit 上的免费 Coding Interview Prep 课时。 这是第 4 节课,共 4 节。 你可以在下方免费阅读本课时的完整内容 — 然后在浏览器中使用内置代码编辑器和全天候 AI 导师进行实践。 这是 Coding Interview Prep 学习路径的一部分,你的进度在网页和 CoddyKit 应用中同步。 Coding Interview Prep 课程共包含 4 节课。

任务调度器问题

任务调度器(LeetCode 621):给定一个 CPU 任务列表(每个任务标记为 A-Z)和冷却时间 n,求完成所有任务所需的最少 CPU 时间片数。同一任务再次运行前,至少必须等待 n 个时间片。允许有空闲时间片。对于任务 ['A','A','A','B','B','B'],当冷却时间为 2 时,答案是 8:A→B→idle→A→B→idle→A→B。

# Task Scheduler example
tasks = ['A','A','A','B','B','B']
n = 2  # cooldown
# One optimal schedule: A B _ A B _ A B
# Intervals: 1 2 3 4 5 6 7 8 → answer = 8

# Another example: tasks=['A','A','A','B','B','C'] n=2
# A B C A B _ A → 7 intervals
print('Understanding the cooldown constraint')
print('Same task needs n intervals gap between runs')

任务调度器的贪心公式

关键洞察是:总时间由出现次数最多的任务决定。如果出现次数最多的任务出现 f 次,且有 max_count 个任务的出现频率为 f,则总时间为 max(len(tasks), (f-1) * (n+1) + max_count)。公式的含义是:创建 f-1 个大小为 n+1 的框架,用其他任务填充它们,然后加上最后一个周期。如果其他任务填满了所有空闲位置(存在许多不同的任务),就直接执行所有任务,不需要空闲时间。

from collections import Counter

def least_interval(tasks, n):
    count = Counter(tasks)
    max_freq = max(count.values())
    # How many tasks have the maximum frequency?
    max_count = sum(1 for c in count.values() if c == max_freq)
    # Formula: max of total tasks (no idle) or frame-based calculation
    frame_time = (max_freq - 1) * (n + 1) + max_count
    return max(len(tasks), frame_time)

print(least_interval(['A','A','A','B','B','B'], 2))  # 8
print(least_interval(['A','A','A','B','B','B'], 0))  # 6 (no cooldown)
print(least_interval(['A','A','A','A','B','C'], 3))  # 10

为什么该公式有效

可以将调度安排想象成一个有 n+1 列的网格(一个任务位置加上 n 个冷却位置)。出现次数最多的任务 A(出现频率为 f)需要 f 行。在第一次和最后一次出现之间,有 f-1 个完整框架,每个框架包含 n+1 个位置。除此之外,还有包含所有最高频任务的最后一个不完整框架。如果有足够多种类的任务,它们会填满所有空闲位置,实际任务数量就会超过框架所需时间——此时取两者中较大的值。

# Visualise frame structure for AAABBB, n=2
# Frame size = n+1 = 3
# f = 3 (A appears 3 times), max_count = 2 (A and B both appear 3 times)
# Grid:
# [A B _]  ← frame 1
# [A B _]  ← frame 2  
# [A B  ]  ← last partial frame (max_count=2 cells)
# Total = (3-1)*3 + 2 = 6 + 2 = 8

# If tasks = AAAABBCC, n=2: max_freq=4 (A), max_count=1
# (4-1)*(2+1)+1 = 9+1 = 10
# But len(tasks)=8 < 10, so answer is 10
tasks2 = ['A','A','A','A','B','B','C','C']
from collections import Counter
count = Counter(tasks2)
mf = max(count.values())
mc = sum(1 for c in count.values() if c == mf)
print(f'Frame formula: ({mf}-1)*{2+1}+{mc} = {(mf-1)*(2+1)+mc}')
print(f'Max(len={len(tasks2)}, frame={max(len(tasks2),(mf-1)*3+mc)}) = {max(len(tasks2),(mf-1)*3+mc)}')

基于堆的模拟替代方案

基于堆的模拟可以给出实际的调度安排(而不仅仅是数量)。每一步都取出当前可用且出现次数最多的任务(最大堆)。执行后应用冷却规则:直到 n 步之后才重新放回该任务。使用队列跟踪冷却中的任务。该方法的时间复杂度为 O(total_time × log k),其中 k 是不同任务的数量。虽然这种方法是正确的,但公式更快。两种方法都要掌握——面试官可能会要求您给出具体的调度安排。

import heapq
from collections import deque, Counter

def task_scheduler_simulate(tasks, n):
    count = Counter(tasks)
    heap = [-c for c in count.values()]  # max-heap using negation
    heapq.heapify(heap)
    time = 0
    cooldown = deque()  # (available_at, neg_count)
    while heap or cooldown:
        time += 1
        if heap:
            c = heapq.heappop(heap) + 1  # use one instance
            if c < 0:  # still has remaining tasks
                cooldown.append((time + n, c))
        if cooldown and cooldown[0][0] == time:
            heapq.heappush(heap, cooldown.popleft()[1])
    return time

print(task_scheduler_simulate(['A','A','A','B','B','B'], 2))  # 8

加油站问题

加油站(LeetCode 134):有 n 个加油站组成一个环。加油站 i 有 gas[i] 单位的汽油,前往下一站的消耗为 cost[i]。从空油箱出发,找出可以完成环路的起始加油站。如果不存在这样的加油站,则返回 -1。如果存在有效答案,题目保证最多只有一个。

# Example:
gas  = [1, 2, 3, 4, 5]
cost = [3, 4, 5, 1, 2]
# net gain per station: gas[i] - cost[i]
net = [g - c for g, c in zip(gas, cost)]
print('Net gain per station:', net)  # [-2, -2, -2, 3, 3]
# Only possible start: station 3 (index 3)
# Tank: 0 +3=3 → 3-1=2 → 2+1=3-2=... let's verify
print('Sum of net:', sum(net))  # 1 > 0 means solution exists

加油站的贪心解法

贪心算法:(1)如果总汽油量小于总消耗量,则不存在解(返回 -1)。(2)否则,恰好存在一个解。通过一次遍历找到它:跟踪 tank(当前燃油量)和 start(候选起始加油站)。如果到达某个加油站后 tank < 0,说明当前 start 无法到达该站——将 tank = 0 重置,并将 start = i + 1。最终的 start 即为答案。

def can_complete_circuit(gas, cost):
    if sum(gas) < sum(cost):
        return -1  # impossible
    tank = 0
    start = 0
    for i in range(len(gas)):
        tank += gas[i] - cost[i]
        if tank < 0:
            tank = 0
            start = i + 1  # current start failed, try next
    return start

gas  = [1, 2, 3, 4, 5]
cost = [3, 4, 5, 1, 2]
print(can_complete_circuit(gas, cost))  # 3

gas2  = [2, 3, 4]
cost2 = [3, 4, 3]
print(can_complete_circuit(gas2, cost2))  # -1

为什么贪心起点是正确的

正确性论证:如果从 start 出发到达加油站 i 后油量变为负数,那么从 start 到 i(包括两端)之间的任何加油站都不可能成为有效起点——它们从自身出发到达加油站 i 时所能提供的燃油,都少于从 start 出发时所能提供的燃油。因此,我们可以安全地跳过所有这些加油站,并尝试 i+1。由于解确实存在(总汽油量 ≥ 总消耗量),最终的候选起点 start 必然有效。

# Proof sketch: why start=i+1 is correct after tank<0 at station i
# If we start at station j (start <= j <= i), tank at j is tank_from_start(j)
# After stations start..j: tank_from_j starts at 0, but we've already used gas[start..j-1]
# Starting at j means: tank_at_i = sum(net[j..i]) = sum(net[start..i]) - sum(net[start..j-1])
# Since sum(net[start..i]) < 0 AND sum(net[start..j-1]) >= 0 (no reset before i),
# tank_at_i when starting at j is even more negative → j cannot work either

def verify_gas_solution(gas, cost, start):
    tank = 0
    n = len(gas)
    for i in range(n):
        idx = (start + i) % n
        tank += gas[idx] - cost[idx]
        if tank < 0: return False
    return True

print(verify_gas_solution([1,2,3,4,5],[3,4,5,1,2], 3))  # True

加油站:暴力法与贪心法

暴力法会尝试每个起始加油站并模拟完整环路——时间复杂度为 O(n²)。贪心的一次遍历解法的时间复杂度为 O(n),空间复杂度为 O(1)。对于包含 10⁵ 个加油站的数组,差异是 10¹⁰ 次操作与 10⁵ 次操作。使贪心方法可行的关键数学性质是:如果总净燃油量非负,就一定存在有效起点,而且它总是运行总和最后一次变为负数的位置之后的那个加油站。

def brute_force_gas(gas, cost):
    n = len(gas)
    for start in range(n):
        tank = 0
        valid = True
        for i in range(n):
            idx = (start + i) % n
            tank += gas[idx] - cost[idx]
            if tank < 0: valid = False; break
        if valid: return start
    return -1

def greedy_gas(gas, cost):
    if sum(gas) < sum(cost): return -1
    tank = start = 0
    for i, (g, c) in enumerate(zip(gas, cost)):
        tank += g - c
        if tank < 0: tank = 0; start = i + 1
    return start

gas = [1,2,3,4,5]; cost = [3,4,5,1,2]
print('Brute:', brute_force_gas(gas,cost), '== Greedy:', greedy_gas(gas,cost))

相关:完成行程的最小成本

完成行程的最短时间(LeetCode 2187)是一个在答案空间上进行二分查找的问题。对时间值 T 进行二分查找:给定时间 T,使用 time[i] 的公交车可完成 floor(T/time[i]) 次行程。如果总行程数 ≥ totalTrips,则 T 足够。请找出满足条件的最小 T。这说明,当对象层面不存在直接的贪心规则时,贪心也可以应用在 the 元层面(对答案进行二分查找)。

def minimum_time(time, total_trips):
    def can_complete(t):
        return sum(t // bus for bus in time) >= total_trips
    
    lo, hi = 1, min(time) * total_trips  # upper bound
    while lo < hi:
        mid = (lo + hi) // 2
        if can_complete(mid):
            hi = mid
        else:
            lo = mid + 1
    return lo

print(minimum_time([1, 2, 3], 5))   # 3 (3/1=3 + 3/2=1 + 3/3=1 = 5)
print(minimum_time([2], 1))          # 2

边界情况与验证

两个问题的重要边界情况:任务调度器 — 当冷却时间 n=0 时,答案就是任务数量(无需空闲)。当所有任务都相同(例如全是 'A')时,空闲时隙会被恰好填满。当任务类型很多且彼此不同时,空闲时隙可能为 0(任务填满所有时间帧)。加油站 — 当总油量恰好等于总消耗时,恰好存在一个有效起点。当某个加油站有足够的油量完成整个环路时,该加油站就是答案。请始终在这些退化情况下验证您的贪心答案。

from collections import Counter

def least_interval(tasks, n):
    if n == 0: return len(tasks)  # no cooldown
    cnt = Counter(tasks)
    mf = max(cnt.values())
    mc = sum(1 for c in cnt.values() if c == mf)
    return max(len(tasks), (mf-1)*(n+1)+mc)

# Edge cases for task scheduler
print(least_interval(['A','A','A'], 2))   # 7: A _ _ A _ _ A
print(least_interval(['A','A','B','B'], 0)) # 4: no idle
print(least_interval(['A','B','C','D'], 3))  # 4: all diff, no idle needed

# Edge case for gas station
def gas_station(gas, cost):
    if sum(gas) < sum(cost): return -1
    tank = start = 0
    for i,(g,c) in enumerate(zip(gas,cost)):
        tank += g-c
        if tank < 0: tank=0; start=i+1
    return start

print(gas_station([5,1,2,3,4],[4,4,1,5,1]))  # 4

贪心模式识别

任务调度器和加油站都遵循 the 贪心模式:(1) 确定瓶颈(出现频率最高的任务/净燃油余额)。(2) 使用一个持续更新的变量(最大频率、油箱)进行单次遍历决策。(3) 当约束被违反时重新开始或重置。常见的贪心问题包括:活动选择、霍夫曼编码、分数背包、跳跃游戏、任务调度器、加油站、合并区间。每个问题都可以通过交换论证或数学不变量进行证明。

# Greedy pattern summary
# Task Scheduler:
#   Bottleneck: max frequency task
#   Formula: max(total_tasks, (max_freq-1)*(n+1)+max_count)
#   O(n) time, O(1) space

# Gas Station:
#   Bottleneck: running sum of (gas-cost) going negative
#   Reset start when tank < 0, valid if total sum >= 0
#   O(n) time, O(1) space

# Both avoid the need for DP by using a clever single-pass insight
from collections import Counter
def combined_demo(tasks, n, gas, cost):
    ti = max(len(tasks), (max(Counter(tasks).values())-1)*(n+1) +
             sum(1 for c in Counter(tasks).values() if c==max(Counter(tasks).values())))
    tank = start = 0
    gs = sum(g-c for g,c in zip(gas,cost)) >= 0
    return ti, start if gs else -1

快速检查

测试您对本课“数据结构与算法——编程面试准备”概念的理解。

课程回顾

在本课中,您学到了:任务调度器的答案 = max(任务总数, (最大频率-1)*(n+1)+最大计数) — 由使用出现频率最高的任务填充基于时间帧的网格推导而来,加油站使用单次遍历,每当油箱余额变为负数时就将起点重置为 i+1;当总油量 ≥ 总消耗时答案有效,以及两个问题都通过识别数学不变量而不是穷举搜索,使用 O(n) 时间和 O(1) 空间。接下来我们将学习分治法模板及其在归并排序之外的应用。

常见问题解答

「任务调度器与加油站」课时是免费的吗?

是的 — 「任务调度器与加油站」的完整文本可在网页上免费阅读。要进行交互式练习(内置代码编辑器和全天候 AI 导师)并解锁 Coding Interview Prep 课程的其余内容,请升级到 CoddyKit PRO。 Coding Interview Prep 课程共包含 4 节课。

「任务调度器与加油站」这节课中我会学到什么?

将贪心思路应用于 CPU 任务调度器的冷却时间问题和环形加油站的可行性问题。 你通过在浏览器中直接运行的动手代码来练习 Coding Interview Prep,全天候 AI 导师会在你学习这节课的过程中回答你的问题。

学习 Coding Interview Prep 需要有经验吗?

无需任何先前经验。CoddyKit 上的 Coding Interview Prep 课程适合初学者到高级学习者,你可以从这里开始或从头开始,按照自己的节奏学习。 这是第 4 节课,共 4 节。

「任务调度器与加油站」课时需要多长时间?

大多数 CoddyKit 课程大约需要 5–10 分钟。每节课都很精短且互动,所以你能稳步进步,并在网页和应用中从离开的地方继续。

我能在这节 Coding Interview Prep 课中编写并运行代码吗?

能。每节 Coding Interview Prep 课都包含内置代码编辑器,你可以在浏览器中直接编写并运行真实代码,并获得即时 AI 反馈 — 无需本地设置。

此课程中的所有课时

  1. 贪心算法与 DP:何时使用哪一种
  2. 区间调度与合并
  3. 跳跃游戏 I 与 II
  4. 任务调度器与加油站
← 返回 Coding Interview Prep