贪心只有一句话:每一步都选当前看起来最好的,并且永不回头。难的不是“选最好的”,而是判断“这样选会不会毁掉全局”。本节把它变成一张 6 行的检查表,再用交换论证把“排序贪心”钉死——改一行 cmp 就换了一个算法,所以那一行必须有证明撑着。
先看判断清单。它不能保证贪心一定对,但能让你在 5 分钟内决定:继续想贪心,还是立刻换成 DP / 搜索。
| 判定 | 信号 / 反信号 | 它意味着什么 | 本节的例子 |
|---|---|---|---|
| 可以贪 | 局部选择可量化 | 每一步的收益或代价都能写成确定的数字 | 接水:某人的等待时间 = 他前面所有人接水时间之和 |
| 可以贪 | 交换不变差 | 交换两个相邻决策,答案不会更好 → 存在一个最优解,第一步就选了局部最优 | T 大的排前面,交换后总等待减少 T_a − T_b |
| 可以贪 | 剩余问题同构 | 选完之后,剩下的还是同一道题,只是规模变小 | 接完一个人,剩下 n − 1 人排同一支队 |
| 不能贪 | 挤占未来资源 | 当前最优吃掉的资源,无法用当前收益定价 | 0/1 背包按性价比拿,剩下的空间装不下下一件 |
| 不能贪 | 需要反悔 | 已经丢掉的选项后面可能想拿回来 | 合并果子要“撤销”贪心选择,得靠堆(下一节) |
| 不能贪 | n ≤ 4 就有反例 | 手算小数据就能推翻它 | 面值 1、3、4 凑 6:贪心 3 枚,最优 2 枚 |
上表里的两个反例值得记一辈子,因为它们把“贪心为什么会错”说透了。硬币:面值 1、3、4,凑 6。贪心先拿最大的 4,剩 2 只能 1 + 1,共 3 枚;最优是 3 + 3,两枚。0/1 背包:容量 20。A 件重 10 值 60(性价比 6),B 件重 20 值 100(性价比 5)。按性价比先拿 A,剩 10 装不下 B,得 60;最优是只拿 B,得 100。两者的共同结构:局部最优消耗了一份“未来资源”,而这份资源的价格没有被算进当前收益。
接水问题为什么没有这个毛病?每个人只占 1 个位置,位置总数固定为 n,谁占都不改变“后面还有几个人要等”。资源不被消耗,只是被排序,所以排序就能解决。这一条判据后面会反复出现:如果“选择”只是在重排一个定长的序列,贪心往往成立;如果“选择”会减少未来可用的资源,贪心往往不成立。
本节没有新库函数,但有一个必须吃透的东西:cmp。在排序贪心里,cmp 就是算法本身。
| 组成 | 含义 / 格式要求 |
|---|---|
返回类型 bool | 只回答一个是非题:a 该不该排在 b 前面。它表达的是“优先级”,不是“大小”。 |
参数 const T & | 按引用传,避免结构体拷贝;const 表示比较过程不修改数据。写值传递也能过,但慢一倍。 |
严格弱序 | cmp(a, a) 必须返回 false,且 a、b 不能同时返回 true。写成 <= 会破坏这一点,std::sort 可能越界写内存,症状是 RE 或随机 WA。 |
平手怎么办 | 主键相等时必须给确定的次键(如编号)。否则相同数据的输出顺序不稳定,本机过、交上去 WA。 |
和贪心的关系 | 贪心的“选谁”在代码里就是这一行不等式。改贪心策略 = 改这一行,然后重新证明。 |
方向记法 | 想让谁先出现,就 return 谁的键 更小。想反过来,就把 < 换成 >——“总等待最小”和“总等待最大”只差这一个符号。 |
struct Node { int t, id; } a[1005];
bool cmp(const Node &x, const Node &y) {
if (x.t != y.t) return x.t < y.t; // 主键:接水时间短的优先
return x.id < y.id; // 次键:时间相同按编号,输出稳定
}
sort(a + 1, a + n + 1, cmp); // 排序只做一件事:把 cmp 认为"优先"的放前面cmp 返回 true 的意思是“a 排在 b 前面”,不是“a 小于 b”。在贪心里这两句话经常方向相反,比如“重的优先”就该写 a.w > b.w。
比较函数里不要写自增、输入输出这类有副作用的代码:std::sort 会调用它 O(n log n) 次。
现在用一道最经典的题把上面的东西落地:排队接水。它的搜索空间大到不可能枚举,但它的结构简单到只需要一次排序。
有 n 个人在一个水龙头前排队接水,第 i 个人接水需要 T_i 个单位时间。一个人的等待时间等于排在他前面的所有人接水时间之和,不包括他自己接水的时间。
请给出一种排队顺序,使 n 个人的平均等待时间最小;若两人接水时间相同,编号更小的人排在前面。
输入格式:第一行一个整数 n;第二行 n 个整数 T_1 … T_n。
输出格式:第一行输出排队顺序(每人的原始编号,空格分隔),第二行为最小的平均等待时间,保留两位小数。
数据范围:1 ≤ n ≤ 1000,1 ≤ T_i ≤ 10⁶,T_i 可能重复。
第 1 步:数清搜索空间。n 个人的顺序有 n! 种,n = 11 时已经 4×10⁷,n = 1000 完全不可能枚举。必须找到一个只依赖“谁在谁前面”的量,而不是去搜索顺序。
第 2 步:把“等待”改写成“贡献”。看排在位置 i(从 1 数)的那个人:他接水用掉 T,他后面还有 n − i 个人,每个人都要白白等这 T。所以这一个人对总等待时间的贡献是 T × (n − i),总等待 = Σ T_i × (n − i)。
这一步做完,题目已经不是“排队题”了:n 是常数,系数 (n−1, n−2, …, 1, 0) 是一列从大到小排好的数,只剩一个问题——怎么把 T 这一列数配到这列系数上,让乘积和最小。
第 3 步:交换论证。假设相邻两人 a 在前、b 在后,且 T_a > T_b。只算这两个人的贡献:交换前 = (n−i)·T_a + (n−i−1)·T_b,交换后 = (n−i)·T_b + (n−i−1)·T_a,相减 = T_a − T_b > 0。也就是说:只要出现“大的在前、小的在后”的相邻对,交换一定让答案变小。任何非升序排列都至少含一个这样的相邻对,而升序排列一个都没有,所以升序就是最优。
证明完毕,代码只剩两件事:排序,然后按贡献法累加。注意“最优”只对等待时间的数值成立,T 相同时顺序可以互换,所以题面才要求“时间相同则编号小的在前”——这正是 cmp 的次键。
sort(a + 1, a + n + 1, cmp); // 贪心的全部内容:按 T 升序
long long sum = 0;
for (int i = 1; i <= n; ++i) sum += 1LL * a[i].t * (n - i); // 贡献法下面是完整程序。n 只有 1000,但用 sort 已经是习惯:同样的写法换到 n = 10⁶ 也不用改。
#include <bits/stdc++.h>
using namespace std;
const int N = 1005;
int n;
struct Node {
int t; // 接水用时
int id; // 原始编号:排序会打乱位置,编号必须跟着走
} a[N];
// 贪心策略:接水时间短的先上
bool cmp(const Node &x, const Node &y) {
if (x.t != y.t) return x.t < y.t; // 主键:时间升序
return x.id < y.id; // 次键:时间相同,编号小的在前
}
int main() {
ios::sync_with_stdio(false);
cin.tie(nullptr);
cin >> n;
for (int i = 1; i <= n; ++i) {
cin >> a[i].t;
a[i].id = i; // 记住它原本是第几个人
}
sort(a + 1, a + n + 1, cmp); // O(n log n),n = 1000 时约 1 万次比较
long long sum = 0; // 总等待时间可达 5*10^11,int 装不下
for (int i = 1; i <= n; ++i) {
sum += 1LL * a[i].t * (n - i); // 第 i 位的人被后面 n-i 人各等一次
if (i > 1) cout << ' '; // 分隔符:第一个数前面不打空格
cout << a[i].id;
}
cout << '\n';
cout << fixed << setprecision(2) << 1.0 * sum / n << '\n';
return 0;
}| 代码片段 | 含义与理由 |
|---|---|
struct Node { int t; int id; } a[N]; | 把“接水时间”和“原始编号”绑在一起。排序会打乱数组下标,编号必须跟着时间一起搬,否则最后无法输出“原来第几个人”。 |
bool cmp(const Node &x, const Node &y) | 只读比较,参数用 const 引用。sort 会调用 O(n log n) 次,值传递每次多拷 8 字节。 |
if (x.t != y.t) return x.t < y.t; | 主键。这一行就是整个贪心算法,改一个符号就变成“求最大等待时间”。 |
return x.id < y.id; | 次键。P1223 题面明写“时间相同则编号小的在前”,不写这一行会在重复数据上 WA。 |
sort(a + 1, a + n + 1, cmp); | 对 a[1..n] 排序。sort 的区间左闭右开,所以右端点写 a + n + 1,不是 a + n。 |
long long sum = 0; | n = 1000、T = 10⁶ 时总等待约 T × n(n−1)/2 ≈ 5×10¹¹,超过 int 的 2.1×10⁹。凡是“求和”先估量级,这是第 2 节就立的规矩。 |
sum += 1LL * a[i].t * (n - i); | 贡献法一行。先乘 1LL 把运算提升到 64 位,避免 int × int 先溢出再赋给 long long。 |
if (i > 1) cout << ' '; | 把空格放在数的前面而不是后面,行末就不会多出空格,严格比对的评测也不会挂。 |
cout << fixed << setprecision(2) << 1.0 * sum / n; | 题目要两位小数。先乘 1.0 变成浮点除法,否则整数除法把 291.90 截成 291。 |
① 找“顺序决定代价”的那一项。本题是等待时间:它由“前面有几个人、各用多久”决定。如果代价和顺序无关,这题不需要贪心,直接算。
② 把总代价写成 Σ(值 × 系数),系数只跟位置有关。本题 Σ T_i × (n − i)。写不出这个式子,说明还没看懂题目。
③ 用交换论证确定排序键。设相邻两项为 a、b,算出“交换前 − 交换后”的差,看它在什么条件下 > 0。差恒正的那一边就是正确答案。
④ 排序 + 一遍扫描。到这步已经没有思维含量,剩下的全是细节:编号、次键、long long、小数位。
四步里 ①② 是数学,③ 是证明,④ 才是代码。学生最常见的失败方式是从 ④ 开始写,然后对着 WA 猜排序键——这等于用提交记录做实验,非常浪费生命。
总等待时间用 int 存:n = 1000、T = 10⁶ 时和可达 5×10¹¹,溢出成负数 → WA,而且小样例全过,只有大数据错。
系数方向写反(写成 Σ T_i × i):等价于降序排序 → WA,样例都过不了,是最好发现的一种错。
cmp 写成 return x.t <= y.t;:违反严格弱序,std::sort 可能越界写内存 → RE(段错误)或答案随机。平手必须用次键区分,绝不能用 <=。
忘了记原始编号,直接输出排序后的位置:数值对、顺序错 → WA。
平均时间写成 sum / n:整数除法丢掉小数,291.90 变 291 → WA。必须 1.0 * sum / n 或 (double)sum / n。
行末多一个空格:部分严格比对(NOIP 系)会判 WA。用 if (i > 1) cout << ' '; 控制。
关掉这一页,把上面那份程序从零敲一遍,交到 P1223,确认输出正好是 291.90。
改一改①:把贡献系数 (n − i) 换成 (n − i + 1),也就是“等待时间包含自己接水的时间”(完成时间之和)。先预测答案会不会变、变成多少,再跑一遍验证。
改一改②:把所有 T_i 都加 1,答案会多多少?提示:多出来的部分是 Σ 1 × (n − i) = n(n−1)/2,与顺序无关。自己算一遍 n = 10 时是 45,再乘到平均值上验证。
设 a₁ ≤ a₂ ≤ … ≤ a_n,b₁ ≤ b₂ ≤ … ≤ b_n,σ 是 1..n 的任意排列。排序不等式说:
Σ a_i · b_{n+1−i} ≤ Σ a_i · b_{σ(i)} ≤ Σ a_i · b_i。左边是“逆序和”(大配小),最小;右边是“同序和”(大配大),最大。
证明只用一次相邻交换。取两个位置,现在配的是 (a_j, b_k) 与 (a_k, b_j),其中 j < k。交换成 (a_j, b_j)、(a_k, b_k) 后,两者之差 = (a_j b_j + a_k b_k) − (a_j b_k + a_k b_j) = (a_k − a_j)(b_k − b_j) ≥ 0。所以“小 a 配大 b”的组合一旦存在,交换成同序一定不会变小——反过来读,逆序和最小。
回看接水问题:总等待 = Σ T_i × (n − i)。系数 (n−1, n−2, …, 0) 是一列已经降序排好的数,T 是另一列。要让乘积和最小,就必须让两列的排序方向相反:T 升序 ↔ 系数降序。这就是“按 T 升序排队”的全部数学。
用样例验一遍:T 升序为 1, 12, 33, 55, 56, 99, 99, 234, 812, 1000,系数 9, 8, …, 0。乘积和 = 9 + 96 + 231 + 330 + 280 + 396 + 297 + 468 + 812 + 0 = 2919,平均 291.90。
推论:凡是“总代价 = Σ(某个值 × 某个由位置决定的系数)”的题,答案几乎都是排序。同模型的还有“n 个任务,第 i 个耗时 t_i,求最小总完成时间”——同一个式子,同一份证明。看到这种结构,先写贡献式,再套排序不等式。
给定一个不超过 250 位的正整数 n 和一个整数 k,删去其中任意 k 个数字,剩下的数字按原左右次序拼成一个新的非负整数。要求剩下的数最小。
输入格式:第一行一个正整数 n(不超过 250 位,必须按字符串读入);第二行一个整数 k(1 ≤ k < n 的位数)。
输出格式:一行,剩下最小的数。前导零不输出;若结果全为 0,输出 0。
先只想“只删 1 位”怎么做:从最高位开始看,删哪一位能让结果变小?
删 1 位时,从前往后找第一个比后一位大的数字删掉它;如果整个串单调不降,就删最后一位。
删 k 位就是把这个动作重复 k 次。别忘最后要处理前导零。
关键观察(交换论证)。设相邻两位 a、b 满足 a > b,前缀相同,删 a 得到的数是 …b…,删 b 得到的数是 …a…,第一个不同的就是这一位,而 b < a,所以删 a 一定更小。于是“删第一个下降点”是唯一正确的单步操作。
若整个串单调不降,删任何非末位都会让那一位被更大的数顶替(变大),只有删末位不会。
重复 k 次即可。每轮 O(n),总 O(k·n) ≤ 250 × 250 = 62500 次操作,远低于时限。
最后去掉前导零,但至少保留一位数字:n = 100、k = 1 时结果是 0,不能输出空行。
#include <bits/stdc++.h>
using namespace std;
int main() {
ios::sync_with_stdio(false);
cin.tie(nullptr);
string s;
int k;
cin >> s >> k;
// 删 k 次,每次删掉"第一个比后一位大的数字"
for (int step = 0; step < k; ++step) {
int i = 0;
// 找第一个下降点 s[i] > s[i+1];找不到时 i 会停在最后一位
while (i + 1 < (int)s.size() && s[i] <= s[i + 1]) ++i;
s.erase(s.begin() + i); // 删掉它(单调不降时删的就是末位)
}
// 去前导零,但至少留一位
int p = 0;
while (p + 1 < (int)s.size() && s[p] == '0') ++p;
cout << s.substr(p) << '\n';
return 0;
}| 片段 | 作用 |
|---|---|
while (i + 1 < (int)s.size() && s[i] <= s[i + 1]) ++i; | 从前往后扫,停在第一个下降点。加 (int) 是防止 size() 的无符号数与 -1 比较时出错。找不到下降点时 i 停在末位。 |
s.erase(s.begin() + i); | 删第 i 位。前缀不变、后面整体左移一位,正好实现“删掉一个数字”。 |
for (int step = 0; step < k; ++step) | 删 k 次而不是一次。每轮都要重新从前往后找,因为删除后下降点的位置可能往前移。 |
while (p + 1 < (int)s.size() && s[p] == '0') ++p; | 去前导零。p + 1 < size 保证至少留一位,避免 100 删 1 位后输出空字符串。 |
cout << s.substr(p); | 从第一个非零位输出到尾。substr 是第 9 节学过的,这里正好用上。 |