LeetCode 134. 加油站:从暴力验证到贪心最优解

72 阅读7分钟

在 LeetCode 中等难度题目中,134. 加油站是一道经典的数组应用题,核心考察对“循环路径”和“油量平衡”的逻辑分析能力。题目看似复杂,但通过逐步拆解,能从暴力验证思路优化到贪心最优解,两种解法各有侧重,适合不同层次的理解需求。本文将详细讲解题目背景、两种解法的逻辑的细节,以及优化思路的推导过程。

一、题目分析

题目描述

一条环路上有 n 个加油站,第 i 个加油站有汽油 gas[i] 升。汽车油箱容量无限,从第 i 个加油站开往第 i+1 个加油站需消耗汽油 cost[i] 升,初始油箱为空。若能按顺序绕环路行驶一周,返回出发加油站编号;否则返回 -1。题目保证若存在解,则解唯一。

核心条件

  1. 环路特性:最后一个加油站的下一站是第一个加油站,需处理索引循环问题。

  2. 油量平衡:行驶过程中油箱油量不能为负,否则无法到达下一站。

  3. 解的唯一性:若存在有效起点,仅需找到这一个即可。

关键前提

若所有加油站的总油量 sum(gas) < 总消耗 sum(cost),则必然无法绕环一周,直接返回 -1;若 sum(gas) ≥ sum(cost),则必然存在唯一有效起点(题目保证解唯一)。这一前提是两种解法的共同基础,可快速排除无解场景。

二、解法一:候选起点暴力验证(易懂优先)

思路推导

既然存在解时唯一,且只有 sum(gas) ≥ sum(cost) 才有解,我们可以先筛选出“潜在有效起点”,再逐个验证是否能绕环一周。潜在起点的筛选逻辑的:从该加油站出发时,油量 gas[i] ≥ 消耗 cost[i],否则第一步就会油量不足,直接排除。

步骤拆解:

  1. 计算总油量和总消耗,若总消耗更大,直接返回 -1。

  2. 遍历所有加油站,筛选出 gas[i] ≥ cost[i] 的候选起点,存入列表。

  3. 对每个候选起点,模拟绕环过程:从起点出发,累计油箱油量,依次经过每个加油站,若中途油量为负则该起点无效,换下一个候选验证;若能绕环一周,则返回该起点。

代码实现


function canCompleteCircuit_1(gas: number[], cost: number[]): number {
  const nodeL = gas.length;
  let gasSum = 0;
  let costSum = 0;
  const mayIndex = [];
  for (let i = 0; i < nodeL; i++) {
    gasSum += gas[i];
    costSum += cost[i];
    // 筛选潜在有效起点:当前加油站油量≥消耗
    if (gas[i] >= cost[i]) {
      mayIndex.push(i);
    }
  }
  // 总油量不足,直接无解
  if (costSum > gasSum) return -1;

  // 逐个验证候选起点
  for (const start of mayIndex) {
    let currentGas = 0; // 当前油箱油量
    let currentIndex = start; // 当前所在加油站索引
    let canComplete = true; // 是否能绕环

    // 模拟绕环一周,共经过 nodeL 个加油站
    for (let j = 0; j < nodeL; j++) {
      currentGas += gas[currentIndex]; // 加当前加油站的油
      currentGas -= cost[currentIndex]; // 减去前往下一站的消耗

      // 油量不足,该起点无效
      if (currentGas < 0) {
        canComplete = false;
        break;
      }

      // 移动到下一站,处理环路索引
      currentIndex = (currentIndex + 1) % nodeL;
    }

    // 验证通过,返回起点
    if (canComplete) {
      return start;
    }
  }
  // 理论上sum(gas)≥sum(cost)时必有解,此处为兜底
  return -1;
};

复杂度分析

  • 时间复杂度:O(n²)。最坏情况下,候选起点数量为 n,每个起点需遍历 n 个加油站验证,总操作数为 n²。

  • 空间复杂度:O(n)。需存储候选起点列表,最坏情况下存储所有 n 个加油站索引。

优缺点

优点:逻辑直观,容易理解,适合新手入门,无需复杂算法思维,仅通过模拟就能得到结果。

缺点:效率较低,在 n 较大(如 10⁴ 级别)时会超时,仅适用于小规模数据。

三、解法二:贪心算法(最优解)

优化思路推导

暴力解法的核心问题是“重复验证无效起点”,我们可以通过贪心策略减少无效验证,将时间复杂度降至 O(n)。关键观察如下:

假设从起点 s 出发,行驶到第 i 个加油站时油量不足(currentGas < 0),则 s 到 i 之间的所有加油站都不能作为有效起点。原因:从 s 到 i-1 时油量均为非负,若从 s+1 出发,少了 s 站的油量补充,只会更早出现油量不足,同理 s 到 i 之间的所有站点都无需验证。

基于此,我们可以在一次遍历中完成“累计油量计算”和“起点更新”,无需候选列表。

算法逻辑

  1. 维护三个变量:totalGas(总油量差值 sum(gas[i]-cost[i]))、currentGas(当前油箱油量)、start(候选起点)。

  2. 遍历每个加油站,计算当前站点的油量差值 diff = gas[i] - cost[i],累计到 totalGas 和 currentGas。

  3. 若 currentGas < 0,说明从当前 start 到 i 之间的站点均无效,将 start 更新为 i+1,同时重置 currentGas 为 0(新起点从空油箱开始)。

  4. 遍历结束后,若 totalGas ≥ 0,返回 start(唯一有效起点);否则返回 -1。

代码实现


function canCompleteCircuit_2(gas: number[], cost: number[]): number {
  const n = gas.length;
  let totalGas = 0; // 总油量差值(替代sum(gas)-sum(cost))
  let currentGas = 0; // 当前油箱油量
  let start = 0; // 候选起点

  for (let i = 0; i < n; i++) {
    const diff = gas[i] - cost[i];
    totalGas += diff;
    currentGas += diff;

    // 关键贪心逻辑:当前油量不足,更新起点为下一站
    if (currentGas < 0) {
      start = i + 1;
      currentGas = 0;
    }
  }

  // 总油量足够则返回起点,否则无解
  return totalGas >= 0 ? start : -1;
};

复杂度分析

  • 时间复杂度:O(n)。仅需遍历一次数组,每个元素操作一次,效率最优。

  • 空间复杂度:O(1)。仅用三个变量存储状态,无额外空间消耗。

核心疑问解答

Q:为什么遍历结束后 start 就是唯一有效起点?

A:因为 totalGas ≥ 0 时必然存在解,且我们通过贪心策略跳过了所有无效起点(s 到 i 之间的站点),最终剩下的 start 是唯一可能的有效起点,无需额外验证。

Q:若 start 超过数组长度怎么办?

A:由于 totalGas ≥ 0 时必有解,遍历结束后 start 一定在 0~n-1 范围内(若 start = n,说明前 n 个站点均无效,但 totalGas ≥ 0 矛盾,故不可能出现)。

四、两种解法对比与实战建议

解法时间复杂度空间复杂度适用场景核心优势
暴力验证O(n²)O(n)小规模数据、面试快速上手逻辑直观,易于调试
贪心算法O(n)O(1)大规模数据、算法优化场景效率最优,空间消耗低

实战建议:面试时,若一时无法想到贪心策略,可先写出暴力解法,再基于“跳过无效起点”的思路推导贪心优化,体现逻辑递进能力;刷题时直接使用贪心算法,应对大规模测试用例更高效。

五、测试用例验证

通过以下测试用例验证两种解法的正确性,覆盖单节点、常规环路、无解场景:


// 测试用例1:单节点(油量等于消耗)
console.log(canCompleteCircuit_1([2], [2])); // 0
console.log(canCompleteCircuit_2([2], [2])); // 0

// 测试用例2:常规环路(有唯一解)
console.log(canCompleteCircuit_1([1,2,3,4,5], [3,4,5,1,2])); // 3
console.log(canCompleteCircuit_2([1,2,3,4,5], [3,4,5,1,2])); // 3

// 测试用例3:无解场景(总油量不足)
console.log(canCompleteCircuit_1([2], [3])); // -1
console.log(canCompleteCircuit_2([2], [3])); // -1

// 测试用例4:多个候选起点
console.log(canCompleteCircuit_1([3,1,1], [1,2,2])); // 0
console.log(canCompleteCircuit_2([3,1,1], [1,2,2])); // 0

六、总结

LeetCode 134. 加油站的核心是“油量平衡”与“无效起点排除”。暴力解法通过筛选候选起点+模拟绕环,降低了思维难度;贪心算法则通过关键观察跳过无效起点,实现了时间和空间的最优解。

解题关键在于理解“sum(gas) ≥ sum(cost) 是有解的必要条件”,以及贪心策略中“无效起点区间跳过”的合理性。掌握这两种解法,既能应对不同场景的需求,也能加深对数组循环问题和贪心思想的理解,为后续复杂算法题打下基础。