📖 第 4.1 章 ⏱️ 约 120 分钟 🎯 中级

第 4.1 章:贪心基础

📝 前置条件: 熟悉排序(第 3.3 章)和基本的 priority_queue 用法(第 3.1 章)。部分题目还涉及区间推理。

贪心算法就像一个旅行者,永远选择最近的绿洲——没有地图,没有计划,只看当下最好的移动。对于合适的问题,这总是奏效的;对于其他问题,它会带来灾难。


📚 目录

小节主题难度
§4.1.1什么样的问题可以用贪心解决?🟢 基础
§4.1.2交换论证法(证明技巧)🟡 核心
§4.1.3活动选择问题🟡 核心
§4.1.4区间调度:最大化 vs 最小化变体🟡 核心
§4.1.5调度问题:最小化最大延迟(EDF)🟡 核心
§4.1.6Huffman 编码——贪心建树🟡 核心
§4.1.7排列贪心:自定义排序标准🟡 核心
§4.1.8任务分配:双序列匹配🟡 核心
§4.1.9区间合并🟢 标准
§4.1.10数字与字符串的贪心🟡 标准
§4.1.11后悔贪心(用堆实现撤销)🔴 进阶
§4.1.12对抗匹配(田忌赛马)🔴 进阶
§4.1.13前缀/后缀贪心与位运算贪心🔴 进阶
练习题5 道练习题 + 1 道挑战题🟡–🔴

💡 建议阅读路径: 初次阅读应按顺序学习 §4.1.1–4.1.5;§4.1.6–4.1.9 可以任意顺序阅读;§4.1.11–4.1.13 是 USACO Gold 及以上的进阶技术。


4.1.1 什么样的问题可以用贪心解决?

贪心方法在问题具有贪心选择性质时奏效:每一步做出局部最优选择,最终得到全局最优解。

与 DP 的对比

考虑用硬币凑出 11 分:

  • 硬币:{1, 5, 6, 9}
  • 贪心:9 + 1 + 1 = 3 枚
  • 最优:6 + 5 = 2 枚

这里贪心失败了。贪心选择(每次选最大的硬币)没有达到全局最优。

但对于美国硬币 {1, 5, 10, 25, 50}:

  • 41 分:贪心 → 25 + 10 + 5 + 1 = 4 枚 ✓(最优)

美国硬币有特殊结构使贪心可行。始终要验证!

完整演示:硬币找零——贪心 vs DP

让我们详细追踪硬币找零例子,看清楚贪心在哪里出错。

题目: 用硬币 {1, 5, 6, 9} 凑出 11 分,最少需要几枚?

贪心做法(每次选 ≤ 剩余金额的最大硬币):

剩余=11 → 选 9(≤11 最大)。剩余=2。已用:[9]
剩余=2  → 选 1(≤2 最大)。剩余=1。已用:[9, 1]
剩余=1  → 选 1(≤1 最大)。剩余=0。已用:[9, 1, 1]
结果:3 枚 ✗

最优(DP)做法:

6 + 5 = 11。已用:[6, 5]
结果:2 枚 ✓

为什么贪心失败了? 贪心立即抓取最大的硬币(9),留下的余数(2)只能用 1 分硬币填满。它「看不出来」跳过 9 用 6+5 会更好。

Coin Change: Greedy vs Optimal

现在对比用美国硬币 {1, 5, 10, 25} 凑 41 分:

剩余=41 → 选 25。剩余=16。已用:[25]
剩余=16 → 选 10。剩余=6。 已用:[25, 10]
剩余=6  → 选 5。 剩余=1。 已用:[25, 10, 5]
剩余=1  → 选 1。 剩余=0。 已用:[25, 10, 5, 1]
结果:4 枚 ✓(最优!)

美国硬币有效是因为每个面额至少是前一个的两倍——永远不需要「撤销」贪心选择。而 {1, 5, 6, 9} 中 5 和 6 太接近,会产生贪心选择阻碍更好组合的情况。

⚠️ 结论: 硬币找零是看起来可以用贪心但并非总是如此的经典例子。除非硬币面额有特殊结构(如美国硬币),否则需要 DP。有疑问时,试一个小反例!

💡 核心思路: 贪心在有「无悔」性质时奏效——一旦做出贪心选择,永远不需要撤销。如果总能用贪心选择替换任何非贪心选择而不使情况变差,贪心就是最优的。

贪心 vs DP 决策路径对比:

Greedy vs DP Decision Path

🔍 如何识别贪心问题

看到新题时,按以下清单检查:

📄 看到新题时,按以下清单检查:
1. 处理元素时有没有自然的「顺序」或「优先级」?
   (如:按截止时间、结束时间、比率、大小排序……)
        ↓ 有
2. 能证明局部最优选择在全局上是安全的吗?
   (交换论证:把贪心选择与任何其他选择互换,永远不会更好)
        ↓ 能
3. 能找到贪心失败的小反例吗?
        ↓ 找不到反例
   → 贪心很可能正确。实现并验证。
        ↓ 找到反例
   → 贪心失败。考虑 DP 或其他方法。

贪心可行的三个信号:

  • ① 排序后,有明确的「按此顺序处理」规则
  • ② 题目要求一遍扫描最大化/最小化计数或代价
  • ③ 子问题独立——选择一个元素不影响剩余选择的「形状」

改用 DP 的三个信号:

  • ① 选择之间有交互(选 A 会改变 B 的可用性)
  • ② 需要考虑多个未来状态
  • ③ 对你尝试的任何贪心规则都能找到反例

4.1.2 交换论证法

交换论证是贪心算法的标准证明技术,回答「怎么证明贪心正确?」这个问题。几乎所有 USACO 的贪心正确性证明都用这个技术。

工作原理

证明模板分四步:

  1. 假设存在一个在某一步做出与我们的贪心算法不同选择的最优解 O。
  2. 找到 O 和贪心第一次不同的位置。
  3. 交换——把贪心的选择放入 O 在该位置的解中。证明结果至少同样好(代价不增,或数量不减)。
  4. 重复直到 O 完全变换成贪心解。由于每次交换维持或改善解,贪心解必然是最优的。

💡 核心思路: 需要证明贪心是唯一最优的——只需证明没有交换可以改善它。即使多个解都达到相同最优,贪心也能找到其中一个。

📋 交换论证证明模板

给定: 贪心规则 G,最优解 O。

第一步——找差异: 设 i 是 O 和 G 第一个不同的下标。

第二步——交换: 构造 O',将位置 i 的 O 的选择替换为 G 的选择。

第三步——比较: 证明 cost(O') ≤ cost(O)(或 count(O') ≥ count(O))。

第四步——结论: 归纳地,反复交换把 O 变成 G 而不恶化解。因此 G 是最优的。

为什么「相邻交换」就够了

一个关键观察:若能证明交换任意两个顺序不对的相邻元素不会恶化解,那么通过标准的「冒泡排序」论证,可以将任意解重排为贪心顺序而不使情况变差。

这就是为什么交换论证几乎总是聚焦于只交换两个相邻元素——完整证明由归纳法得出。

具体示例:调度最小化加权和

题目: 有 N 个作业,作业 i 的处理时间为 t[i],权重为 w[i]。所有作业在一台机器上顺序运行。作业 i 的加权完成时间 = w[i] × (包含作业 i 在内的所有作业处理时间之和)。最小化总加权完成时间。

样例输入:

3
2 3
1 5
4 2

(格式:每行 t[i] w[i])

样例输出:

37

什么顺序最优? 用交换论证:

考虑两个相邻作业 A(处理时间 a,权重 w_A)和 B(处理时间 b,权重 w_B),设 S 是这两个作业之前所有作业的总处理时间:

顺序A 的加权代价B 的加权代价这两个作业的总代价
A → Bw_A × (S + a)w_B × (S + a + b)w_A·a + w_B·b + (w_A + w_B)·S + w_B·a
B → Aw_B × (S + b)w_A × (S + b + a)w_B·b + w_A·a + (w_A + w_B)·S + w_A·b

A → B 更好当:w_B·a < w_A·b,即 w_A/t_A > w_B/t_B权重/时间比更高的先做)。

贪心规则:w[i]/t[i] 降序排序。

📄 C++ 完整代码
#include <bits/stdc++.h>
using namespace std;

int main() {
    ios_base::sync_with_stdio(false);
    cin.tie(NULL);

    int n;
    cin >> n;

    vector<pair<int,int>> jobs(n);  // {t, w}
    for (auto &[t, w] : jobs) cin >> t >> w;

    // 按 w/t 比值降序排序(比值更高的先做)
    sort(jobs.begin(), jobs.end(), [](const auto &a, const auto &b) {
        // a.second/a.first > b.second/b.first  →  a.second * b.first > b.second * a.first
        return (long long)a.second * b.first > (long long)b.second * a.first;
    });

    long long total = 0, curTime = 0;
    for (auto [t, w] : jobs) {
        curTime += t;
        total += (long long)w * curTime;
    }

    cout << total << "\n";
    return 0;
}
// 时间复杂度:O(N log N)

图示:贪心交换论证

Greedy Exchange Argument

上图说明了交换论证:若两个相邻元素相对于贪心标准「顺序不对」,交换它们会产生至少同样好的解。通过反复交换,可以把任何解变换成贪心解而不损失价值。

交换论证失败的情况

有时找不到有效的交换——这说明贪心不适用:

  • 0/1 背包: 无法用一件物品的一部分替换另一件物品,所以交换不保持约束。
  • 任意面额的硬币找零: 交换硬币选择实际上可能在其他位置强制使用更多硬币。
  • 一般加权区间调度: 选择一个高利润的短作业可能阻塞两个中等利润但合计超过它的作业。

在所有这些情况下,交换论证失败,需要用 DP


4.1.3 活动选择问题

题目: 给定 N 个活动,每个活动有开始时间 s[i] 和结束时间 f[i]。每次只能进行一个活动。若两个活动有重叠(一个在另一个结束前开始),则它们冲突。选择最多数量的不重叠活动

样例输入:

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

样例输出:

3

(最优选择是活动 (1,3)、(6,8)、(8,11)——或等价地 (1,3)、(5,7)、(8,11))


为什么可以用贪心解决

直觉上:在所有从上一个选定活动之后开始的活动中,接下来该选哪个?结束最早的那个——它「占用」最少的未来时间,为后续活动留下最大空间。

任何其他选择(选结束更晚的活动)只会有害:它阻塞的未来活动至少与结束最早的选择一样多,甚至更多。

这就是贪心选择性质: 局部最优选择(选结束最早的相容活动)导致全局最优解。

图示:活动选择甘特图

Activity Selection

甘特图在时间轴上展示所有活动。选中的活动(绿色)不重叠且数量最多,被拒绝的活动(灰色)因与已选活动重叠而跳过。贪心规则是:始终选结束时间最早且不冲突的活动。

贪心算法:

  1. 结束时间排序活动
  2. 每次选择与已选活动相容且结束时间最早的活动

Activity Selection Greedy Process

💡 为什么按结束时间排序? 选择结束最早的活动为后续活动留下最多时间。按开始时间排序可能选到开始很早但结束很晚的活动,占用大量时间。

📄 C++ 完整代码
// 活动选择 — O(N log N)
#include <bits/stdc++.h>
using namespace std;

int main() {
    ios_base::sync_with_stdio(false);
    cin.tie(NULL);

    int n;
    cin >> n;

    vector<pair<int,int>> activities(n);  // {结束时间, 开始时间}
    for (int i = 0; i < n; i++) {
        int s, f;
        cin >> s >> f;
        activities[i] = {f, s};  // 按结束时间排序
    }

    sort(activities.begin(), activities.end());  // ← 关键:按结束时间排序

    int count = 0;
    int lastEnd = -1;  // 最后一个选定活动的结束时间

    for (auto [f, s] : activities) {
        if (s >= lastEnd) {      // 该活动在上一个结束后才开始
            count++;
            lastEnd = f;         // 更新最后结束时间
        }
    }

    cout << count << "\n";
    return 0;
}

完整演示

活动: [(1,3), (2,5), (3,9), (6,8), (5,7), (8,11), (10,12)](格式:开始, 结束)

第一步——按结束时间排序:

活动:   A      B      C      D      E      F      G
(s,e):(1,3)  (2,5)  (5,7)  (6,8)  (3,9)  (8,11) (10,12)

排序后:A(1,3), B(2,5), C(5,7), D(6,8), E(3,9), F(8,11), G(10,12)

第二步——贪心选择(初始 lastEnd = -1):

活动 A (1,3):  start=1 ≥ lastEnd=-1 ✓ 选中。lastEnd=3。count=1
活动 B (2,5):  start=2 ≥ lastEnd=3?否(2 < 3)。跳过。
活动 C (5,7):  start=5 ≥ lastEnd=3 ✓ 选中。lastEnd=7。count=2
活动 D (6,8):  start=6 ≥ lastEnd=7?否(6 < 7)。跳过。
活动 E (3,9):  start=3 ≥ lastEnd=7?否(3 < 7)。跳过。
活动 F (8,11):start=8 ≥ lastEnd=7 ✓ 选中。lastEnd=11。count=3
活动 G (10,12):start=10 ≥ lastEnd=11?否(10 < 11)。跳过。

结果:选中 3 个活动——A(1,3), C(5,7), F(8,11)

时间轴(A~G 表示活动):
时间:  0  1  2  3  4  5  6  7  8  9  10 11 12
       |  |  |  |  |  |  |  |  |  |  |  |  |
A:       [===]                                   ✓ 选中
B:          [======]                             ✗ 与 A 重叠
C:                  [======]                     ✓ 选中
D:                     [======]                  ✗ 与 C 重叠
E:             [============]                    ✗ 与 A 和 C 重叠
F:                           [======]            ✓ 选中
G:                              [======]         ✗ 与 F 重叠

4.1.4 区间调度:最大化 vs 最小化变体

本节涵盖三个相关的区间问题,看起来相似但需要微妙不同的贪心策略。

图示:数轴上的区间调度

Interval Scheduling


最大化:最多不重叠区间

这正是 §4.1.3 的活动选择问题。按结束时间排序,贪心选择如上所述。


最小化:「刺穿」所有区间所需最少点数

题目: 给定数轴上 N 个区间,找最少数量的「点」(每个点是一个实数),使每个区间至少包含一个点。仅共享端点的两个区间都被视为包含该端点。

贪心策略:右端点升序排序。维护 lastPoint(最后放置的点)。对每个区间:

  • lastPoint 已在该区间内(lastPoint >= l[i]):该区间已被覆盖,跳过。
  • 否则:在 r[i] 放一个新点(尽量靠右,最大化对后续区间的覆盖),计数加一。
📄 C++ 完整代码
#include <bits/stdc++.h>
using namespace std;

int main() {
    ios_base::sync_with_stdio(false);
    cin.tie(NULL);

    int n;
    cin >> n;
    vector<pair<int,int>> intervals(n);  // {右端点, 左端点}
    for (auto &[r, l] : intervals) cin >> l >> r;

    sort(intervals.begin(), intervals.end());  // 按右端点排序

    int points = 0;
    long long lastPoint = LLONG_MIN;

    for (auto [r, l] : intervals) {
        if (lastPoint < l) {          // 当前点不覆盖该区间
            lastPoint = r;            // 在右端放新点
            points++;
        }
    }

    cout << points << "\n";
    return 0;
}
// 时间复杂度:O(N log N)

最小化:最少区间覆盖一段范围

题目: 给定 N 个区间和目标范围 [0, T],从集合中选最少数量的区间使其并集完全覆盖 [0, T]。不可能时输出「Impossible」。

贪心策略:左端点升序排序。维护 covered(当前已覆盖到的位置,初始为 0)。每步在所有满足 l[i] ≤ covered 的区间中(它们可以延伸覆盖),选右端点最大的farthest)。将 covered 推进到 farthest,计数加一。

📄 C++ 完整代码
#include <bits/stdc++.h>
using namespace std;

int main() {
    ios_base::sync_with_stdio(false);
    cin.tie(NULL);

    int T, n;
    cin >> T >> n;

    vector<pair<int,int>> intervals(n);
    for (auto &[l, r] : intervals) cin >> l >> r;

    sort(intervals.begin(), intervals.end());  // 按左端点排序

    int covered = 0;    // 当前已覆盖到 'covered'
    int count = 0;
    int i = 0;

    while (covered < T) {
        int farthest = covered;

        // 在左端点 <= covered 的所有区间中,找最远的右端点
        while (i < n && intervals[i].first <= covered) {
            farthest = max(farthest, intervals[i].second);
            i++;
        }

        if (farthest == covered) {
            // 没有区间能延伸覆盖——不可能
            cout << "Impossible\n";
            return 0;
        }

        covered = farthest;
        count++;
    }

    cout << count << "\n";
    return 0;
}
// 时间复杂度:O(N log N)

⚠️ 与刺穿的关键区别: 刺穿按端点排序(尽量宽地覆盖当前区间);覆盖按端点排序(从停下的地方尽量向右延伸覆盖)。


4.1.5 调度问题:最小化最大延迟(EDF)

题目: 一台机器,N 个作业。作业 i 有:

  • 处理时间 t[i]——运行所需时长
  • 截止时间 d[i]——理想上应完成的时间

机器按顺序运行作业(无重叠,无空闲)。作业 i 的延迟量max(0, 完成时间[i] − d[i])——超出截止时间的量(按时完成则为 0)。最小化所有作业中最大延迟量

样例输入:

4
3 6
2 8
1 4
4 9

样例输出:

1

解释: 按截止时间升序排序:job3(t=1,d=4), job1(t=3,d=6), job2(t=2,d=8), job4(t=4,d=9)。

作业 3:运行 [0,1],  完成于 1。延迟量 = max(0, 1-4)  = 0
作业 1:运行 [1,4],  完成于 4。延迟量 = max(0, 4-6)  = 0
作业 2:运行 [4,6],  完成于 6。延迟量 = max(0, 6-8)  = 0
作业 4:运行 [6,10],完成于 10。延迟量 = max(0, 10-9) = 1
最大延迟量 = 1 ✓

贪心策略:最早截止日期优先(EDF)

规则: 按截止时间升序排列作业,按该顺序无间隙运行。

为什么 EDF 最优——交换论证:

设最优调度中两个相邻作业 A 和 B,d[A] > d[B](A 截止更晚但先运行)。设 S 是这两个作业之前所有作业的完成时间:

调度A 的延迟量B 的延迟量
A → Bmax(0, S + t[A] − d[A])max(0, S + t[A] + t[B] − d[B])
B → Amax(0, S + t[B] − d[B])max(0, S + t[B] + t[A] − d[A])

由于 d[A] > d[B],B 更紧迫。A→B 顺序中:B 在 S + t[A] + t[B] 完成,与 B→A 顺序相同——但 B 的截止时间更早,可能延迟更多。交换到 B→A 永远不会增加最大延迟量。因此,任何非 EDF 调度都可以通过交换改善或维持,EDF 是最优的。

EDF Scheduling — Minimize Maximum Lateness

📄 C++ 完整代码
#include <bits/stdc++.h>
using namespace std;

int main() {
    int n;
    cin >> n;

    vector<pair<int,int>> jobs(n);  // {截止时间, 处理时间}
    for (int i = 0; i < n; i++) cin >> jobs[i].second >> jobs[i].first;

    sort(jobs.begin(), jobs.end());  // 按截止时间排序

    int time = 0;
    int maxLateness = 0;

    for (auto [deadline, proc] : jobs) {
        time += proc;                          // 该作业的完成时间
        int lateness = max(0, time - deadline); // 延迟多久?
        maxLateness = max(maxLateness, lateness);
    }

    cout << maxLateness << "\n";
    return 0;
}

4.1.6 Huffman 编码(贪心建树)

题目: 有 N 个符号,每个出现频率为 freq[i]。想为每个符号分配一个二进制码字(0 和 1 的字符串),使没有码字是另一个的前缀(前缀无关码)。总编码代价 = 所有符号的 freq[i] × depth[i] 之和,其中 depth[i] 是符号 i 码字的长度。最小化总代价。

贪心规则: 始终合并频率最小的两个节点(用最小堆)。

📄 C++ 完整代码
#include <bits/stdc++.h>
using namespace std;

int main() {
    int n;
    cin >> n;

    priority_queue<long long, vector<long long>, greater<long long>> pq;  // 最小堆
    for (int i = 0; i < n; i++) {
        long long f; cin >> f;
        pq.push(f);
    }

    long long totalCost = 0;
    while (pq.size() > 1) {
        long long a = pq.top(); pq.pop();
        long long b = pq.top(); pq.pop();
        totalCost += a + b;  // 合并 a 和 b 的代价
        pq.push(a + b);      // 合并后的组频率为 a+b
    }

    cout << totalCost << "\n";
    return 0;
}

为什么总是合并最小的两个? 频率最低的两个符号应该在树中最深(码字最长),因为罕见符号的长码字对总代价的贡献较小。通过总是合并当前最小的两个,确保使用最频繁的符号保留在根附近。

USACO 中的实际应用: Huffman 算法出现在「最小代价合并 N 堆」问题中。每次需要合并两堆,支付合并后大小之和,答案就是所有合并操作的总和——由 Huffman 贪心算法计算。


4.1.7 排列贪心:自定义排序标准

经典题一:最小化总完成时间(最短作业优先)

贪心策略: 按处理时间升序排序(最短作业优先,SJF)。

为什么 SJF 最优?(交换论证)

设最优顺序中两个相邻作业 A(处理时间 a)和 B(处理时间 b),a > b(B 更短但排在 A 后面)。设 T 是这两个作业之前的累计完成时间:

顺序A 的完成时间B 的完成时间两者之和
A → BT + aT + a + b2T + 2a + b
B → AT + bT + b + a2T + a + 2b

由于 a > b,2T + 2a + b > 2T + a + 2b,所以 B→A 给出更小的和。

📄 由于 a > b,`2T + 2a + b > 2T + a + 2b`,所以 B→A 给出更小的和。
#include <bits/stdc++.h>
using namespace std;

int main() {
    ios_base::sync_with_stdio(false);
    cin.tie(NULL);

    int n;
    cin >> n;
    vector<int> t(n);
    for (int &x : t) cin >> x;

    sort(t.begin(), t.end());  // SJF:按处理时间升序

    long long totalCompletion = 0;
    long long curTime = 0;
    for (int i = 0; i < n; i++) {
        curTime += t[i];
        totalCompletion += curTime;
    }

    cout << totalCompletion << "\n";
    return 0;
}
// 时间复杂度:O(N log N)

SJF — Minimize Total Completion Time


经典题二:最大数(拼接贪心)

题目: 给定 N 个非负整数,将它们排列后拼接成最大的数。输出为字符串。

贪心策略: 自定义比较器:对两个数 a 和 b(作为字符串),若 str(a) + str(b) > str(b) + str(a) 则 a 排在 b 前面。

📄 C++ 完整代码
#include <bits/stdc++.h>
using namespace std;

int main() {
    ios_base::sync_with_stdio(false);
    cin.tie(NULL);

    int n;
    cin >> n;
    vector<string> nums(n);
    for (string &s : nums) cin >> s;

    // 自定义排序:a+b > b+a 时 a 排在前面
    sort(nums.begin(), nums.end(), [](const string &a, const string &b) {
        return a + b > b + a;
    });

    // 边界情况:全为零
    if (nums[0] == "0") {
        cout << "0\n";
        return 0;
    }

    string result = "";
    for (const string &s : nums) result += s;
    cout << result << "\n";
    return 0;
}
// 时间复杂度:O(N log N · L),L = 最大数字位数

逐步追踪(nums = ["3", "30", "34", "5", "9"]):

比较部分对:
"3"+"30"="330" vs "30"+"3"="303"  →  "3" 排前面
"9"+"5"="95"  vs "5"+"9"="59"    →  "9" 排前面
"34"+"3"="343" vs "3"+"34"="334" →  "34" 排前面

排序后:["9", "5", "34", "3", "30"]
结果:  "9534330" ✓

⚠️ 警告: 不能简单地按数值大小排序!例如 "3" > "30" 按数值,但拼接 "330" > "303",所以 "3" 应排前面。始终用拼接比较器。


经典题三:最大化(或最小化)内积

贪心规则:

  • 最大化 ∑ A[i] × B[i]:A 和 B 都按升序排序(同方向配对)
  • 最小化 ∑ A[i] × B[i]:A 按升序,B 按降序排序(反方向配对)

这是排列不等式:大配大、小配小最大化;大配小、小配大最小化。

📄 这是**排列不等式**:大配大、小配小最大化;大配小、小配大最小化。
// 最大化 sum(A[i] * B[i])
sort(A.begin(), A.end());
sort(B.begin(), B.end());
long long maxSum = 0;
for (int i = 0; i < n; i++) maxSum += (long long)A[i] * B[i];

// 最小化 sum(A[i] * B[i])
sort(A.begin(), A.end());
sort(B.begin(), B.end(), greater<int>());
long long minSum = 0;
for (int i = 0; i < n; i++) minSum += (long long)A[i] * B[i];

4.1.8 任务分配:双序列匹配

任务分配问题涉及将两个有序序列的元素匹配以优化某个目标。关键模式:对两个序列排序,然后用双指针扫描或直接下标配对。

最大化完成任务数(双指针)

贪心: 对两个序列排序,用双指针贪心匹配。

📄 C++ 完整代码
#include <bits/stdc++.h>
using namespace std;

int main() {
    ios_base::sync_with_stdio(false);
    cin.tie(NULL);

    int n;
    cin >> n;

    vector<int> ability(n), difficulty(n);
    for (int &x : ability) cin >> x;
    for (int &x : difficulty) cin >> x;

    sort(ability.begin(), ability.end());
    sort(difficulty.begin(), difficulty.end());

    // 双指针:贪心地把最弱的有能力工人分配给每个任务
    int completed = 0;
    int i = 0, j = 0;  // i:工人指针,j:任务指针

    while (i < n && j < n) {
        if (ability[i] >= difficulty[j]) {
            // 工人 i 能完成任务 j——匹配
            completed++;
            i++;
            j++;
        } else {
            // 工人 i 太弱——尝试更强的工人
            i++;
        }
    }

    cout << completed << "\n";
    return 0;
}
// 时间复杂度:O(N log N)

4.1.9 区间合并

区间合并是另一种经典贪心:将所有重叠区间合并为一组不重叠的区间。

贪心算法:

  1. 左端点升序排序区间
  2. 维护当前合并区间 [curL, curR]
  3. 对每个新区间 [l, r]:
    • 若 l ≤ curR(重叠或相邻):延伸 curR = max(curR, r)
    • 否则:完成当前合并区间,开始新的

Interval Merging — Step-by-Step

📄 ![Interval Merging — Step-by-Step](../images/interval_merging.svg
#include <bits/stdc++.h>
using namespace std;

int main() {
    ios_base::sync_with_stdio(false);
    cin.tie(NULL);

    int n;
    cin >> n;

    vector<pair<int,int>> intervals(n);
    for (auto &[l, r] : intervals) cin >> l >> r;

    sort(intervals.begin(), intervals.end());  // 按左端点排序

    vector<pair<int,int>> merged;

    for (auto [l, r] : intervals) {
        if (merged.empty() || l > merged.back().second) {
            // 无重叠——开始新的合并区间
            merged.push_back({l, r});
        } else {
            // 重叠——延伸右端点
            merged.back().second = max(merged.back().second, r);
        }
    }

    cout << merged.size() << " 个合并区间:\n";
    for (auto [l, r] : merged) {
        cout << "[" << l << "," << r << "] ";
    }
    cout << "\n";
    return 0;
}
// 时间复杂度:O(N log N)

逐步追踪(输入已排序:[1,3],[2,6],[8,10],[15,18]):

[1,3]:   merged 为空 → 直接加入。merged=[[1,3]]
[2,6]:   2 <= 3(重叠)→ 延伸:[1, max(3,6)]=[1,6]。merged=[[1,6]]
[8,10]:  8 > 6(不重叠)→ 加新区间。merged=[[1,6],[8,10]]
[15,18]: 15 > 10(不重叠)→ 加新区间。merged=[[1,6],[8,10],[15,18]]

输出:[[1,6],[8,10],[15,18]] ✓

4.1.10 数字与字符串的贪心

经典题:删除 K 个数字得到最小数

题目: 给定一串数字(表示一个大整数),恰好删除 K 个数字(保持剩余数字的原始顺序)以形成最小的整数。

例子:

"1432219",K=3  →  "1219"

贪心思路: 维护单调栈。从左到右扫描:

  • 若栈顶 > 当前数字且还有删除机会:弹出栈顶(删除较大的数字)
  • 否则:压入当前数字
  • 扫描完后若还有删除机会:从栈的右端删除

Monotone Stack — Remove K Digits

📄 C++ 完整代码
#include <bits/stdc++.h>
using namespace std;

int main() {
    string num;
    int k;
    cin >> num >> k;

    string stk = "";  // 用字符串作单调栈

    for (char c : num) {
        // 当栈顶大于当前数字且还有删除机会时弹出
        while (k > 0 && !stk.empty() && stk.back() > c) {
            stk.pop_back();
            k--;
        }
        stk.push_back(c);
    }

    // 若还有删除机会,从右端删除
    stk.resize(stk.size() - k);

    // 移除前导零
    int start = 0;
    while (start < (int)stk.size() - 1 && stk[start] == '0') start++;

    cout << stk.substr(start) << "\n";
    return 0;
}
// 时间复杂度:O(N)

经典题:最少跳跃次数

题目: 给定数组 A[0..n-1]A[i] 是从位置 i 可以跳跃的最大步数。从索引 0 出发,用最少次数到达最后一个索引。

📄 C++ 完整代码
#include <bits/stdc++.h>
using namespace std;

int main() {
    int n;
    cin >> n;
    vector<int> A(n);
    for (int &x : A) cin >> x;

    int jumps = 0;
    int curEnd = 0;    // 当前跳跃能到达的最远位置
    int farthest = 0;  // 目前可到达的最远位置

    for (int i = 0; i < n - 1; i++) {
        farthest = max(farthest, i + A[i]);  // 更新最远可达
        if (i == curEnd) {                   // 到达当前跳跃范围的末尾
            jumps++;
            curEnd = farthest;               // 跳到最远位置
            if (curEnd >= n - 1) break;      // 已经可以到达末尾
        }
    }

    cout << jumps << "\n";
    return 0;
}
// 时间复杂度:O(N)

经典题:买卖股票最大利润(贪心版)

题目: 给定每日股价,可以无限次买卖(但每次只能持有一股),最大化总利润。

贪心思路: 只要明天价格高于今天,就「今天买明天卖」。等价于累加所有正日差。

📄 C++ 完整代码
#include <bits/stdc++.h>
using namespace std;

int main() {
    int n;
    cin >> n;
    vector<int> prices(n);
    for (int &x : prices) cin >> x;

    int profit = 0;
    for (int i = 1; i < n; i++) {
        if (prices[i] > prices[i - 1]) {
            profit += prices[i] - prices[i - 1];  // 捕获每次上涨
        }
    }

    cout << profit << "\n";
    return 0;
}
// 时间复杂度:O(N)

⚠️ 注意: 这是「无限次交易」版本。「最多一次交易」→ 追踪最低买入价的单次扫描。「最多两次/K 次交易」→ 需要 DP。


4.1.11 后悔贪心

后悔贪心是最强大也最容易被忽视的贪心技术。核心思路:

做出贪心决策,但同时保留「撤销」它的能力——如果该决策后来不是最优的,用堆(优先队列)以 O(log N) 时间撤销它。

经典题:K 次操作获得最大利润(支持撤销)

贪心 + 后悔做法:

  1. 维护最大堆
  2. 每步:取堆顶 x(最大收益),然后插入 -x(「后悔节点」——撤销此操作的代价)
  3. 若之后从堆中取出 -x,相当于「取消」之前的操作
📄 3. 若之后从堆中取出 `-x`,相当于「取消」之前的操作
#include <bits/stdc++.h>
using namespace std;

int main() {
    ios_base::sync_with_stdio(false);
    cin.tie(NULL);

    int n, k;
    cin >> n >> k;

    priority_queue<long long> pq;  // 最大堆
    for (int i = 0; i < n; i++) {
        long long x; cin >> x;
        pq.push(x);
    }

    long long total = 0;
    for (int i = 0; i < k; i++) {
        long long top = pq.top(); pq.pop();
        if (top <= 0) break;  // 取该元素会损失——停止
        total += top;
        pq.push(-top);        // 插入后悔节点:撤销此操作的代价
    }

    cout << total << "\n";
    return 0;
}

后悔的魔力: 取出 x 并插入 -x 后,若堆顶变为 y,我们可以选择:

  • 取 y(正常贪心)
  • -x(相当于「取消 x,用下一个可用元素替换」)

这自动找到了 K 次操作的最优序列。


最小化 K 台机器的最大完工时间(LPT)

贪心:最长处理时间优先(LPT)

降序排列处理时间,将每个作业分配给完成时间最早的机器(用最小堆维护)。

📄 按**降序**排列处理时间,将每个作业分配给完成时间最早的机器(用最小堆维护)。
#include <bits/stdc++.h>
using namespace std;

int main() {
    ios_base::sync_with_stdio(false);
    cin.tie(NULL);

    int n, k;
    cin >> n >> k;

    vector<int> t(n);
    for (int &x : t) cin >> x;

    sort(t.begin(), t.end(), greater<int>());  // 降序:最长作业优先

    // 最小堆:存储每台机器的当前完成时间(初始全为 0)
    priority_queue<long long, vector<long long>, greater<long long>> machines;
    for (int i = 0; i < k; i++) machines.push(0);

    for (int i = 0; i < n; i++) {
        long long earliest = machines.top(); machines.pop();
        machines.push(earliest + t[i]);  // 将作业分配给最早空闲的机器
    }

    long long makespan = 0;
    while (!machines.empty()) {
        makespan = max(makespan, machines.top());
        machines.pop();
    }

    cout << makespan << "\n";
    return 0;
}
// 时间复杂度:O(N log N + N log K)

4.1.12 对抗匹配(田忌赛马)

经典的**「田忌赛马」**问题是对抗贪心匹配的原型:双方各有 N 匹马;你可以自由选择出场顺序,对手顺序已知。最大化你赢得的比赛数。

策略(双指针,O(N)):

对你的马 A(升序)和对手的马 B(升序)排序:

  • 若你最强的马 A[hi] > 对手最强的马 B[bhi] → 用自己最强的打对手最强的(赢一场)
  • 若你最强的 A[hi] ≤ 对手最强的 B[bhi] → 用自己最弱的消耗对手最强的(策略性认输,保留更强的马)

Adversarial Matching — Tian Ji's Horse Racing

📄 C++ 完整代码
#include <bits/stdc++.h>
using namespace std;

int main() {
    ios_base::sync_with_stdio(false);
    cin.tie(NULL);

    int n;
    cin >> n;

    vector<int> A(n), B(n);
    for (int &x : A) cin >> x;
    for (int &x : B) cin >> x;

    sort(A.begin(), A.end());
    sort(B.begin(), B.end());

    int wins = 0;
    int lo = 0, hi = n - 1;    // A 的双端指针(弱端和强端)
    int blo = 0, bhi = n - 1;  // B 的双端指针

    while (lo <= hi) {
        if (A[hi] > B[bhi]) {
            // A 最强打赢 B 最强——赢一场
            wins++;
            hi--;
            bhi--;
        } else {
            // A 最强打不赢 B 最强——用 A 最弱消耗 B 最强
            lo++;
            bhi--;
        }
    }

    cout << wins << "\n";
    return 0;
}
// 时间复杂度:O(N log N)

4.1.13 前缀/后缀贪心与位运算贪心

前缀/后缀贪心

许多问题可以通过**一次从左扫描(前缀)和一次从右扫描(后缀)**来解决,然后合并结果。

经典应用:分发糖果(双遍扫描)

📄 C++ 完整代码
#include <bits/stdc++.h>
using namespace std;

int main() {
    int n;
    cin >> n;
    vector<int> rating(n);
    for (int &x : rating) cin >> x;

    vector<int> candy(n, 1);  // 每人至少 1 颗糖

    // 第一遍:满足「比左邻居评分高则多拿」
    for (int i = 1; i < n; i++) {
        if (rating[i] > rating[i - 1])
            candy[i] = candy[i - 1] + 1;
    }

    // 第二遍:满足「比右邻居评分高则多拿」
    for (int i = n - 2; i >= 0; i--) {
        if (rating[i] > rating[i + 1])
            candy[i] = max(candy[i], candy[i + 1] + 1);
    }

    cout << accumulate(candy.begin(), candy.end(), 0) << "\n";
    return 0;
}
// 时间复杂度:O(N)

位运算贪心

经典题:最大化数组中两个元素的异或值

贪心(字典树): 将所有数字插入二进制字典树。对每个数字 x,在字典树中每层贪心地走「对立」分支(从高位到低位),逐位最大化 XOR 值。

📄 C++ 完整代码
#include <bits/stdc++.h>
using namespace std;

const int MAXBIT = 30;

struct Trie {
    int ch[2];
    Trie() { ch[0] = ch[1] = -1; }
};

vector<Trie> trie(1);

void insert(int x) {
    int node = 0;
    for (int i = MAXBIT; i >= 0; i--) {
        int bit = (x >> i) & 1;
        if (trie[node].ch[bit] == -1) {
            trie[node].ch[bit] = trie.size();
            trie.push_back(Trie());
        }
        node = trie[node].ch[bit];
    }
}

int maxXOR(int x) {
    int node = 0, res = 0;
    for (int i = MAXBIT; i >= 0; i--) {
        int bit = (x >> i) & 1;
        int want = 1 - bit;  // 贪心:尝试对立位使 XOR = 1
        if (trie[node].ch[want] != -1) {
            res |= (1 << i);
            node = trie[node].ch[want];
        } else {
            node = trie[node].ch[bit];
        }
    }
    return res;
}

int main() {
    int n;
    cin >> n;
    vector<int> nums(n);
    for (int &x : nums) cin >> x;

    for (int x : nums) insert(x);

    int ans = 0;
    for (int x : nums) ans = max(ans, maxXOR(x));

    cout << ans << "\n";
    return 0;
}
// 时间复杂度:O(N × MAXBIT) = O(32N)

💡 通用位运算贪心模式: 从高位到低位处理。在每一位,贪心地选择使结果该位等于 1(或 0,取决于目标)的分支。字典树支持每次查询 O(MAXBIT) 时间。


⚠️ 第 4.1 章常见错误

  1. 把贪心用在 DP 问题上: 贪心更简单不代表它正确。始终用小反例测试。任意面额的硬币找零是经典陷阱。

  2. 排序标准用错: 活动选择时按开始时间而非结束时间排序是经典 bug。为什么这样排序的论证(交换论证)才告诉你正确的标准。

  3. 重叠判断差一: s >= lastEnd(允许相邻活动)vs s > lastEnd(要求有间隔)。检查题目要求哪种。

  4. 不证明就假设贪心有效: 始终用小例子验证,或简短地交换论证一下。若找不到反例且能草拟贪心选择「安全」的理由,大概率是正确的。

  5. 忘记排序: 贪心算法几乎总是从排序开始。忘记排序意味着贪心「顺序」不存在。

  6. 比较器中整数溢出: 按比率 w/t 排序时,避免浮点比较。用交叉乘法:w_A * t_B > w_B * t_A。乘法前始终强制转换为 long long

  7. 在错误的子问题上贪心: 有些问题看起来像「每次选最优元素」,但「最优」取决于未来上下文。若你在第 i 步的贪心选择改变了第 i+1 步的最优性,很可能需要 DP。


本章总结

📌 核心要点

题目类型贪心策略排序依据时间识别信号
最多不重叠区间选结束最早的区间右端点升序O(N log N)「最多活动/会议」
最少点刺穿所有区间在每个未覆盖区间的右端放点右端点升序O(N log N)「最少箭/传感器覆盖所有」
最少区间覆盖范围每步选最远延伸的左端点升序O(N log N)「最少线段覆盖 [L,R]」
区间合并按左端点排序,扫描合并左端点升序O(N log N)「合并重叠范围」
最小化最大延迟(EDF)最早截止日期优先截止时间升序O(N log N)「最小化最大延迟」
Huffman 编码合并两个最小频率最小堆O(N log N)「最小代价合并 N 堆」
最小化总完成时间(SJF)最短作业优先处理时间升序O(N log N)「最小化加权完成时间总和」
最大数(拼接)比较器:a+b vs b+a自定义比较器O(N log N·L)「排列数字/字符串得最大数」
排列不等式同向最大化,反向最小化两数组都排序O(N log N)「最大/最小化两数组的点积」
双序列匹配两数组排序后双指针匹配两数组都排序O(N log N)「匹配 A[i] 和 B[j] 最大化满足对数」
删除 K 个数字(最小结果)单调栈——栈顶大于当前时弹出不需要排序O(N)「删 K 个数字得最小数」
股票交易(无限次)累加每个正日差不需要排序O(N)「无限买卖,最大利润」
后悔贪心贪心选取 + 在堆中插入后悔节点最大/最小堆O(N log N)「K 次操作,可隐式撤销」
多机调度(LPT)最长作业优先 + 最小堆分配处理时间降序O(N log K)「N 个作业,K 台机器,最小完工时间」
对抗匹配(田忌赛马)最强打最强;否则最弱消耗最强双端指针O(N log N)「双方各自最优分配,最大赢场数」
前缀/后缀双遍扫描从两侧分别扫描,取最大合并无/自定义O(N)「每个元素依赖左侧最小和右侧最大」
位运算贪心(字典树+逐位)在每层贪心选对立位O(N·MAXBIT)「数组中两个元素的最大异或值」

❓ 常见问题

Q1:怎么判断一个问题能不能用贪心解?

A:三个信号:① 排序后有清晰的处理顺序;② 可以用交换论证证明贪心选择永远不比其他选择差;③ 找不到反例。若找到了(如硬币找零 {1,5,6,9}),贪心失败——改用 DP。

Q2:贪心和 DP 的真正区别是什么?

A:贪心在每步做出局部最优选择且从不回头。DP 考虑所有可能的选择,从子问题解构建全局最优。贪心是 DP 的特例——当局部最优恰好等于全局最优时可以用贪心。

Q3:「二分答案 + 贪心检查」模式是什么?

A:当题目问「最小化最大值」或「最大化最小值」时,对答案 X 二分查找,用贪心的 check(X) 验证可行性。参见第 4.2 章的 Convention 题目。

Q4:活动选择为什么按结束时间而非开始时间排序?

A:按结束时间排序确保我们总是选择最早「释放资源」的活动,为后续活动留下最大空间。按开始时间排序可能选到开始很早但结束很晚的活动,阻挡后续所有活动。

Q5:什么时候用后悔贪心而不是普通贪心?

A:当:① 题目允许最多 K 次操作且 K 较小;② 每次操作可以用反向操作「撤销」;③ 普通贪心给出次优答案,因为早期选择阻碍了后来更好的选择。关键思路是在堆中插入 -x 后悔节点,让你以 O(log N) 隐式撤销任何之前的选择。


练习题

题目关键技术难度
4.1.1 会议室 II区间调度 + 最小堆🟡 中等
4.1.2 加油站环形贪心 + 前缀和🔴 困难
4.1.3 最少站台数事件扫描🟡 中等
4.1.4 分数背包比率贪心🟢 简单
4.1.5 跳跃游戏可达性贪心🟡 中等
🏆 挑战题区间刺穿(USACO Silver)🔴 困难

题目 4.1.1 — 会议室 II 🟡 中等

题目: N 场会议,各有开始时间 start[i] 和结束时间 end[i]。找最少需要多少间会议室使所有会议都能无重叠进行。

输入:

3
0 30
5 10
15 20

输出: 2

💡 提示

最少会议室数 = 任意时刻最多重叠的会议数。用最小堆追踪结束时间(每间会议室何时空闲)。对每场新会议,检查最早空闲的会议室能否复用。

✅ 完整题解

核心思路:

开始时间排序会议,最小堆存储每间会议室的结束时间。对每场新会议:

  • 若堆顶(最早结束的会议室)≤ 新会议开始时间 → 复用该会议室
  • 否则 → 开新会议室

堆的最终大小就是答案。

#include <bits/stdc++.h>
using namespace std;

int main() {
    ios_base::sync_with_stdio(false);
    cin.tie(NULL);

    int n;
    cin >> n;

    vector<pair<int,int>> meetings(n);
    for (int i = 0; i < n; i++)
        cin >> meetings[i].first >> meetings[i].second;

    sort(meetings.begin(), meetings.end());  // 按开始时间排序

    // 最小堆:存储每间使用中会议室的结束时间
    priority_queue<int, vector<int>, greater<int>> pq;

    for (auto [start, end] : meetings) {
        if (!pq.empty() && pq.top() <= start) {
            // 复用最早空闲的会议室
            pq.pop();
        }
        pq.push(end);  // 该会议室被占用到 'end' 时刻
    }

    cout << pq.size() << "\n";
    return 0;
}
// 时间复杂度:O(N log N)

题目 4.1.2 — 加油站 🔴 困难

题目: N 个加油站排成圆圈。加油站 i 有 gas[i] 升油;从 i 到 i+1 消耗 cost[i] 升。油箱初始为空、容量无限。能否完成整圈?如果能,输出起始站下标(答案唯一)。

示例:

gas  = [1, 2, 3, 4, 5]
cost = [3, 4, 5, 1, 2]

输出:3(从第 3 站出发可以完成整圈)

💡 提示

核心思路:若总油量 ≥ 总耗油量,则恰好存在一个有效起始站。贪心扫描:每当累计油箱降至零以下,将起始站重置为下一站。

✅ 完整题解

两个关键定理:

  1. 可行性:sum(gas) < sum(cost),无解。
  2. 唯一起始站定理: 若有解,每当从站 s 出发的运行油箱变为负数,s 到故障点之间的任何站都不是有效起始点。因此下一个候选站必须紧接在故障点之后。
#include <bits/stdc++.h>
using namespace std;

int main() {
    ios_base::sync_with_stdio(false);
    cin.tie(NULL);

    int n;
    cin >> n;

    vector<int> gas(n), cost(n);
    for (int &x : gas) cin >> x;
    for (int &x : cost) cin >> x;

    int totalTank = 0;  // 总净油量(决定可行性)
    int tank = 0;       // 从 'start' 出发的当前油量
    int start = 0;      // 当前候选起始站

    for (int i = 0; i < n; i++) {
        int gain = gas[i] - cost[i];
        tank += gain;
        totalTank += gain;

        if (tank < 0) {
            // 从 'start' 无法到达 i+1 — 重置
            start = i + 1;
            tank = 0;
        }
    }

    if (totalTank < 0) {
        cout << -1 << "\n";  // 无解
    } else {
        cout << start << "\n";
    }
    return 0;
}
// 时间复杂度:O(N)——单次扫描

题目 4.1.3 — 最少站台数 🟡 中等

题目: N 辆火车,各有到达时间 arr[i] 和离开时间 dep[i]。若火车到达时所有站台都被占用,它必须等待。找最少需要多少个站台使没有火车需要等待。

示例:

arr = [9:00, 9:40, 9:50, 11:00, 15:00, 18:00]
dep = [9:10, 12:00, 11:20, 11:30, 19:00, 20:00]

输出:3

💡 提示

双指针/事件扫描:将所有到达(+1)和出发(-1)事件合并为一个排序列表,扫描时维护当前站台数。峰值就是答案。注意:相同时间时,出发事件先于到达事件处理(离开的火车先让出站台再占用)。

✅ 完整题解

方法一:事件扫描(推荐)

#include <bits/stdc++.h>
using namespace std;

int main() {
    ios_base::sync_with_stdio(false);
    cin.tie(NULL);

    int n;
    cin >> n;

    vector<pair<int,int>> events;  // {时间, 类型}:类型=0 出发,类型=1 到达
    for (int i = 0; i < n; i++) {
        int a, d;
        cin >> a >> d;
        events.push_back({a, 1});   // 到达
        events.push_back({d, 0});   // 出发(类型=0 < 1,相同时间出发先处理)
    }

    sort(events.begin(), events.end());

    int platforms = 0, maxPlatforms = 0;
    for (auto [time, type] : events) {
        if (type == 1) platforms++;   // 火车到达
        else platforms--;             // 火车出发
        maxPlatforms = max(maxPlatforms, platforms);
    }

    cout << maxPlatforms << "\n";
    return 0;
}

方法二:双指针(经典)

#include <bits/stdc++.h>
using namespace std;

int main() {
    int n;
    cin >> n;

    vector<int> arr(n), dep(n);
    for (int &x : arr) cin >> x;
    for (int &x : dep) cin >> x;

    sort(arr.begin(), arr.end());
    sort(dep.begin(), dep.end());

    int platforms = 1, maxPlatforms = 1;
    int i = 1, j = 0;

    while (i < n && j < n) {
        if (arr[i] <= dep[j]) {
            platforms++;
            i++;
        } else {
            platforms--;
            j++;
        }
        maxPlatforms = max(maxPlatforms, platforms);
    }

    cout << maxPlatforms << "\n";
    return 0;
}
// 时间复杂度:O(N log N)

题目 4.1.4 — 分数背包 🟢 简单

题目: N 件物品,物品 i 重量 w[i]、价值 v[i],背包容量 W。可以取任意分量。最大化总价值。

示例:

N=3, W=50
物品:(w=10, v=60), (w=20, v=100), (w=30, v=120)

输出:240.0

💡 提示

贪心有效是因为允许取分量。按价值/重量比(单位价值)降序排序,尽可能多地取比率最高的物品直到背包装满。

✅ 完整题解
#include <bits/stdc++.h>
using namespace std;

int main() {
    ios_base::sync_with_stdio(false);
    cin.tie(NULL);

    int n;
    double W;
    cin >> n >> W;

    vector<pair<double,double>> items(n);  // {价值, 重量}
    for (int i = 0; i < n; i++)
        cin >> items[i].second >> items[i].first;

    // 按单位价值(v/w)降序排序
    sort(items.begin(), items.end(), [](const auto &a, const auto &b) {
        return a.first / a.second > b.first / b.second;
    });

    double totalValue = 0.0;
    double remaining = W;

    for (auto [v, w] : items) {
        if (remaining <= 0) break;
        if (w <= remaining) {
            totalValue += v;
            remaining -= w;
        } else {
            totalValue += v * (remaining / w);
            remaining = 0;
        }
    }

    cout << fixed << setprecision(2) << totalValue << "\n";
    return 0;
}
// 时间复杂度:O(N log N)

题目 4.1.5 — 跳跃游戏 🟡 中等

题目: 给定非负整数数组 A,从索引 0 出发,在位置 i 可以向前跳最多 A[i] 步。判断是否能到达最后一个索引(n-1)。

示例:

A = [2, 3, 1, 1, 4] → true  (0→1→4)
A = [3, 2, 1, 0, 4] → false (过不了位置 3)
💡 提示

维护 farthest = 目前可到达的最远下标。扫描每个可达位置,更新 farthest。若某时刻 i > farthest,位置 i 不可达——返回 false。

✅ 完整题解
#include <bits/stdc++.h>
using namespace std;

bool canJump(vector<int>& A) {
    int n = A.size();
    int farthest = 0;

    for (int i = 0; i < n; i++) {
        if (i > farthest) return false;
        farthest = max(farthest, i + A[i]);
        if (farthest >= n - 1) return true;
    }
    return true;
}

int main() {
    int n;
    cin >> n;
    vector<int> A(n);
    for (int &x : A) cin >> x;
    cout << (canJump(A) ? "true" : "false") << "\n";
}

为什么贪心正确: 每步不选具体跳跃——只是追踪从当前所有可达位置出发,所有可能跳跃的并集。这等价于同时考虑所有可能的跳跃路径。


🏆 挑战题:USACO 2016 February Silver——区间刺穿

题目: FJ 有 N 段围栏,各定义为数轴上的 [L_i, R_i]。找最少的「锚点」数,使每段围栏都至少包含一个锚点。

✅ 完整题解

贪心策略:右端点升序排序。维护 lastPoint(最后一个锚点的位置,初始 −∞)。对每段:若 lastPoint 不在 [L_i, R_i] 内(即 lastPoint < L_i),在 R_i 放一个新锚点(尽量靠右以覆盖更多后续区间)。

#include <bits/stdc++.h>
using namespace std;

int main() {
    ios_base::sync_with_stdio(false);
    cin.tie(NULL);

    int n;
    cin >> n;

    vector<pair<int,int>> segs(n);  // {右端点, 左端点}
    for (int i = 0; i < n; i++) {
        int l, r;
        cin >> l >> r;
        segs[i] = {r, l};
    }

    sort(segs.begin(), segs.end());  // 按右端点排序

    int count = 0;
    long long lastPoint = LLONG_MIN;

    for (auto [r, l] : segs) {
        if (lastPoint < l) {
            // 当前锚点不覆盖该段——放新锚点
            lastPoint = r;   // 放在右端以覆盖尽量多的后续区间
            count++;
        }
    }

    cout << count << "\n";
    return 0;
}
// 时间复杂度:O(N log N)

与活动选择的联系: 区间刺穿和最多不重叠区间是对偶问题:最少刺穿点数 = 最多不重叠区间数(区间调度的 König 定理)。两者都按右端点排序,代码结构几乎相同。