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

前言

上一篇 贪心策略与交换论证 介绍了交换论证的基本方法。这篇聚焦贪心最经典的应用领域——区间问题

区间问题在日常生活中随处可见:一天有 NN 场电影(各有开始和结束时间),最多能看几场?NN 个任务各有时间窗口,同时进行的任务不能共用资源,最少需要几份资源?这些问题有一个共同的贪心范式:按某个端点排序,然后按某种规则选取

问题的本质

最多不相交区间(活动选择)

给定 NN 个区间 [Li,Ri)[L_i, R_i),选尽可能多的区间使得它们互不相交。

贪心策略:按右端点排序,能选就选。

为什么按右端点而不是左端点? 按左端点排序可能选到一个很早开始但很晚结束的区间,霸占大量时间。按右端点排序则保证每次选的区间结束得最早,给后面留出最多空间。

交换论证:假设最优解的第一个区间不是右端点最小的。设贪心选了区间 AA(右端点最小),最优解选了区间 BB(右端点更大)。因为 RARBR_A \le R_B,把 BB 换成 AA 不会和后面的区间冲突(AA 结束更早,只会更好)。所以存在一个最优解第一步和贪心一样。对剩余子问题递归,贪心就是最优的。

最少点覆盖

用最少的点,使得每个区间至少包含一个点。

策略:按右端点排序,在第一个未覆盖区间的右端点放一个点,跳过所有包含该点的区间。代码和活动选择几乎一样。

区间分组(最少会议室)

NN 个会议区间,同时进行的不能共用会议室。最少需要几个?

策略:扫描线。把所有 LiL_i(+1 事件)和 RiR_i(-1 事件)排序,扫描过程中计数器的峰值就是答案。

理论 + 代码

活动选择的标准实现

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

pair<int, int> seg[300009]; // (R_i, L_i) — 右端点在前便于排序

int main() {
    int N;
    scanf("%d", &N);
    for (int i = 0; i < N; i++) {
        int L, R; scanf("%d%d", &L, &R);
        seg[i] = {R, L};                             // ① 存为 (右端点, 左端点)
    }
    sort(seg, seg + N);                              // ② 按右端点排序

    int cnt = 0, lastEnd = -1;                       // ③ lastEnd = 上一个选中区间的右端点
    for (int i = 0; i < N; i++) {
        if (seg[i].second >= lastEnd) {              // ④ 不重叠(允许首尾相接)
            cnt++;
            lastEnd = seg[i].first;                  // ⑤ 更新右端点
        }
    }
    printf("%d\n", cnt);
}

逐行解析

  • ① 存为 (R, L) 而非 (L, R),这样 sort 自动按 first(右端点)排序。
  • ② 按右端点升序排列。
  • lastEnd = -1 表示还没有选中任何区间。
  • ④ 用 >= 而非 >,允许一个区间在 RiR_i 结束的瞬间另一个区间在 RiR_i 开始。
  • ⑤ 选中后更新 lastEnd 为当前区间的右端点。

扫描线求峰值

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

pair<int, int> events[600009]; // (时间, +1/-1)

int main() {
    int N;
    scanf("%d", &N);
    for (int i = 0; i < N; i++) {
        int L, R; scanf("%d%d", &L, &R);
        events[2*i] = {L, 1};                        // 开始事件
        events[2*i+1] = {R, -1};                     // 结束事件
    }
    sort(events, events + 2*N);

    int cur = 0, ans = 0;
    for (int i = 0; i < 2*N; i++) {
        cur += events[i].second;
        ans = max(ans, cur);                         // 峰值 = 最少会议室
    }
    printf("%d\n", ans);
}

逐行解析

  • 把每个区间拆成两个事件:开始(+1)和结束(-1)。
  • 排序后扫描。同一时刻 second = -1(结束)排在 second = 1(开始)前面,因为 1<1-1 < 1。这保证了一个会议结束的瞬间另一个可以立刻开始。

例题

例题 1:M&A 082 — Interval Scheduling Problem(活动选择模板)

题目NN 场电影,第 ii 场在 [Li,Ri)[L_i, R_i) 期间放映。最多能完整看几场?一场结束的瞬间可以开始下一场。

数据范围1N3×1051 \le N \le 3 \times 10^50Li<Ri864000 \le L_i < R_i \le 86400

—— AtCoder M&A 082

分析:标准活动选择。按右端点排序,贪心选取。

输入样例

3
123 86399
1 86400
86399 86400

输出2

代码

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

pair<int, int> seg[300009];

int main() {
    int N;
    scanf("%d", &N);
    for (int i = 0; i < N; i++) {
        int L, R; scanf("%d%d", &L, &R);
        seg[i] = {R, L};
    }
    sort(seg, seg + N);

    int cnt = 0, lastEnd = -1;
    for (int i = 0; i < N; i++) {
        if (seg[i].second >= lastEnd) {              // ① ≥ 允许首尾相接
            cnt++;
            lastEnd = seg[i].first;
        }
    }
    printf("%d\n", cnt);
}

逐行解析

  • seg[i].second >= lastEnd:当前电影的开始时间 ≥ 上一场结束时间,不冲突。

验证:3 场 (123,86399),(1,86400),(86399,86400)(123,86399), (1,86400), (86399,86400)。排序后 (86399,123),(86400,1),(86400,86399)(86399,123), (86400,1), (86400,86399)

  • 选第 1 场(R=86399R=86399),lastEnd=86399
  • 第 2 场 L=1<86399L=1 < 86399,跳过
  • 第 3 场 L=8639986399L=86399 \ge 86399,选。共 2 场。✓

例题 2:TB A39 — Interval Scheduling Problem(同 M&A 082)

题目:同上。

—— AtCoder Tessoku Book A39

说明:Tessoku Book A 系列和 M&A 共享同一题。代码与例题 1 完全相同。


例题 3:区间分组——最少会议室

场景NN 个会议区间 [Li,Ri)[L_i, R_i),同时进行的不能共用会议室。最少需要几个会议室?

数据范围1N3×1051 \le N \le 3 \times 10^5

分析:扫描线法。把所有时间点排序,遇到开始 +1,遇到结束 -1,峰值即答案。

代码

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

pair<int, int> events[600009];

int main() {
    int N;
    scanf("%d", &N);
    for (int i = 0; i < N; i++) {
        int L, R; scanf("%d%d", &L, &R);
        events[2*i] = {L, 1};                        // ① 开始事件
        events[2*i+1] = {R, -1};                     // ② 结束事件
    }
    sort(events, events + 2*N);                      // ③ 按时间排序

    int cur = 0, ans = 0;
    for (int i = 0; i < 2*N; i++) {
        cur += events[i].second;                     // ④ 计数器加减
        ans = max(ans, cur);                         // ⑤ 记录峰值
    }
    printf("%d\n", ans);
}

逐行解析

  • ①② 每个区间拆成两个事件:开始(+1)和结束(-1)。
  • ③ 同一时刻结束(-1)排在开始(+1)前面,因为 pair 先比 firstfirst 相同时比 second,而 1<1-1 < 1
  • ④⑤ 扫描过程中,cur 是当前同时进行的会议数。峰值就是最少需要的会议室数。

验证:3 个会议 (1,4),(2,5),(3,6)(1,4), (2,5), (3,6)。事件:(1,+1),(2,+1),(3,+1),(4,1),(5,1),(6,1)(1,+1),(2,+1),(3,+1),(4,-1),(5,-1),(6,-1)

时间事件cur
1+11
2+12
3+13
4-12
5-11
6-10

峰值 = 3,最少需要 3 个会议室。✓(三个会议在时刻 3 同时进行)


例题 4:最少点覆盖

场景NN 个区间 [Li,Ri][L_i, R_i]。用最少的点使每个区间至少包含一个点。

数据范围1N3×1051 \le N \le 3 \times 10^5

分析:按右端点排序。在第一个未覆盖区间的右端点放一个点,跳过所有包含该点的区间。

代码

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

pair<int, int> seg[300009]; // (R, L)

int main() {
    int N;
    scanf("%d", &N);
    for (int i = 0; i < N; i++) {
        int L, R; scanf("%d%d", &L, &R);
        seg[i] = {R, L};
    }
    sort(seg, seg + N);

    int cnt = 0, point = -2e9;                       // ① point = 上一个放置的点
    for (int i = 0; i < N; i++) {
        if (seg[i].second > point) {                 // ② 当前区间未被覆盖
            cnt++;
            point = seg[i].first;                    // ③ 在右端点放一个点
        }
    }
    printf("%d\n", cnt);
}

逐行解析

  • point = -2e9 确保第一个区间一定会被处理。
  • ② 如果区间的左端点 > 已放置的点,说明该区间没被覆盖。
  • ③ 在右端点放点是最优的——放在右端点可以覆盖最多后续区间(因为后续区间的左端点只会更大)。

验证:4 个区间 (1,3),(2,5),(4,7),(6,8)(1,3), (2,5), (4,7), (6,8)。排序后同。

  • 区间 1 未覆盖,在 3 放点。覆盖 (1,3),(2,5)(1,3), (2,5)
  • 区间 3:L=4>3L=4 > 3,未覆盖。在 7 放点。覆盖 (4,7),(6,8)(4,7), (6,8)
  • 共 2 个点。✓

例题 5:带权活动选择——区间 DP

场景NN 个区间 [Li,Ri)[L_i, R_i),每个有权值 WiW_i。选若干不相交区间使权值和最大。

数据范围1N2×1051 \le N \le 2 \times 10^5

分析:无权版用贪心,但带权版需要 DP。按右端点排序后,dp[i]dp[i] = 考虑前 ii 个区间时的最大权值和。dp[i]=max(dp[i1], dp[j]+Wi)dp[i] = \max(dp[i-1],\ dp[j] + W_i),其中 jj 是最后一个满足 RjLiR_j \le L_i 的区间。用二分搜索找 jj

代码

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

pair<int, int> seg[200009]; // (R, L)
long long W[200009];
long long dp[200009];

int main() {
    int N;
    scanf("%d", &N);
    for (int i = 1; i <= N; i++) {
        int L, R; long long w;
        scanf("%d%d%lld", &L, &R, &w);
        seg[i] = {R, L};
        W[i] = w;
    }
    sort(seg + 1, seg + N + 1);                      // ① 按右端点排序

    int sortedR[200009];
    for (int i = 1; i <= N; i++) sortedR[i] = seg[i].first;

    dp[0] = 0;
    for (int i = 1; i <= N; i++) {
        // ② 二分找最后一个 R_j <= L_i 的 j
        int j = lower_bound(sortedR + 1, sortedR + i, seg[i].second) - sortedR - 1;
        dp[i] = max(dp[i-1], dp[j] + W[i]);          // ③ 不选 or 选
    }
    printf("%lld\n", dp[N]);
}

逐行解析

  • ① 按右端点排序,保证处理第 ii 个区间时前面所有的右端点都不超过 RiR_i
  • lower_bound 找第一个 Li\ge L_i 的右端点位置,减 1 得到最后一个 Li\le L_i 的。这保证了区间 jj 和区间 ii 不重叠。
  • ③ 不选第 ii 个区间:dp[i1]dp[i-1]。选第 ii 个:找不冲突的最后一个区间 jjdp[j]+Widp[j] + W_i

参考文献

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

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


系列索引

第零章 基础工具

第一章 搜索技术

第二章 数学基础

第三章 数据结构

第四章 图论

第五章 动态规划

第六章 贪心

第七章 字符串

第八章 进阶