LeetCode 134 加油站:从双循环暴力 到 贪心
- 一、最开始的想法:每个起点都试一遍
- 二、每走一步,油箱到底发生了什么?
- 三、暴力方法的问题:大量路段被重复计算
- 四、一次失败为什么能排除一整段起点?
- [五、怎么减少 for的使用?](#五、怎么减少 for的使用?)
- [六、失败以后要操作什么 ?](#六、失败以后要操作什么 ?)
- 七、但题目不是环形的吗?为什么只扫描一遍?
- 八、还需要什么参数呢?
- 九、为什么这叫贪心?
- 十、思路确定以后再写代码
- 十一、复杂度变化
这道题给了两个数组:
text
gas[i]
表示在第 i 个加油站可以获得多少油;
text
cost[i]
表示从第 i 个加油站开到下一个加油站需要消耗多少油。
整个加油站首尾相连:
text
0 → 1 → 2 → ... → n-1
↑ ↓
└─────────────────┘
题目要求找到一个起点,使得从这里出发后能够完整绕一圈,再回到这个起点。
一、最开始的想法:每个起点都试一遍
我最开始的想法很直接:
既然不知道哪个位置能作为起点,那就每一个都试一次。
于是第一层循环负责枚举起点:
text
i = 0 → 从 0 出发试一圈
i = 1 → 从 1 出发试一圈
i = 2 → 从 2 出发试一圈
选定起点以后,还需要真的沿着环往前走,所以自然还需要第二层循环作为步数。
所以,整个暴力思路就是:
text
枚举一个起点
↓
从这个起点完整跑一圈
↓
中途油不够就失败
↓
换下一个起点重新跑
所以使用了两个 for 循环。。。当然也很顺利的时间超出限制了。。
所以开始了优化之路。。
那么在优化的过程中,我们要了解一下每一步在干什么,然后再去想怎么用其他的更高效的方式去替代。
二、每走一步,油箱到底发生了什么?
假设现在位于第 i 个加油站。
先在这里得到:
text
gas[i]
然后从第 i 个站开到下一个站,需要消耗:
text
cost[i]
所以这一整步结束以后,油箱的净变化就是:
text
gas[i] - cost[i]
可以把它记成:
text
diff[i] = gas[i] - cost[i]
比如:
text
gas[i] = 5
cost[i] = 3
那么:
text
diff[i] = +2
说明经过这一站并开到下一站以后,油箱净增加 2。
如果:
text
gas[i] = 2
cost[i] = 4
那么:
text
diff[i] = -2
说明这一段路会让油箱净减少 2。
所以从某个起点一路往前走,本质上就是不断累积:
text
gas[i] - cost[i]
也就是:
text
tank += gas[i] - cost[i]
这里的 tank 表示:
从当前起点出发,一直走到当前位置以后,还剩多少油。
只要某一步出现:
text
tank < 0
就说明当前这个起点已经失败了,因为连这一段都开不过去,更不用说完整绕一圈。
三、暴力方法的问题:大量路段被重复计算
假设一共有 n 个加油站。
暴力方法最多要尝试:
text
n 个起点
而每一个起点又可能完整走:
text
n 个站
所以最坏时间复杂度就是:
text
O(n²)
所以就想想什么是可以跑去的:
某个起点已经失败以后,我真的还需要把后面的每个位置重新作为起点,再把刚才那些路重新走一遍吗?
例如从:
text
start = 0
出发,一直走到:
text
i = 4
才第一次失败。
暴力方法接下来会继续:
text
从 1 出发重新跑
从 2 出发重新跑
从 3 出发重新跑
从 4 出发重新跑
这些路线里会大量重复计算刚刚已经走过的路。
所以优化的关键其实变成了:
从一次失败中,能不能得到更多信息?然后后面可以继续复用?
或许可以减少计算量
四、一次失败为什么能排除一整段起点?
假设从当前:
text
start
出发,一直到位置:
text
i
才第一次出现:
text
tank < 0
这意味着,在到 i 之前,油箱余额一直没有变成负数。
换句话说,从 start 出发经过中间某个位置时,前面实际上已经给后面的路积累了一些非负的余油。
因为能顺利开到下一个站点,就代表油是有富余的,所以从 i 车战能顺利开到下一站靠的是之前的积累
一旦油量不足,那无论从这段范围内的哪个车站开始,没有了之前的积累,还是会失败的
举个例子:
text
位置: 0 1 2 3
diff: +5 -3 +2 -5
这里:
text
diff[i] = gas[i] - cost[i]
表示经过第 i 个站,并开向下一个站以后,油箱的净变化;
而之前设定过 tank 表示从当前起点 start 出发以后,一路累计到现在,油箱里实际剩下的油。
假设:
text
start = 0
那么油箱变化就是:
text
初始:tank = 0
处理位置 0:tank = 0 + 5 = 5
处理位置 1:tank = 5 - 3 = 2
处理位置 2:tank = 2 + 2 = 4
处理位置 3:tank = 4 - 5 = -1
所以从 0 出发,第一次在位置 3 这里失败。
🚩中间位置其实一直在享受前面已经积累下来的余油。
比如考虑位置 2 能不能作为新的起点。
原来从 0 出发时,在处理位置 2 之前,油箱里已经有:
text
tank = 2
这 2 单位油就是经过位置 0、1 以后留下来的余油。
所以原来的情况其实是:
text
到达位置 2 时:tank = 2
处理位置 2 后:tank = 4
处理位置 3 后:tank = -1
也就是说,位置 2 原本并不是从 0 油量开始,它还带着前面额外留下来的 2 单位油,但即使这样,最后仍然撑不过位置 3。
那如果直接把位置 2 当作新的起点,就不会再有前面留下来的这 2 单位油,而是重新从:
text
tank = 0
开始:
text
处理位置 2:tank = 0 + 2 = 2
处理位置 3:tank = 2 - 5 = -3
反而失败得更早。
所以:
从 start 出发时,中间位置已经获得了前面累计下来的余油;
即使带着这些额外的油,最终都撑不过 i,那么直接从中间某个位置重新以 0 油量出发,只会更不可能成功。
因此,如果从 start 出发,第一次在 i 失败,那么:
text
start ~ i
之间的所有位置都可以一起排除,新的候选起点直接变成:
text
start = i + 1
不需要再把中间这些位置一个个重新尝试。
五、怎么减少 for的使用?
暴力方法中的 i 本来表示:
当前尝试哪个位置作为起点。
所以逻辑是:
text
起点 0 → 跑一圈
起点 1 → 再跑一圈
起点 2 → 再跑一圈
......
但优化以后,我们已经知道:
某个起点失败时,可以顺便排除一整段位置。
于是就不再需要每个起点重新跑一圈。
这时变量的职责发生了变化。
现在用 start 记录:
当前还没有被淘汰的候选起点。
而 i 只负责:
从左到右把整个数组扫描一次。
所以:
text
🚩 暴力版本:
i = 当前尝试的起点
🚩 到了贪心版本变成:
start = 当前候选起点
i = 当前扫描到的位置
比如:
text
0 1 2 3 4 5 6
↑
start
↑
i
一开始:
text
start = 0
i = 0
随后 i 一直往后扫描:
text
i = 1
i = 2
i = 3
i = 4
如果在:
text
i = 4
时发现:
text
tank < 0
那么:
text
0 ~ 4
全部排除,新的候选起点直接变成:
text
start = 5
然后:
text
tank = 0
从新的候选起点重新累计。
下一轮 for 本来就会让:
text
i = 5
所以根本不需要再开一个新的循环去"从 5 开始重新跑"。
i 继续往后扫描就行。
对,这里确实是前面少了一步:你前面已经做出了贪心的行为,但还没有告诉读者"这其实就是贪心",结果到了第七节突然冒出"贪心优化以后",会显得概念突然出现。
你其他地方都不用动,只需要在 第五节结尾,也就是这句后面:
text
i 继续往后扫描就行。
这里其实就是开始尝试使用 贪心 的思路了。菜鸟编程 贪心算法
前面的暴力方法是:
text
一个起点失败
→ 只知道这个起点失败
→ 换下一个起点重新尝试
而现在我们利用刚刚得到的信息进一步推出:
text
start 在 i 这里失败
↓
start ~ i 之间的起点都不可能成功
↓
这些位置以后全部不用再尝试
↓
直接把新的候选起点放到 i + 1
这种做法其实就是这道题里的贪心:
根据当前已经确定的信息,直接排除一整批不可能的选择,并且以后不再回头重新尝试。
不过, 这里的"贪心"并不是常见印象里的:
text
每次选最大的
而是:
text
能确定哪些位置不可能
→ 就立刻全部淘汰
→ 只保留下一个仍有可能的候选起点
→ 极大的减少了尝试的次数, 也是当前最有利的策略
也正因为做了这个决定,原来暴力方法里不断"换一个起点,再重新跑一遍"的工作才被省掉,于是两个 for 被逐渐压缩成了一次扫描。
六、失败以后要操作什么 ?
tank 要清零
这里的 tank 始终表示:
从当前
start出发,到当前i为止的累计净油量。
如果从:
text
start = 0
走到:
text
i = 4
失败,那么:
text
0 ~ 4
已经全部被排除。
新的候选起点是:
text
start = 5
既然现在要重新从 5 出发,那么前面那一段的油量当然不能继续带过来。
所以需要:
text
tank = 0
然后重新累计:
text
gas[5] - cost[5]
gas[6] - cost[6]
......
七、但题目不是环形的吗?为什么只扫描一遍?
这里是我后来比较困惑的地方。
假设最后得到:
text
start = 5
数组是:
text
0 1 2 3 4 5 6
↑
start
真正从 5 出发绕一圈应该是:
text
5 → 6 → 0 → 1 → 2 → 3 → 4 → 回到 5
但是现在的 for 只会:
text
0 → 1 → 2 → 3 → 4 → 5 → 6
并不会在 6 以后真的重新回到 0。
贪心优化以后,我们已经不再真的模拟"从每个候选起点开完整一圈"。
前面的:
text
0 ~ 4
对应的:
text
gas[i] - cost[i]
其实在第一次扫描时已经计算过了。
所以我们不需要真的让指针再从 6 回到 0,把这些位置重新计算一次。
不过, 仅仅知道候选起点还不够,我们还需要回答另一个问题:
整个环所有油加起来,到底够不够跑一圈?
所以还需要一个新的变量:
text
total
八、还需要什么参数呢?
每经过一个站,我们除了更新:
text
tank += gas[i] - cost[i]
还需要同时更新:
text
total += gas[i] - cost[i]
两者看起来都在累加 diff,但负责的问题不同。
tank 只统计:
从当前候选起点
start到当前位置i的累计油量。
它的作用是淘汰起点。
如果:
text
tank < 0
说明当前候选起点失败,于是:
text
start = i + 1
tank = 0
而 total 从头到尾都不会清零。
它统计的是:
text
整个 gas 的总量 - 整个 cost 的总量
也就是整个环跑一圈以后,总油量到底够不够。
如果最终:
text
total < 0
说明所有加油站提供的油加起来,都比完整跑一圈需要的油少。
这种情况下无论从哪里出发都不可能成功, 直接:
text
return -1
如果:
text
total >= 0
说明整个环的总油量是够的。
与此同时,前面的 tank 已经帮助我们不断排除了不可能的起点,因此最后留下来的 start 就是合法起点。
所以, 到了此时, 我们可以了解到几个变量的职责:
text
i → 负责从 0 到 n-1 扫描一次
tank → 判断当前 start 到 i 这一段能不能撑住
start → 记录淘汰失败区间以后留下来的候选起点
total → 判断整个环的总油量够不够
九、为什么这叫贪心?
我之前对贪心算法的印象可能是:
text
每次选最大的 or 每次选当前最好的
但贪心更核心的特点其实是:
根据当前已经确定的信息,直接做一个局部决定,并且以后不再反悔。
这道题中,当:
text
从 start 出发,在 i 这里失败
我们马上得到:
text
start ~ i 全部不可能
于是直接决定:
text
start = i + 1
以后不会再回来重新尝试:
text
start + 1
start + 2
...
i
因为这些位置已经被证明失败。
所以这里的贪心可以理解成:
一旦能够确定一整段位置不可能,就立刻把它们全部淘汰,不再浪费时间重复搜索。
十、思路确定以后再写代码
前面的推导想清楚以后,代码反而很短:
python
class Solution:
def canCompleteCircuit(self, gas: list[int], cost: list[int]) -> int:
start = 0
tank = 0
total = 0
for i in range(len(gas)):
diff = gas[i] - cost[i]
tank += diff
total += diff
if tank < 0:
start = i + 1
tank = 0
if total < 0:
return -1
return start
🔖 再回头看每一部分:
python
start = 0
表示最开始先把 0 当作候选起点。
python
tank = 0
保存当前候选起点到当前位置的累计剩余油量。
python
total = 0
保存整个环的总净油量。
python
for i in range(len(gas)):
这里的 i 已经不再表示起点,而只是负责把整个数组扫描一遍。
python
diff = gas[i] - cost[i]
表示经过第 i 个站并开到下一个站以后,油量的净变化。
python
tank += diff
total += diff
一个用于检查当前候选起点,一个用于统计整圈总量。
如果:
python
if tank < 0:
说明当前 start 失败,并且 start ~ i 都可以一起淘汰,所以:
python
start = i + 1
tank = 0
🔖 最后遍历结束以后,还需要判断整个环到底有没有解:
python
if total < 0:
return -1
total 保存的是整圈的净油量:
text
total = Σ(gas[i] - cost[i])
如果:
text
total < 0
说明所有加油站提供的油加起来,都不够支付完整一圈的消耗,所以无论从哪里出发都一定失败。
如果:
text
total >= 0
则说明整圈总油量是够的。
那这时候可以直接:
python
return start
因为前面的扫描过程中,tank 已经不断帮我们淘汰了不可能的起点:
text
某个 start 在 i 处失败
→ start ~ i 全部排除
→ start = i + 1
所以最终留下来的 start,已经是经过这些失败信息筛选后留下的候选位置。
而且从最终的 start 到数组末尾,tank 一直没有再次变成负数;
否则 start 还会继续更新。
再结合:
text
total >= 0
说明整圈总体油量足够,因此从这个最终留下的 start 出发,也能够继续绕过数组末尾并完成前面的部分。
tank 负责找到一个不会在途中失败的候选起点,total 负责保证整圈的总油量足够。两者同时成立,最终的 start 才能确定为答案。
十一、复杂度变化
这道题最开始的思路非常直接:
text
不知道哪个起点能成功
↓
每个起点都试一次
↓
每个起点完整跑一圈
↓
两个 for
↓
O(n²)
真正的优化的时候,先去观察暴力方法到底重复做了什么。
如果从:
text
start
出发,一直到:
text
i
才第一次失败,那么这次失败不只说明:
text
start 不行
还能够进一步证明:
text
start ~ i
之间的所有位置都不可能成为合法起点。
于是原本暴力方法还会重新尝试的:
text
start + 1
start + 2
...
i
全部都可以跳过。
下一次直接:
text
start = i + 1
所以原本第二层循环里大量重复验证起点的工作被直接删掉了。
最后只需要让 i 从左到右扫描整个数组一次:
text
tank → 在扫描过程中淘汰不可能的起点
start → 记录新的候选起点
total → 判断整个环是否真的存在解
于是:
text
O(n²)
就变成了:
text
O(n)
嗯,那么有个有用的点就是:
把失败也当成有用的信息。一次失败不仅能证明当前起点不行,还能顺便排除一整段起点,从而删除大量重复计算。