想系统提升编程能力、查看更完整的学习路线,欢迎访问 AI Compass:github.com/tingaicompa… 仓库持续更新刷题题解、Python 基础和 AI 实战内容,适合想高效进阶的你。
📖 第70课:加油站
模块:贪心算法 | 难度:Medium ⭐⭐ LeetCode 链接:leetcode.cn/problems/ga… 前置知识:第8课(盛最多水的容器-贪心)、第66课(跳跃游戏-贪心) 预计学习时间:30分钟
🎯 题目描述
在一条环路上有 n 个加油站,第 i 个加油站有汽油 gas[i] 升。你有一辆油箱容量无限的汽车,从第 i 个加油站开往第 i+1 个加油站需要消耗汽油 cost[i] 升。
你从其中的一个加油站出发,开始时油箱为空。给定两个整数数组 gas 和 cost,如果你可以按顺序绕环路行驶一周,则返回出发时加油站的编号,否则返回 -1。如果存在解,则保证它是唯一的。
示例:
输入:gas = [1,2,3,4,5], cost = [3,4,5,1,2]
输出:3
解释:
从 3 号加油站出发:
加油 4 升,油箱 = 4
到 4 号站消耗 1 升,剩余 3 升
加油 5 升,油箱 = 8
到 0 号站消耗 2 升,剩余 6 升
加油 1 升,油箱 = 7
到 1 号站消耗 3 升,剩余 4 升
加油 2 升,油箱 = 6
到 2 号站消耗 4 升,剩余 2 升
加油 3 升,油箱 = 5
到 3 号站消耗 5 升,剩余 0 升
成功绕行一周!
约束条件:
n == gas.length == cost.length1 <= n <= 10^50 <= gas[i], cost[i] <= 10^4
🧪 边界用例(面试必考)
| 用例类型 | 输入 | 期望输出 | 考察点 |
|---|---|---|---|
| 最小输入 | gas=[2], cost=[1] | 0 | 单站成环 |
| 无解 | gas=[1,2], cost=[2,3] | -1 | 总油量不足 |
| 刚好够 | gas=[5], cost=[5] | 0 | 总油量等于总消耗 |
| 大规模 | n=10^5 | — | 性能边界O(n) |
💡 思路引导
生活化比喻
想象你在玩一个环形跑道游戏,每个站点可以获得能量,到下一站需要消耗能量。你需要找到一个起点,保证全程能量不会变成负数。
🐌 笨办法:从每个站点尝试出发,模拟整个跑圈过程,看能否完成一圈。需要尝试 n 次,每次遍历 n 个站点,时间复杂度 O(n²)。
🚀 聪明办法:先检查总能量是否≥总消耗(如果不够,任何起点都不行)。然后一次遍历找起点:当前累计能量变负数时,说明之前的所有站点都不能作为起点,起点必须在当前位置之后!
关键洞察
数学定理:如果总油量 ≥ 总消耗,则一定存在唯一解! 我们只需要找到这个起点。
🧠 解题思维链
这一节模拟你在面试中"从零开始思考"的过程。
Step 1:理解题目 → 锁定输入输出
- 输入:
gas[i]= 第 i 站加油量,cost[i]= 从 i 到 i+1 的消耗 - 输出:起始加油站编号,无解返回 -1
- 限制:环形数组,油箱无限容量
Step 2:先想笨办法(暴力法)
从每个站点 i 出发,模拟绕行一圈,检查油量是否始终 ≥ 0。
- 时间复杂度:O(n²) — n 个起点,每个起点模拟 n 步
- 瓶颈在哪:重复模拟,大量冗余计算
Step 3:瓶颈分析 → 优化方向
关键观察:
- 总油量 < 总消耗 → 无解(这是必要条件)
- 如果从 i 出发,到达 j 时油量变负 → i 到 j 之间的任何站点都不能作为起点!
- 为什么?因为从 i+1, i+2, ..., j-1 出发,到达 j 时油量只会更少(少了前面的累积)
优化思路:
- 先检查总油量 ≥ 总消耗,不满足直接返回 -1
- 一次遍历,维护当前累计油量,变负数时更新起点为下一个位置
Step 4:选择武器
- 选用:贪心算法 + 数学推理
- 理由:利用总油量判定可行性,贪心选择起点,一次遍历 O(n)
🔑 模式识别提示:当题目出现"环形结构 + 累积约束",考虑"贪心 + 数学证明"
🔑 解法一:暴力模拟(超时,仅用于理解)
思路
枚举每个起点,模拟绕行一圈,检查油量是否始终非负。
图解过程
gas = [1, 2, 3, 4, 5]
cost = [3, 4, 5, 1, 2]
尝试起点 0:
0→1: 1-3=-2 ❌ 失败
尝试起点 1:
1→2: 2-4=-2 ❌ 失败
尝试起点 2:
2→3: 3-5=-2 ❌ 失败
尝试起点 3:
3→4: 4-1=3 ✅
4→0: 3+5-2=6 ✅
0→1: 6+1-3=4 ✅
1→2: 4+2-4=2 ✅
2→3: 2+3-5=0 ✅ 成功!
返回 3
Python代码
from typing import List
def canCompleteCircuit_brute(gas: List[int], cost: List[int]) -> int:
"""
解法一:暴力模拟
思路:枚举每个起点,模拟绕行一圈
"""
n = len(gas)
for start in range(n):
tank = 0
success = True
# 模拟从 start 出发绕行一圈
for i in range(n):
current = (start + i) % n
tank += gas[current]
tank -= cost[current]
if tank < 0:
success = False
break
if success:
return start
return -1
# ✅ 测试
print(canCompleteCircuit_brute([1,2,3,4,5], [3,4,5,1,2])) # 期望输出:3
print(canCompleteCircuit_brute([2,3,4], [3,4,3])) # 期望输出:-1
复杂度分析
- 时间复杂度:O(n²) — 外层 n 个起点,内层模拟 n 步
- 具体地说:如果 n=1000,需要约 1,000,000 次操作
- 空间复杂度:O(1) — 只用常数变量
优缺点
- ✅ 直观,易于理解
- ❌ 时间复杂度过高,LeetCode 超时
🏆 解法二:一次遍历贪心(最优解)
优化思路
核心策略:
- 先判断可行性:
sum(gas) >= sum(cost)→ 有解,否则无解 - 一次遍历找起点:
- 维护当前累计油量
tank - 如果
tank < 0,说明当前起点及之前的所有站点都不行,起点设为下一个位置 - 最终剩下的起点就是答案
- 维护当前累计油量
数学证明(重要!):
- 如果总油量 ≥ 总消耗,则一定存在解
- 如果从 i 到 j 失败(tank < 0),则 i, i+1, ..., j 都不能作为起点
- 因为从 i+1 出发,到 j 时油量 = (从 i+1 到 j 的净增) < (从 i 到 j 的净增) < 0
💡 关键想法:总油量够 → 一定有解!一次遍历贪心跳过不可能的起点!
图解过程
gas = [1, 2, 3, 4, 5]
cost = [3, 4, 5, 1, 2]
Step 1:检查可行性
total_gas = 1+2+3+4+5 = 15
total_cost = 3+4+5+1+2 = 15
15 >= 15 ✅ 有解!
Step 2:一次遍历找起点
i=0: tank=0, gain=1-3=-2 < 0 → 起点不能是 0,start=1
i=1: tank=0, gain=2-4=-2 < 0 → 起点不能是 0~1,start=2
i=2: tank=0, gain=3-5=-2 < 0 → 起点不能是 0~2,start=3
i=3: tank=0, gain=4-1=3 ✅ tank=3
i=4: tank=3, gain=5-2=3 ✅ tank=6
遍历结束,start=3,返回 3
验证:从 3 出发能否绕行一圈?
3→4: 4-1=3
4→0: 3+5-2=6
0→1: 6+1-3=4
1→2: 4+2-4=2
2→3: 2+3-5=0 ✅
Python代码
def canCompleteCircuit(gas: List[int], cost: List[int]) -> int:
"""
解法二:一次遍历贪心(最优解)
思路:总油量够则有解,贪心找起点
"""
# Step 1:判断可行性
total_gas = sum(gas)
total_cost = sum(cost)
if total_gas < total_cost:
return -1 # 总油量不足,无解
# Step 2:一次遍历找起点
tank = 0 # 当前累计油量
start = 0 # 起始加油站
for i in range(len(gas)):
tank += gas[i] - cost[i]
# 如果油量变负,说明 start~i 都不能作为起点
if tank < 0:
start = i + 1 # 起点设为下一个位置
tank = 0 # 重置油量
return start
# ✅ 测试
print(canCompleteCircuit([1,2,3,4,5], [3,4,5,1,2])) # 期望输出:3
print(canCompleteCircuit([2,3,4], [3,4,3])) # 期望输出:-1
print(canCompleteCircuit([5,1,2,3,4], [4,4,1,5,1])) # 期望输出:4
print(canCompleteCircuit([2], [2])) # 期望输出:0
复杂度分析
- 时间复杂度:O(n) — 计算总和 O(n) + 一次遍历 O(n)
- 具体地说:如果 n=10000,仅需约 20000 次操作
- 空间复杂度:O(1) — 只用常数变量
为什么是最优?
- 时间 O(n) 已是理论最优(至少要遍历一次数组)
- 贪心策略保证正确性,无需回溯
优缺点
- ✅ 时间 O(n),空间 O(1),性能最优
- ✅ 代码简洁,易于实现
- ✅ 数学证明严谨,正确性有保障
🐍 Pythonic 写法
利用 Python 的 sum() 和列表推导:
def canCompleteCircuit(gas: List[int], cost: List[int]) -> int:
# 检查可行性
if sum(gas) < sum(cost):
return -1
# 一次遍历找起点
tank, start = 0, 0
for i, (g, c) in enumerate(zip(gas, cost)):
tank += g - c
if tank < 0:
start, tank = i + 1, 0
return start
解释:
zip(gas, cost)同时遍历两个数组enumerate()获取索引和值- 代码更紧凑,逻辑清晰
⚠️ 面试建议:先写清晰版本展示思路,再提 Pythonic 写法展示语言功底。 面试官更看重你的思考过程,而非代码行数。
📊 解法对比
| 维度 | 解法一:暴力模拟 | 🏆 解法二:一次遍历贪心(最优) |
|---|---|---|
| 时间复杂度 | O(n²) | O(n) ← 时间最优 |
| 空间复杂度 | O(1) | O(1) |
| 代码难度 | 简单 | 简单 |
| 面试推荐 | ⭐ | ⭐⭐⭐ ← 首选 |
| 适用场景 | 仅用于理解 | 通用,面试首选 |
为什么是最优解:
- 时间 O(n) 已达理论下限(至少要看一遍所有数据)
- 贪心策略 + 数学证明保证正确性
- 代码简洁,易于实现和理解
面试建议:
- 先口述核心思路:"总油量够则有解,一次遍历贪心找起点"
- 重点强调数学证明:"为什么从 i 到 j 失败,则 i~j 都不能作起点"
- 手动演示示例,展示贪心跳过不可能起点的过程
- 强调为什么这是最优:时间 O(n) 理论最优,数学严谨
🎤 面试现场
模拟面试中的完整对话流程,帮你练习"边想边说"。
面试官:请你解决一下这道题。
你:(审题30秒)好的,这道题要求在环形加油站中找到一个起点,能够绕行一圈。让我先想一下...
我的第一个想法是枚举每个起点,模拟绕行一圈,时间复杂度 O(n²)。
但我发现一个关键性质:如果总油量 ≥ 总消耗,则一定有解!这是因为环形结构,油量的盈余和亏损会相互抵消。
所以优化策略是:
- 先检查
sum(gas) >= sum(cost),不满足直接返回 -1 - 一次遍历找起点:如果当前累计油量变负,说明之前的起点都不行,起点设为下一个位置
时间复杂度 O(n),空间 O(1)。
面试官:为什么从 i 到 j 失败,则 i 到 j 之间的点都不能作起点?
你:关键观察:如果从 i 出发,到 j 时油量变负,那么从 i+1, i+2, ..., j-1 出发,到 j 时油量只会更少!
因为从 i+1 出发少了 i 站的油量累积,到 j 时的油量 = (从 i+1 到 j 的净增) < (从 i 到 j 的净增) < 0。
所以一旦失败,可以贪心地跳过这一段,起点设为 j+1。
面试官:很好,请写一下代码。
你:(边写边说)首先检查总油量,然后维护 tank 和 start,遍历过程中如果 tank < 0 就更新起点。
面试官:测试一下?
你:用示例 gas=[1,2,3,4,5], cost=[3,4,5,1,2] 走一遍...
总油量 = 15,总消耗 = 15,有解 ✅
i=0: tank=-2 → start=1
i=1: tank=-2 → start=2
i=2: tank=-2 → start=3
i=3: tank=3 ✅
i=4: tank=6 ✅
返回 3 ✅
再测一个无解的:gas=[2,3,4], cost=[3,4,3]
总油量 = 9,总消耗 = 10,返回 -1 ✅
高频追问
| 追问 | 应答策略 |
|---|---|
| "为什么总油量≥总消耗就一定有解?" | "数学定理:环形结构中,如果总盈余≥0,则必存在一个起点使得累计盈余始终≥0。证明可以用反证法:假设不存在,则每个起点都会在某处变负,累加所有段的亏损会超过总盈余,矛盾!" |
| "如果有多个解怎么办?" | "题目保证解唯一。实际上,如果总油量>总消耗,可能有多个解,但题目数据保证唯一" |
| "能否优化空间到 O(1)?" | "已经是 O(1) 了,只用了常数变量" |
| "如果要返回所有可能的起点呢?" | "需要 O(n²) 遍历每个起点,无法优化。但通常题目只要求一个解" |
🎓 知识点总结
Python技巧卡片 🐍
# 1. sum() 快速求和 — 比手动循环更简洁
total = sum(gas) # 等价于 sum = 0; for g in gas: sum += g
# 2. zip() 同时遍历多个列表 — 优雅处理多数组
for g, c in zip(gas, cost):
print(f"gas={g}, cost={c}")
# 3. 多重赋值 — 同时更新多个变量
start, tank = i + 1, 0 # 等价于 start=i+1; tank=0
💡 底层原理(选读)
为什么"总油量≥总消耗"就一定有解?
数学证明(反证法):
- 假设总油量 ≥ 总消耗,但不存在合法起点
- 则从任何起点出发,都会在某处油量变负
- 将环形路线分成若干段,每段在某处失败
- 累加所有段的亏损,总亏损 > 总盈余
- 但这与"总油量 ≥ 总消耗"矛盾!
- 因此假设不成立,必存在合法起点 ✅
贪心正确性:
- 如果从 i 到 j 失败,则 i, i+1, ..., j 都不能作起点
- 因为从 i+1 开始,少了 i 的累积,到 j 时更差
- 所以可以贪心跳过这一段,起点设为 j+1
算法模式卡片 📐
- 模式名称:环形数组贪心 + 数学推理
- 适用条件:环形结构,累积约束,求起点/分割点
- 识别关键词:"环形"、"绕行一圈"、"累积和"、"起点"
- 模板代码:
def find_start_in_circle(gains):
# 1. 检查可行性(总增益 ≥ 0)
if sum(gains) < 0:
return -1
# 2. 一次遍历找起点
tank = 0
start = 0
for i, gain in enumerate(gains):
tank += gain
if tank < 0:
start = i + 1
tank = 0
return start
易错点 ⚠️
-
忘记检查总油量:直接遍历找起点,遗漏无解情况
- 为什么错:总油量不足时不存在解,但代码可能返回错误答案
- 正确做法:先检查
sum(gas) >= sum(cost)
-
起点更新错误:设为
i而非i+1- 为什么错:i 已经证明不行,应该从 i+1 重新开始
- 正确做法:
start = i + 1
-
忘记重置 tank:更新起点后 tank 没有归零
- 为什么错:新起点应该从空油箱开始计算
- 正确做法:
tank = 0同时更新
🏗️ 工程实战(选读)
这个算法思想在真实项目中的应用,让你知道"学了有什么用"。
- 场景1:物流配送中,找到最优配送起点,确保全程载重不超限
- 场景2:能源管理中,找到电网的最优供电节点,确保全网电压稳定
- 场景3:游戏设计中,环形地图的资源分配,找到最优出生点
🏋️ 举一反三
完成本课后,试试这些同类题目来巩固知识:
| 题目 | 难度 | 相关知识点 | 提示 |
|---|---|---|---|
| LeetCode 135. 分发糖果 | Hard | 贪心 + 两次遍历 | 左右各一次贪心 |
| LeetCode 376. 摆动序列 | Medium | 贪心 | 贪心统计波峰波谷 |
| LeetCode 253. 会议室 II | Medium | 贪心 + 堆 | 最少会议室数量 |
📝 课后小测
试试这道变体题,不要看答案,自己先想5分钟!
题目:如果油箱容量有限制 capacity,如何判断能否绕行一圈?
💡 提示(实在想不出来再点开)
需要在遍历过程中同时检查 tank <= capacity 和 tank >= 0 两个约束!
✅ 参考答案
def canCompleteCircuitWithLimit(gas: List[int], cost: List[int], capacity: int) -> int:
"""
变体:油箱容量有限制
策略:遍历时检查油量上下界
"""
if sum(gas) < sum(cost):
return -1
n = len(gas)
for start in range(n):
tank = 0
success = True
for i in range(n):
current = (start + i) % n
tank += gas[current]
# 检查容量上限
if tank > capacity:
success = False
break
tank -= cost[current]
# 检查油量下限
if tank < 0:
success = False
break
if success:
return start
return -1
核心思路:有容量限制后,贪心策略失效,需要回到 O(n²) 暴力模拟,因为容量约束打破了"总量够就有解"的定理。
如果这篇内容对你有帮助,推荐收藏 AI Compass:github.com/tingaicompa… 更多系统化题解、编程基础和 AI 学习资料都在这里,后续复习和拓展会更省时间。