134. 加油站
目录
题目链接:
题目:
解题思路:
代码:
总结:
题目链接:
134. 加油站 - 力扣(LeetCode)
题目:
解题思路:
使用前缀和,若前缀和小于0,说明前面都不可以作为起始点,(可以举一个例子尝试一下(如有区间1和区间2,两段和小于0,而区间2的和大于0,那么区间1的和肯定<0,那么肯定还得重新找前缀和))最后如果加油量小于消耗量,肯定没办法走一圈,
代码:
class Solution {public int canCompleteCircuit(int[] gas, int[] cost) {int sum=0;int cur=0;int idx=0;for(int i=0;i<gas.length;i++){sum+=gas[i]-cost[i];cur+=gas[i]-cost[i];if(cur<0){idx=i+1;cur=0;}}if(sum<0){return -1;}return idx;}
}
加油站问题的一次优雅征服:从暴力到贪心的算法之旅
在算法的世界里,有些问题初看之下似乎只能用蛮力解决,但深入思考后,往往能发现其内在的规律,从而找到令人拍案叫绝的高效解法。今天我们要探讨的 “加油站问题”(Gas Station Problem)就是这样一个典型的例子。
我将带你从最直观的暴力解法入手,分析其缺陷,然后一步步引导你发现问题的核心规律,最终理解并掌握那个简洁而高效的贪心算法。
一、问题重述:我们要解决什么?
首先,让我们清晰地理解问题:
场景:有一个环形的路线,上面有 N 个加油站。
资源:
gas[i]:第 i 个加油站可以加的油量。
cost[i]:从第 i 个加油站开到下一个加油站(第 (i+1)%N 个)需要消耗的油量。
目标:你有一辆油箱容量无限的车,从其中一个加油站出发,尝试绕这个环形路线一周。你需要判断是否存在这样一个起点,使得你可以成功完成绕行。如果存在,返回这个起点的索引;如果不存在,返回 -1。
约束:
车在任何时候的油量都不能为负数。
只能按顺序从一个加油站开到下一个,不能跳着开。
这个问题的关键在于找到一个 “幸运” 的起点,从这个点开始,你的油量 “收支” 在整个环路上始终保持非负。
二、直觉与暴力:最直接的想法
面对这个问题,最直接的想法是什么?
暴力解法思路:
遍历每一个加油站 i,将其视为候选起点。
从起点 i 出发,模拟绕环一周的过程。
在模拟过程中,不断累加剩余油量 tank。tank += gas[j] - cost[j]。
如果在任何时候 tank 变为负数,说明从起点 i 出发无法完成绕行,立即停止模拟,并尝试下一个起点 i+1。
如果成功绕行一周(回到起点)且 tank 始终非负,说明找到了答案,返回 i。
如果遍历完所有起点都失败,返回 -1。
暴力解法代码(示意):
java
运行
public int canCompleteCircuitBruteForce(int[] gas, int[] cost) {
int n = gas.length;
for (int i = 0; i < n; i++) {
int tank = 0;
boolean canComplete = true;
for (int j = 0; j < n; j++) {
int currentStation = (i + j) % n;
tank += gas[currentStation] - cost[currentStation];
if (tank < 0) {
canComplete = false;
break;
}
}
if (canComplete) {
return i;
}
}
return -1;
}
暴力解法分析:
时间复杂度:O(N^2)。因为我们有 N 个起点,每个起点最多需要遍历 N 个加油站。在 N 很大时(例如 N=10^5),这种解法会超时。
空间复杂度:O(1)。只使用了几个额外变量。
暴力解法虽然能解决问题,但效率低下。我们需要一种更聪明的方法。
三、洞察与飞跃:贪心算法的核心思想
让我们跳出模拟的思维,转而分析问题的本质。
核心观察 1:整体可行性判断
一个最基本的事实是:如果所有加油站的总油量 total_gas 小于所有路段的总消耗 total_cost,那么无论从哪里出发,都不可能绕环一周。
用数学语言表达就是:sum(gas[0...n-1]) < sum(cost[0...n-1]) => 必然无解,返回 -1。
这是一个全局的判断标准。如果这个条件不满足,我们就可以直接宣布失败。
核心观察 2:局部失败的启示(贪心的关键)
现在,假设总油量是足够的 (sum(gas) >= sum(cost)),那么必然存在至少一个起点可以成功绕行。问题是如何快速找到它。
我们来思考一下失败的情况。
假设我们从起点 start 出发,一路开到了第 i 个加油站。在到达第 i 个加油站后,我们的剩余油量为 tank。接下来,我们要从第 i 个加油站开到第 i+1 个。
tank += gas[i] (在第 i 站加油)
tank -= cost[i] (从第 i 站开到第 i+1 站)
如果在这个过程之后,tank 变成了负数,意味着什么?
意味着:从 start 到 i 之间的任何一个加油站 k,都不可能作为成功的起点。
为什么?
我们从 start 出发,到达 k 时,我们的油量 tank(start -> k) 一定是大于等于 0 的。否则我们在 k 之前就已经失败了。
我们从 k 出发,最终在 i -> i+1 这一段失败了。这说明 tank(k -> i+1) < 0。
现在,假设我们神奇地从 start 直接 “传送” 到 k,并且此时的油量就是 tank(start -> k)。那么我们从 k 出发的最终油量将是 tank(start -> k) + tank(k -> i+1)。
因为 tank(start -> k) >= 0 且 tank(k -> i+1) < 0,所以 tank(start -> k) + tank(k -> i+1) 必然小于 tank(k -> i+1)。
这意味着,如果从 k 出发都无法到达 i+1,那么从 start 出发(到达 k 时还有剩余油量)也同样无法到达 i+1。我们已经证明了这一点。
结论:一旦我们在 i -> i+1 段失败,所有位于 start 和 i 之间(包括 start,不包括 i+1)的加油站都可以被排除掉,不再作为候选起点。
这个发现是决定性的!它告诉我们,我们不需要回溯去检查那些已经被证明不可能的起点。
四、算法设计:优雅的贪心实现
基于以上两个核心观察,我们可以设计出贪心算法的步骤:
初始化变量:
totalTank:用于累加整个环路的总油量与总消耗的差值。用于最终判断是否存在解。
currentTank:用于累加从当前候选起点出发的剩余油量。
startIndex:记录当前的候选起点,初始化为 0。
遍历加油站:从第一个加油站(索引 0)开始遍历整个环路。
更新油量:在每一个加油站 i,更新 totalTank 和 currentTank。
plaintext
totalTank += gas[i] - cost[i];
currentTank += gas[i] - cost[i];
判断并更新起点:如果在到达加油站 i 后,currentTank 变为负数,这意味着从 startIndex 到 i 的这段路是 “亏本” 的,我们无法从 startIndex 出发到达 i+1。根据我们的核心观察 2,我们需要:
将候选起点更新为 i+1。因为 startIndex 到 i 之间的任何点都不可能是解。
重置 currentTank 为 0。因为我们将从新的起点 i+1 重新开始计算剩余油量。
最终判断:遍历完所有加油站后,我们检查 totalTank:
如果 totalTank < 0:说明总油量不足,无法绕行一周,返回 -1。
如果 totalTank >= 0:说明总油量足够,根据问题的数学性质,必然存在一个解。而我们在遍历过程中通过不断更新 startIndex,最终得到的 startIndex 就是那个唯一的(或其中一个)可行起点。返回 startIndex。
为什么最终的 startIndex 一定是解?因为我们已经排除了所有不可能的起点。当遍历结束时,如果总油量足够,那么剩下的这个 startIndex 必然是那个能让你 “一路绿灯” 的幸运起点。
五、代码解析:一行一行读懂它
现在,让我们来解析你提供的这段代码,它正是上述贪心算法的完美实现。
java
运行
class Solution {
public int canCompleteCircuit(int[] gas, int[] cost) {
// 1. 初始化变量
int totalTank = 0; // 用于累加整个环路的总盈亏
int currentTank = 0; // 用于累加从当前起点开始的盈亏
int startIndex = 0; // 记录当前的候选起点
// 2. 遍历每一个加油站
for (int i = 0; i < gas.length; i++) {
// 3. 更新总盈亏和当前盈亏
totalTank += gas[i] - cost[i];
currentTank += gas[i] - cost[i];
// 4. 如果从当前起点出发,到不了下一站 (i -> i+1)
if (currentTank < 0) {
// 说明从 startIndex 到 i 的任何一个点都不能作为起点
// 将下一个点 i+1 设为新的候选起点
startIndex = i + 1;
// 重置当前盈亏,因为要从新起点开始计算
currentTank = 0;
}
}
// 5. 遍历结束后,根据总盈亏判断是否存在解
// 如果总油量 < 总消耗,返回 -1
if (totalTank < 0) {
return -1;
}
// 如果总油量 >= 总消耗,那么我们找到的 startIndex 就是解
return startIndex;
}
}
代码执行流程模拟:让我们用一个例子来走一遍流程:gas = [1, 2, 3, 4, 5], cost = [3, 4, 5, 1, 2]
i = 0:
totalTank += 1-3 → totalTank = -2
currentTank += 1-3 → currentTank = -2
currentTank < 0 为 true。
startIndex 更新为 0+1=1。
currentTank 重置为 0。
i = 1:
totalTank += 2-4 → totalTank = -4
currentTank += 2-4 → currentTank = -2
currentTank < 0 为 true。
startIndex 更新为 1+1=2。
currentTank 重置为 0。
i = 2:
totalTank += 3-5 → totalTank = -6
currentTank += 3-5 → currentTank = -2
currentTank < 0 为 true。
startIndex 更新为 2+1=3。
currentTank 重置为 0。
i = 3:
totalTank += 4-1 → totalTank = -3
currentTank += 4-1 → currentTank = 3
currentTank < 0 为 false。什么也不做。
i = 4:
totalTank += 5-2 → totalTank = 0
currentTank += 5-2 → currentTank = 6
currentTank < 0 为 false。什么也不做。
遍历结束:
totalTank 的值为 0,满足 >= 0 的条件。
最终的 startIndex 的值为 3。
函数返回 3。这与我们手动计算的结果一致(从索引 3 的加油站出发,油量变化为:4→3→6→5→0,始终非负)。
六、复杂度分析与总结
复杂度分析
时间复杂度:O(N)。我们只需要对加油站数组进行一次完整的遍历。
空间复杂度:O(1)。我们只使用了常数个额外变量来存储状态,与输入数组的大小无关。
总结
这个贪心算法之所以优雅,在于它:
高效:O(N) 的时间复杂度,完美适用于大规模数据。
简洁:代码实现非常简短,逻辑清晰。
深刻:它并非凭空猜测,而是建立在对问题本质(局部失败与全局可行性)的深刻洞察之上。
这个问题告诉我们,面对看似复杂的循环或最优解问题时,不要急于用暴力去破解。尝试去分析失败的模式,寻找可以 “剪枝” 或 “跳跃” 的机会,往往能让你找到通往高效算法的捷径。这种从失败中学习并调整策略的思想,正是贪心算法的精髓所在。
希望这篇文章能帮助你彻底理解这个问题和解法!
总结:
本文介绍了如何高效解决LeetCode 134题的加油站问题。通过分析问题本质,提出了贪心算法的优化解法:1) 使用前缀和判断可行性;2) 当当前油量为负时,直接跳过不可能作为起点的区间。该算法只需一次遍历,时间复杂度O(N),空间复杂度O(1)。相比暴力解法,该方法通过数学洞察大幅提升了效率,是贪心算法应用的经典案例。