📖 第70课:加油站

2 阅读14分钟

想系统提升编程能力、查看更完整的学习路线,欢迎访问 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] 升。

你从其中的一个加油站出发,开始时油箱为空。给定两个整数数组 gascost,如果你可以按顺序绕环路行驶一周,则返回出发时加油站的编号,否则返回 -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.length
  • 1 <= n <= 10^5
  • 0 <= 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:瓶颈分析 → 优化方向

关键观察:

  1. 总油量 < 总消耗 → 无解(这是必要条件)
  2. 如果从 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 超时

🏆 解法二:一次遍历贪心(最优解)

优化思路

核心策略:

  1. 先判断可行性:sum(gas) >= sum(cost) → 有解,否则无解
  2. 一次遍历找起点:
    • 维护当前累计油量 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) 已达理论下限(至少要看一遍所有数据)
  • 贪心策略 + 数学证明保证正确性
  • 代码简洁,易于实现和理解

面试建议:

  1. 先口述核心思路:"总油量够则有解,一次遍历贪心找起点"
  2. 重点强调数学证明:"为什么从 i 到 j 失败,则 i~j 都不能作起点"
  3. 手动演示示例,展示贪心跳过不可能起点的过程
  4. 强调为什么这是最优:时间 O(n) 理论最优,数学严谨

🎤 面试现场

模拟面试中的完整对话流程,帮你练习"边想边说"。

面试官:请你解决一下这道题。

:(审题30秒)好的,这道题要求在环形加油站中找到一个起点,能够绕行一圈。让我先想一下...

我的第一个想法是枚举每个起点,模拟绕行一圈,时间复杂度 O(n²)。

但我发现一个关键性质:如果总油量 ≥ 总消耗,则一定有解!这是因为环形结构,油量的盈余和亏损会相互抵消。

所以优化策略是:

  1. 先检查 sum(gas) >= sum(cost),不满足直接返回 -1
  2. 一次遍历找起点:如果当前累计油量变负,说明之前的起点都不行,起点设为下一个位置

时间复杂度 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。

面试官:很好,请写一下代码。

:(边写边说)首先检查总油量,然后维护 tankstart,遍历过程中如果 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

💡 底层原理(选读)

为什么"总油量≥总消耗"就一定有解?

数学证明(反证法):

  1. 假设总油量 ≥ 总消耗,但不存在合法起点
  2. 则从任何起点出发,都会在某处油量变负
  3. 将环形路线分成若干段,每段在某处失败
  4. 累加所有段的亏损,总亏损 > 总盈余
  5. 但这与"总油量 ≥ 总消耗"矛盾!
  6. 因此假设不成立,必存在合法起点 ✅

贪心正确性:

  • 如果从 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

易错点 ⚠️

  1. 忘记检查总油量:直接遍历找起点,遗漏无解情况

    • 为什么错:总油量不足时不存在解,但代码可能返回错误答案
    • 正确做法:先检查 sum(gas) >= sum(cost)
  2. 起点更新错误:设为 i 而非 i+1

    • 为什么错:i 已经证明不行,应该从 i+1 重新开始
    • 正确做法:start = i + 1
  3. 忘记重置 tank:更新起点后 tank 没有归零

    • 为什么错:新起点应该从空油箱开始计算
    • 正确做法:tank = 0 同时更新

🏗️ 工程实战(选读)

这个算法思想在真实项目中的应用,让你知道"学了有什么用"。

  • 场景1:物流配送中,找到最优配送起点,确保全程载重不超限
  • 场景2:能源管理中,找到电网的最优供电节点,确保全网电压稳定
  • 场景3:游戏设计中,环形地图的资源分配,找到最优出生点

🏋️ 举一反三

完成本课后,试试这些同类题目来巩固知识:

题目难度相关知识点提示
LeetCode 135. 分发糖果Hard贪心 + 两次遍历左右各一次贪心
LeetCode 376. 摆动序列Medium贪心贪心统计波峰波谷
LeetCode 253. 会议室 IIMedium贪心 + 堆最少会议室数量

📝 课后小测

试试这道变体题,不要看答案,自己先想5分钟!

题目:如果油箱容量有限制 capacity,如何判断能否绕行一圈?

💡 提示(实在想不出来再点开)

需要在遍历过程中同时检查 tank <= capacitytank >= 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 学习资料都在这里,后续复习和拓展会更省时间。