```mermaid graph LR A[Mermaid Keeper] --> B[保持激活] ```

前言

前面五章用了各种技巧——暴力、搜索、数学、数据结构、图论、DP。这一章回到最直觉的策略:每一步都选眼前最好的选择。这就是贪心(Greedy)。

贪心之所以难,不在于”怎么贪”,而在于”凭什么贪是对的”。DP 通过枚举所有状态保证最优,但贪心只走一条路——如果这条路不对,就全盘皆输。所以证明贪心的正确性才是核心技能。

最常用的证明方法是交换论证:假设最优解在某一步和贪心不同,证明把它们交换过来不会更差。如果每一步都能这样交换,那贪心解就一定是最优的。

问题的本质

贪心什么时候有效?

贪心有效的前提是贪心选择性质:存在一个最优解,它的第一步和贪心的选择一样。如果这个性质成立,我们就可以放心地做贪心第一步,然后对剩余子问题继续贪心。

常见的贪心有效场景:

  • 排序后配对:两组数一一配对,最小化距离和(排序后对应配对)
  • 按截止时间/结束时间排序:区间调度(选结束最早的)
  • 按报酬排序:每个时间点选最大可用报酬(Taro’s Job)
  • 中位数选址:曼哈顿距离之和最小化

交换论证:为什么排序配对是最优的?

以 T90 014 为例:NN 个学生和 NN 个学校分别在不同位置,一一配对使总距离最小。

贪心策略:两组都排序,第 ii 小的学生配第 ii 小的学校。

证明:假设最优解中,第 ii 小的学生 aa 配了第 jj 小的学校 bbj>ij > i),而第 ii 小的学校 cc 配了某个更大的学生 ddd>ad > a)。交换这两对:aaccddbb。因为 ada \le dcbc \le b,可以证明交换后总距离不会增加(三角不等式)。重复交换,最终得到排序配对——说明排序配对不比最优解差。

理论 + 代码

模型一:排序配对

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

int main() {
    int N;
    scanf("%d", &N);
    int A[100009], B[100009];
    for (int i = 0; i < N; i++) scanf("%d", &A[i]);
    for (int i = 0; i < N; i++) scanf("%d", &B[i]);

    sort(A, A + N);                                  // ① 学生位置排序
    sort(B, B + N);                                  // ② 学校位置排序
    long long ans = 0;
    for (int i = 0; i < N; i++)
        ans += abs(A[i] - B[i]);                     // ③ 对应配对
    printf("%lld\n", ans);
}

逐行解析

  • ①② 两组都升序排序。
  • ③ 第 ii 小配第 ii 小。为什么这是最优的?交换论证——任何交叉配对都可以”理顺”而不增加总距离。

模型二:中位数选址(曼哈顿距离)

xx 坐标和 yy 坐标独立。xip\sum |x_i - p|pp = 中位数时最小。这是因为:中位数左侧有 N/2\le N/2 个点,右侧也有 N/2\le N/2 个点。把 pp 左移 1,右侧 >N/2> N/2 个点的距离各减 1,左侧 <N/2< N/2 个点各加 1,净效果可能更差。所以中位数是最优点。

例题

例题 1:T90 014 — We Used to Sing a Song Together(★3,排序配对)

题目NN 个学生在位置 AiA_iNN 个学校在位置 BjB_j。每个学生去不同学校,最小化总距离 AiBπ(i)\sum |A_i - B_{\pi(i)}|

数据范围1N1051 \le N \le 10^5

—— AtCoder Typical 90 014

分析:排序配对贪心。两组排序后一一配对。

输入样例

6
8 6 9 1 2 0
1 5 7 2 3 9

输出5

代码

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

int main() {
    int N;
    scanf("%d", &N);
    int A[100009], B[100009];
    for (int i = 0; i < N; i++) scanf("%d", &A[i]);
    for (int i = 0; i < N; i++) scanf("%d", &B[i]);

    sort(A, A + N);                                  // ① 排序
    sort(B, B + N);
    long long ans = 0;
    for (int i = 0; i < N; i++)
        ans += abs(A[i] - B[i]);                     // ② 对应配对
    printf("%lld\n", ans);
}

逐行解析

  • ① 两组都升序排序。O(NlogN)O(N \log N)
  • abs(A[i] - B[i]) 就是第 ii 小学生到第 ii 小学校的距离。

验证A=[8,6,9,1,2,0][0,1,2,6,8,9]A = [8,6,9,1,2,0] \to [0,1,2,6,8,9]B=[1,5,7,2,3,9][1,2,3,5,7,9]B = [1,5,7,2,3,9] \to [1,2,3,5,7,9]。配对:01+12+23+65+87+99=1+1+1+1+1+0=5|0-1|+|1-2|+|2-3|+|6-5|+|8-7|+|9-9| = 1+1+1+1+1+0 = 5。✓


例题 2:T90 070 — Plant Planning(★4,中位数选址)

题目NN 个工厂坐标 (Xi,Yi)(X_i, Y_i)。选一个发电所位置使曼哈顿距离总和最小。

数据范围1N1051 \le N \le 10^5

—— AtCoder Typical 90 070

分析:x 和 y 独立。Xip\sum |X_i - p|pp 取中位数时最小。排序后取中间值。

输入样例

2
-1 2
1 1

输出3

代码

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

int main() {
    int N;
    scanf("%d", &N);
    long long X[100009], Y[100009];
    for (int i = 0; i < N; i++) scanf("%lld%lld", &X[i], &Y[i]);

    sort(X, X + N);                                  // ① x 坐标排序
    sort(Y, Y + N);                                  // ② y 坐标排序
    long long mx = X[N / 2];                         // ③ x 中位数
    long long my = Y[N / 2];                         // ④ y 中位数
    long long ans = 0;
    for (int i = 0; i < N; i++)
        ans += abs(X[i] - mx) + abs(Y[i] - my);     // ⑤ 曼哈顿距离总和
    printf("%lld\n", ans);
}

逐行解析

  • ①② xxyy 坐标分别排序。
  • ③④ 取中位数。NN 为偶数时取 X[N/2] 即可(任何两个中间值之间的点都一样优)。
  • ⑤ 对每个工厂计算曼哈顿距离并累加。

验证N=2N=2(1,2),(1,1)(-1,2), (1,1)。X 排序 [1,1][-1,1],中位数 =1= 11-1(取 X[1]=1X[1]=1)。Y 排序 [1,2][1,2],中位数 Y[1]=2Y[1]=2。选 (1,2)(1,2)11+22+11+12=2+0+0+1=3|-1-1|+|2-2|+|1-1|+|1-2| = 2+0+0+1 = 3。✓


例题 3:TB B39 — Taro’s Job(贪心选工作)

题目DD 天、NN 个工作。工作 ii 在第 XiX_i 天后可选,报酬 YiY_i。每天最多做 1 个工作。求最大总报酬。

数据范围1N2×1051 \le N \le 2 \times 10^51D20001 \le D \le 2000

—— AtCoder Tessoku Book B39

分析:教材 6.4 节。贪心策略:从第 1 天到第 DD 天,每天选所有已解锁工作中报酬最大的。用优先队列维护。

为什么从第 1 天开始而不是从第 DD 天倒推?因为工作一旦解锁就一直可用。从前往后遍历,每天把 Xi当前天X_i \le \text{当前天} 的工作加入候选,选最大的做。

代码

#include <cstdio>
#include <queue>
#include <algorithm>
using namespace std;

int main() {
    int N, D;
    scanf("%d%d", &N, &D);
    pair<int, int> jobs[200009];                     // (X_i, Y_i)
    for (int i = 0; i < N; i++) scanf("%d%d", &jobs[i].first, &jobs[i].second);

    sort(jobs, jobs + N);                            // ① 按 X_i 排序
    priority_queue<int> pq;                          // ② 最大堆
    long long ans = 0;
    int idx = 0;
    for (int day = 1; day <= D; day++) {
        while (idx < N && jobs[idx].first <= day) {
            pq.push(jobs[idx].second);               // ③ 新解锁的工作加入堆
            idx++;
        }
        if (!pq.empty()) {
            ans += pq.top();                          // ④ 选报酬最大的
            pq.pop();
        }
    }
    printf("%lld\n", ans);
}

逐行解析

  • ① 按 XiX_i(解锁日期)排序,这样每天只需把新的工作加入堆。
  • ② 最大堆(priority_queue 默认最大堆),存放已解锁但未做的工作的报酬。
  • ③ 所有 XidayX_i \le day 的工作在此刻解锁,加入堆。
  • ④ 每天选堆顶(最大报酬)的工作做。

验证N=5,D=4N=5, D=4,工作 (1,1),(2,4),(2,3),(3,4),(4,2)(1,1),(2,4),(2,3),(3,4),(4,2)

新解锁堆内容选择累计
1(1,1){1}11
2(2,4),(2,3){4,3}45
3(3,4){4,3}49
4(4,2){3,2}312

答案 = 12。✓


例题 4:TB A38 — Black Company 1(贪心上界)

题目DD 天劳动计划,每天 0-24 小时。NN 个条件:[Li,Ri][L_i, R_i] 期间最勤劳日不超过 HiH_i 小时。求最大总劳动时间。

数据范围1D3651 \le D \le 3650N100000 \le N \le 10000

—— AtCoder Tessoku Book A38

分析:每天最多 24 小时,但受到多个条件限制。对每一天 dd,它被所有满足 LidRiL_i \le d \le R_i 的条件覆盖。为了满足所有条件,第 dd 天的工作时间不能超过 mind[Li,Ri]Hi\min_{d \in [L_i, R_i]} H_i。没有条件覆盖的天可以工作 24 小时。

贪心策略:对每天取所有覆盖条件的 HiH_i 最小值,没有条件的取 24。

代码

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

int main() {
    int D, N;
    scanf("%d%d", &D, &N);
    int limit[366];
    for (int i = 1; i <= D; i++) limit[i] = 24;      // ① 初始每天最多 24 小时

    for (int i = 0; i < N; i++) {
        int L, R, H;
        scanf("%d%d%d", &L, &R, &H);
        for (int d = L; d <= R; d++)
            limit[d] = min(limit[d], H);              // ② 取最小上界
    }

    int ans = 0;
    for (int i = 1; i <= D; i++)
        ans += limit[i];                              // ③ 累加每天的最大工时
    printf("%d\n", ans);
}

逐行解析

  • ① 初始假设每天都可以工作 24 小时。
  • ② 每个条件限制 [L,R][L, R] 期间所有天都不超过 HH。对涉及的天取 min。
  • ③ 每天取到了所有条件的交集(最小上界),全部加起来就是最大总工时。

验证D=5,N=3D=5, N=3,条件 (1,2,22),(2,3,16),(3,5,23)(1,2,22), (2,3,16), (3,5,23)

条件覆盖limit
1H=22H=2222
2H=22,H=16H=22, H=16min(22,16)=16
3H=16,H=23H=16, H=23min(16,23)=16
4H=23H=2323
5H=23H=2323

答案 = 22+16+16+23+23 = 100。✓


例题 5:TB A40 — Triangle(计数正三角形)

题目NN 根棒,长度 AiA_i。选 3 根不同的棒组成正三角形,有多少种选法?

数据范围3N2×1053 \le N \le 2 \times 10^51Ai1001 \le A_i \le 100

—— AtCoder Tessoku Book A40

分析:正三角形需要三条边等长。所以选 3 根长度相同的棒。统计每个长度出现次数 cc,答案 = (c3)\sum \binom{c}{3}

代码

#include <cstdio>
using namespace std;

int cnt[109]; // cnt[x] = 长度 x 出现次数

int main() {
    int N;
    scanf("%d", &N);
    for (int i = 0; i < N; i++) {
        int a; scanf("%d", &a);
        cnt[a]++;                                     // ① 统计频次
    }
    long long ans = 0;
    for (int x = 1; x <= 100; x++) {
        long long c = cnt[x];
        if (c >= 3)
            ans += c * (c - 1) * (c - 2) / 6;        // ② C(c, 3)
    }
    printf("%lld\n", ans);
}

逐行解析

  • ① 用桶计数统计每个长度出现的次数。Ai100A_i \le 100 所以只需 101 个桶。
  • (c3)=c(c1)(c2)6\binom{c}{3} = \frac{c(c-1)(c-2)}{6}。从 cc 根等长棒中选 3 根的组合数。

验证A=[1,2,1,2,1,2,1]A = [1,2,1,2,1,2,1]。长度 1 出现 4 次,(43)=4\binom{4}{3}=4。长度 2 出现 3 次,(33)=1\binom{3}{3}=1。答案 = 5。✓

参考文献

教材讲解 — 競技プログラミングの鉄則 第 6 章

系统练习 — 競技プログラミングの鉄則

实战练习 — 競プロ典型 90 問

基础练习 — アルゴリズムと数学 演習問題集


系列索引

第零章 基础工具

第一章 搜索技术

第二章 数学基础

第三章 数据结构

第四章 图论

第五章 动态规划

第六章 贪心

第七章 字符串

第八章 进阶