想系统提升编程能力、查看更完整的学习路线,欢迎访问 AI Compass:github.com/tingaicompa… 仓库持续更新刷题题解、Python 基础和 AI 实战内容,适合想高效进阶的你。
📖 第100课:任务调度器
模块:堆与优先队列 | 难度:Medium ⭐⭐ LeetCode 链接:leetcode.cn/problems/ta… 前置知识:第97课(前K个高频元素)、Counter、贪心思想 预计学习时间:30分钟
🎯 题目描述
给定一个由若干任务组成的数组,每个任务用大写字母A-Z表示。每个任务可以在1个单位时间内完成,但相同任务之间必须间隔至少n个单位时间(称为"冷却时间")。
你可以按任意顺序执行任务,但必须在空闲时等待以满足冷却要求。问完成所有任务至少需要多少个单位时间?
示例:
输入:tasks = ["A","A","A","B","B","B"], n = 2
输出:8
解释:执行顺序可以是 A -> B -> idle -> A -> B -> idle -> A -> B
时间间隔为 2,总共需要 8 个单位时间
约束条件:
- 1 <= tasks.length <= 10^4
- tasks[i] 是大写英文字母
- 0 <= n <= 100
🧪 边界用例(面试必考)
| 用例类型 | 输入 | 期望输出 | 考察点 |
|---|---|---|---|
| 无冷却 | tasks=["A","A","A"], n=0 | 3 | 冷却时间为0,无需等待 |
| 单任务 | tasks=["A"], n=2 | 1 | 只有一个任务,无冷却 |
| 高频任务 | tasks=["A","A","A","A","A","A","B","C","D"], n=2 | 16 | 最高频任务主导总时间 |
| 任务种类多 | tasks=["A","B","C","D","E","F","G"], n=1 | 7 | 任务种类足够,无需空闲 |
| 最大规模 | len(tasks)=10000, n=100 | — | 性能边界 |
💡 思路引导
生活化比喻
想象你是一名厨师,需要做3道菜:红烧肉(A)、炒青菜(B)、煲汤(C)。
问题是:每做完一道红烧肉后,锅必须冷却2分钟才能再做下一道红烧肉(冷却时间n=2)。
🐌 笨办法:傻傻等待。做完A1 → 等2分钟 → 做A2 → 等2分钟 → 做A3,中间什么都不干。浪费时间!
🚀 聪明办法:充分利用冷却时间!做完A1 → 插入做B → 插入做C → 再做A2 → 插入做B → 插入做C → 再做A3。这样冷却时间都被利用了,没有空闲。
关键洞察:如果A太多(比如有10个A),而B、C很少,那即使穿插也不够填满所有冷却间隙,最终还是得等待。
关键洞察
出现次数最多的任务决定了整体时间的下限! 其他任务尽量填充在最高频任务之间的冷却间隙中。
🧠 解题思维链
Step 1:理解题目 → 锁定输入输出
- 输入:任务列表 tasks(字符数组),冷却时间 n(整数)
- 输出:完成所有任务的最小总时间(整数)
- 限制:相同任务之间必须间隔至少n个时间单位
Step 2:先想笨办法(暴力模拟)
可以用队列模拟整个执行过程:维护一个优先队列(堆),每次取出频率最高的任务执行,执行后放入冷却队列,等n个时间单位后再放回堆。
- 时间复杂度:O(total_time * log k),其中total_time可能非常大
- 瓶颈在哪:需要逐个时间单位模拟,效率低
Step 3:瓶颈分析 → 优化方向
模拟的瓶颈在于逐个时间单位推进。能不能直接计算出最终时间?
- 核心问题:总时间由什么决定?
- 优化思路:数学公式直接计算,利用最高频任务的特性
Step 4:选择武器
- 选用:数学公式 + 贪心思想(最优解)或堆 + 模拟(直观解)
- 理由:
- 数学公式能直接算出答案,O(n)时间
- 堆模拟能展示算法思路,适合面试讲解
🔑 模式识别提示:当题目涉及"冷却时间"、"间隔安排",优先考虑"贪心 + 数学公式"
🔑 解法一:堆 + 模拟(直观解)
思路
用最大堆维护任务频率,每次取频率最高的min(k+1个任务)执行(k=n+1是一个周期),执行后更新频率,重新放回堆。统计总时间。
图解过程
示例:tasks = ["A","A","A","B","B","B"], n = 2
初始频率:A:3, B:3
最大堆:[3,3]
周期1(长度=3):
取A(频率3) → 执行A → 频率变2
取B(频率3) → 执行B → 频率变2
取C(无) → 空闲idle
执行序列:A B idle
更新堆:[2,2]
时间:3
周期2(长度=3):
取A(频率2) → 执行A → 频率变1
取B(频率2) → 执行B → 频率变1
取C(无) → 空闲idle
执行序列:A B idle
更新堆:[1,1]
时间:3+3=6
周期3(长度=2):
取A(频率1) → 执行A → 频率变0
取B(频率1) → 执行B → 频率变0
执行序列:A B
更新堆:[]
时间:6+2=8
总时间:8
Python代码
from typing import List
from collections import Counter
import heapq
def leastInterval_heap(tasks: List[str], n: int) -> int:
"""
解法一:堆 + 模拟
思路:用最大堆每次贪心选择频率最高的任务
"""
if n == 0:
return len(tasks)
# 统计每个任务的频率
freq = Counter(tasks)
# 最大堆(用负数模拟)
max_heap = [-count for count in freq.values()]
heapq.heapify(max_heap)
total_time = 0
while max_heap:
# 一个周期可以执行 n+1 个任务
cycle = []
for _ in range(n + 1):
if max_heap:
cycle.append(-heapq.heappop(max_heap))
# 执行后更新频率
for count in cycle:
if count - 1 > 0:
heapq.heappush(max_heap, -(count - 1))
# 如果堆空了,说明是最后一个周期,只需实际任务数
# 否则需要完整周期长度(可能有idle)
total_time += len(cycle) if not max_heap else n + 1
return total_time
# ✅ 测试
print(leastInterval_heap(["A","A","A","B","B","B"], 2)) # 期望输出:8
print(leastInterval_heap(["A","A","A","A","A","A","B","C","D","E","F","G"], 2)) # 期望输出:16
print(leastInterval_heap(["A","B","C","D"], 1)) # 期望输出:4
复杂度分析
- 时间复杂度:O(total_time * log k) — k为任务种类数(最多26),total_time为最终时间
- 具体地说:如果有1000个任务,可能需要模拟1000多个时间单位,每次堆操作O(log 26)
- 空间复杂度:O(k) — 堆的大小,最多26种任务
优缺点
- ✅ 思路直观,容易理解执行过程
- ✅ 适合面试时先讲解算法思路
- ❌ 时间复杂度较高,有更优的数学解法
🏆 解法二:数学公式(最优解)
优化思路
通过数学分析,我们发现:
- 最高频任务的频率max_freq决定了最少需要多少个"框架周期"
- 每个周期之间需要n个间隔
- 其他任务尽量填充到这些间隔中
关键公式:
设最高频任务频率为 max_freq,出现max_freq次的任务有 max_count 个
最少时间 = (max_freq - 1) * (n + 1) + max_count
但是,如果任务总数len(tasks)比这个公式大,说明任务足够多,不需要空闲,直接返回len(tasks)
💡 关键想法:最高频任务之间形成"骨架",其他任务填充"血肉",如果血肉不够才需要idle
图解过程
示例1:tasks = ["A","A","A","B","B","B"], n = 2
统计频率:
A:3, B:3
max_freq = 3
max_count = 2(A和B都是3次)
可视化骨架:
A _ _ A _ _ A
↓
A B _ A B _ A B
公式:(3-1) * (2+1) + 2 = 2*3 + 2 = 8
总时间:max(8, 6) = 8
示例2:tasks = ["A","A","A","B","C","D","E","F"], n = 2
统计频率:
A:3, 其他都是1
max_freq = 3
max_count = 1
可视化骨架:
A _ _ A _ _ A
↓
A B C A D E A F
公式:(3-1) * (2+1) + 1 = 2*3 + 1 = 7
实际任务数:8
总时间:max(7, 8) = 8(任务足够多,无需空闲)
Python代码
def leastInterval(tasks: List[str], n: int) -> int:
"""
解法二:数学公式(最优解)
思路:最高频任务决定骨架,其他任务填充间隙
"""
if n == 0:
return len(tasks)
# 统计每个任务的频率
freq = Counter(tasks)
# 找出最高频率
max_freq = max(freq.values())
# 统计有多少个任务达到最高频率
max_count = sum(1 for count in freq.values() if count == max_freq)
# 公式计算最少时间
min_time = (max_freq - 1) * (n + 1) + max_count
# 如果任务总数更多,说明不需要空闲
return max(min_time, len(tasks))
# ✅ 测试
print(leastInterval(["A","A","A","B","B","B"], 2)) # 期望输出:8
print(leastInterval(["A","A","A","A","A","A","B","C","D","E","F","G"], 2)) # 期望输出:16
print(leastInterval(["A","B","C","D"], 1)) # 期望输出:4
print(leastInterval(["A","A","A"], 0)) # 期望输出:3
复杂度分析
- 时间复杂度:O(m) — m为任务总数,只需遍历一次统计频率
- 具体地说:如果有10000个任务,只需O(10000)一次遍历,远快于模拟
- 空间复杂度:O(k) — k为任务种类数,最多26
为什么是最优解
- ✅ 时间O(m)已经达到理论最优(至少要统计所有任务)
- ✅ 空间O(1)常数级(最多26种任务)
- ✅ 代码简洁,面试中容易写对
- ✅ 通过数学公式直接计算,无需模拟
🐍 Pythonic 写法
利用 Counter 的 most_common() 方法简化:
def leastInterval_pythonic(tasks: List[str], n: int) -> int:
"""Pythonic写法:利用most_common()"""
freq = Counter(tasks)
max_freq = freq.most_common(1)[0][1] # 最高频率
max_count = sum(1 for f in freq.values() if f == max_freq)
return max((max_freq - 1) * (n + 1) + max_count, len(tasks))
⚠️ 面试建议:先写清晰版本展示思路,再提 Pythonic 写法展示语言功底。 面试官更看重你的数学推导过程,而非代码行数。
📊 解法对比
| 维度 | 解法一:堆模拟 | 🏆 解法二:数学公式(最优) |
|---|---|---|
| 时间复杂度 | O(total_time * log k) | O(m) ← 时间最优 |
| 空间复杂度 | O(k) | O(k) |
| 代码难度 | 中等 | 简单(关键在推导) |
| 面试推荐 | ⭐⭐ | ⭐⭐⭐ ← 首选 |
| 适用场景 | 展示算法思路 | 面试首选,效率最高 |
为什么数学公式是最优解:
- 时间O(m)已经是理论最优(必须至少看一遍所有任务)
- 避免了逐个时间单位的模拟,直接数学计算
- 代码量少,不容易出错
面试建议:
- 先用1分钟口述堆模拟的思路(表明你理解贪心策略)
- 立即切换到🏆最优解:数学公式法
- 重点讲解公式推导:"最高频任务形成骨架,其他任务填充间隙"
- 手绘示意图帮助面试官理解
- 强调为什么这是最优:时间O(m)已达理论下限
🎤 面试现场
模拟面试中的完整对话流程,帮你练习"边想边说"。
面试官:请你解决一下这道任务调度器问题。
你:(审题30秒)好的,这道题要求在冷却时间约束下完成所有任务,求最少总时间。让我先想一下...
我的第一个想法是用堆模拟:每次贪心选择频率最高的任务执行,维护冷却队列。时间复杂度是O(total_time * log k)。
不过我们可以用数学公式优化到O(m),核心思路是:最高频任务决定骨架,其他任务填充间隙。公式是(max_freq - 1) * (n + 1) + max_count,再和任务总数取最大值。
面试官:很好,请写一下数学公式的代码。
你:(边写边说)
# 第一步:统计频率
freq = Counter(tasks)
# 第二步:找最高频率和达到最高频率的任务数
max_freq = max(freq.values())
max_count = sum(1 for f in freq.values() if f == max_freq)
# 第三步:套公式
min_time = (max_freq - 1) * (n + 1) + max_count
# 第四步:和任务总数取最大(处理任务很多的情况)
return max(min_time, len(tasks))
面试官:为什么是这个公式?
你:(画图解释)比如tasks=["A","A","A","B","B","B"], n=2。A出现3次,是最高频。我可以这样排列:
A _ _ A _ _ A
这形成了一个骨架,有(max_freq-1)个间隙,每个间隙长度n+1。最后还要加上最后一个周期的max_count个任务。所以是(3-1)*(2+1)+2=8。
如果其他任务足够多,比如有10个不同任务,那直接执行就行,不需要空闲,所以要取max(公式值, 任务总数)。
面试官:测试一下?
你:
- 用示例["A","A","A","B","B","B"], n=2:(3-1)*3+2=8 ✓
- 边界:["A"], n=2:max((1-1)*3+1, 1)=1 ✓
- 无冷却:["A","A"], n=0:max((2-1)*1+1, 2)=2 ✓
高频追问
| 追问 | 应答策略 |
|---|---|
| "为什么公式中要-1再+max_count?" | "因为最高频任务形成的周期是max_freq-1个间隙,最后一个周期不需要完整的n+1长度,只需要max_count个任务即可" |
| "如果有多个任务都是最高频怎么办?" | "公式中的max_count就是处理这个情况的,它统计了达到最高频的任务数,这些任务都会出现在每个周期的末尾" |
| "空间能更优吗?" | "已经是O(k)常数空间了(最多26种任务),无法进一步优化" |
| "实际工程中怎么用?" | "类似CPU任务调度、多核处理器的任务分配,都需要考虑冷却时间或依赖关系" |
🎓 知识点总结
Python技巧卡片 🐍
# 技巧1:Counter统计频率 — 字典子类,专门用于计数
from collections import Counter
freq = Counter(['A','A','B']) # Counter({'A': 2, 'B': 1})
# 技巧2:most_common() — 获取频率最高的元素
freq.most_common(1) # [('A', 2)]
max_freq = freq.most_common(1)[0][1]
# 技巧3:堆模拟最大堆 — Python只有最小堆,用负数模拟最大堆
import heapq
max_heap = [-3, -2, -1]
heapq.heapify(max_heap) # 最大元素3在堆顶
💡 底层原理(选读)
为什么Python的heapq只有最小堆?
Python的设计哲学是"一个问题只有一个明显的解决方法"。最小堆已经足够,需要最大堆时只需对所有值取负数即可。这样避免了重复实现,保持标准库简洁。
Counter的底层实现: Counter继承自dict,内部就是普通字典。
most_common()方法实际上是对字典进行排序,时间复杂度O(k log k),k为元素种类数。
算法模式卡片 📐
- 模式名称:贪心 + 数学公式
- 适用条件:当问题涉及"间隔安排"、"冷却时间"、"周期调度"时
- 识别关键词:"冷却"、"间隔"、"相同任务不能连续"
- 模板代码:
def task_scheduler_pattern(tasks, n):
# 1. 统计频率
freq = Counter(tasks)
# 2. 找最高频及其数量
max_freq = max(freq.values())
max_count = sum(1 for f in freq.values() if f == max_freq)
# 3. 套公式
min_time = (max_freq - 1) * (n + 1) + max_count
# 4. 取最大值(处理任务充足的情况)
return max(min_time, len(tasks))
易错点 ⚠️
- 忘记处理n=0的情况:冷却时间为0时,直接返回任务总数
- 公式中忘记-1:是
(max_freq-1)*(n+1),不是max_freq*(n+1),因为最后一个周期不需要完整长度 - 忘记max_count:多个任务达到最高频时,最后一个周期需要max_count个位置,不是1个
- 忘记取max:当任务种类很多时,可能不需要空闲,要取max(公式值, 任务总数)
🏗️ 工程实战(选读)
这个算法思想在真实项目中的应用,让你知道"学了有什么用"。
- 场景1:多核CPU任务调度。现代CPU需要避免同一类型密集计算连续执行导致过热,通过插入其他类型任务实现"冷却"。
- 场景2:网络请求限流。对同一IP的请求设置冷却时间,避免DDoS攻击。类似本题的"相同任务间隔n"。
- 场景3:游戏技能冷却。MOBA游戏中技能释放后需要冷却,类似任务调度问题。
🏋️ 举一反三
完成本课后,试试这些同类题目来巩固知识:
| 题目 | 难度 | 相关知识点 | 提示 |
|---|---|---|---|
| LeetCode 767. 重构字符串 | Medium | 贪心+堆 | 类似任务调度,要求相邻字符不同 |
| LeetCode 358. K距离间隔重排字符串 | Hard | 贪心+堆 | 任务调度的升级版 |
| LeetCode 1481. 不同整数的最少数目 | Medium | 贪心+堆 | 频率统计+贪心选择 |
📝 课后小测
试试这道变体题,不要看答案,自己先想5分钟!
题目:给定任务数组和冷却时间n,要求返回最优执行顺序的字符串(包括idle),而不是时间长度。比如tasks=["A","A","A","B","B"],n=2,返回"AB_AB_A"(其中_表示idle)。
💡 提示(实在想不出来再点开)
仍然基于最高频任务的骨架,用堆维护剩余任务,每个周期贪心取频率最高的n+1个任务。
✅ 参考答案
def taskSchedulerOrder(tasks: List[str], n: int) -> str:
"""返回执行顺序字符串"""
if n == 0:
return ''.join(tasks)
freq = Counter(tasks)
max_heap = [(-count, char) for char, count in freq.items()]
heapq.heapify(max_heap)
result = []
while max_heap:
cycle = []
for _ in range(n + 1):
if max_heap:
count, char = heapq.heappop(max_heap)
cycle.append((count, char))
result.append(char)
elif max_heap: # 还有任务但当前周期不够
result.append('_') # 添加空闲
for count, char in cycle:
if count + 1 < 0: # 还有剩余(-count > 1)
heapq.heappush(max_heap, (count + 1, char))
return ''.join(result)
核心思路:用堆模拟,每次取频率最高的任务,不足n+1个时补充"_"表示空闲。
如果这篇内容对你有帮助,推荐收藏 AI Compass:github.com/tingaicompa… 更多系统化题解、编程基础和 AI 学习资料都在这里,后续复习和拓展会更省时间。