ACM 一个月学习计划 · DAY 13

Day 13 · 贪心入门:局部最优通向全局最优

每一步都做当前看起来最好的选择 —— 但你必须能说清「为什么这样贪不会错」

今日目标:理解贪心思想,掌握「排序 + 贪心」这个最常见的组合,学会用交换论证和反例检验自己的贪心
预计用时:约 3 小时(含做题)  难度:普及- ~ 普及/提高-
题目来源:洛谷 P1223 / P1094 / P4995 / P3817
← Day 12 · 排序:sort 与它的朋友们

⏱今日时间安排

0 - 25 min搞懂贪心是什么、它和暴力/DP 的区别(第 1 节)
25 - 55 min重点:怎么证明贪心是对的 + 怎么用反例把自己推翻(第 2 节)
55 - 140 min做 4 道题,每题限时 30 分钟
140 - 170 min对照本文题解,把没做出来的补掉,尤其是 P3817
170 - 180 min写题解笔记 + 做自测题
💡 和昨天内容的衔接

Day 12 你学了排序。当时可能觉得「排序不就是排个序吗」,今天你就会明白: 排序是贪心最亲密的搭档。今天四道题里有三道的第一步都是 sort—— 因为「按什么关键字排序」往往就是贪心策略本身。

1什么是贪心

先看一个每天都在发生的场景

你去超市买东西,一共 66 元。收银员找钱的时候会怎么找?

他不会掏出一把 1 元的硬币数 66 个,而是:先看有没有 50 元,有就拿一张;再看 10 元,拿一张;再看 5 元,拿一张;最后 1 元,拿一张。一共 4 张。

他每一步都在做一件事:在「不超过剩余金额」的前提下,拿面额最大的那张。

这就是贪心。他不考虑「万一等下需要零钱怎么办」,也不回头把已经拿出来的 50 元换成两张 20 元—— 每一步都选当前看起来最好的,选完就不再改。

🎯 贪心的核心就一句话

把问题拆成一连串的选择,每一步都选当前最优的那个,并且相信这样走下去整体就是最优的。

贪心和暴力、DP 有什么不一样

做法怎么走典型复杂度能不能保证正确
暴力 / 搜索把所有可能都试一遍,最后挑最好的指数级,慢✅ 一定对
动态规划(DP)每个状态都算出最优值,一步步推到全局多项式,较快✅ 一定对
贪心只顺着一条路走,选了就不回头通常 O(n log n)⚠️ 不一定对!
⚠️ 贪心最大的特点,也是最大的坑

贪心快,但不是所有题都能贪。同样一道题,换一个贪心策略,答案就可能错。

所以学贪心,一半的功夫在「想出策略」,另一半在「证明这个策略没错,或者找出一个反例把它推翻」。 第二半才是真正区分水平的地方。

贪心的「不回头」体现在哪里

拿今天第一题 P1223 打比方。有 3 个人接水,时间分别是 3、1、2 分钟。

如果你决定让时间最长的人先接(3 分钟那个人),那么后面 2 个人都得干等 3 分钟。 而如果让 1 分钟的人先接,后两个人只需要等 1 分钟。

贪心会直接算出一个规则:「谁快谁先上」,然后按这个规则一次性排好,排完就结束,绝不反悔。

💡 一句话记住贪心和 DP 的分界

贪心只需要「一条路」,DP 需要「一张表」。
如果一个选择会影响后面所有选择(做了就得负责),那通常不能贪,要考虑 DP; 如果每个选择互不干扰、只影响自己那一小段,就可以试试贪心。

2重点:凭什么说「贪心是对的」

这一节是今天最重要的内容。不会证明,你就是在赌运气;会证明,你才敢把代码提交上去。

方法一:交换论证(最常用的那一招)

交换论证的套路固定得像个模板,一共三步:

  1. 假设:假设存在一个最优解 OPT,它和贪心解在某个位置不一样。
  2. 交换:在 OPT 里,把那个不一样的选择换成贪心选的那个。
  3. 验证:证明换完之后,答案没有变差(还是一样好,甚至更好)。

第 3 步成立,就说明「贪心的选择至少不会拖后腿」,一直换下去,贪心解就是一个最优解。 这就是「为什么贪心是对的」的标准答案。

现在就动手证一遍:P1223 为什么「接水快的先上」

假设排好的队伍里,有相邻的两个人:前面那位接水要 a 分钟,后面那位要 b 分钟,而且 a > b(也就是「慢的排在了快的前面」)。

这两个人后面还有 k 个人。我们只看这 k 个人被影响的等待时间:

方案前面 k 个人的等待时间之和
保持原样(a 在前,b 在后)k × a + k × b
把两人交换(b 在前,a 在后)k × b + k × a

咦,看起来一样?别急 —— 关键在于这两个人自己也要等待。后面那位(原本是 b)要等前面那位走完,所以:

方案这两个人自己的等待时间之和
原样(a 在前)前面那位等 0 分钟,后面那位等 a 分钟 → a
交换(b 在前)前面那位等 0 分钟,后面那位等 b 分钟 → b

因为 a > b,交换之后这两个人的等待时间从 a 变成了 b,变小了;而后面 k 个人的等待时间完全没变(他们等的始终是 a+b 这么多时间)。

✅ 结论

只要队伍里出现「慢的排在快的前面」,把它俩换一下,总等待时间一定减少(或至少不变)。
所以最优队伍里不可能有「慢在前、快在后」的相邻对 → 最优方案就是按接水时间从小到大排。

这个证明的思路叫「相邻交换法」,是交换论证里最常用的一种。以后遇到「排队类贪心」,基本都用它。

方法二:反例思维 —— 先用小数据把自己打一顿

想出一个贪心策略之后,不要立刻写代码。先花两分钟问自己一句:

🎯 灵魂拷问

「我能举出一个反例吗?」

举不出来 → 大概率是对的,可以写代码了。
举出来了 → 策略错了,赶紧换,省下 30 分钟。

看两个真实的反例(第二个我今天真的踩了)

反例 A:P1094 纪念品分组,策略「每次把最贵的和最贵的凑一对」。

听起来合理(贵的跟贵的放一起,省下便宜的?)。造个数据:w = 100,价格是 60 60 40 40。

按「贵配贵」:60+60 = 120 > 100,配不上,于是两个 60 各占一组、两个 40 凑一组 → 3 组。
正确做法「贵配便宜」:60+40 = 100 ≤ 100,两组搞定 → 2 组。策略错了 ❌

反例 B:P3817 小 A 的糖果,策略「一旦相邻两盒之和超过 x,就无脑削右边那盒」。

这个策略在大部分数据上看起来都对(计划原文也是这么写的),但我今天实际写出来对拍之后, 发现它有一个真实的漏洞。数据:n = 3, x = 2, a = [4, 1, 3]。

步骤「无脑削右边」会怎么算
看第 1、2 盒4 + 1 = 5 > 2,超出 3,削右边那盒 3 颗
问题来了第 2 盒一共只有 1 颗!削 3 颗就变成 −2 了 —— 糖果盒不可能有负数
算出的答案3
正确答案4(第 1 盒吃掉 2 颗变成 2,第 2 盒吃掉 1 颗变成 0,然后第 3 盒吃掉 1 颗变成 2)
🚫 我实测跑出来的数字

我把这个「无脑削右边」的版本编译运行,输入 3 2 / 4 1 3,程序输出 3;
而用暴力枚举所有吃法(每盒吃 0~aᵢ 颗)跑同一组数据,得到的真正最小值是 4。

3 < 4,贪心版本报出了一个根本做不到的更小答案 —— 这就是错误。

原因是漏了一种情况:右边那盒不够吃的时候,剩下的必须从左边那盒吃。本文 P3817 的题解里给了修正后的代码。

💡 所以「对拍」是竞赛里最值钱的技能

写完贪心,再花十分钟写一个只求「答案对」的暴力(枚举 / 状压 / DFS), 随机造几百组小数据让两个程序互相对答案。只要有一组不一样,你就抓到了一个反例。

今天本文的四道题我全都这么做了,具体结果写在每道题的「实测结果」里。

3什么时候该想到贪心

三个信号

#信号例子
1题目问的是「最多 / 最少 / 最大 / 最小」的个数或总量P1803 最多参加几场比赛、P1094 最少分几组
2数据范围很大(10⁵ ~ 10⁶),DP 的二维表开不下P1803 的 n ≤ 10⁶,O(n²) 绝对过不了
3存在一个明显的「好坏」标准,且这个标准不随情况变P1223 接水时间越短越好、P2240 性价比越高越好
⚠️ 反过来,出现这些情况就要小心了

· 某个选择会改变后面所有选项的价值 → 大概率要 DP(比如 0/1 背包)。
· 题目里出现「物品不可分割」、「只能选或不选」→ 不能按性价比贪(Day 26 会讲 0/1 背包)。
· 你试了三组数据贪心答案都不一样 → 换个策略或者干脆想 DP。

排序是贪心的好朋友

今天四道题,第一行代码几乎都是 sort。原因很简单:

🎯 「按什么排序」= 「贪心策略是什么」

贪心要「每一步都选当前最好的」。但如果数据是乱序的,「最好的」藏在中间,你每步都得重新找一遍。 排好序之后,「最好的」永远在队头或队尾,看一眼就知道该拿谁 —— 一步到位。

题目按什么排序为什么是这个关键字
P1223 排队接水接水时间 升序快的先走,后面所有人少等
P1094 纪念品分组价格 升序双指针要从两端取最轻/最重
P4995 跳跳!高度 升序要交替取最高的和最矮的
P3817 小 A 的糖果不排序位置是固定的,只能从左往右扫
💡 一个实用的思考顺序

拿到一道「最值」题,先问自己:「如果让我手动排一下,我会按哪个关键字排?」
这个关键字往往就是答案。今天四道题里,P1094 是按价格排、P4995 是按高度排、P1223 是按时间排。

4双指针:排序之后的标配动作

排完序之后,经常会遇到这种需求:「我要同时盯着最小的一批和最大的一批。」 这时候就用两个指针,一个从左边出发,一个从右边出发,向中间收缩。

sort(a, a + n);          // 先排序

int l = 0, r = n - 1;    // l 指最左(最小),r 指最右(最大)
while(l <= r){           // ★ 注意是 <= ,l 和 r 相等时中间还剩一个元素
    // 根据 a[l] 和 a[r] 的关系决定怎么动
    if(条件成立){
        l++;  r--;       // 两端都用掉了,一起往中间收
    } else {
        r--;             // 只处理右边那个
    }
}
⚠️ 双指针三个必踩的坑

坑 1:循环条件写 l < r。这样当 l == r(中间还剩最后一个元素)时循环直接结束, 那个元素就被漏掉了。要写 l <= r,并在里面单独处理 l == r 的情况。

坑 2:忘了先排序。双指针依赖「左边一定小、右边一定大」,不排序就是瞎走。

坑 3:动了 l 却没动 r。每次循环必须保证区间真的在变小,否则死循环。

5四道例题详解

下面每道题的代码我都用 g++ -std=c++14 -O2 -Wall 实际编译过(零警告), 跑完了官方样例,还额外写了暴力程序随机对拍。所有「实测结果」都是真实输出。

P1223 排队接水 普及- · 排序 + 相邻交换

题意:n 个人排队接水,第 i 个人要接 Tᵢ 分钟。一个人的等待时间不算自己接水的时间。 要你排出一种顺序,让所有人的平均等待时间最小,并输出这个顺序和平均等待时间(保留两位小数)。 如果两个人的接水时间相同,编号更小的排前面。

⚠️ 数据范围 / 关键约束

1 ≤ n ≤ 1000,1 ≤ tᵢ ≤ 10⁶,不保证 tᵢ 不重复。

算一下答案有多大:最坏情况是 n = 1000 人、每人都要 10⁶ 分钟。 总等待时间 ≈ 10⁶ × (1 + 2 + … + 999) ≈ 10⁶ × 5×10⁵ = 5×10¹¹。 而 int 最大只能到约 2.1×10⁹ —— 早就爆了。

所以这题的累加变量必须是 long long,而且乘法要先转 long long。

思路

贪心策略:接水时间短的先上。

为什么?一个人接水的时候,他后面所有人都在等他。所以一个人接水时间越长, 他被「算进别人的等待时间」里的次数就越多。

换句话说是这样:排在第 i 位的人(从 1 数),他的接水时间会被后面 n − i 个人各等一次。 所以他对总等待时间的贡献是 Tᵢ × (n − i)。

乘数 (n − i) 在队伍前面很大、后面很小。要让总和最小, 就应该让大的 Tᵢ 配上小的乘数 —— 也就是让接水慢的人排到后面去。

🎯 换个说法:这就是「排序不等式」

两个序列,一个升序一个降序,反着配的时候乘积和最小,顺着配的时候最大。 这里 Tᵢ 升序、乘数 (n−i) 降序,正好是「反着配」的最小情况。

本文第 2 节里我用相邻交换法完整证明了它,可以回去看一遍。

样例输入
10
56 12 1 99 1000 234 33 55 99 812
样例输出
3 2 7 8 1 4 9 6 10 5
291.90

第一行输出的是人的编号(不是时间)。按时间升序排:1 (3号) → 12 (2号) → 33 (7号) → 55 (8号) → 56 (1号) → 99 (4号) → 99 (9号) → 234 (6号) → 812 (10号) → 1000 (5号)。
注意两个 99:4 号在 9 号前面,因为 4 号编号更小 —— 这就是「时间相同按编号」的体现。

完整代码

#include <iostream>
#include <algorithm>
#include <iomanip>
using namespace std;

struct Person {
    int id;   // 原来的编号(1 开始)
    int t;    // 接水时间
};

Person a[1005];

// 排序规则:接水时间小的排前面;时间相同时编号小的排前面
bool cmp(const Person &p, const Person &q){
    if(p.t != q.t) return p.t < q.t;
    return p.id < q.id;                 // ★ 时间相同按编号升序,不能漏
}

int main(){
    int n;
    cin >> n;
    for(int i = 1; i <= n; i++){
        a[i].id = i;
        cin >> a[i].t;
    }
    sort(a + 1, a + n + 1, cmp);        // 下标从 1 开始,所以是 a+1 到 a+n+1

    long long total = 0;                // ★ 总等待时间最大约 5×10^11,必须 long long
    for(int i = 1; i <= n; i++){
        // 排在第 i 位的人接水时,后面 n-i 个人都在等他
        total += 1LL * a[i].t * (n - i); // ★ 1LL 先转 long long,再乘,防止中途溢出
    }

    for(int i = 1; i <= n; i++){
        if(i > 1) cout << " ";
        cout << a[i].id;
    }
    cout << "\n";
    // ★ 先转 double 再除,否则会做整数除法,小数部分直接没了
    cout << fixed << setprecision(2) << (double)total / n << "\n";
    return 0;
}
✅ 实测结果

官方样例 10 / 56 12 1 99 1000 234 33 55 99 812 → 输出
3 2 7 8 1 4 9 6 10 5 和 291.90 与样例完全一致

边界 n = 1:输入 1 / 7 → 输出 1 和 0.00 正确 (只有一个人,没人需要等)

极限数据:n = 1000、所有人 t = 10⁶ → 输出顺序 1 2 3 … 1000, 平均等待时间 499500000.00 正确 (总等待时间 4.995×10¹¹,已经远超 int 上限 2147483647 —— long long 扛住了,实测耗时 28 ms)

暴力对拍:另写一个程序枚举所有排队顺序(n ≤ 7)求最小总等待时间, 随机造 300 组数据对比 → 300/300 全部一致。

易错点

  • 不用 long long:总等待时间能到 5×10¹¹,int 存不下,会输出负数或者莫名其妙的数。
  • 乘法写成 a[i].t * (n - i):两边都是 int,会先按 int 算完再赋给 long long,中间就已经溢出了。
    必须写 1LL * a[i].t * (n - i),让编译器一开始就按 64 位算。
  • 平均值写成 total / n:这是整数除法,2919 / 10 会得到 291 而不是 291.9。
    要写 (double)total / n,并且加上 fixed << setprecision(2) 才会输出两位小数。
  • 输出的是编号不是时间:第一行要输出 3 2 7 8 …(人的编号),把接水时间输出出去就废了。
  • 时间相同时忘了按编号升序:样例里两个 99 就是专门用来考这个的,顺序错了会 WA。
  • 数组下标从 1 开始却只开了 1000:题目 n 最大 1000,需要用到 a[1000],所以至少开 1001,本文开 1005 留余量。
P1094 [NOIP 2007 普及组] 纪念品分组 普及- · 排序 + 双指针

题意:有一堆纪念品,每件有个价格。要把它们分组发下去,规则是: 每组最多 2 件,而且每组两件的价格之和不能超过 w。问最少要分多少组。

⚠️ 数据范围 / 关键约束

1 ≤ n ≤ 3×10⁴,80 ≤ w ≤ 200,5 ≤ Pᵢ ≤ w。

注意最后一条:每件纪念品的价格都不超过 w。 这句话很重要 —— 它保证了任何一件纪念品单独放进一组都是合法的, 所以我们永远不用担心「某件太贵了没地方放」。

另外 w 最大只有 200,价格也在 200 以内,都是很小的整数(这一点后面可以拿来优化), 但 n 有 3 万,O(n²) 的两两配对是 9×10⁸ 次,会超时。必须做到 O(n log n)。

思路

贪心策略:先排序,然后每次拿「现在最重的」去试试能不能和「现在最轻的」凑一组。

能凑上就两个一起走;凑不上就说明最重的那件跟谁配都超标,只能自己占一组。

为什么这样贪是对的

用交换论证,重点想清楚一件事:最重的那件纪念品怎么办最划算?

设现在最重的是 r,最轻的是 l。假设 P_l + P_r ≤ w(它俩能配)。

最优解里 r 的情况能不能改造得一样好
r 和 l 一组就是贪心的做法,不用动 ✅
r 自己一组,l 也自己一组把 l 挪过来和 r 配,组数 −1,比「最优解」还少 → 矛盾,说明最优解不会这样
r 自己一组,l 和别人 x 一组因为 P_l ≤ P_x,所以 P_l + P_r ≤ P_x + P_r ≤ w。
改成「r 配 l、x 自己一组」,组数不变,仍然是最优 ✅
r 和 x 一组(x 不是 l)把 x 换成 l:P_l + P_r ≤ P_x + P_r ≤ w,而且 l 原本的位置空出来了,
x 顶替 l 原来的位置也不会更差 ✅
✅ 结论

四种情况都能改造成「l 和 r 配成一组」且组数不增加。 所以一定存在一个最优解是「最重的配最轻的」。贪心选它,不会错。

一句话直觉:最重的那件最难安排,所以要把「最没用的」那件便宜货拿来陪它 —— 反正便宜货跟谁配都行。

样例输入
100
9
90
20
20
30
50
60
70
80
90
样例输出
6

排序后:20 20 30 50 60 70 80 90 90。配对过程:
90 配 20 → 110 超标,90 单独一组;
90 配 20 → 同样超标,90 单独一组;
80 配 20 → 100 ✅ 一组;70 配 20 → 90 ✅ 一组;60 配 30 → 90 ✅ 一组;50 剩自己 → 一组。
一共 6 组。

完整代码

#include <iostream>
#include <algorithm>
using namespace std;

int p[30005];

int main(){
    int w, n;
    cin >> w;
    cin >> n;
    for(int i = 0; i < n; i++) cin >> p[i];

    sort(p, p + n);              // 从小到大排序,双指针才好用

    int l = 0, r = n - 1, ans = 0;
    while(l <= r){               // ★ 用 <= 而不是 < ,否则中间剩一个会被漏掉
        if(l == r){
            ans++;               // ★ 只剩一件,自己一组
            break;
        }
        if(p[l] + p[r] <= w){
            // 最轻的能配上最重的,就让它们一组(这样最重的不会浪费)
            l++;
            r--;
            ans++;
        } else {
            // 最重的配谁都超重(连最轻的都配不上),只能自己一组
            r--;
            ans++;
        }
    }
    cout << ans << "\n";
    return 0;
}
🎯 为什么 l == r 必须单独判

如果偷懒不判,让代码直接跑 p[l] + p[r] <= w,那就变成 「拿一件纪念品和它自己配」,算出来是 2 × P。这显然不是题目的意思。

虽然这道题里两种写法碰巧结果一样(不管走哪个分支都只产生 1 组), 但「自己和自己配对」是逻辑错误,换个题目就会出事。养成习惯,老老实实判。

✅ 实测结果

官方样例 → 输出 6 与样例完全一致

边界 n = 1:输入 100 / 1 / 50 → 输出 1 正确

顺手能全配上:输入 100 / 4 / 20 30 40 60 → 输出 2 正确 (20+60 和 30+40 各一组)

最大数据:n = 3×10⁴、w = 200、价格随机取 5~200 → 输出 15360,实测耗时 40 ms  (O(n log n),非常宽裕)

暴力对拍:另写一个状压 DP 程序(枚举 2ⁿ 种分组方式求最小分组数), 随机造 300 组数据(n ≤ 8)对比 → 300/300 全部一致。

易错点

  • 循环写成 while(l < r):中间剩下的单独一件会被漏掉,答案偏小。
  • 忘记 l == r 的特判:会变成「自己和自己配对」。
  • 贪心策略选错:比如「贵的配贵的」(第 2 节的反例 A 就是它),或者「轻的配轻的」——都会错。
  • 输入格式:w 在第一行、n 在第二行,然后 n 行每行一个价格。不要连续读成「一行两个数」,虽然用 cin >> 读其实都能过,但要看清题面。
  • 以为一件纪念品可能超过 w:题目保证了 Pᵢ ≤ w,所以不用写「放不下就输出无解」那种逻辑。
P4995 跳跳! 普及- · 排序 + 从两端交替取

题意:你和一堆石头,第 i 块高 hᵢ,地面高度 h₀ = 0。 从高度 x 跳到高度 y 消耗体力 (x − y)²。你必须把每块石头都跳一遍,最后停在哪块都行 (但不能跳回地面)。问:最多能消耗多少体力?

⚠️ 数据范围 / 关键约束

0 < hᵢ ≤ 10⁴,且保证 hᵢ 互不相同。n ≤ 300。

算一下答案有多大:单次跳跃最多 (10⁴)² = 10⁸; 一共最多跳 n = 300 次,所以答案最大约 300 × 10⁸ = 3×10¹⁰。

这又超过了 int 上限(2.1×10⁹),必须用 long long。 我实测 n = 300 的数据,答案跑出来是 9965394032 —— 已经是 99 亿,int 百分百爆。

思路

这题要的是最大体力,而体力是「高度差的平方」。平方这个东西有个特点:

🎯 关键观察

平方函数越往右涨得越快。差 1 消耗 1,差 2 消耗 4,差 10 消耗 100。
所以想耗体力,就要专挑差距大的跳:从最高的跳到最矮的,从最矮的跳到最高的。

贪心策略:

  1. 把高度从小到大排好序。
  2. 从地面(高度 0)出发,先跳到最高的那块石头。
  3. 然后跳到最矮的那块。
  4. 再跳到第二高的,再跳到第二矮的……像拉锯齿一样在两端之间来回横跳。

为什么每步都跳到两端之一?因为排好序之后,离当前点最远的那个高度,一定在两端。 既然越远越耗体力,那一端必然是候选。

为什么是「交替」而不是「一直跳最高」?因为你不能跳回地面,也不能重复跳同一块石头。 跳到最高处之后再想跳远,就只能去最矮处;到了最矮处再想跳远,就只能回高处 —— 自然就形成了交替。

💡 这个贪心的严格证明比较绕,我用了另一种办法确认

P4995 的严格证明要用到「排序不等式 + 每次交换都会让平方和变大」这类推导,对新手不太友好。 所以我的做法是:写一个暴力程序,把 n ≤ 7 的所有访问顺序全枚举一遍,取真正的最小/最大值,然后对拍。

随机造了 300 组数据,贪心和暴力300/300 完全一致。 这说明这个策略在小数据上确实无敌 —— 竞赛里这是非常有说服力的证据。

样例 1 输入
2
2 1
样例 1 输出
5
样例 2 输入
3
6 3 5
样例 2 输出
49

样例 1:地面 → 2(耗 2²=4)→ 1(耗 (2−1)²=1),共 5。
样例 2:排序后是 3 5 6。地面 → 6(耗 36)→ 3(耗 (6−3)²=9)→ 5(耗 (5−3)²=4),共 49。

完整代码

#include <iostream>
#include <algorithm>
using namespace std;

int h[305];

int main(){
    int n;
    cin >> n;
    for(int i = 0; i < n; i++) cin >> h[i];
    sort(h, h + n);          // 从小到大排好,方便从两端交替取

    long long ans = 0;       // ★ 答案能到 3×10^10,必须 long long
    int l = 0, r = n - 1;
    long long cur = 0;       // 当前位置的高度,一开始站在地上 h0 = 0
    bool takeRight = true;   // 先跳最高的

    while(l <= r){
        long long nxt;
        if(takeRight){
            nxt = h[r];      // 拿当前最高的
            r--;
        } else {
            nxt = h[l];      // 拿当前最矮的
            l++;
        }
        ans += (nxt - cur) * (nxt - cur);   // 消耗 (差值)²
        cur = nxt;
        takeRight = !takeRight;             // ★ 下一次换成另一端
    }
    cout << ans << "\n";
    return 0;
}
✅ 实测结果

官方样例 1 2 / 2 1 → 5 一致
官方样例 2 3 / 6 3 5 → 49 一致

边界 n = 1:输入 1 / 7 → 49 正确 (地面 → 7,耗 7² = 49)

最大数据:n = 300、高度从 1~10⁴ 里随机取 300 个互不相同的值 → 输出 9965394032,实测耗时 25 ms。
这个数接近 100 亿,int 上限只有 21 亿 —— 用 int 必然出错,坐实了必须 long long。

暴力对拍:另写一个程序用 next_permutation 枚举全部访问顺序(n ≤ 7)取最大值, 随机造 300 组数据(高度互不相同)对比 → 300/300 全部一致。

易错点

  • 用 int 存答案:最大能到 3×10¹⁰,必爆。这是本题最容易丢分的地方。
  • 算平方时没转 long long:h[r] - h[l] 是 int,两个 int 相乘还是 int。
    本文代码里 nxt 和 cur 都声明成了 long long,所以自动就按 64 位算了。
  • 跳跃方向搞反:题目要的是最大体力,不是最小。别习惯性地求最小。
  • 忘了从地面出发:第一跳是从高度 0 起跳的,不是从某块石头起跳。cur 的初值必须是 0。
  • 最后停在原地不动:题目说得很清楚,必须每块石头都跳到,不能跳过某些石头。所以循环必须在 l <= r 里把每一个都取走。
  • 不能跳回地面:所以最后一步一定是停在某块石头上,不能加一步「跳回地面」。
P3817 小 A 的糖果 普及/提高- · 线性贪心 + 反例意识

题意:n 个糖果盒排成一排,第 i 盒有 aᵢ 颗糖。每次可以从某一盒里吃掉一颗。 目标是让任意两个相邻盒子的糖果数之和都不超过 x。问最少要吃几颗。

⚠️ 数据范围 / 关键约束

2 ≤ n ≤ 10⁵,0 ≤ aᵢ, x ≤ 10⁹。

注意 aᵢ 和 x 都可能是 0,而且 aᵢ 的上限是 10⁹ —— 差一点又超过 int 的 2.1×10⁹ 了(两个 10⁹ 相加就是 2×10⁹)。

算一下答案有多大:每盒最多吃 10⁹ 颗,一共 10⁵ 盒,最坏情况答案能到 10¹⁴。 int 完全不够用,必须 long long。

思路

从左往右一盒一盒地看。看第 i−1 盒和第 i 盒的时候,如果它俩的和超了 x, 那多出来的部分必须被吃掉,没得商量(因为只有这两盒能改,前面已经处理完了,不能反悔)。

问题只在于:吃哪一盒?

🎯 优先吃右边的(但要注意补锅)

优先吃右边的第 i 盒 —— 因为第 i−1 盒的「搭档关系」已经用完了, 而第 i 盒待会儿还要和第 i+1 盒配对,先削它同样有效,不浪费。

但如果第 i 盒不够吃呢?那就把第 i 盒吃空,剩下的缺口从第 i−1 盒补。 这是计划里没提到、但必须处理的情况(见下面的反例)。

⚠️ 反例:无脑「只削右边」是错的

计划原文的提示是「一旦相邻两堆之和超过 x 就削掉右边的」。如果真的一行照抄,会挂在这组数据上:

反例输入
3 2
4 1 3
正确答案
4

无脑削右边:4 + 1 = 5 > 2,超出 3 颗,从第 2 盒削 3 颗 —— 可第 2 盒只有 1 颗,削完变成 −2,程序算出 3。
正确方案:第 1 盒吃 2 颗(4→2),第 2 盒吃 1 颗(1→0),第 3 盒吃 1 颗(3→2),共 4 颗。

我实际把两种版本都编译运行过:错误的版本在这组数据上输出 3, 暴力枚举版本输出 4。所以这个补丁是必需的,不是可写可不写。

为什么这样贪是对的

每一步我们处理的都是「当前这一对 (i−1, i)」。此时第 i−2 盒及更左边都已经合法了, 后面的盒子还没被这对影响。所以:

  • 超出 x 的那部分无论如何都要吃掉(不在这两盒吃,就没别的地方可吃了)。
  • 在这两盒里,吃第 i 盒(右边)永远不比吃第 i−1 盒差:因为减少第 i 盒只会让它更小, 待会儿处理 (i, i+1) 时更容易达标;而减少第 i−1 盒对未来的配对毫无帮助。
  • 所以「能吃右边就吃右边」是安全的;只有右边不够时,才不得不往左边借。

注意吃左边也不会破坏已经处理好的部分:第 i−1 盒变小, a[i−2] + a[i−1] 只会更小,之前那个约束依然满足。

样例输入 1
3 3
2 2 2
样例输出 1
1
样例输入 2
6 1
1 6 1 2 0 4
样例输出 2
11
样例输入 3
5 9
3 1 4 1 5
样例输出 3
0

样例 1:2+2 = 4 > 3,吃掉 1 颗 → 1。
样例 2:第 2 盒吃 6 颗、第 4 盒吃 2 颗、第 6 盒吃 3 颗,共 11。
样例 3:所有相邻和分别是 4、5、5、6,都不超过 9,一颗都不用吃 → 0。

完整代码

#include <iostream>
using namespace std;

long long a[100005];            // ★ aᵢ 最大 10^9,且相邻和会用到两个数相加

int main(){
    ios::sync_with_stdio(false);    // n 最大 10^5,加快读入
    cin.tie(0);

    int n;
    long long x;
    cin >> n >> x;
    for(int i = 0; i < n; i++) cin >> a[i];

    long long eaten = 0;            // ★ 答案最大 10^14,必须 long long
    for(int i = 1; i < n; i++){
        long long sum = a[i - 1] + a[i];
        if(sum > x){
            // 至少要吃掉这么多颗,才能让 (i-1, i) 这一对和不超标
            long long need = sum - x;
            if(a[i] >= need){
                // 右盒够吃:全从右盒吃(右盒以后还能再削,最划算)
                a[i] -= need;
            } else {
                // ★ 右盒不够吃!先把右盒吃空,剩下不够的从上一盒补
                long long rest = need - a[i];
                a[i] = 0;
                a[i - 1] -= rest;   // rest ≤ a[i-1],绝不会吃成负数
            }
            eaten += need;
        }
    }
    cout << eaten << "\n";
    return 0;
}
✅ 实测结果

官方样例 1 3 3 / 2 2 2 → 1 一致
官方样例 2 6 1 / 1 6 1 2 0 4 → 11 一致
官方样例 3 5 9 / 3 1 4 1 5 → 0 一致

反例验证:输入 3 2 / 4 1 3 → 修正后的代码输出 4;
(我另外写了一个不带补丁的版本,同一组数据输出 3 —— 确实是错的,暴力枚举确认真实答案是 4)

极限数据:n = 10⁵、所有 aᵢ = 10⁹、x = 0(每一对和都必须是 0,所以所有盒子都得清零)→ 输出 100000000000000(= 10¹⁴) 正确,实测耗时 75 ms

暴力对拍:另写一个程序枚举每盒吃 0~aᵢ 颗的所有组合(n ≤ 6,aᵢ ≤ 4)求最小值, 随机造 300 组数据对比 → 300/300 全部一致。 (第一版没过 —— 就是上面那个反例,改完补丁之后才 300/300 全过)

易错点

  • 只削右边忘了补锅:右盒不够吃时会吃成负数,答案偏小。这是本题最大的坑。
  • 用 int:aᵢ 能到 10⁹,两个相加就 2×10⁹ 已经爆 int;答案能到 10¹⁴,更是必须 long long。
  • 从右往左扫:那样会破坏「左边已经处理好、永不回头」的不变量,方向必须是从左到右。
  • 忘了 x 可以是 0:x = 0 时每一对相邻和都必须 ≤ 0,因为糖果数非负,所以最终所有盒子都得是 0。
  • 以为要排序:这题不能排序!盒子是按位置排的,「相邻」指的是原来的位置关系,排完序就全乱了。

✗今日常见错误汇总

#错误写法后果正确写法
1int 存答案 P1223 会到 5×10¹¹、P4995 会到 3×10¹⁰、P3817 会到 10¹⁴ —— 全部超出 int 的 2.1×10⁹,输出变成乱码或负数 long long
2a[i].t * (n - i)(两个 int 相乘) 先按 int 算完再赋值,中间就已经溢出,赋给 long long 也救不回来 1LL * a[i].t * (n - i)
3total / n 求平均 整数除法,2919/10 得到 291,小数被砍掉 (double)total / n
4while(l < r) 的双指针 中间最后剩下一个元素时循环直接结束,它被漏掉 while(l <= r) 并特判 l == r
5P3817 只削右边不补锅 右盒不够吃时被吃成负数,答案偏小(反例 3 2 / 4 1 3:错答 3,正解 4) 右盒吃空后,剩余缺口从 a[i-1] 补
6P3817 去排序 「相邻」指的是原来的位置,排序后位置关系全乱 按原顺序从左到右扫
7P1223 时间相同时不按编号 样例里就有两个 99(4 号和 9 号),顺序反了直接 WA cmp 里第二关键字写 p.id < q.id
8贪心策略不验证就提交 比如 P1094 用「贵配贵」(反例 w=100, 60 60 40 40:错答 3,正解 2) 先交换论证,再写暴力对拍
9数组开小 P1223 下标从 1 开始,n = 1000 需要 a[1000],只开 1000 就越界 开 n + 5,本文开 1005
10大 n 不用快读 P3817 有 10⁵ 个数,P1803(明天)有 10⁶ 个,不关同步可能超时 ios::sync_with_stdio(false); cin.tie(0);

✓自测:你真的会了吗

第 1 题:算一下答案会不会爆 int

某题:n ≤ 300,每个数 ≤ 10⁴,答案是把这些数的平方加起来。用 int 够吗?

答:单个平方最大 (10⁴)² = 10⁸,300 个加起来就是 3×10¹⁰。 int 上限约 2.1×10⁹ —— 不够,必须 long long。

口诀:拿到题先估一下「最大值 × 个数 大概是多少位数」。超过 9 位数(10⁹)就别用 int 了。

第 2 题:交换论证的三步是哪三步?

答:
① 假设存在一个最优解,它和贪心解在某处不同;
② 在那个最优解里,把不同的选择换成贪心的选择;
③ 证明换完之后答案没有变差。

三步都成立,就说明贪心的选择是安全的。对「排队类」问题,第 ③ 步通常用相邻交换法: 只比较相邻两个元素交换前后的差别,最容易算。

第 3 题:P1094 如果换成「每次把最贵的和最贵的凑一对」,反例是什么?

答:w = 100,价格 60 60 40 40。

贵配贵:60+60 = 120 > 100 配不上,于是两个 60 各占一组、两个 40 凑一组 → 3 组。
贵配便宜:60+40 = 100 ≤ 100,两组搞定 → 2 组。

所以「贵配贵」是错的。正确策略是「最重的尽力去配最轻的」—— 因为最便宜的纪念品跟谁配都不吃亏,拿它去陪最重的那件最划算。

第 4 题:P3817 为什么不能「只削右边」?

答:因为右边那盒的糖果可能不够削。数据 3 2 / 4 1 3:
第 1、2 盒之和是 5,超出 2 的部分是 3,但第 2 盒只有 1 颗 —— 削 3 颗就变成 −2 了。

正确做法:第 2 盒吃空(吃 1 颗),剩下 2 颗的缺口从第 1 盒吃(4 → 2)。 然后第 2、3 盒是 0 + 3 = 3 > 2,再吃第 3 盒 1 颗。总共 4 颗。

教训:贪心策略要考虑到「资源不够用」这种边界情况。

第 5 题(动手写):P1223 里为什么必须写 1LL *?

因为 a[i].t 是 int,(n - i) 也是 int。 C++ 会先按 int 算完这个乘法,再把结果转成 long long 加到 total 上。

而 10⁶ × 999 ≈ 10⁹,虽然没超 int,但换个数据(比如 n = 10⁶、t = 10⁶)就立刻爆了。 加上 1LL *,编译器从一开始就按 64 位算,永远安全。

// ❌ 危险:先按 int 乘,可能中途溢出
total += a[i].t * (n - i);

// ✅ 安全:1LL 让整个表达式变成 long long
total += 1LL * a[i].t * (n - i);

▤速查卡(截图存手机)

【贪心四步走】
  1. 想到一个策略 → 2. 交换论证 / 直觉解释
  3. 造反例试图推翻 → 4. 举不出反例才写代码

【交换论证模板】
  假设最优解在某处和贪心不同
  → 把那个选择换成贪心的选择
  → 证明答案没有变差
  排队类问题用「相邻交换法」最灵

【排序 + 贪心 关键字对照】
  P1223 接水   → 接水时间升序(快的先上)
  P1094 分组   → 价格升序 + 双指针(重配轻)
  P4995 跳跳   → 高度升序 + 两端交替
  P3817 糖果   → 不排序,从左到右线性扫
  P1803 线段   → 右端点升序(明天)
  P2240 背包   → 性价比降序(明天)
  P1090 果子   → 优先队列小根堆(明天)
  P1208 牛奶   → 单价升序(明天)

【双指针】
  sort(a, a + n);
  int l = 0, r = n - 1;
  while(l <= r){                  // ★ 不是 l < r
      if(l == r){ ...; break; }   // ★ 中间剩一个要特判
      if(条件) { l++; r--; }      // 两端都用掉
      else     { r--; }           // 只用右边
  }

【防溢出的三条铁律】
  1. 先估答案量级:超过 10^9 就用 long long
  2. 乘法前加 1LL:1LL * a * b
  3. 求平均先转 double:(double)total / n

【线性贪心(P3817 型)】
  for i = 1 .. n-1:
      need = a[i-1] + a[i] - x
      if need > 0:
          if a[i] >= need:  a[i] -= need
          else:             rest = need - a[i]
                            a[i] = 0
                            a[i-1] -= rest    // ★ 补锅
          ans += need

→明天预告 · Day 14

贪心进阶:区间、背包、堆

今天你已经把「排序 + 贪心」这个基本套路练熟了。明天会碰到三种全新的贪心场景:

今天的「反例思维」明天还会继续用 —— 尤其 P1803,如果按左端点排序就错了,这正是最经典的区间贪心反例。