LeetCode 134 加油站:从 双循环暴力 演变到 贪心

LeetCode 134 加油站:从双循环暴力 到 贪心

LeetCode 134 加油站

这道题给了两个数组:

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)

嗯,那么有个有用的点就是:

把失败也当成有用的信息。一次失败不仅能证明当前起点不行,还能顺便排除一整段起点,从而删除大量重复计算。

相关推荐
sogw-三叶草️1 小时前
LeetCode 989. 数组形式的整数加法
数据结构·算法
醇氧1 小时前
CentOS7.9 Yum 安装 Redis6.x(RPM 包,无需编译,推荐 remi 源)
linux·python
傲世仙尊2 小时前
重写的顿悟-lambda类型擦除条件变量的真相与sendto里的bind
linux·网络
维克兜率天2 小时前
【维克】均值回归:跌多了会涨,涨多了会跌
开发语言·笔记·python·算法·均值算法·回归·量化
zhangrelay2 小时前
ROS2 Lyrical实验5导航Nav2
linux·笔记·学习·ubuntu·机器人
yi0112 小时前
DAY 14: LeetCode 394. 字符串解码|递归和栈到底怎么处理嵌套?
数据结构·笔记·python·算法·leetcode
一条破秋裤2 小时前
Linux 线程分离与主动取消:pthread_detach、pthread_cancel
java·linux·jvm
j7~2 小时前
【Linux网络】四十六.《高级 IO (上篇)》-- 详解
linux·运维·网络
IT大白鼠2 小时前
彭大帅的AI运维助手——自然语言管理 Linux 集群与网络设备——第 0 篇 · 导读:把 Linux 运维交给 AI,到底靠谱吗
linux·运维·人工智能