qinghuan
← 回到知识探索

计算机趣闻 / 可下载小项目

会议越早结束就先选?场次最多和收入最高是两道题

同一间会议室,选两场各收 1 的短会,还是一场收 10 的长会?把最早结束规则的保证和边界放到同一份程序里核对。

展开本文目录
  1. 下载完整练习包
  2. 先动手看看
  3. 先把“好”写成一个明确数字
  4. 为什么最早结束能保证场次最多?
  5. 考虑最后一场,把收益分成选与不选
  6. 用全部子集作参考,不让两份递推互相证明
  7. 不要把看起来短的代码自动说成 O(n log n)
  8. 现实排期多一条条件,模型就需要重看
  9. 记住这三点
  10. 别把例子用过头
  11. 自己试一小步
  12. 查原始资料
开始前,会这些就够了
遇到不熟悉的词,先看这 2 个解释
交换论证
把最优方案的一项换成当前选择,说明结果仍一样好。
加权区间调度
每场申请有不同价值,选不重叠的一组让总价值最大。
打开完整术语小抄 ↗

你只有一间会议室,所有申请的开始和结束时间都已给定。如果目标是接纳最多场,优先挑结束最早的申请很有效;若目标改为收入最多,同一个选择却可能让你亏掉大单。

本文固定一个细节:时间段用 [开始,结束) 表示,前一场结束时后一场可以立刻开始,没有清场时间。所有申请持续时间严格大于零,输入是整批已知的申请。

把例子带到自己的电脑上

下载完整练习包

需要 Python 3.10 或更新版本,无第三方依赖;制作时实测 Windows / Python 3.14.0。使用标准库,不需要安装第三方包。下载 ZIP 后选择“全部提取”,进入能看到 README.md 的文件夹。

下载 ZIP · 5 个文件
包里有什么
  • interval.py:完整可运行示例
  • README.md:运行说明与独立练习
  • observed.stdout.txt:本次实际输出
  • verification.json:源码和产物验证记录
  • SHA256SUMS.txt:内容摘要

在这个文件夹打开 PowerShell,逐行运行

python --version
python -B interval.py

解压到独立练习文件夹,在含 interval.py 的目录打开终端运行。整批已知申请、单间房、正时长半开区间;数量与非负收益两个目标;收益教学实现O(n²),不改真实日历。

找不到命令或文件时,先按 README 检查环境和所在目录。文中标出的阅读时间只估算文章篇幅,安装环境和动手练习可按自己的节奏进行。

排得更多,还是总收益更高?

同一间会议室有五个候选任务。结束时刻可以紧接下一个开始时刻,例如 A 在 3 结束,B 在 3 开始,两者不冲突。

始终选最早结束的兼容任务:A、B、D,共 3 个,总收益 11。

本次目标的最优结果:A、B、D;最多 3 个任务。

这个目标只数任务个数,修改 E 的收益不会改变答案。

查看五个任务与完整比较范围
任务时间区间收益图中选中
A[0, 3)4是
B[3, 6)4是
C[0, 6)15否
D[6, 8)3是
E[2, 7)20否

页面枚举全部 32 个子集,其中 11 个互不冲突,再分别比较数量与收益;并列最优全部列出,图中显示第一组。

这是单资源、固定起止时刻的五任务实验,收益使用无单位整数。枚举仅适合小输入;正文介绍最早结束的交换论证,并解释带权版本为何需要另选算法。

先把“好”写成一个明确数字

三份申请是 A=[0,2),收益1;B=[2,4),收益1;C=[0,4),收益10。按结束时间先选 A,然后可选 B,共两场、总收益2。选择 C 只有一场,但总收益10。

问场次数量时 A+B 最好,问收益时 C 最好。没有哪份结果凭空算错;错误是把第一道题的保证直接搬到第二道题。程序将两份结果分开输出,避免用同一个 best 字段混淆指标。

为什么最早结束能保证场次最多?

把任意一份场次最多的安排按结束时间排好。它的第一场不可能比全体最早结束的申请更早结束。用我们的第一场替换它,后面原本能接上的场次依旧能接上,因此不减少场次数。

现在锁定这一场,忽略与它冲突的申请,对剩下部分重复同样的替换。每一步都能找到一份场次数最多、且接受当前选择的安排,这就是选择安全的原因。论证依赖每场价值都按一场计算;若替换会损失收益,结论就不再回答收入问题。

考虑最后一场,把收益分成选与不选

先按结束时间编号。考虑当前申请 j:若不接它,最佳收益等于前 j-1 份的最佳收益;若接它,就拿到它的收益,再加上“在它开始之前已结束的申请”能带来的最佳收益。两种情况取较大。

p(j) 表示前面最后一个能在 j 开始时已经结束的申请。递推写成 best[j]=max(best[j-1],value[j]+best[p(j)])。代码用前缀长度作数组下标,因此会出现 p+1;不要机械照抄数学编号。相等收益时本例保留不选方案,只是确定性规则,不声称方案唯一。

用全部子集作参考,不让两份递推互相证明

运行 python -B interval.py。maximum_count_plan 是 [0,1],count_plan_value 是 2;maximum_value_result 是收益 10 与编号 [2]。编号取自原始输入,排序不会重新命名申请。

程序用时间点 0 到 4 形成十个候选区间,并枚举它们组成的全部 1024 份申请集合。参考算法枚举每份集合的所有子集,再逐对判断是否相交,分别找最大数量和最大收益;它不复用贪心顺序或动态规划递推。重复时间段、相邻端点、负时间点、空列表和非法零时长另有检查。

展开收起代码 · 小实验python · 100 行

长行可在代码区域内左右滚动,按 Tab 可把焦点移入代码区。

小实验 · python
"""Maximum count is not maximum revenue in interval scheduling."""
from itertools import combinations
import json


def validate(jobs):
    jobs = tuple(jobs)
    if len(jobs) > 1000:
        raise ValueError("teaching limit: 1000 jobs")
    for start, end, value in jobs:
        if any(type(x) is not int for x in (start, end, value)) or start >= end or value < 0:
            raise ValueError("integer start < end and nonnegative integer value required")
    return jobs


def earliest_finish(jobs):
    jobs = validate(jobs)
    chosen, end = [], None
    for i in sorted(range(len(jobs)), key=lambda i: (jobs[i][1], jobs[i][0], i)):
        if end is None or jobs[i][0] >= end:
            chosen.append(i)
            end = jobs[i][1]
    return chosen


def maximum_value(jobs):
    jobs = validate(jobs)
    order = sorted(range(len(jobs)), key=lambda i: (jobs[i][1], jobs[i][0], i))
    best, plans = [0], [()]
    for j, i in enumerate(order):
        # Deliberately use a visible linear scan; this version is O(n^2).
        p = j - 1
        while p >= 0 and jobs[order[p]][1] > jobs[i][0]:
            p -= 1
        take = jobs[i][2] + best[p + 1]
        if take > best[-1]:
            best.append(take)
            plans.append(plans[p + 1] + (i,))
        else:
            best.append(best[-1])
            plans.append(plans[-1])
    return best[-1], list(plans[-1])


def exhaustive(jobs):
    # Independent oracle: all subsets, all pairs; no finish-order recurrence.
    if len(jobs) > 12:
        raise ValueError("oracle limited to twelve jobs")
    best_count, best_value = 0, 0
    for mask in range(1 << len(jobs)):
        chosen = [i for i in range(len(jobs)) if mask >> i & 1]
        if all(jobs[a][1] <= jobs[b][0] or jobs[b][1] <= jobs[a][0]
               for a, b in combinations(chosen, 2)):
            best_count = max(best_count, len(chosen))
            best_value = max(best_value, sum(jobs[i][2] for i in chosen))
    return best_count, best_value


def main():
    checks = 0
    def check(ok):
        nonlocal checks
        if not ok:
            raise AssertionError("interval check failed")
        checks += 1
    pool = [(a, b, (a * 3 + b) % 7) for a, b in combinations(range(5), 2)]
    cases = 0
    for mask in range(1 << len(pool)):
        jobs = [j for i, j in enumerate(pool) if mask >> i & 1]
        count, value = exhaustive(jobs)
        chosen = earliest_finish(jobs)
        dp_value, dp_plan = maximum_value(jobs)
        check(len(chosen) == count)
        check(dp_value == value)
        check(sum(jobs[i][2] for i in dp_plan) == dp_value)
        check(all(jobs[a][1] <= jobs[b][0] for a, b in zip(dp_plan, dp_plan[1:])))
        cases += 1
    demo = [(0, 2, 1), (2, 4, 1), (0, 4, 10)]
    chosen = earliest_finish(demo)
    check(chosen == [0, 1])
    check(maximum_value(demo) == (10, [2]))
    check(earliest_finish(iter(demo)) == chosen)
    check(len(earliest_finish([(-3, -1, 2), (-1, 0, 2)])) == 2)
    check(exhaustive([(0, 2, 1), (0, 2, 3)]) == (1, 3))
    for jobs in [[(1, 1, 1)], [(2, 1, 1)], [(0, 1, -1)], [(0, True, 1)]]:
        try:
            earliest_finish(jobs)
        except ValueError:
            check(True)
        else:
            raise AssertionError("invalid interval accepted")
    print(json.dumps({"checks_passed": checks, "exhaustive_job_sets": cases,
                      "jobs_start_end_value": demo, "maximum_count_plan": chosen,
                      "count_plan_value": sum(demo[i][2] for i in chosen),
                      "maximum_value_result": maximum_value(demo),
                      "scope": "one room; fixed half-open intervals [start,end); adjacency allowed; offline"}, indent=2))


if __name__ == "__main__":
    main()

怎样运行:在独立练习文件夹中执行 python -B interval.py。2026-10-18 已在 Windows、Python 3.14.0 运行此份源码并通过自带检查。整批已知申请、单间房、正时长半开区间;数量与非负收益两个目标;收益教学实现O(n²),不改真实日历。

运行后对照结果
{
  "checks_passed": 4105,
  "exhaustive_job_sets": 1024,
  "jobs_start_end_value": [
    [
      0,
      2,
      1
    ],
    [
      2,
      4,
      1
    ],
    [
      0,
      4,
      10
    ]
  ],
  "maximum_count_plan": [
    0,
    1
  ],
  "count_plan_value": 2,
  "maximum_value_result": [
    10,
    [
      2
    ]
  ],
  "scope": "one room; fixed half-open intervals [start,end); adjacency allowed; offline"
}

不要把看起来短的代码自动说成 O(n log n)

数量版本排序后只扫描一次,时间 O(n log n),另存排序和选择需要 O(n) 空间。本篇收益版本为了直观看到 p(j),逐项向前扫描,还保存每一项的方案元组,所以时间与保存全部方案的空间最坏都是 O(n²)。

用二分查找 p(j),只保存收益和回溯决定,可把常见实现降到 O(n log n) 时间和 O(n) 空间;这不是当前教学代码已做到的优化。有限枚举检查有利于发现边界错误,所有规模的正确性仍依赖选与不选的完整划分。

现实排期多一条条件,模型就需要重看

如果换场要清理十分钟,可在相容判断里加入固定间隔;如果清理时间由前后两场共同决定,就不能直接沿用这份简单前缀递推。多间房、线上实时到达、允许移动时间或拆分会议,也不在本例内。

本例不改真实日历、不估测收入、不做线上性能测试。它只对已知、固定、半开区间的单资源选择验证两个不同目标。输入上限 1000,指数级参考上限 12。

记住这三点

  • 最早结束保证最多场次,收益变化后需重新定义最优。
  • 带收益可以按最后一场选与不选做动态规划。
  • 端点规则、清场时间和资源数量属于题目条件。

别把例子用过头

  • 直接按最高收益或最短时长排序就宣称全局最优。
  • 把 start>=end 写成 start>end,误删可首尾相接的会议。
  • 复制课件复杂度却忽略当前源码的扫描和方案复制。

自己试一小步

给一个长会、三个短会,你能分别写出数量和收益方案吗?

  1. 自己设计四份正时长申请,其中至少两份可以首尾相接。
  2. 选择一组收益,使最多场次方案与最大收益方案不同。
  3. 把收益统一改成1,比较两个算法得到的最优值。

做到这些,就算完成

  • 交付原始区间、端点约定、选择编号和对应指标。
  • 允许存在多个同样好的方案;用指标与相容性核验。
  • 对自己设计的输入,分别报告数量与收益两个目标的最优值。

查原始资料

想确认一个细节,先看这份资料对应的部分,不必从头读完所有文档。