← 目录 第 16 / 60 节 全部 60 节

16 · 贪心入门:交换论证与排序贪心

把“能不能贪”变成一张可检验的清单:三信号三反信号;再用交换论证证明“接水问题按接水时间升序”唯一最优,并把贡献法 Σ T_i × (n − i) 写成三行代码。
⭐ 已学范围普及-预计 60 分钟贪心交换论证排序
本节出现的新函数 / 新语法(先看懂再用)

贪心只有一句话:每一步都选当前看起来最好的,并且永不回头。难的不是“选最好的”,而是判断“这样选会不会毁掉全局”。本节把它变成一张 6 行的检查表,再用交换论证把“排序贪心”钉死——改一行 cmp 就换了一个算法,所以那一行必须有证明撑着。

贪心不是灵感,是“先猜结论,再交换证明”。没有证明之前,你的贪心只是一个等着被 hack 的猜测。

先看判断清单。它不能保证贪心一定对,但能让你在 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 就是算法本身。

新函数拆解 · cmp(自定义比较函数)—— 贪心策略写在哪一行
bool cmp(const T &a, const T &b); // 返回 true 表示 a 应排在 b 前面
组成含义 / 格式要求
返回类型 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) 次。

现在用一道最经典的题把上面的东西落地:排队接水。它的搜索空间大到不可能枚举,但它的结构简单到只需要一次排序。

例题 · 排队接水洛谷 P1223普及-去洛谷做这题 ↗

有 n 个人在一个水龙头前排队接水,第 i 个人接水需要 T_i 个单位时间。一个人的等待时间等于排在他前面的所有人接水时间之和,不包括他自己接水的时间。

请给出一种排队顺序,使 n 个人的平均等待时间最小;若两人接水时间相同,编号更小的人排在前面。

输入格式:第一行一个整数 n;第二行 n 个整数 T_1 … T_n。

输出格式:第一行输出排队顺序(每人的原始编号,空格分隔),第二行为最小的平均等待时间,保留两位小数。

数据范围:1 ≤ n ≤ 1000,1 ≤ T_i ≤ 10⁶,T_i 可能重复。

输入: 10 56 12 1 99 1000 234 33 55 99 812 输出: 3 2 7 8 1 4 9 6 10 5 291.90
三步:数清搜索空间 → 改写成贡献 → 交换论证定顺序

第 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 的次键。

贪心部分只有 3 行
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⁶ 也不用改。

完整可编译代码(C++17)
#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 猜排序键——这等于用提交记录做实验,非常浪费生命。

这一节的 6 个坑,每个都有具体症状

总等待时间用 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,求最小总完成时间”——同一个式子,同一份证明。看到这种结构,先写贡献式,再套排序不等式。

举一反三 · 想清楚这些再往下走
  1. 如果每个人接水前还有一段与顺序无关的准备时间 s_i(准备时水龙头也被占着),排序键应该改成 T_i 还是 T_i + s_i?用贡献式 Σ (T_i + s_i)(n − i) 检验你的直觉。
  2. 如果改成两个水龙头(两台机器),让所有人接完的总时间最短,按 T 升序贪心还对吗?先试 4 个任务、2 台机器,看能不能造出反例。这说明“多加一个资源”为什么会把问题变难。
  3. 如果目标是让总等待时间最大,代码要改哪一行?交换论证里的差值会变成什么符号?
  4. 如果目标是让最后一个人尽早接完水(总时长的最短化),任何顺序的结果都一样吗?为什么?这个现象对应判断清单里的哪一条?
  5. 硬币面值是 1、5、10、25 时,贪心为什么一定对?面值换成 1、3、4 就错——请说清“贪心正确”依赖的到底是题目的什么性质,而不是题目的名字。
  6. 给定一道新题,你怎么在 2 分钟内判断该不该贪?把你实际会问自己的 3 个问题写下来。
自测:能全部回答再进下一节
挑战题 · 删数问题
洛谷 P1106普及-去洛谷做这题 ↗

给定一个不超过 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 节学过的,这里正好用上。
这题最容易错的地方
  • 用 int / long long 读 n:250 位必然溢出,必须读成 string,否则连样例都过不了。
  • 只删一次就输出:忘了 k 可能大于 1,答案会明显偏大。
  • 忘记处理前导零:输入 1000、k = 1 时得到 000,正确输出是 0。
  • 删除后没有重新从前往后扫,用了旧的下标位置,结果少了或多了数字。
延伸阅读 / 参考