当前目录
← Back

Activity Selection 活动选择问题


Activity Selection 活动选择问题

核心结论

对所有活动按结束时间从早到晚排序,然后依次选择与已选活动兼容的活动,即可得到数量最多的互不重叠活动集合。

  • 输入已按结束时间排序:时间复杂度为 Θ(n)\Theta(n)。
  • 输入未排序:总时间复杂度为 O(nlog⁡n)O(n\log n)。
  • 贪心选择:每次选择当前可选活动中结束最早的活动。

1. 问题定义

给定 nn 个活动组成的集合:

S={a0,a1,…,an−1}S=\{a_0,a_1,\dots,a_{n-1}\}

每个活动 aia_i 占用一个半开时间区间:

ai=[si,fi)a_i=[s_i,f_i)

其中:

  • sis_i:活动 aia_i 的开始时间;
  • fif_i:活动 aia_i 的结束时间;
  • 满足 si<fis_i<f_i。

采用半开区间 [si,fi)[s_i,f_i) 后,若一个活动恰好在另一个活动结束时开始,则二者兼容。

两个活动 aia_i 和 aja_j 兼容,当且仅当它们的时间区间不重叠:

fi≤sj或fj≤sif_i\le s_j \quad\text{或}\quad f_j\le s_i

目标是在所有活动中选择一个集合 A⊆SA\subseteq S,使得:

  1. AA 中任意两个活动相互兼容;
  2. ∣A∣|A| 最大。

形式化表示为:

max⁡∣A∣\max |A| s.t. ∀ai,aj∈A, i≠j,[fi≤sj]∨[fj≤si]\text{s.t. } \forall a_i,a_j\in A,\ i\ne j,\quad [f_i\le s_j]\lor[f_j\le s_i]

优化目标

活动选择问题最大化的是活动数量,而不是总持续时间、总价值或资源利用率。


2. 基本思想

活动选择问题使用 Greedy Algorithm 贪心算法 求解。

贪心策略是:

在所有与当前已选活动兼容的候选活动中,选择结束时间最早的活动。

选择结束最早的活动,会使资源尽早空闲,从而为后续活动保留尽可能大的可用时间范围。

设上一个被选择活动的结束时间为 last_finish。对于按结束时间升序排列后的每个活动 aia_i:

  • 若 si≥last_finishs_i\ge \text{last\_finish},选择该活动;
  • 否则,该活动与上一个已选活动冲突,跳过它。

记忆方式

结束得越早,留给后续活动的空间越大。

2.1 为什么不是选择开始最早的活动

开始最早的活动可能持续很长时间,从而阻塞大量后续活动。

例如:

  • a0=[0,10)a_0=[0,10)
  • a1=[1,2)a_1=[1,2)
  • a2=[2,3)a_2=[2,3)
  • a3=[3,4)a_3=[3,4)

若选择开始最早的 a0a_0,只能选择 11 个活动;而选择 a1,a2,a3a_1,a_2,a_3 可以选择 33 个活动。

2.2 为什么不是选择持续时间最短的活动

持续时间最短并不意味着结束最早。一个很短但开始较晚的活动,可能浪费前面的大段可用时间,也可能阻塞后续安排。

因此,活动选择问题的正确贪心关键字是:

finish time\boxed{\text{finish time}}

而不是开始时间或持续时间。


3. 具体过程

3.1 算法步骤

  1. 将所有活动按照结束时间 fif_i 从小到大排序。
  2. 初始化结果集合 A=∅A=\varnothing。
  3. 选择排序后第一个活动,将其加入 AA。
  4. 记录最近一次选择的活动结束时间 last_finish。
  5. 从前到后扫描剩余活动:
    • 若当前活动开始时间满足 si≥last_finishs_i\ge \text{last\_finish},则选择当前活动;
    • 更新 last_finish = f_i;
    • 否则跳过当前活动。
  6. 扫描结束后返回 AA。

3.2 流程图

flowchart TD
    A[按结束时间升序排列活动] --> B[结果集合 selected 置空]
    B --> C[令 last_finish 为负无穷]
    C --> D{是否还有未扫描活动}
    D -- 否 --> H[返回 selected]
    D -- 是 --> E{当前活动开始时间 ≥ last_finish}
    E -- 是 --> F[选择当前活动]
    F --> G[更新 last_finish 为当前结束时间]
    G --> D
    E -- 否 --> D

3.3 循环不变量

在每次扫描活动 aia_i 之前:

  1. selected 中的活动两两兼容;
  2. last_finish 是 selected 中最后一个活动的结束时间;
  3. 对已经扫描过的活动,selected 是按照”结束最早”策略得到的最优前缀选择。

当算法选择满足 si≥last_finishs_i\ge\text{last\_finish} 的活动时,新活动与之前所有活动兼容,因为之前选中的最后一个活动结束得最晚,而当前活动仍在其结束后开始。


4. 伪代码

4.1 输入未排序的通用版本

ACTIVITY-SELECTION(activities):
    # activities[i] = (start, finish, id)
    # 使用 0-based 下标

    activities ← SORT-BY-FINISH-TIME-ASCENDING(activities)

    selected ← empty list
    last_finish ← -∞

    for i ← 0 to LENGTH(activities) - 1:
        if activities[i].start ≥ last_finish:
            APPEND(selected, activities[i])
            last_finish ← activities[i].finish

    return selected

4.2 输入已按结束时间排序的版本

GREEDY-ACTIVITY-SELECTOR(activities):
    n ← LENGTH(activities)

    if n = 0:
        return empty list

    selected ← [activities[0]]
    last_selected ← 0

    for i ← 1 to n - 1:
        if activities[i].start ≥ activities[last_selected].finish:
            APPEND(selected, activities[i])
            last_selected ← i

    return selected

两种初始化方式

可以先选择排序后的第一个活动,也可以令 last_finish = -∞ 后统一扫描全部活动。后者更容易处理空输入。

4.3 递归版本

假设活动已经按结束时间升序排列,last 表示上一个已选活动的下标:

RECURSIVE-ACTIVITY-SELECTOR(activities, last, next):
    n ← LENGTH(activities)
    i ← next

    while i < n and activities[i].start < activities[last].finish:
        i ← i + 1

    if i = n:
        return empty list

    return [activities[i]] +
           RECURSIVE-ACTIVITY-SELECTOR(activities, i, i + 1)

递归版本通常需要额外设置一个结束时间为 −∞-\infty 的虚拟活动,或单独处理第一次选择。实际实现更推荐迭代版本。


5. Python 实现

from dataclasses import dataclass
from typing import Iterable


@dataclass(frozen=True)
class Activity:
    """一个半开时间区间 [start, finish)。"""

    id: str
    start: int
    finish: int

    def __post_init__(self) -> None:
        if self.start >= self.finish:
            raise ValueError(
                f"活动 {self.id} 必须满足 start < finish,"
                f"实际得到 ({self.start}, {self.finish})"
            )


def activity_selection(activities: Iterable[Activity]) -> list[Activity]:
    """返回数量最多的一组两两兼容活动。"""

    # 不修改调用者传入的数据,并保留确定性的并列排序规则。
    ordered = sorted(
        activities,
        key=lambda activity: (
            activity.finish,
            activity.start,
            activity.id,
        ),
    )

    selected: list[Activity] = []
    last_finish = float("-inf")

    for activity in ordered:
        if activity.start >= last_finish:
            selected.append(activity)
            last_finish = activity.finish

    return selected


if __name__ == "__main__":
    activities = [
        Activity("a0", 1, 4),
        Activity("a1", 3, 5),
        Activity("a2", 0, 6),
        Activity("a3", 5, 7),
        Activity("a4", 3, 9),
        Activity("a5", 5, 9),
        Activity("a6", 6, 10),
        Activity("a7", 8, 11),
        Activity("a8", 8, 12),
        Activity("a9", 2, 14),
        Activity("a10", 12, 16),
    ]

    answer = activity_selection(activities)
    print([activity.id for activity in answer])

预期输出:

['a0', 'a3', 'a7', 'a10']

5.1 结果验证

可以通过以下两个条件验证输出:

  1. 可行性:相邻已选活动满足
fik≤sik+1f_{i_k}\le s_{i_{k+1}}
  1. 最优性:对于小规模输入,可枚举所有子集,与贪心结果的活动数量进行比较。
from itertools import combinations


def is_compatible(subset: tuple[Activity, ...]) -> bool:
    ordered = sorted(subset, key=lambda activity: activity.start)
    return all(
        ordered[i].finish <= ordered[i + 1].start
        for i in range(len(ordered) - 1)
    )


def brute_force_activity_selection(
    activities: list[Activity],
) -> list[Activity]:
    best: tuple[Activity, ...] = ()

    for size in range(len(activities) + 1):
        for subset in combinations(activities, size):
            if is_compatible(subset) and len(subset) > len(best):
                best = subset

    return list(best)

暴力验证仅用于小规模测试

暴力枚举需要检查 2n2^n 个子集,时间复杂度为指数级,不能替代贪心算法处理大规模输入。


6. 正确性证明

活动选择算法的正确性依赖两个性质:

  1. 贪心选择性质;
  2. 最优子结构。

6.1 贪心选择性质

定理

对任意非空活动集合,至少存在一个最优解包含结束时间最早的活动。

证明:交换论证

设 aga_g 是所有活动中结束时间最早的活动:

fg=min⁡ifif_g=\min_i f_i

设 OO 是任意一个最优解,并将 OO 中第一个被执行的活动记为 aoa_o。

由于 aga_g 的结束时间最早,因此:

fg≤fof_g\le f_o

将最优解 OO 中的 aoa_o 替换为 aga_g。

原来排在 aoa_o 后面的每个活动,其开始时间都不小于 fof_o。又因为 fg≤fof_g\le f_o,所以这些活动的开始时间也不小于 fgf_g。因此,替换后所有活动仍然相互兼容。

替换操作没有减少活动数量,所以得到的仍然是一个最优解,并且该最优解包含 aga_g。

故结束时间最早的活动是一个安全选择。证毕。

6.2 最优子结构

选择结束时间最早的活动 aga_g 后,剩余候选集合为:

S′={ai∈S∣si≥fg}S'=\{a_i\in S\mid s_i\ge f_g\}

原问题转化为:在 S′S' 中选择数量最多的兼容活动。

若整体最优解中关于 S′S' 的部分不是 S′S' 的最优解,则可以用 S′S' 的更优解替换它,从而得到一个活动数量更多的整体解,这与原解最优矛盾。

因此,原问题的最优解由以下两部分组成:

  1. 贪心选择 aga_g;
  2. 子问题 S′S' 的最优解。

即:

OPT⁡(S)={ag}∪OPT⁡(S′)\operatorname{OPT}(S) = \{a_g\}\cup\operatorname{OPT}(S')

6.3 归纳结论

第一次选择结束最早的活动是安全的;选择之后得到的剩余问题仍然是同类型的活动选择问题。因此,可以不断重复相同的贪心选择,最终得到全局最优解。


7. 时空间复杂度

设活动数量为 nn,最终选择了 kk 个活动,其中 0≤k≤n0\le k\le n。

7.1 输入已经按结束时间排序

算法只进行一次线性扫描:

T(n)=Θ(n)T(n)=\Theta(n)
  • 时间复杂度:Θ(n)\Theta(n);
  • 除输出外的额外空间复杂度:O(1)O(1);
  • 保存输出结果需要:O(k)O(k),最坏为 O(n)O(n)。

7.2 输入没有排序

需要先按结束时间排序:

T(n)=O(nlog⁡n)+Θ(n)=O(nlog⁡n)T(n)=O(n\log n)+\Theta(n)=O(n\log n)
  • 时间复杂度:O(nlog⁡n)O(n\log n);
  • 使用新数组保存排序结果时,额外空间通常为 O(n)O(n);
  • 若允许原地排序,除排序算法自身空间外,贪心扫描只需 O(1)O(1) 额外空间;
  • 输出结果仍需 O(k)O(k) 空间。

7.3 递归版本

递归调用深度最多为被选择活动数 kk,最坏为 nn:

  • 时间复杂度:Θ(n)\Theta(n),不含排序;
  • 递归栈空间:O(k)O(k),最坏为 O(n)O(n)。
场景时间复杂度除输出外额外空间
已排序,迭代实现Θ(n)\Theta(n)O(1)O(1)
未排序,先排序O(nlog⁡n)O(n\log n)取决于排序实现
已排序,递归实现Θ(n)\Theta(n)O(n)O(n) 最坏情况

8. 问题需要具有的性质

8.1 贪心选择性质

局部最优选择必须能够出现在某个全局最优解中。

对于活动选择问题,局部选择是:

选择结束时间最早的兼容活动\text{选择结束时间最早的兼容活动}

交换论证证明了该选择不会破坏最优性。

8.2 最优子结构

完成一次贪心选择后,剩余活动构成一个规模更小但结构相同的活动选择问题;整体最优解包含剩余子问题的最优解。

8.3 无后效性

一旦选择了当前活动,后续决策只需要知道该活动的结束时间,不需要重新考虑更早的活动。

当前状态可以压缩为:

last_finish\text{last\_finish}

8.4 活动价值相同

标准活动选择问题默认每个活动的收益相同,即每选择一个活动都贡献 11。

若每个活动具有不同价值 wiw_i,目标变为最大化:

∑ai∈Awi\sum_{a_i\in A} w_i

则”结束最早”贪心策略通常不再正确,该问题变为带权区间调度(Weighted Interval Scheduling),通常使用 Dynamic Programming 动态规划 求解。

8.5 单一资源与固定区间

标准问题还隐含以下条件:

  • 所有活动竞争同一个不可并行使用的资源;
  • 每个活动的开始、结束时间已经确定;
  • 活动不可拆分;
  • 选中活动后必须完整执行;
  • 目标仅为最大化活动数量。

不能机械套用贪心

只要目标函数或约束发生变化,就必须重新证明贪心选择性质。存在”区间”并不意味着一定可以使用结束时间最早策略。


9. 典型例子

给定以下活动,已经按照结束时间升序排列:

活动开始时间 sis_i结束时间 fif_i区间
a0a_014[1,4)[1,4)
a1a_135[3,5)[3,5)
a2a_206[0,6)[0,6)
a3a_357[5,7)[5,7)
a4a_439[3,9)[3,9)
a5a_559[5,9)[5,9)
a6a_6610[6,10)[6,10)
a7a_7811[8,11)[8,11)
a8a_8812[8,12)[8,12)
a9a_9214[2,14)[2,14)
a10a_{10}1216[12,16)[12,16)

9.1 执行过程

初始:

A=∅,last_finish=−∞A=\varnothing,\qquad \text{last\_finish}=-\infty
扫描活动判断操作当前选择集合
a0=[1,4)a_0=[1,4)1≥−∞1\ge-\infty选择{a0}\{a_0\}
a1=[3,5)a_1=[3,5)3<43<4跳过{a0}\{a_0\}
a2=[0,6)a_2=[0,6)0<40<4跳过{a0}\{a_0\}
a3=[5,7)a_3=[5,7)5≥45\ge4选择{a0,a3}\{a_0,a_3\}
a4=[3,9)a_4=[3,9)3<73<7跳过{a0,a3}\{a_0,a_3\}
a5=[5,9)a_5=[5,9)5<75<7跳过{a0,a3}\{a_0,a_3\}
a6=[6,10)a_6=[6,10)6<76<7跳过{a0,a3}\{a_0,a_3\}
a7=[8,11)a_7=[8,11)8≥78\ge7选择{a0,a3,a7}\{a_0,a_3,a_7\}
a8=[8,12)a_8=[8,12)8<118<11跳过{a0,a3,a7}\{a_0,a_3,a_7\}
a9=[2,14)a_9=[2,14)2<112<11跳过{a0,a3,a7}\{a_0,a_3,a_7\}
a10=[12,16)a_{10}=[12,16)12≥1112\ge11选择{a0,a3,a7,a10}\{a_0,a_3,a_7,a_{10}\}

最终结果:

A={a0,a3,a7,a10}A=\{a_0,a_3,a_7,a_{10}\}

活动数量:

∣A∣=4|A|=4

9.2 时间轴

时间:  0 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16
选择:   [a0---) [a3-) [a7-----) [a10----------)
区间:   [1,4)   [5,7) [8,11)    [12,16)

不同最优解可能选择不同活动,但最大活动数量相同。算法保证返回一个最优解,不保证最优解唯一。


10. 常见错误

错误 1:按开始时间排序

开始越早不代表越有利。持续时间很长的早开始活动可能占据整个时间段。

错误 2:按持续时间排序

最短活动不一定结束最早,因此不一定为后续活动留下最大空间。

错误 3:忽略预处理成本

若输入未排序,不能只写 O(n)O(n);完整复杂度应为 O(nlog⁡n)O(n\log n)。

错误 4:把

si>fjs_i>f_j 写成唯一兼容条件 若允许一个活动在另一个活动结束的同一时刻开始,正确条件是:

si≥fjs_i\ge f_j

错误 5:用于带权活动选择

标准贪心只保证最大化活动数量,不保证最大化活动总价值。


11. 与动态规划的关系

活动选择问题也可以用动态规划描述,但标准问题具有更强的贪心选择性质,因此不需要枚举所有分割位置。

方法核心思想典型复杂度是否必要
动态规划枚举子问题与决策常见为 O(n2)O(n^2)标准问题中不必要
贪心算法直接选择结束最早活动排序后 Θ(n)\Theta(n)标准最优方法

贪心算法可以看作利用问题结构,将原本可能存在的多个决策分支压缩为一个安全选择。


12. 适用场景

活动选择模型常用于:

  • 单个会议室安排最多场会议;
  • 单台机器处理最多个固定时间作业;
  • 单个教室安排最多门课程;
  • 单一频道安排最多个固定时段节目;
  • 单个工作人员接受最多个不冲突预约;
  • 非带权区间调度最大化。

当问题出现多台机器、活动权重、活动依赖关系或可调整时间等条件时,需要使用其他模型或重新证明算法。


13. 一页总结

总结

问题:选择数量最多的互不重叠活动。

排序:按结束时间升序。

选择条件:

si≥last_finishs_i\ge\text{last\_finish}

正确性依据:

  • 贪心选择性质:结束最早的活动可以包含在某个最优解中;
  • 最优子结构:选择一个活动后,剩余问题仍是活动选择问题。

复杂度:

  • 已排序:Θ(n)\Theta(n);
  • 未排序:O(nlog⁡n)O(n\log n);
  • 迭代扫描额外空间:O(1)O(1),不含输出。

限制:只适用于所有活动价值相同、目标为最大化活动数量的标准模型。


参考资料

  • Thomas H. Cormen, Charles E. Leiserson, Ronald L. Rivest, Clifford Stein. Introduction to Algorithms, 3rd Edition, Section 16.1: The activity-selection problem.
  • Greedy Algorithms, Comp 122 course slides, Fall 2003.
  • Obsidian Flavored Markdown Skill
文章大纲
  1. Activity Selection 活动选择问题
  2. 1. 问题定义
  3. 2. 基本思想
  4. 2.1 为什么不是选择开始最早的活动
  5. 2.2 为什么不是选择持续时间最短的活动
  6. 3. 具体过程
  7. 3.1 算法步骤
  8. 3.2 流程图
  9. 3.3 循环不变量
  10. 4. 伪代码
  11. 4.1 输入未排序的通用版本
  12. 4.2 输入已按结束时间排序的版本
  13. 4.3 递归版本
  14. 5. Python 实现
  15. 5.1 结果验证
  16. 6. 正确性证明
  17. 6.1 贪心选择性质
  18. 6.2 最优子结构
  19. 6.3 归纳结论
  20. 7. 时空间复杂度
  21. 7.1 输入已经按结束时间排序
  22. 7.2 输入没有排序
  23. 7.3 递归版本
  24. 8. 问题需要具有的性质
  25. 8.1 贪心选择性质
  26. 8.2 最优子结构
  27. 8.3 无后效性
  28. 8.4 活动价值相同
  29. 8.5 单一资源与固定区间
  30. 9. 典型例子
  31. 9.1 执行过程
  32. 9.2 时间轴
  33. 10. 常见错误
  34. 11. 与动态规划的关系
  35. 12. 适用场景
  36. 13. 一页总结
  37. 参考资料