贪心算法实战:从NOIP经典题“旅行家的预算”看GESP备考策略
2026/9/18 14:59:11 网站建设 项目流程

1. 一道1999年的NOIP老题,凭什么到现在还挂在GESP练习单上

在洛谷上搜P1016,你会看到题目名字叫“旅行家的预算”,来源是NOIP 1999提高组。单论年龄,它比现在绝大多数备赛GESP的学生年龄都大。可奇怪的是,在很多GESP四、五、六级甚至七级的学习路线清单里,这道题都会被反复拎出来。原因很简单:它把“读题建模、贪心决策、边界判断、浮点输出”四件事压缩到了一个不超过100行的程序里,而这四件事恰好是GESP从四级开始往后每一级都在考察的方向。

很多人第一眼看到这道题,会以为它是个模拟题:一辆车从A到B,路上有加油站,油箱容量有限,求最少花钱。模拟一下每个加油站应该加多少油不就行了?真写起来就会发现问题没那么简单——你当前油量不是整数,下一站不是只有一个,油价又各不相同,加多加少直接影响后续所有决策。所以它实际上是“带约束的最优化问题”,考察的是贪心策略。

从GESP的分级来说:四级开始明确要求“能够理解贪心算法的基本思想,并解决简单的贪心问题”;五级要求“能对贪心策略的正确性进行解释说明”;六级及以上则需要在复杂约束下构造正确的贪心模型。P1016恰好横跨了这三个层次。四级的孩子可以把这道题当作“高级模拟”来练,哪怕证明不完整;五级的孩子必须能说清楚“为什么油箱里还有油时也可能需要继续加油”;六级的孩子则应该能自己推出那两个关键分支。

做这道题之前,比较合理的前置训练线是:能熟练使用结构体排序,能处理双精度浮点数输出,能看懂“无解”型题目的输出约定。如果这三样都还不太稳,建议先去做一两道排序和简单模拟的题再回来,否则容易把时间浪费在调试语法而不是思考策略上。

还有一点值得说:这道题的题面很长,但信息密度不低。我见过不少学生一看到“计算结果是四舍五入至小数点后两位”就开始慌,其实它只是提醒你用固定两位小数输出,不存在什么高精度要求。真正容易丢分的,反而是那些藏在题干角落里的条件,比如“出发时油箱是空的”“油站N可以为零”这类话。

2. 题目参数与最优路径到底要算什么

2.1 五个输入量和一个清零的油箱

题目会给五个量:两地距离D1,油箱最大容量C,每升油能跑的公里数D2,出发点的油价P,沿途加油站数量N。接着给N行,每行两个数:该油站离出发点的距离,以及该站的每升油价。

这里最关键的一句话是“假设出发时油箱是空的”。这意味着你在起点必须至少先买一点油才能走。而且买油只能按“升”思考,不是按“钱”思考——你先看需要多少升,再决定在哪个站买。

需要注意,N可以为0。也就是说路上可能一个加油站都没有。这时候别慌,直接从起点开车到终点,能到就只买“全程耗油量”对应的油,不能到就输出No Solution。

2.2 样例手算:为什么不是“见站就加”

洛谷原题的样例是这样的:

275.6 11.9 27.4 2.8 2 102.0 2.9 220.0 2.2

一辆车,两地相距275.6公里,油箱11.9升,每升跑27.4公里,起点油价2.8元,两个加油站分别距起点102公里和220公里。

先算一箱油最多能跑多远:11.9 × 27.4 = 326.06公里,比全程长。所以理论上如果起点油价是全程最低,加满一箱直接怼到终点都行。但实际情况是,220公里处的油价比起点便宜(2.2 < 2.8),所以正确的省钱思路是:利用220公里处的低价油跑最后一段,而不是在起点的2.8元油价上多买。

从起点到第二个加油站,距离220公里,需要的油量是220 ÷ 27.4 ≈ 8.03升。从起点只买这8.03升,花 8.03 × 2.8 ≈ 22.48元。到了220公里处,油箱里油量已经归零(忽略浮点误差)。从这个站到终点还剩55.6公里,需要 55.6 ÷ 27.4 ≈ 2.03升,花 2.03 × 2.2 ≈ 4.47元。合计约 26.95元。

第一个加油站设在102公里处,油价2.9,比起点还贵。最优路线里完全可以不停它,哪怕路过也当它不存在。这就是这道题的第一层思考:加油站不是“经过就必须消费”,而是“只有符合省钱条件才消费”。

2.3 把终点当成一个0元虚拟加油站

写代码时,很多同学的习惯是“循环模拟到最后一个站,再单独处理终点”。这个思路没错,但会多出一整段特判代码,还容易漏。更干净的做法是把终点视为一个虚拟加油站,距离设为D1,油价设为0。

为什么油价设为0?因为到达终点之后,你不需要再买任何油。而在贪心算法里,“价格最低”天然意味着“我一定要尽量少在这个站之前花钱”。把终点油价设为0之后,前面所有的“找更便宜的站”逻辑能自动在终点处收口:只要终点在当前油量能到达的范围内,算法就会选择只加“刚好够到终点”的油量,而不是提前加满。

这个技巧非常实用,在很多最短路和区间覆盖题目里都能用。凡是遇到“最后一个点不是加油站但需要单独处理”的情况,加一个虚拟目标点往往能让逻辑统一,也减少边界bug。

3. 贪心策略的完整推导:三种局面对应三个决策

3.1 为什么这道题适合贪心而不是动态规划

看到“最小费用”四个字,很多人第一反应是动态规划。但仔细分析就会发现,这个问题的状态非常连续:油量是实数,不是整数;加油站的选择也只受到“能不能到达”的约束,而不受之前选择路径的影响。也就是说,在当前站点做决策时,后续的费用只取决于“我从这里带了多少油出发”“后面油价分布如何”,跟之前是怎么到这里的无关。这就是无后效性。

更关键的是,油价是一个一维序列,你只能从左往右开。这么强的线性结构下,贪心几乎总是优于DP:既节省时间,又容易证明。动态规划需要把油量离散化成格点,反而引入了大量状态和精度问题,属于自己给自己找麻烦。

3.2 决策A:可达范围内存在比本站更便宜的油站

假设我在当前站点,油箱加满后能覆盖到后面若干站。如果在这些站里,存在一个站的价格比我当前站便宜,那我应该怎么买?

答案不是“买到那个站”,而是“一滴不多加,只买刚好能开到那个站的油量”。

理由很直观:既然前面的站更便宜,我在当前这个高价站每多买一升,就多亏一升的差价。最好的做法就是让低价油站尽可能多承担后续路程的油耗,当前高价站的油只用来“够到”低价站即可。

还有个细节:当“更便宜的站”不止一个时,应该选哪一个?标准实现里选的是“第一个价格低于当前油价的站”,也就是从当前站往后扫描时第一次遇到比本站便宜的站,而不是“范围内最便宜的那个”。

有人会问:如果后面有个特别便宜的站,我应该直接跳过这个“第一个便宜站”吗?不用。因为即使到了第一个便宜站,如果它并不是全局最低,算法在那站会继续执行同样的判断,找到下一个更便宜的站,再只加“刚好够到下一站”的油。这样最终效果是完全等价的,而且每次只向前推进一小步,程序逻辑更简单。

3.3 决策B:可达范围内所有油站都比本站贵

如果没有一个加油站比当前站便宜,那当前站点就是后面这一段里油价最低的。这时候策略完全反过来:把油箱加满。

为什么会加满而不是只加到够去最便宜的那一站?因为以后每一滴油都比现在贵。你在这个站能带走的每一升油,都会替代未来某个高价站的油。油箱容量就那么多,加的越多,节省越多,所以必须顶格加满。

但加满之后开向哪儿?答案是“可达范围内油价最低的那个站”。因为你已经加满了,油是满的,下一个决策点放在哪里能最大程度降低未来成本?当然是最便宜的那个站点。到了那里,再重复同样的贪心判断。

3.4 无解判断要放在最前面

如果当前站点加满油后,连下一站都到不了,那就是彻底的无解,直接输出No Solution。

这句话听起来很简单,但实际实现中有一半以上的人会在这个环节出错。因为你不能只看“起点到终点能不能一箱油跑完”,还要看“每一段相邻加油站之间的距离有没有超过一箱油续航”。只要有一段超过,无论怎么规划,最终都会在中间抛锚,因此必然无解。

这个无解检查必须放在主循环之前统一做,而不是在循环里发现到不了时才临时输出。统一检查的另一个好处是:它可以保证主循环里“当前站点加满后至少能到下一站”这个前提永远成立,贪心分支不需要再对“范围为空”做额外处理。

4. 完整C++实现:结构体、排序与主循环

4.1 数据建模:起点和终点都放进结构体

我习惯这样定义:

#include <bits/stdc++.h> using namespace std; struct Node { double dis; // 离起点的距离 double pri; // 油价 }; int main() { double D1, C, D2, P; int N; cin >> D1 >> C >> D2 >> P >> N; vector<Node> a; a.push_back({0.0, P}); // 起点:距离0,油价P for (int i = 0; i < N; i++) { double d, p; cin >> d >> p; a.push_back({d, p}); } a.push_back({D1, 0.0}); // 终点:距离D1,油价0 sort(a.begin(), a.end(), [](const Node& x, const Node& y) { return x.dis < y.dis; });

把起点也放进结构体,是为了让循环逻辑从“当前位置”出发,不需要为起点单独写一份处理代码。把终点当成0元加油站后,主循环里最后一站的处理和普通站完全一致。排序在这里是必须的,尽管大部分测试数据已经是升序输入,但你不能依赖这个约定。

4.2 无解预检

double maxRun = C * D2; for (int i = 0; i < (int)a.size() - 1; i++) { if (a[i + 1].dis - a[i].dis > maxRun) { cout << "No Solution" << endl; return 0; } }

这个for循环检查的是“任意相邻两站之间的距离是否超过一箱油续航”。注意排序之后才是相邻,所以排序这一步必须放在检查前面。一旦出现某一段距离超标,说明无论怎么规划,车辆都会在这一段中间耗尽油量,直接无解。

4.3 主循环与三种决策

double oil = 0.0; // 当前油量 double ans = 0.0; // 总花费 int i = 0; // 当前所在加油站的下标 while (i < (int)a.size() - 1) { int cheaper = -1; // 第一个比当前油价更便宜的站 int minPos = -1; // 可达范围内油价最低的站 for (int j = i + 1; j < (int)a.size(); j++) { if (a[j].dis - a[i].dis > maxRun) break; if (minPos == -1 || a[j].pri < a[minPos].pri) { minPos = j; } if (cheaper == -1 && a[j].pri < a[i].pri) { cheaper = j; } } if (cheaper != -1) { // 决策A:只加刚好能到 cheaper 站的油 double need = (a[cheaper].dis - a[i].dis) / D2; if (need - oil > 1e-9) { ans += (need - oil) * a[i].pri; oil = need; } oil -= need; i = cheaper; } else { // 决策B:本站加满,然后去范围内最便宜的站 ans += (C - oil) * a[i].pri; oil = C; double need = (a[minPos].dis - a[i].dis) / D2; oil -= need; i = minPos; } } printf("%.2f\n", ans); return 0; }

这段代码的主循环终止条件是i到达终点虚拟站。由于终点油价是0,从任意非终点站出发,终点一定是“更便宜站点”的候选,所以算法不会在错误的时间进入决策B。只有当终点不在当前油量加满后的可达范围内时,才会真的执行“加满并去范围最低站点”的分支。

4.4 复杂度够不够用

这个程序最外层循环是O(N)个决策点,每个决策点内部会扫描后面所有站点,因此最坏复杂度是O(N²)。原题N最多100,O(N²)绰绰有余。GESP的代码提交环境中,这样的复杂度也完全能过。即使N放大到1000,O(N²)也就是百万级运算,依然没问题。

如果非要进一步优化,可以在排序后用一个单调栈或双指针维护“下一个更低价格的位置”,把复杂度降到O(N log N),但对这道题来说属于过度设计。竞赛里“能过就是好代码”,不需要追求理论上的极致。

5. “No Solution”判定顺序与浮点数精度陷阱

5.1 先排序,再检查相邻站距离

无解检查的前提是“相邻”。如果输入数据没有按距离排好序,直接检查输入顺序里的相邻站,结果一定是错的。

虽然题目通常保证加油站按距离递增给出,但你不能赌。尤其是自己造数据测试的时候,一定要先排序。这个看似无关紧要的小步骤,能避免一堆莫名其妙的问题。

还有一个小细节:排序后如果出现距离相同的站点,处理时要有心理准备。虽然原题几乎不会这样出,但如果你自己扩展练习,重复距离会让“相邻站距离为0”,预检不会报错,但主循环里需要判断是哪个站更便宜。我一般遇到这种情况,会在排序比较器里额外规定:距离相同时,油价低的排前面。这样即使数据里有重复位置,贪心逻辑也能自动选择更优价格。

5.2 N=0的情况

当N=0时,输入里不会出现任何加油站。处理起来很简单:先是起点和终点两个点放进结构体,排序后两个点相邻。如果D1 > C × D2,无解;否则从起点只买D1/D2升油,费用是D1/D2 × P。

用上文代码跑N=0也完全没问题:a里只有起点和终点两个元素,预检检查两点距离是否超过maxRun,主循环里从起点出发,终点价格0一定小于起点价格P,于是进入决策A,加刚好够到终点的油。所以不需要单独写特判。

5.3 double比较和输出格式

这题所有数字都用double来存,不要用float,否则可能在边界距离上翻车。比较两个浮点数大小,尽量不要写成x > y这种直接判断,而是用x - y > 1e-9之类的容差比较。

输出时用printf("%.2f\n", ans),或者cout << fixed << setprecision(2) << ans << endl;,注意要保留两位小数。这里的四舍五入并不是要求你手写,标准格式化输出会自动处理。

有个细节值得提醒:题目描述里写的“计算结果四舍五入至小数点后两位”,在实际评测中就是要求精确输出两位小数。不要自己额外做任何“四舍五入”操作,避免二次舍入误差。

5.4 一个容易忽略的浮点场景

当油量计算出现“刚好够到下一站”的情况时,浮点误差可能让程序认为差一点点,于是多买了0.0000001升油。这通常不会影响最终答案的两位小数输出,但如果某个测试点数据恰好卡在边界上,可能就会出现微小偏差。

稳妥的做法是,在判断if (oil < need)时改为if (need - oil > 1e-9),这样既保留真实比较语义,又避免了浮点噪声带来的误判。代码里其他浮点比较也建议统一加上这个容差。

5.5 实践中的自测数据

写完代码后,我一般会准备几组自测数据:

// 例1:起点加满10L,到50公里处油价4元的加油站,再加5L到终点 // 输入: 100 10 10 5 1 50 4 // 输出应该是:70.00 // 例2:一箱油根本撑不到下一个站 // 输入: 100 10 5 5 1 60 4 // 10*5=50 < 60,输出 No Solution // 例3:全程不需要加油 // 输入: 50 10 10 5 0 // 输出 25.00 // 例4:中间全是贵的油,起点加满直接跑 // 输入: 100 10 10 5 1 10 100 // 起点加满10L花50,终到,答案50.00

例4很多人会写错:因为10公里处有个天价加油站,但到终点需要10升油,一箱油正好够,所以完全不用理它。正确输出是50.00,而不是“在起点只加10公里的油然后去天价站”。

6. 从NOIP到GESP:一道题拆出四级、五级、六级三种考法

6.1 四级视角:把贪心当高级模拟来练

GESP四级的大纲里,“贪心”并不是一个特别深的考点,更多是让你接触“每一步都做当前最优选择”这个思想。P1016恰好能承担这个角色。

如果孩子只学到四级,我建议先不要给他们讲“决策A/B的完整证明”,而是让他们先看懂代码流程:先检查能不能到,再在每个站找后面有没有更便宜的站。有,就少加;没有,就加满。这个口诀足够他们把这个题调通。

调通之后,可以做几道类似结构的题做迁移训练,比如简单的区间选点、部分背包。它们都在强调同一个道理:局部最优能推出全局最优的条件是什么。四级的孩子能意识到这一点,就已经超过很多“只会写模板”的同龄选手了。

6.2 五级视角:必须能解释“为什么这么做是对的”

到了GESP五级,单纯能AC是不够的,评委会看你能不能把策略讲清楚。这时候需要做一些更扎实的证明训练。

反证法是最好用的证明方式。比如决策A:如果在当前高价站多买了油,而后面有低价站,那么这多买的油本来可以在低价站买,因此当前方案一定不是最优;所以“只买刚好够到低价站”才是最优。决策B的证明思路类似:后面所有站都更贵,在当前站少买了一升,未来就得在更贵的站补一升,总费用只会增加,所以必须加满。

这个“反证+交换论证”的思维,是五级和四级在同一个题目上的关键分水岭。写题解、画图、给同学讲题,都是不错的训练方式。如果只是闷头刷题,很难真正过渡到“会证明”的层次。

6.3 六级以上视角:把单点决策抽象成普适模型

到六级以后,题目不会只考课本原题,而会换皮。比如把“油箱容量”改成“电池容量”,把“油价”改成“充电价格”,甚至把“单程”改成“可以回头”,你就需要重新设计贪心决策。

P1016真正的价值在于它提供了一个非常好的“多约束贪心”模板:在某个可决策节点上,如何利用范围枚举选择下一步目标。这个模板在后续的很多题里都能复现,比如带往返运输的最小费用、带容量限制的区间覆盖、甚至某些资源调度问题。

六级及以上的备赛,不应该满足于背题解,而是要尝试自己改题。我带的不少学生会在做完P1016之后,自己加一两个限制条件再问自己怎么做。这种训练对GESP七、八级以及后续冲击更高赛事的帮助,往往比多刷十道类似题更有效。

6.4 推荐练习顺序

如果要从零开始挑战这道题,我给一条比较顺的路线:

  1. 先用30分钟自己思考,画出能到达的站点范围图。
  2. 看一遍标准代码的框架,不急着看全,先把“无解检查”和“主循环”的边界搞清楚。
  3. 自己敲一遍,跑通样例。
  4. 把样例手算一遍,确认结果对得上。
  5. 自己构造3-4个边界测试点,尤其是“N=0”“一箱油刚好到”“中间有很贵的站”这几类。
  6. 尝试不看代码,用文字把贪心策略写出来。

这套流程走完,这道题才算真正吃透。

7. 写完之后,我沉淀下的几条实操体会

第一,这道题最容易被低估的地方是“读题”。很多学生第一遍看完题面,以为只需要在“最便宜的加油站加满油”就够了,样例都能过,但一提交就只能拿到部分分。真正把“每个站点的油量状态”当作连续量来理解和建模,才是这道题的核心门槛。

第二,代码里不要写“看起来很聪明”的优化。比如有人会在主循环里反复做浮点运算优化,或把油价做一大堆常数处理。这些在这个数据范围下都毫无意义,反而容易引入精度错误。保持简单、直接、容差到位,是我在这道题上最推荐的写法。

第三,如果调试时发现输出只差0.01,先别急着怀疑算法,先检查是不是格式化输出写错了,或者某一处直接用==比较了浮点数。我见过太多因为这两点被卡半小时的情况。

第四,这道题对一个备赛者最宝贵的训练,不是“学会了贪心”,而是“建立起了从题面提取约束条件、抽象成数学模型、写代码、造数据验证”的完整闭环。GESP从四级到六级,每一级都在反复强化这条链路。把这套方法论练稳了,比多刷十道题更管用。

我个人的习惯是,每带一批学生刷P1016,都会要求他们提交完AC后再写一段不超过200字的“解题说明”,强迫自己把决策A和决策B的逻辑用大白话讲清楚。这个习惯后来帮他们在GESP五级、六级里解决了很多需要“解释设计思路”的题型。

如果你也是自己备考而不是跟班学,建议同样试试这个办法:AC之后合上代码,用两段话把“什么时候少加”“什么时候加满”写出来。写不明白的地方,就是你还没有真正吃透的地方。

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

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

立即咨询