☰
PAT A1033加油站问题:贪心算法边界与资源约束决策
2026/10/13 1:10:14 网站建设 项目流程

1. 从加油站问题看贪心算法的边界感

PAT A1033 这道题在算法练习圈里有个更通俗的名字——"加油还是不加满"。题目场景很直白:一辆车从起点开到终点,沿途有若干加油站,每个站油价不同,油箱有容量上限,问怎么加油最省钱,或者判断能不能开到终点。听起来像生活常识题,但真正动手写的时候,很多人会在同一个地方反复栽跟头。

我第一次做这道题时,写了一个自认为"很贪心"的策略:每次到加油站,如果下一站更便宜就少加点,如果下一站更贵就加满。逻辑上似乎无懈可击,但提交后一半测试点报错。后来拿纸笔模拟了几组数据才发现,问题出在"下一站"这个短视的视野上——真正的最优策略需要看到"下一个比当前便宜的站"在哪里,而不是只看相邻的一站。

这道题的核心价值不在于教会你某个具体算法模板,而在于训练一种带约束的贪心思维:在资源有限(油箱容量)、目标明确(到达终点)、决策点离散(加油站)的条件下,如何做出全局最优的局部选择。它和区间调度、跳跃游戏、分发糖果这些经典贪心题共享同一套底层逻辑,但多了一层"资源可以提前储备"的维度,难度恰好卡在"需要想清楚但不需要高级数据结构"的甜点区。

适合读这篇内容的人:正在刷 PAT 甲级、准备算法面试、或者单纯想搞明白贪心算法什么时候能用什么时候会翻车的开发者。我会从题目建模开始,把每一步决策的推导过程拆开,重点讲清楚那些"看起来对但实际错"的直觉陷阱,最后给出一份可以直接对照调试的完整实现。

2. 把生活问题翻译成可计算模型

2.1 题目要素的精确提取

先把题目用自然语言复述一遍,确保没有歧义。已知条件包括:油箱最大容量 Cmax(单位升)、每升油能跑的距离 Davg、起点到终点的总距离 D、沿途加油站数量 N。每个加油站给出两个信息:到起点的距离 Di 和该站油价 Pi。车辆初始油箱为空,但起点假设有一个加油站(这个细节很多人会忽略)。要求计算从起点到终点的最小油费,如果无法到达则输出能跑到的最大距离。

这里有几个容易被读漏的约束。第一,油箱初始是空的,意味着你必须在起点加油,如果起点没有加油站,直接输出 0.00 并结束。第二,加油站的位置是离散的,车经过某个站时可以加任意量的油,不要求加满。第三,油价是每升的价格,费用等于加油量乘以单价。第四,如果终点恰好有加油站,不影响计算,因为到了终点就不需要再加油了。

把这些要素整理成变量表,写代码时对照检查,能避免很多低级错误:

变量名含义单位注意事项
Cmax油箱容量升浮点数,参与乘法
Davg每升油行驶距离公里/升浮点数
D起点到终点距离公里浮点数
N加油站数量个整数
Di第i站到起点距离公里需排序
Pi第i站每升油价元/升浮点数

2.2 为什么必须按距离排序

题目给出的加油站顺序是随机的,但车辆行驶方向是单向的,所以第一步必须把所有加油站按距离从小到大排序。这个操作看起来简单,但排序后需要处理一个特殊情况:如果排序后第一个加油站的距离大于0,说明起点没有加油站,车辆无法启动,直接输出 0.00。

排序还有一个隐藏作用:它把"能否到达下一站"的判断变成了相邻元素之间的比较。车辆从当前站出发,能到达的最远距离是当前距离加上满油续航(Cmax * Davg)。如果这个最远距离小于下一个加油站的距离,说明中间出现了断档,无法继续前进。

注意:排序后要检查是否有距离相同的加油站。题目通常保证不会出现这种情况,但稳妥起见可以在读入时去重或者保留油价最低的那个。

2.3 贪心策略的直觉与反直觉

大多数人第一反应是"每次加最少的油撑到下一站",这个策略在下一站更便宜时是对的,但在下一站更贵时就错了。正确的思路是:在当前站,找到满油续航范围内第一个油价低于当前站的加油站。如果存在这样的站,就只加刚好能到达那个站的油量;如果不存在,就把油箱加满,然后开到续航范围内油价最低的那个站。

这个策略的直觉解释是:你希望尽可能在便宜的地方多加油,在贵的地方少加油。如果前方有更便宜的站,你没必要在当前贵站囤油;如果前方全是更贵的站,那就在当前站尽量多囤,并且选择最便宜的那个作为下一站。

但这里有个反直觉的点:当决定加满油时,下一站的选择标准是"续航范围内油价最低",而不是"距离最近"或"油价低于当前"。因为加满油后你可以在续航范围内任意选择下一站,当然要选油价最低的那个来继续决策。

3. 贪心决策的完整推导链路

3.1 单步决策的三种情况

站在任意一个加油站 i,油箱里可能有剩余油量 remain。此时需要做出决策,根据前方加油站的情况分为三类:

情况一:续航范围内存在油价低于当前站的加油站 j。此时最优策略是只加刚好能到达 j 的油量。具体计算:需要油量 = (Dj - Di) / Davg。如果 remain 已经足够,就不加油直接开过去;如果不够,补足差额。费用增加 = 补足量 * Pi。到达 j 后 remain 更新为 0(因为刚好用完)或者剩余量减去消耗。

情况二:续航范围内不存在更便宜的站,但存在能到达的站。此时在当前站加满油,然后选择续航范围内油价最低的站 k 作为下一站。加满需要的油量 = Cmax - remain,费用增加 = 该油量 * Pi。到达 k 后,remain = Cmax - (Dk - Di) / Davg。

情况三:续航范围内没有任何加油站。此时判断能否直接到达终点。如果能,计算到达终点所需油量,补足后输出总费用;如果不能,输出当前距离加上剩余油量能跑的距离。

这三种情况覆盖了所有可能的决策分支,写代码时用 if-else 结构清晰表达即可。

3.2 剩余油量的传递与更新

剩余油量 remain 是贯穿整个算法的状态变量,它的更新规则必须精确。假设从站 i 到站 j 的距离为 dist,消耗油量为 dist / Davg。到达 j 后:

  • 如果之前在 i 站是"刚好加到 j",则 remain = 0。
  • 如果之前在 i 站是"加满后开到 j",则 remain = Cmax - dist / Davg。
  • 如果之前在 i 站没加油(remain 足够),则 remain = remain - dist / Davg。

这里有个容易出错的细节:当 remain 足够到达 j 时,不需要加油,但 remain 要减去消耗量。很多人会忘记更新 remain,导致后续计算全部偏移。

3.3 终点处理的特殊性

终点不是一个加油站,但需要单独处理。当当前站的续航范围内没有更便宜的站,也没有其他加油站时,要看能否直接到达终点。如果能,所需油量 = (D - Di) / Davg,补足差额后累加费用,然后结束。如果不能,输出 Di + remain * Davg 作为最大可达距离。

还有一种情况:当前站就是最后一个加油站,且续航范围内没有其他站。此时同样判断能否到达终点,逻辑一致。

提示:终点的距离 D 要作为一个虚拟的"加油站"参与比较,但它的油价可以视为 0(因为到了终点就不需要油了),这样在寻找"更便宜的站"时,终点总是会被优先考虑。

4. 代码实现中的关键细节与易错点

4.1 数据结构的选择

用结构体数组存储加油站信息是最自然的选择:

struct Station { double price; double distance; };

排序时按 distance 升序:

sort(stations, stations + N, [](const Station& a, const Station& b) { return a.distance < b.distance; });

C++ 的 sort 配合 lambda 表达式简洁高效。如果用 C 语言,需要写一个比较函数。Python 的话直接用stations.sort(key=lambda x: x[0])即可。

4.2 浮点数比较的陷阱

这道题涉及大量浮点数运算,直接用==比较两个 double 是危险的。比如判断 remain 是否足够到达下一站时,应该用remain * Davg >= dist - 1e-8而不是remain * Davg >= dist。1e-8 是常用的容差,根据题目数据范围可以调整。

另一个浮点数陷阱是输出格式。题目要求保留两位小数,用printf("%.2f\n", cost)即可。但要注意如果 cost 是 -0.00 的情况,虽然罕见但最好加个判断。

4.3 完整代码框架与注释

#include <cstdio> #include <algorithm> using namespace std; struct Station { double price; double distance; }; bool cmp(Station a, Station b) { return a.distance < b.distance; } int main() { double Cmax, D, Davg; int N; scanf("%lf %lf %lf %d", &Cmax, &D, &Davg, &N); Station stations[510]; for (int i = 0; i < N; i++) { scanf("%lf %lf", &stations[i].price, &stations[i].distance); } // 按距离排序 sort(stations, stations + N, cmp); // 起点没有加油站 if (N == 0 || stations[0].distance > 0) { printf("The maximum travel distance = 0.00\n"); return 0; } double totalCost = 0.0; double remain = 0.0; // 当前剩余油量 int current = 0; // 当前加油站索引 while (current < N) { // 寻找续航范围内第一个更便宜的站 int nextCheaper = -1; int cheapest = -1; double cheapestPrice = 1e9; for (int i = current + 1; i < N; i++) { double dist = stations[i].distance - stations[current].distance; if (dist > Cmax * Davg) break; // 超出续航 if (stations[i].price < stations[current].price) { nextCheaper = i; break; // 第一个更便宜的站 } if (stations[i].price < cheapestPrice) { cheapestPrice = stations[i].price; cheapest = i; } } // 检查能否到达终点 double distToEnd = D - stations[current].distance; bool canReachEnd = (distToEnd <= Cmax * Davg); if (nextCheaper != -1) { // 情况一:有更便宜的站 double need = (stations[nextCheaper].distance - stations[current].distance) / Davg; if (remain < need) { totalCost += (need - remain) * stations[current].price; remain = need; } remain -= need; current = nextCheaper; } else if (cheapest != -1) { // 情况二:没有更便宜的,但有其他站 // 先判断终点是否比所有站都便宜(即终点可达且不需要再加油) if (canReachEnd && distToEnd / Davg <= Cmax) { double need = distToEnd / Davg; if (remain < need) { totalCost += (need - remain) * stations[current].price; } printf("%.2f\n", totalCost); return 0; } // 加满油 totalCost += (Cmax - remain) * stations[current].price; remain = Cmax; // 开到最便宜的站 double dist = stations[cheapest].distance - stations[current].distance; remain -= dist / Davg; current = cheapest; } else { // 情况三:续航范围内没有其他站 if (canReachEnd) { double need = distToEnd / Davg; if (remain < need) { totalCost += (need - remain) * stations[current].price; } printf("%.2f\n", totalCost); return 0; } else { double maxDist = stations[current].distance + remain * Davg; printf("The maximum travel distance = %.2f\n", maxDist); return 0; } } } return 0; }

这段代码有几个地方需要特别说明。第一,在情况二的判断中,我加了一个"终点是否可达且比所有站都便宜"的检查,这是因为如果终点在续航范围内,且当前站已经是最后一个能到达的站,那么直接开到终点可能比加满油再绕路更划算。第二,remain -= need之后 remain 应该为 0,但浮点数运算可能产生极小负数,实际使用时可以加个if (remain < 0) remain = 0;。第三,循环条件current < N在正常情况下会在到达终点时 return,不会无限循环。

4.4 测试用例的构造与验证

光看代码不够,必须用几组边界数据验证。我常用的测试集包括:

测试场景输入示例预期输出考察点
起点无站Cmax=10, D=100, Davg=10, N=00.00边界处理
直达终点起点有站,续航足够最小费用终点判断
中途断档两站间距大于续航最大距离断档检测
全贵站所有站价格递增起点加满贪心选择
便宜站在后远处有更便宜的站少加油前瞻逻辑

拿"全贵站"这组数据来说:起点油价 5,第二站 6,第三站 7,续航足够到第三站。正确策略是在起点加满,因为后面越来越贵。如果错误地只加到第二站,费用会更高。这组数据能有效区分正确和错误的贪心策略。

5. 从这道题延伸出的通用解题框架

5.1 贪心算法的适用条件再审视

这道题能用的贪心策略,本质上依赖于两个性质:最优子结构和贪心选择性质。最优子结构是指从当前站到终点的最优解包含从下一站到终点的最优解;贪心选择性质是指每次选择"第一个更便宜的站"或"最便宜的站"不会导致全局次优。

但这两个性质不是凭空成立的。如果加油站可以往回开,或者油价会随时间变化,贪心就不适用了。所以每次用贪心之前,先问自己:局部最优选择是否一定导向全局最优?如果答案不确定,要么找反例,要么换动态规划。

5.2 与动态规划解法的对比

这道题其实也可以用动态规划做,定义 dp[i] 为到达第 i 个加油站的最小费用。但状态转移需要考虑剩余油量,维度会膨胀到二维甚至三维,复杂度远高于贪心。贪心在这里的优势是 O(N) 或 O(N^2)(取决于寻找下一站的方式),而 DP 至少 O(N^2) 且常数更大。

这给我们一个启示:当问题具有明确的决策顺序和单调性时,优先考虑贪心。如果贪心找不到明显的选择依据,再退回到 DP。

5.3 类似题目的迁移思路

PAT 和 LeetCode 上有一批和"资源约束下的路径决策"相关的题目,比如跳跃游戏系列、最低加油次数、区间覆盖等。它们的共同点是:有一个线性推进的过程,每个决策点有多个选择,目标是最大化或最小化某个指标。解决这类题的一般步骤是:

  1. 明确状态变量(当前位置、剩余资源、已花费代价)。
  2. 分析每个决策点的可选动作。
  3. 寻找贪心选择的依据(通常是"最值"或"第一个满足条件的")。
  4. 验证边界情况(起点、终点、断档)。
  5. 用暴力搜索或 DP 对拍小数据。

这套流程不仅适用于加油站问题,也适用于大多数贪心类题目。

6. 调试过程中积累的实战经验

6.1 用对拍验证贪心策略

贪心策略最容易出错的地方是"看起来对但实际有反例"。我的习惯是写一个暴力搜索版本,对小规模数据(N ≤ 8)枚举所有可能的加油方案,然后和贪心版本对拍。如果连续几百组随机数据都一致,基本可以确认策略正确。

暴力搜索的思路是:在每个站,枚举加油量(离散化为 0.5 升的倍数),递归搜索所有可能,取最小费用。虽然效率低,但作为验证工具足够了。

6.2 浮点数精度的实际影响

这道题的测试数据通常不会卡浮点数精度,但保险起见,所有涉及距离和油量的比较都加 1e-8 的容差。输出时用%.2f自动四舍五入,不需要手动处理。

有一个隐蔽的坑:如果计算出的最大距离是 0.00,但实际应该是 0.004,%.2f会输出 0.00,看起来没问题。但如果实际是 0.005,会输出 0.01,可能和预期不符。所以中间计算尽量用 double,不要提前截断。

6.3 代码可读性的取舍

这道题的逻辑分支较多,写代码时容易写成一大坨 if-else。我的建议是把"寻找下一个站"的逻辑抽成一个函数,返回下一个站的索引和需要加的油量。这样主循环会清晰很多:

pair<int, double> findNext(Station stations[], int N, int current, double Cmax, double Davg, double D, double remain) { // 返回 {下一站索引, 需要加的油量} // 如果无法到达,返回 {-1, 最大距离} }

虽然 PAT 考试时时间紧张,不一定有时间做这种重构,但平时练习时养成习惯,对面试和实际工作都有好处。

6.4 常见错误清单

最后整理一份我踩过的坑,供对照检查:

  • 忘记检查起点是否有加油站。
  • 排序后没有重新检查第一个站的距离。
  • 剩余油量更新时忘记减去消耗。
  • 寻找更便宜的站时,break 条件写错,导致跳过了更近的站。
  • 终点判断时,忘记考虑"终点在续航范围内但当前站不是最后一个站"的情况。
  • 输出最大距离时,忘记加上当前站的距离。
  • 浮点数比较直接用>或<,没有加容差。

这份清单基本覆盖了 90% 的提交错误。每次 WA 的时候对着过一遍,通常能快速定位问题。

7. 从解题到工程思维的迁移

这道题表面上是算法练习,但它训练的思维方式在实际工程中很常见。比如在物流路径规划中,车辆需要在不同加油站补充燃料,每个站价格不同,如何规划加油策略以最小化成本,和这道题的核心逻辑完全一致。再比如云计算中的资源预留,你需要在不同时间点决定是否预留计算资源,预留价格随时间波动,目标是最小化总成本,这也是同一类问题。

区别在于,工程场景中的约束更复杂:加油站可能排队、油价可能动态变化、车辆可能有多个油箱。但基本的贪心框架仍然适用,只需要在决策时加入更多条件。所以把这道题吃透,不只是为了通过 PAT 考试,更是为以后解决更复杂的资源优化问题打基础。

我在实际项目中遇到过一个类似的场景:某系统需要在不同区域部署缓存节点,每个节点的存储成本不同,数据需要按顺序经过这些节点,目标是总成本最低。当时第一反应就是套用加油站问题的贪心策略,把"加油站"换成"缓存节点","油量"换成"数据容量","油价"换成"存储单价",逻辑完全通用。虽然最后因为业务约束做了调整,但核心的决策框架直接复用了。

这种迁移能力,才是刷题真正的价值所在。不是记住某道题的解法,而是识别出问题背后的结构,然后把已知的解法映射过去。PAT A1033 就是一个很好的训练样本,它的结构清晰、约束明确、边界丰富,适合反复琢磨。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询