相关笔记

概览

活动选择问题是贪心算法的经典入门案例。核心任务是:给定 个活动,每个活动 有开始时间 和结束时间 ,在互不冲突的前提下选出尽可能多的活动。

  • 贪心策略:选择最早结束的活动,为后续活动留出最多空间
  • 最优子结构:选择一个活动后,剩余问题等价于一个更小的子问题
  • 时间复杂度(假设活动已按结束时间排序)
  • 关键定理:Theorem 15.1 证明贪心选择总是安全的

知识结构总览

flowchart TD
    A["活动选择问题<br/>n个活动,选最大兼容子集"] --> B["问题建模"]
    B --> B1["活动集 S = {a₁, a₂, ..., aₙ}"]
    B1 --> B2["每个活动 aᵢ: [sᵢ, fᵢ)"]
    B2 --> B3["兼容条件: sᵢ ≥ fⱼ 或 sⱼ ≥ fᵢ"]

    A --> C["求解路径"]
    C --> C1["方法一:动态规划"]
    C1 --> C1a["子问题 Sᵢⱼ<br/>c[i,j] 递推"]
    C1a --> C1b["O(n³) 时间"]

    C --> C2["方法二:贪心算法"]
    C2 --> C2a["贪心选择:最早结束的活动"]
    C2a --> C2b["Theorem 15.1 证明安全性"]
    C2b --> C2c["递归 → 迭代"]
    C2c --> C2d["Θ(n) 时间"]

    A --> D["关键性质"]
    D --> D1["最优子结构"]
    D --> D2["贪心选择性质"]
    D --> D3["自顶向下设计"]

核心思想

核心思路

活动选择问题的核心思路是:每次选择结束时间最早的活动。这样做的好处是,选择一个活动后,它为后续活动”释放”资源的时间最早,从而为剩余活动留下了尽可能多的时间空间。直觉上,结束得越早,留给别人的机会越多。这与日常生活中”短会优先”的会议室调度策略完全一致。

问题定义

给定 个活动的集合 ,每个活动 有一个开始时间 和一个结束时间 ,其中 。活动 发生在半开区间 上。

兼容性定义:两个活动 兼容的(compatible),当且仅当它们的区间不重叠,即:

目标:选出一个最大规模的互相兼容的活动子集

排序假设:假设活动已按结束时间单调递增排序:

f_1 \leq f_2 \leq \cdots \leq f_n \tag{15.1}

最优子结构

表示在 结束之后、 开始之前的所有活动的集合:

假设 的一个最大兼容活动子集,且包含活动 。将 拆分为三部分:

其中 (在 开始之前结束的活动),(在 结束之后开始的活动)。

因此,最优解的大小为:

【剪切-粘贴论证(反证法:若子问题解非最优则可替换得到更优解)】 剪切-粘贴论证(cut-and-paste argument):如果 是最优解,那么 也必须分别是 的最优解。否则,假设存在 的一个兼容活动子集 满足 ,那么可以用 替换 ,得到 ,其大小为 ,这与 是最优解的假设矛盾。对 的论证完全对称。

【最优子结构递推(枚举 S_ij 中所有活动取最大值)】 DP 递推公式:令 表示 中最优解的大小,则:

c[i,j] = \begin{cases} 0 & \text{若 } S_{ij} = \emptyset \\ \max\limits_{a_k \in S_{ij}} \{c[i,k] + c[k,j] + 1\} & \text{若 } S_{ij} \neq \emptyset \end{cases} \tag{15.2}

如果不知道最优解包含哪个活动 ,就必须穷举 中的所有活动。但活动选择问题有一个更重要的性质可以利用。

贪心选择

贪心选择(Greedy Choice)

在活动选择问题中,贪心选择是选择 结束时间最早的活动。直觉是:结束得越早,留给后续活动的可用时间越多。由于活动已按结束时间排序,贪心选择就是

【贪心选择简化子问题(最早结束活动保证无需考虑其之前的子问题)】 为什么选择 后只需考虑一个子问题?

选择 后,剩余子问题为 。不需要考虑在 开始之前结束的活动,原因如下:

  • (活动有正的持续时间)
  • 是所有活动中最早的结束时间
  • 因此,没有任何活动的结束时间可以
  • 所以,所有与 兼容的活动都必须在 结束之后开始

【贪心选择性质(最早结束活动必属于某个最大兼容子集)】 Theorem 15.1:考虑任意非空子问题 ,令 中结束时间最早的活动。则 包含在 的某个最大兼容活动子集中。

【交换论证(分情况讨论:a_j=a_m 直接成立,a_j≠a_m 替换后仍最优)】 证明

的一个最大兼容活动子集,令 中结束时间最早的活动。

  • 情况一:若 ,则 已经在 中,证明完毕。
  • 情况二:若 【用 a_m 替换 a_j 构造 A’_k】 构造集合 ,即用 替换

【验证替换后 A’_k 仍兼容:f_m f_j 保证 a_m 不与其它活动冲突】 需要验证 中的活动互相兼容:

  1. 中的活动互相兼容(已知条件)
  2. 中最早结束的活动
  3. (因为 中结束时间最早的,而

由于 的结束时间不晚于 ,且 中所有其他活动兼容,因此 也与 中的所有活动兼容。所以 是一个兼容活动子集。

【|A’_k| = |A_k|,故 A’_k 也是最大兼容子集且包含 a_m】 由于 也是 的最大兼容活动子集,且包含

递归贪心算法

算法执行流程

  1. 从活动 k+1 开始向后扫描,找到第一个与活动 k 兼容的活动 m(即 finish[k] start[m]
  2. 若找不到兼容活动(m > n),则返回空集
  3. 若找到兼容活动 m,将 m 加入结果集
  4. 以 m 为新的起点,递归选择 m 之后的活动
  5. 返回 {m} 与递归结果的并集
flowchart TD
    A["输入: s, f, k, n"] --> B["m = k + 1"]
    B --> C{"m <= n 且 s[m] < f[k]?"}
    C -- 是 --> D["m = m + 1"]
    D --> C
    C -- 否 --> E{"m <= n?"}
    E -- 是 --> F["返回 {aₘ} ∪ 递归调用(s, f, m, n)"]
    E -- 否 --> G["返回空集"]
RECURSIVE-ACTIVITY-SELECTOR(s, f, k, n)
1  m = k + 1
2  while m ≤ n and s[m] < f[k]     // 找 Sₖ 中第一个兼容的活动
3      m = m + 1
4  if m ≤ n
5      return {aₘ} ∪ RECURSIVE-ACTIVITY-SELECTOR(s, f, m, n)
6  else return ∅

算法解释

  • 输入:数组 (开始时间)、(结束时间)、索引 (定义子问题 )、规模
  • 初始调用:添加虚拟活动 ),调用 RECURSIVE-ACTIVITY-SELECTOR(s, f, 0, n)
  • 第1-3行:从 开始向后扫描,找到第一个满足 的活动 (即 中最早结束的兼容活动)
  • 第4-5行:如果找到了兼容活动 ,将其加入解集,并递归求解子问题
  • 第6行:如果没有找到兼容活动,返回空集

【摊还计数论证(每个活动在while循环中恰好被检查一次)】 运行时间分析:假设活动已按结束时间排序,运行时间为 。原因:在所有递归调用中,每个活动在 while 循环的测试中恰好被检查一次。具体地,活动 在满足 的最后一次递归调用中被检查。

迭代贪心算法

算法执行流程

  1. 选择第一个活动 A[1](最早结束),加入结果集
  2. 设 k = 1,从第 2 个活动开始遍历剩余活动
  3. 对每个活动 i:若 start[i] >= finish[k](与最近选中的活动兼容),则选择活动 i
  4. 更新 k = i,继续遍历
  5. 遍历结束,返回所有被选活动
flowchart TD
    A["输入: s, f, n"] --> B["A = {a₁}, k = 1"]
    B --> C["m = 2"]
    C --> D{"m <= n?"}
    D -- 否 --> G["返回 A"]
    D -- 是 --> E{"s[m] >= f[k]?"}
    E -- 是 --> F["A = A ∪ {aₘ}, k = m"]
    E -- 否 --> H["m = m + 1"]
    F --> H
    H --> D
GREEDY-ACTIVITY-SELECTOR(s, f, n)
1  A = {a₁}
2  k = 1
3  for m = 2 to n
4      if s[m] ≥ f[k]        // aₘ 是否在 Sₖ 中?
5          A = A ∪ {aₘ}      // 是,选择它
6          k = m              // 从这里继续
7  return A

算法解释

  • 第1-2行:选择活动 (最早结束),初始化集合 ,令
  • 第3-6行:依次检查每个活动 ),如果 与最近加入的活动 兼容(即 ),则将 加入 ,并更新

【单调性论证(排序后最后加入活动的结束时间最大,只需比较一次)】 关键性质:由于活动按结束时间递增排序, 中最后加入的活动 的结束时间 始终是 中所有活动的最大结束时间:

f_k = \max\{f_i : a_i \in A\} \tag{15.3}

因此,只需检查 就能确定 是否与 中所有活动兼容,无需逐一比较。

运行时间:与递归版本相同,为 (假设已排序)。如果未排序,先排序需要 时间。


补充理解与拓展

区间调度问题的三种经典变体

活动选择问题是更一般的区间调度问题(Interval Scheduling)的特例。在实际工程中,区间调度有三种常见变体,分别对应不同的优化目标:

变体优化目标贪心策略经典题目
最大兼容子集选中最多不重叠区间按结束时间排序,选最早结束LeetCode 435
最少资源分配用最少资源覆盖所有区间按开始时间排序,用最小堆追踪LeetCode 253
最大权重区间调度选中权重和最大的不重叠区间动态规划(贪心不适用)

前两种变体可以用贪心算法高效求解,第三种变体因为需要比较”选”与”不选”的权重,贪心选择性质不成立,必须使用动态规划。这恰好印证了15.2 贪心策略要素中”贪心选择性质是贪心算法的必要前提”这一核心观点。

实际应用场景

  • 会议室调度:企业OA系统自动分配会议室,LeetCode 253 “Meeting Rooms II”要求计算最少需要多少间会议室才能容纳所有会议
  • CPU任务调度:单处理器上最大化完成的任务数(对应最大兼容子集变体),多核处理器上最小化所需核心数(对应最少资源分配变体)
  • 航班登机口分配:机场用最少登机口安排所有航班,与最少资源分配变体完全对应
  • 课程表编排:学校在有限教室中安排尽可能多的课程

来源:UMD CMSC 451 Lecture 5 “Greedy Algorithms for Scheduling”; University of Washington CSE 421 “Design and Analysis of Algorithms” Lecture Notes

替换论证——贪心正确性证明的核心技术

活动选择问题中 Theorem 15.1 的证明使用了替换论证(Exchange Argument),这是贪心算法正确性证明中最通用、最重要的技术。其核心思路是:

  1. 任取一个最优解
  2. 找到最优解与贪心解的第一个不同决策点
  3. 证明可以将最优解在该决策点的选择替换为贪心选择,替换后的解仍然是最优的
  4. 归纳可知,贪心解与最优解在所有决策点上一致,因此贪心解也是最优的

替换论证之所以有效,是因为它将”证明贪心解最优”转化为一个更弱的问题:“证明存在一个包含贪心选择的最优解”。后者通常可以通过简单的交换操作来证明。

在本章中,替换论证被反复使用:

  • Theorem 15.1(活动选择):将最优解中第一个活动替换为最早结束的活动
  • Lemma 15.2(Huffman编码):将最优树中最深层的两个叶节点替换为频率最低的两个字符
  • Theorem 15.5(离线缓存):将最优策略的驱逐决策替换为 furthest-in-future 的驱逐决策

掌握替换论证是理解贪心算法正确性的关键。建议在阅读每一条定理证明时,明确识别”替换了什么""为什么替换后仍然最优”这两个核心问题。

来源:UMD CMSC 451 Lecture 5; Kleinberg & Tardos, “Algorithm Design”, Chapter 4 “Greedy Algorithms”


易混淆点与辨析

误区辨析

误区一:贪心策略可以任意选择

并非所有贪心策略都能得到最优解。例如:

  • 选择持续时间最短的活动 → 不一定能得到最优解
  • 选择与其他活动重叠最少的活动 → 不一定能得到最优解
  • 选择最早开始的活动 → 不一定能得到最优解

只有选择最早结束的活动这一贪心策略被证明是正确的(Theorem 15.1)。

误区二:贪心算法不需要证明

虽然贪心算法的代码通常很简单,但必须严格证明贪心选择的安全性。没有证明的贪心策略可能只是启发式方法,不能保证最优性。Theorem 15.1 的证明是活动选择问题贪心算法正确性的基石。

误区三:贪心算法和动态规划可以互换使用

虽然活动选择问题同时可以用 DP 和贪心求解,但贪心算法利用了贪心选择性质,将时间复杂度从 (DP 方法)降低到 。对于不具备贪心选择性质的问题(如 0-1 背包问题),贪心算法无法保证最优性,必须使用动态规划。


习题精选

题号题目描述难度核心考点
15.1-1基于递推式(15.2)给出活动选择问题的动态规划算法★★★DP 与贪心的对比
15.1-2选择最晚开始的兼容活动,证明其最优性★★★贪心策略的多样性
15.1-3给出反例说明”最短持续时间""最少重叠""最早开始”贪心策略不正确★★贪心策略的局限性
15.1-4教室分配问题(区间图着色)★★★★贪心算法的扩展应用
15.1-5带权活动选择问题(最大化总价值)★★★★从贪心回归 DP

视频学习指南

资源链接说明
MIT 6.006 Lecture 12YouTube贪心算法导论 + 活动选择
MIT 6.006 Lecture 13YouTube更多贪心算法示例
abdul bari - Greedy AlgorithmsYouTube直观讲解贪心思想
GeeksforGeeks Activity SelectionGFG图文详解 + 代码实现

教材原文

CLRS 第4版 15.1节原文

贪心方法相当强大,适用于广泛的问题。后续章节将展示许多可以视为贪心方法应用的算法,包括最小生成树算法(第21章)、单源最短路径的 Dijkstra 算法(22.3节)以及贪心集合覆盖启发式算法(35.3节)。最小生成树算法是贪心方法的经典例子。虽然你可以独立阅读本章和第21章,但将它们一起阅读可能会更有帮助。

15.1 活动选择问题

我们的第一个例子是调度多个竞争活动的问题,这些活动需要独占使用公共资源,目标是选择一个最大规模的互相兼容的活动集合。想象你负责调度一间会议室。你收到了一组 个拟议活动,希望预约这间会议室,而会议室一次只能服务一个活动。每个活动 有开始时间 和结束时间 ,其中 。如果被选中,活动 在半开时间区间 内进行。活动 是兼容的,如果区间 不重叠。也就是说, 是兼容的,如果 。在活动选择问题中,你的目标是选择一个最大规模的互相兼容的活动子集。假设活动已按结束时间单调递增排序。

我们将看到如何解决这个问题,分为几个步骤。首先我们将探索一个动态规划解法,在其中考虑多个选择来确定在最优解中使用哪些子问题。然后我们将观察到你只需要考虑一个选择——贪心选择——并且当你做出贪心选择时,只剩下一个子问题。基于这些观察,我们将开发一个递归贪心算法来解决活动选择问题。最后,我们将通过将递归算法转换为迭代算法来完成贪心解法的开发过程。


参见Wiki


第15章-贪心算法 活动选择问题