活动选择与调度问题

贪心算法的经典应用 -- 从活动选择到多机调度的全面解析

核心主题: 活动选择与区间调度问题的算法设计与分析

主要内容: 活动选择贪心策略、加权活动选择DP、区间调度问题族、任务调度EDD规则、多机调度近似算法、时间复杂度分析与正确性证明

关键词: 活动选择, 区间调度, 贪心算法, 任务调度, 最早结束时间, 多机调度, EDD, 动态规划, 近似算法

一、活动选择问题(Activity Selection Problem)

问题定义

活动选择问题(Activity Selection Problem)是贪心算法最经典的入门问题之一。给定 n 个活动,每个活动 i 有一个开始时间 s[i] 和结束时间 f[i],同一时间只能进行一个活动。目标是选择最多数量的互不重叠的活动

形式化定义:寻找最大子集 S ⊆ {1, 2, ..., n},使得对于任意 i, j ∈ S(i ≠ j),有 f[i] ≤ s[j] 或 f[j] ≤ s[i]。

1.1 最早结束时间贪心策略

活动选择问题的核心贪心策略是最早结束时间优先(Earliest Finish Time First, EFT):

  1. 将所有活动按结束时间 f[i] 从小到大排序
  2. 选择结束时间最早的活动
  3. 移除所有与该活动时间冲突的活动
  4. 重复上述步骤直到所有活动处理完毕

算法伪代码

// 贪心活动选择算法 GreedyActivitySelector(s[], f[], n): // s[i]: 活动i的开始时间, f[i]: 活动i的结束时间 // 假设活动已按结束时间升序排列: f[1] ≤ f[2] ≤ ... ≤ f[n] A = {1} // 选择第一个活动 k = 1 // 当前最后选中的活动索引 for m = 2 to n: if s[m] ≥ f[k]: // 如果活动m与前一个选中活动不冲突 A = A ∪ {m} k = m return A

C++ 实现

struct Activity { int id; int start; int finish; }; // 按结束时间排序的比较函数 bool cmp(Activity a, Activity b) { return a.finish < b.finish; } vector<int> greedyActivitySelect(vector<Activity>& acts) { // 按结束时间排序 sort(acts.begin(), acts.end(), cmp); vector<int> selected; selected.push_back(acts[0].id); int lastFinish = acts[0].finish; for (int i = 1; i < acts.size(); i++) { if (acts[i].start >= lastFinish) { selected.push_back(acts[i].id); lastFinish = acts[i].finish; } } return selected; }

Python 实现

def greedy_activity_selector(activities): """ 贪心活动选择算法 Args: activities: list of (id, start, finish) Returns: list of selected activity ids """ # 按结束时间排序 activities.sort(key=lambda x: x[2]) selected = [activities[0][0]] last_finish = activities[0][2] for act in activities[1:]: act_id, start, finish = act if start >= last_finish: selected.append(act_id) last_finish = finish return selected # 示例 activities = [(1, 1, 4), (2, 3, 5), (3, 0, 6), (4, 5, 7), (5, 3, 8), (6, 5, 9), (7, 6, 10), (8, 8, 11), (9, 8, 12), (10, 2, 13)] result = greedy_activity_selector(activities) print(f"选中的活动ID: {result}") print(f"最大活动数量: {len(result)}") # 输出: 选中的活动ID: [1, 4, 8] # 最大活动数量: 3

1.2 交换论证法正确性证明

交换论证法证明贪心策略的最优性

设贪心算法选择的活动集合为 G = {g₁, g₂, ..., gₖ},最优解的活动集合为 O = {o₁, o₂, ..., oₘ},且按结束时间递增排列。我们要证明 k = m。

证明思路:通过"交换论证"(Exchange Argument),逐步将最优解转换为贪心解而不改变活动数量。

基础步骤:贪心选择的第一个活动 g₁ 是结束时间最早的活动,即 f[g₁] ≤ f[o₁]。因为 g₁ 是所有活动中结束时间最早的。如果 o₁ ≠ g₁,我们可以将 o₁ 替换为 g₁,得到新的最优解 O' = {g₁, o₂, ..., oₘ}。由于 f[g₁] ≤ f[o₁],g₁ 的结束时间不比 o₁ 晚,所以 O' 中的活动仍然互不重叠且数量不变。

归纳步骤:假设贪心算法已经选择了前 i 个活动 g₁, g₂, ..., gᵢ,并且我们已将最优解的前 i 个活动替换为 g₁, g₂, ..., gᵢ。现在考虑贪心选择的第 i+1 个活动 gᵢ₊₁。由于 gᵢ₊₁ 是在不与 gᵢ 冲突的前提下结束时间最早的活动,而 oᵢ₊₁ 也是在相同约束下选择的活动(但可能不是最早结束的),因此 f[gᵢ₊₁] ≤ f[oᵢ₊₁]。与基础步骤类似的交换论证表明,我们可以将 oᵢ₊₁ 替换为 gᵢ₊₁ 而不影响最优性。

结论:通过归纳,我们可以将最优解 O 逐步转换为贪心解 G,且 m = k。因此贪心算法找到的是最优解。

时间复杂度分析

  • 排序:O(n log n),对所有活动按结束时间排序
  • 选择阶段:O(n),一次线性扫描选出所有兼容活动
  • 总时间复杂度:O(n log n),排序是主导因素
  • 空间复杂度:O(1) 或 O(n),取决于是否需要存储排序后的活动列表

二、加权活动选择问题(Weighted Activity Selection)

问题扩展

在实际应用中,每个活动通常有不同的权重(如收益、优先级等)。加权活动选择问题的目标是选择一组互不重叠的活动,使得总权重最大化。此时,最早的结束时间贪心策略不再适用,因为选择权重高的活动可能比选择多个权重低的活动更优。

2.1 动态规划解法

动态规划状态设计

将所有活动按结束时间排序后,定义:

  • dp[i]:前 i 个活动中能获得的最大总权重
  • prev[i]:与活动 i 不冲突且结束时间最晚的活动索引(即结束时间 ≤ s[i] 的最大的 j)

状态转移方程:

// dp[i] = max(选择活动i, 不选择活动i) dp[i] = max(dp[i-1], weight[i] + dp[prev[i]])

其中 prev[i] 可以通过二分查找在 O(log n) 时间内找到,整体时间复杂度为 O(n log n)。

C++ 实现

struct Activity { int start, finish, weight; }; int weightedActivitySelect(vector<Activity>& acts) { int n = acts.size(); // 按结束时间排序 sort(acts.begin(), acts.end(), [](const Activity& a, const Activity& b) { return a.finish < b.finish; }); // 计算 prev[i]:与活动i兼容的最后一个活动 vector<int> prev(n, -1); for (int i = 0; i < n; i++) { // 二分查找结束时间 ≤ acts[i].start 的最晚活动 int lo = 0, hi = i - 1; while (lo <= hi) { int mid = lo + (hi - lo) / 2; if (acts[mid].finish <= acts[i].start) { if (acts[mid + 1].finish <= acts[i].start) lo = mid + 1; else { prev[i] = mid; break; } } else { hi = mid - 1; } } } // 动态规划求解 vector<int> dp(n, 0); dp[0] = acts[0].weight; for (int i = 1; i < n; i++) { int include = acts[i].weight; if (prev[i] != -1) include += dp[prev[i]]; dp[i] = max(dp[i - 1], include); } return dp[n - 1]; }

Python 实现(含路径回溯)

def weighted_activity_selector(activities): """ 加权活动选择 - DP + 二分查找 Args: activities: list of (start, finish, weight) Returns: (max_weight, selected_activities) """ # 按结束时间排序 acts = sorted(enumerate(activities), key=lambda x: x[1][1]) n = len(acts) # 计算 prev[i] finish_times = [acts[i][1][1] for i in range(n)] prev = [-1] * n for i in range(n): start = acts[i][1][0] # 二分查找最后一个 finish ≤ start 的活动 lo, hi = 0, i - 1 while lo <= hi: mid = (lo + hi) // 2 if finish_times[mid] <= start: prev[i] = mid lo = mid + 1 else: hi = mid - 1 # DP 求解 dp = [0] * n choice = [False] * n dp[0] = acts[0][1][2] choice[0] = True for i in range(1, n): # 选择活动i include = acts[i][1][2] if prev[i] != -1: include += dp[prev[i]] # 不选活动i exclude = dp[i - 1] if include > exclude: dp[i] = include choice[i] = True else: dp[i] = exclude choice[i] = False # 回溯找出选中哪些活动 selected = [] i = n - 1 while i >= 0: if choice[i]: selected.append(acts[i][0]) i = prev[i] else: i -= 1 selected.reverse() return dp[n - 1], selected # 示例 acts = [(1, 4, 5), # (start, finish, weight) (3, 5, 3), (0, 6, 8), (5, 7, 6), (3, 8, 4), (5, 9, 2), (6, 10, 7), (8, 11, 9)] max_weight, selected = weighted_activity_selector(acts) print(f"最大权重: {max_weight}") print(f"选中的活动索引: {selected}") # 输出: 最大权重: 20 # 选中的活动索引: [0, 7]

2.2 二分查找优化详解

为什么需要二分查找?

如果使用线性扫描查找 prev[i],每次需要 O(n) 时间,总时间复杂度退化为 O(n²)。由于活动已按结束时间排序,finish 数组是单调递增的,这天然适合二分查找。每次查找 prev[i] 只需要 O(log n) 时间,DP 阶段的总体复杂度为 O(n log n)。与排序阶段组合,整体算法复杂度为 O(n log n)。

加权活动选择时间复杂度

  • 排序:O(n log n)
  • 计算 prev 数组:O(n log n),每个活动二分查找
  • DP 迭代:O(n)
  • 总时间复杂度:O(n log n)
  • 空间复杂度:O(n) 用于存储 dp, prev, choice 数组

对比未优化的 DP 实现(线性查找 prev):O(n²),二分查找将复杂度从二次降到对数线性。

三、区间调度问题族

活动选择问题是区间调度(Interval Scheduling)问题族中的一个基本问题。该问题族包含多个变种,每个都有不同的约束条件和优化目标。

3.1 区间调度问题分类

问题类型 目标 算法 时间复杂度
最大不重叠区间 (Max Non-overlapping Intervals) 选择最多互不重叠区间 最早结束时间贪心 O(n log n)
区间分组 (Interval Partitioning) 最少教室数安排所有课程 贪心 + 最小堆 O(n log n)
区间覆盖 (Minimum Interval Cover) 最少区间覆盖目标区间 贪心扩展最远覆盖 O(n log n)
机器调度 (Machine Scheduling) 最少机器完成所有任务 最早开始时间 + 最小堆 O(n log n)
加权区间调度 (Weighted Interval Scheduling) 最大权重不重叠区间 DP + 二分查找 O(n log n)

3.2 区间分组问题(Interval Partitioning)

问题描述

给定 n 个课程(区间),每门课程有固定的上课时间 [s[i], f[i]),需要安排教室使得所有课程都能进行且互不冲突。目标是使用最少的教室数量

这是经典的"教室调度"问题,等价于求最大重叠深度——即在任何时间点同时进行的最多课程数量。

贪心算法:最早开始时间优先 + 最小堆

def interval_partitioning(intervals): """ 区间分组 - 最少教室数量 Args: intervals: list of (start, finish) Returns: (min_classrooms, assignment) """ # 按开始时间排序 intervals.sort(key=lambda x: x[0]) # 最小堆,存储每个教室最后一节课的结束时间 import heapq rooms = [] # (finish_time, room_id) assignment = [] # 每个课程的教室分配 next_room_id = 0 for start, finish in intervals: if rooms and rooms[0][0] <= start: // 有空闲教室,复用 finish_time, room_id = heapq.heappop(rooms) assignment.append(room_id) heapq.heappush(rooms, (finish, room_id)) else: // 需要新教室 room_id = next_room_id next_room_id += 1 assignment.append(room_id) heapq.heappush(rooms, (finish, room_id)) return len(rooms), assignment

算法正确性关键

  • 按开始时间处理保证了所有区间以时间顺序扫描
  • 最小堆始终保持当前占用教室中最早结束时间的教室在堆顶
  • 如果最早结束的教室在上课开始时已空闲,则复用;否则必须开新教室
  • 最少教室数 = 最大重叠深度 = 所有时间点上重叠区间的最大数量
  • 时间复杂度:O(n log n),每个区间一次堆操作

3.3 区间覆盖问题(Minimum Interval Cover)

问题描述

给定一个目标区间 [T_start, T_end] 和 n 个可用区间 [s[i], f[i]],选择最少数量的区间覆盖整个目标区间。每个可用区间可以部分覆盖目标区间,且区间之间可以重叠。

算法策略:每次在当前覆盖终点左侧的区间中,选择向右延伸最远的区间。

区间覆盖贪心算法

def min_interval_cover(target_start, target_end, intervals): """ 最少区间覆盖目标区间 Args: target_start: 目标区间起点 target_end: 目标区间终点 intervals: list of (start, finish) Returns: list of selected intervals (or None if impossible) """ # 按开始时间排序 intervals.sort(key=lambda x: x[0]) selected = [] covered_until = target_start i = 0 n = len(intervals) while covered_until < target_end: # 寻找能覆盖当前点且延伸最远的区间 best = -1 farthest = covered_until while i < n and intervals[i][0] <= covered_until: if intervals[i][1] > farthest: farthest = intervals[i][1] best = i i += 1 if best == -1: return None # 无法覆盖 selected.append(intervals[best]) covered_until = farthest return selected

区间覆盖算法正确性证明

设贪心算法选择的区间序列为 G = {g₁, g₂, ..., gₖ},覆盖的范围为 [T_start, far(g₁), far(g₂), ..., T_end]。设最优解为 O = {o₁, o₂, ..., oₘ}。

归纳假设:贪心算法在第 t 步后的覆盖范围 ≥ 最优解在第 t 步后的覆盖范围。

基础步骤:第一步,贪心算法在所有覆盖 T_start 的区间中选择延伸最远的区间 g₁,所以 far(g₁) ≥ far(o₁)。

归纳步骤:假设前 t-1 步后,贪心覆盖到 far(g_{t-1}) ≥ far(o_{t-1})。在第 t 步,贪心算法在所有起点 ≤ far(g_{t-1}) 的区间中选最远的。由于 far(o_{t-1}) ≤ far(g_{t-1}),所以 o_t 的起点肯定 ≤ far(g_{t-1}),因此贪心算法在第 t 步的可选集合包含 o_t。贪心选择最远的区间,所以 far(g_t) ≥ far(o_t)。

结论:贪心算法使用的区间数量 ≤ 最优解使用的区间数量,因此贪心算法是最优的。

四、任务调度(Job Scheduling)

问题背景

任务调度问题(Job Scheduling / Sequencing)研究如何安排一组任务在单台或多台机器上执行,以优化某个目标函数。这是一个在操作系统、生产调度、项目管理等领域有广泛应用的经典问题。

4.1 最小化最大延迟(Minimizing Maximum Lateness)

问题定义

给定 n 个任务,每个任务 i 有处理时间 p[i] 和截止时间 d[i]。所有任务在单台机器上按某个顺序执行(不可抢占)。设任务 i 的完成时间为 C[i],则其延迟 L[i] = max(0, C[i] - d[i])。目标是最小化最大延迟 L_max = max L[i]。

4.2 EDD 规则(Earliest Due Date)

EDD 算法:按截止时间最早优先排序

EDD(Earliest Due Date)规则由 Jackson 于 1955 年提出:将任务按截止时间从小到大排列,按此顺序执行即可最小化最大延迟。

// EDD 调度算法 - 最小化最大延迟 struct Job { int id; int processing_time; // 处理时间 p[i] int deadline; // 截止时间 d[i] }; void EDD_Schedule(vector<Job>& jobs) { // 按截止时间排序 sort(jobs.begin(), jobs.end(), [](const Job& a, const Job& b) { return a.deadline < b.deadline; }); int time = 0; int max_lateness = 0; for (const auto& job : jobs) { time += job.processing_time; int lateness = max(0, time - job.deadline); max_lateness = max(max_lateness, lateness); printf("任务 %d: 完成时间=%d, 截止时间=%d, 延迟=%d\n", job.id, time, job.deadline, lateness); } printf("最大延迟: %d\n", max_lateness); }

EDD 规则最优性证明(交换论证)

假设最优解 S 中任务不是按截止时间排序的,则存在相邻任务 i 和 j,使得 d[i] > d[j] 但 i 排在 j 之前。我们证明交换这两个任务不会增加最大延迟。

交换前:设任务 i 和 j 开始前的完成时间为 T。

  • C[i] = T + p[i], L[i] = max(0, T + p[i] - d[i])
  • C[j] = T + p[i] + p[j], L[j] = max(0, T + p[i] + p[j] - d[j])

交换后:将 i 和 j 互换。

  • C'[j] = T + p[j], L'[j] = max(0, T + p[j] - d[j])
  • C'[i] = T + p[j] + p[i], L'[i] = max(0, T + p[j] + p[i] - d[i])

由于 d[i] > d[j],交换前 L[j] ≥ L[i](因为 j 截止更早但排更晚)。交换后 i 的完成时间不变(C'[i] = C[i]),而 j 的完成时间提前了(C'[j] < C[j]),所以 L'[j] ≤ L[j] 且 L'[i] = L[i]。因此交换后最大延迟不会增加。

通过有限次交换,可以将任意最优解转换为 EDD 顺序,且最大延迟不增加,因此 EDD 是最优的。

4.3 Moore-Hodgson 算法(最小化延迟任务数)

问题变种

与最小化最大延迟不同,Moore-Hodgson 算法解决的是最小化延迟任务数量问题:在单台机器上安排任务,目标是最小化超过截止时间的任务数。

算法步骤:

  1. 按 EDD 规则(截止时间最早优先)执行所有任务
  2. 如果当前任务导致延迟,从已执行的任务中选择处理时间最长的任务移除
  3. 重复直到所有剩余任务都能按时完成

Moore-Hodgson 算法实现

def moore_hodgson(jobs): """ 最小化延迟任务数 Args: jobs: list of (processing_time, deadline) Returns: (min_late_jobs, schedule) """ # 按截止时间排序 jobs.sort(key=lambda x: x[1]) # sort by deadline import heapq scheduled = [] # 已调度任务的处理时间(最大堆,存负值) time = 0 late_count = 0 for p, d in jobs: time += p heapq.heappush(scheduled, -p) // 最大堆 if time > d: // 当前任务延迟 // 移除处理时间最长的任务 longest = -heapq.heappop(scheduled) time -= longest late_count += 1 // scheduled 中剩余的就是按时完成的任务 on_time_count = len(scheduled) return late_count, on_time_count

4.4 Horn 算法(最小化加权完成时间之和)

问题定义

当每个任务有权重 w[i] 时,一个常见的优化目标是最小化加权完成时间之和 Σ(w[i] × C[i])。Horn 算法通过"最短处理时间优先"(Shortest Processing Time, SPT)规则解决该问题。

Smith 规则:按 p[i]/w[i] 的比值从小到大排序(即"权重密度"从高到低)。对于单位权重的特例,简化为按处理时间从小到大排序(SPT)。

Smith 规则(最小化 Σ w[i]C[i])

def smith_rule_schedule(jobs): """ Smith规则:按 p[i]/w[i] 升序排列 最小化加权完成时间之和 Σ(w[i] × C[i]) """ # 按 p/w 比值排序 jobs.sort(key=lambda x: x[0] / x[1]) time = 0 total_weighted_completion = 0 for p, w in jobs: time += p total_weighted_completion += w * time return total_weighted_completion

任务调度算法复杂度总结

算法 目标 时间复杂度 排序规则
EDD(Jackson) 最小化最大延迟 O(n log n) 按截止时间升序
Moore-Hodgson 最小化延迟任务数 O(n log n) EDD + 移除最长任务
SPT 最小化总完成时间 O(n log n) 按处理时间升序
Smith 规则 最小化加权总完成时间 O(n log n) 按 p[i]/w[i] 升序
Horn 算法 可抢占最小化 ΣwC O(n log n) SRPT(最短剩余时间)

五、多机调度问题(Multiprocessor Scheduling)

问题定义

多机调度问题(Multiprocessor Scheduling / Load Balancing)是一个经典的 NP-难问题。给定 m 台相同的机器和 n 个任务(每个任务有处理时间 p[i]),目标是将任务分配到 m 台机器上,使得最大完工时间(makespan)最小化。该问题在学术上被称为 P || C_max。

5.1 贪心近似算法:List Scheduling

Greedy List Scheduling 算法

Graham 于 1966 年提出的贪心算法:维护每个机器的当前负载,每次将下一个任务分配给当前负载最小的机器。

def list_scheduling(machines, processing_times): """ Graham's List Scheduling 贪心近似算法 Args: machines: 机器数量 m processing_times: list of processing times Returns: (makespan, assignment) """ import heapq # 最小堆: (current_load, machine_id) loads = [(0, i) for i in range(machines)] heapq.heapify(loads) assignment = [None] * len(processing_times) for i, p in enumerate(processing_times): load, machine = heapq.heappop(loads) assignment[i] = machine heapq.heappush(loads, (load + p, machine)) // 最大完工时间 = 最大负载 makespan = max(loads, key=lambda x: x[0])[0] return makespan, assignment

近似比分析

List Scheduling 算法确保:

  • makespan ≤ (2 - 1/m) × OPT,其中 OPT 是最优解
  • 设 L* 为最优 makespan,则:L* ≥ max(max p[i], Σp[i]/m)
  • 贪心算法的 makespan ≤ max(Σp[i]/m + max p[i]) ≤ 2 × OPT
  • 这个界是紧的:存在实例使得算法恰好达到 2 - 1/m 倍

5.2 LPT 算法(Longest Processing Time First)

LPT 算法:任务按处理时间降序排列再调度

Graham 证明了将任务按处理时间从大到小排序后再进行 List Scheduling,可以显著改进近似比。

def lpt_scheduling(machines, processing_times): """ LPT(最长处理时间优先)+ List Scheduling 近似比: 4/3 - 1/(3m) """ // 按处理时间降序排列 sorted_times = sorted(enumerate(processing_times), key=lambda x: x[1], reverse=True) import heapq loads = [(0, i) for i in range(machines)] heapq.heapify(loads) assignment = [None] * len(processing_times) for orig_idx, p in sorted_times: load, machine = heapq.heappop(loads) assignment[orig_idx] = machine heapq.heappush(loads, (load + p, machine)) makespan = max(loads, key=lambda x: x[0])[0] return makespan, assignment

LPT 算法近似比证明

定理:LPT 算法的近似比为 4/3 - 1/(3m)。

证明思路:

  1. 设 LPT 的 makespan 为 C_max,最优解为 C*。C* ≥ max{max p[i], Σp[i]/m}。
  2. 假设 LPT 的 makespan 由机器 M_k 上的任务 j 决定,且 j 是最后分配到 M_k 上的任务。
  3. 由于 LPT 按降序排列,分配到 M_k 之前的所有任务(含 j)中,j 的处理时间是最短的。
  4. 在将 j 分配到 M_k 之前,M_k 的负载 ≤ 所有机器的平均负载(因为贪心选择最小负载机器),即 ≤ (Σp[i] - p[j]) / m。
  5. 因此 C_max = (Σp[i] - p[j]) / m + p[j] = Σp[i]/m + (1 - 1/m)p[j]。
  6. 如果 p[j] ≤ C*/3,则 C_max ≤ Σp[i]/m + (1 - 1/m)C*/3 ≤ C* + (1 - 1/m)C*/3 = (4/3 - 1/(3m))C*。
  7. 如果 p[j] > C*/3,则除了 j 外,最多还有 m-1 个任务的处理时间 ≥ p[j](因为 LPT 排序)。因此所有任务数 ≤ m,LPT 给出最优解。

5.3 多机调度算法对比

算法 近似比 时间复杂度 特点
List Scheduling(任意顺序) 2 - 1/m O(n log m) 在线算法,不需要知道全部任务
LPT(降序排列) 4/3 - 1/(3m) O(n log n + n log m) 离线算法,先排序再调度
PTAS(多项式时间近似方案) 1 + ε 依赖于 ε 理论上可任意接近最优解

多机调度的 NP-难性质

多机调度问题 P || C_max 是NP-难的(即使只有 2 台机器也是弱 NP-难的,可通过划分问题归约证明)。这意味着除非 P = NP,否则不存在多项式时间的最优算法。因此贪心近似算法是实际应用中合理的选择。

  • 对于 m ≤ 2(特殊情况),存在伪多项式时间最优算法(DP)
  • 对于固定 m,可以通过 DP 在 O(n × (Σp)^(m-1)) 时间内求解
  • 实际系统中,LPT 算法由于其简单性和良好的近似比而被广泛使用

六、时间复杂度分析与正确性证明总结

6.1 算法一览表

问题 算法 时间复杂度 正确性证明方法 最优性
活动选择 最早结束时间贪心 O(n log n) 交换论证 最优(精确解)
加权活动选择 DP + 二分查找 O(n log n) 最优子结构证明 最优(精确解)
区间分组 最早开始 + 最小堆 O(n log n) 下界匹配论证 最优(精确解)
区间覆盖 最远扩展贪心 O(n log n) 归纳法 + 交换论证 最优(精确解)
任务调度(最大延迟) EDD 规则 O(n log n) 相邻交换论证 最优(精确解)
任务调度(延迟任务数) Moore-Hodgson O(n log n) 最优子结构 最优(精确解)
多机调度(List) 贪心最小负载 O(n log m) 近似比分析 近似(2 - 1/m)
多机调度(LPT) 降序 + 最小负载 O(n log n) 近似比分析 近似(4/3 - 1/(3m))

6.2 正确性证明方法总结

三种核心证明技术

  1. 交换论证(Exchange Argument):

    适用于活动选择、区间覆盖、EDD 调度等贪心算法。核心思想:从任意最优解出发,通过一系列交换操作将其转化为贪心解,且每一步不降低解的质量。从而证明贪心解也是最优解。

  2. 最优子结构 + 归纳法:

    适用于加权活动选择 DP 等具有最优子结构的问题。证明问题的最优解包含子问题的最优解,从而动态规划能正确求解。

  3. 近似比分析(Approximation Ratio):

    适用于多机调度等 NP-难问题的近似算法。通过分析算法的解与最优解之间的上界关系,证明算法在多项式时间内能给出有质量保证的近似解。

6.3 算法设计启示

从调度问题汲取的设计智慧

  • 排序是预处理的关键:几乎所有调度问题都先对任务进行某种排序(按结束时间、截止时间、处理时间、比值等),这通常是算法设计的出发点
  • 贪心 vs DP:当所有活动权重相等时贪心适用;当权重不同时贪心失效,需要 DP
  • 精确 vs 近似:对于 NP-难问题,不要追求不可能的精确解,而应设计有近似保证的算法
  • 数据结构选择:最小堆(优先队列)是调度算法实现的关键工具,用于维护最小/最大值
  • 时空权衡:二分查找用 O(log n) 时间换取更优的 O(n log n) 总复杂度,避免了 O(n²) 的暴力扫描
  • 证明驱动设计:证明最优性/近似比的过程往往能揭示算法改进的方向(如从 List Scheduling 到 LPT)

核心要点总结

七、进一步思考与扩展

7.1 调度问题的现实应用

应用场景

  • 操作系统进程调度:SRPT(最短剩余时间优先)、多级反馈队列等均源自经典调度理论
  • 云计算资源分配:虚拟机的物理机放置问题等价于区间分组问题
  • 生产线调度:工厂流水线的任务分配是多机调度问题的直接应用
  • 物流配送规划:仓库拣货任务的排序与路径规划结合了活动选择和区间调度
  • 项目管理(PERT/CPM):任务依赖关系和资源约束调度是调度问题的进一步扩展

7.2 扩展研究方向

进阶问题

  1. 带依赖约束的调度(Precedence Constraints):任务之间存在先后依赖关系,如 P|prec|C_max
  2. 流水车间调度(Flow Shop Scheduling):每个任务需要经过多台机器按顺序处理
  3. 作业车间调度(Job Shop Scheduling):每个任务有自己独特的机器加工路径
  4. 在线调度(Online Scheduling):任务信息在决策时不完全已知,需要在线算法
  5. 随机调度(Stochastic Scheduling):任务的处理时间服从概率分布
  6. 能量感知调度(Energy-aware Scheduling):在调度的同时考虑能耗约束

学习建议

  • 从活动选择问题入手,深入理解交换论证的每一个细节
  • 手动推导加权活动选择的 DP 状态转移方程,体会"选或不选"的决策模式
  • 将区间调度问题族的算法横向对比,理解共性(排序)和差异(目标函数不同导致策略不同)
  • 对多机调度问题,重点关注近似比证明的思路而非繁琐的计算细节
  • 在 LeetCode / LintCode 上搜索相关题目进行实战练习(如 LeetCode 435 无重叠区间、452 用最少数量的箭射爆气球等)