贪心算法应用:带权任务间隔调度问题详解
贪心算法是一种在每一步选择中都采取在当前状态下最好或最优(即最有利)的选择,从而希望导致结果是全局最好或最优的算法。带权任务间隔调度问题是贪心算法的一个经典应用场景。
问题定义
**带权任务间隔调度问题(Weighted Interval Scheduling Problem)**描述如下:
- 给定一组任务,每个任务有一个开始时间、结束时间和权重(价值)
- 目标:选择一组互不重叠的任务,使得这些任务的总权重最大
与普通间隔调度问题不同之处在于,这里每个任务有不同的权重,我们需要考虑权重而不仅仅是任务数量。
问题分析
输入表示
每个任务可以表示为三元组 (start, end, weight)
:
class Task {
int start;
int end;
int weight;
public Task(int start, int end, int weight) {
this.start = start;
this.end = end;
this.weight = weight;
}
}
关键概念
- 兼容任务:两个任务如果它们的执行时间不重叠,则称为兼容任务
- p(j):对于任务j,p(j)是最大的i < j且任务i与任务j兼容的任务索引
贪心算法解决方案
1. 动态规划解法(非贪心)
首先我们看一下动态规划解法,以便与贪心算法对比:
public int weightedIntervalScheduling(Task[] tasks) {
// 按结束时间排序
Arrays.sort(tasks, (a, b) -> a.end - b.end);
int n = tasks.length;
int[] dp = new int[n + 1];
for (int j = 1; j <= n; j++) {
int i = latestNonOverlapping(tasks, j - 1);
int weight = tasks[j - 1].weight;
dp[j] = Math.max(weight + (i != -1 ? dp[i + 1] : 0), dp[j - 1]);
}
return dp[n];
}
private int latestNonOverlapping(Task[] tasks, int j) {
for (int i = j - 1; i >= 0; i--) {
if (tasks[i].end <= tasks[j].start) {
return i;
}
}
return -1;
}
这种方法时间复杂度为O(n²),我们可以用贪心算法优化。
2. 贪心算法优化
贪心算法的关键在于如何选择任务。我们可以通过以下步骤实现:
- 将所有任务按照结束时间排序
- 使用二分查找快速找到p(j)
- 使用记忆化或自底向上的动态规划
优化后的Java实现:
import java.util.Arrays;
import java.util.Comparator;
public class WeightedIntervalScheduling {
static class Task {
int start;
int end;
int weight;
public Task(int start, int end, int weight) {
this.start = start;
this.end = end;
this.weight = weight;
}
}
public static int schedule(Task[] tasks) {
// 1. 按结束时间排序
Arrays.sort(tasks, Comparator.comparingInt(a -> a.end));
int n = tasks.length;
int[] dp = new int[n + 1];
// 预处理p数组,p[j]表示在任务j之前最后一个不与j冲突的任务索引
int[] p = new int[n];
for (int j = 0; j < n; j++) {
p[j] = binarySearch(tasks, j);
}
// 动态规划填表
dp[0] = 0;
for (int j = 1; j <= n; j++) {
int include = tasks[j - 1].weight;
if (p[j - 1] != -1) {
include += dp[p[j - 1] + 1];
}
dp[j] = Math.max(include, dp[j - 1]);
}
return dp[n];
}
// 使用二分查找找到最后一个不与tasks[j]冲突的任务
private static int binarySearch(Task[] tasks, int j) {
int low = 0;
int high = j - 1;
int key = tasks[j].start;
int result = -1;
while (low <= high) {
int mid = (low + high) / 2;
if (tasks[mid].end <= key) {
result = mid;
low = mid + 1;
} else {
high = mid - 1;
}
}
return result;
}
public static void main(String[] args) {
Task[] tasks = {
new Task(1, 3, 5),
new Task(2, 5, 6),
new Task(4, 6, 5),
new Task(6, 7, 4),
new Task(5, 8, 11),
new Task(7, 9, 2)
};
System.out.println("Maximum weight: " + schedule(tasks));
}
}
3. 算法复杂度分析
- 排序:O(n log n)
- 预处理p数组:n次二分查找,每次O(log n),总共O(n log n)
- 动态规划填表:O(n)
总时间复杂度:O(n log n),比纯动态规划的O(n²)更高效。
贪心选择性质证明
贪心算法的正确性需要证明其具有贪心选择性质和最优子结构性质。
贪心选择性质:全局最优解可以通过局部最优(贪心)选择达到。
对于带权任务间隔调度问题:
- 设任务集按结束时间排序为1, 2, …, n
- 设O是最优解,且任务j是O中结束时间最早的任务
- 如果任务j不在贪心解中,可以用j替换O中第一个任务,不会降低总权重
- 因此存在包含任务j的最优解
最优子结构性质:问题的最优解包含子问题的最优解。
在选择任务j后,剩余问题是求解在j结束后开始的任务集合的最大权重调度,这正是原问题的子问题。
变种与扩展
1. 输出具体任务序列
除了计算最大权重,我们还可以记录具体选择了哪些任务:
public static List<Task> scheduleWithTasks(Task[] tasks) {
Arrays.sort(tasks, Comparator.comparingInt(a -> a.end));
int n = tasks.length;
int[] dp = new int[n + 1];
int[] choice = new int[n + 1]; // 记录选择
int[] p = new int[n];
for (int j = 0; j < n; j++) {
p[j] = binarySearch(tasks, j);
}
dp[0] = 0;
for (int j = 1; j <= n; j++) {
int include = tasks[j - 1].weight;
if (p[j - 1] != -1) {
include += dp[p[j - 1] + 1];
}
if (include > dp[j - 1]) {
dp[j] = include;
choice[j] = 1; // 选择任务j
} else {
dp[j] = dp[j - 1];
choice[j] = 0; // 不选择任务j
}
}
// 回溯找出选择的任务
List<Task> result = new ArrayList<>();
int j = n;
while (j > 0) {
if (choice[j] == 1) {
result.add(tasks[j - 1]);
j = p[j - 1] + 1; // 跳到前一个兼容任务
} else {
j--;
}
}
Collections.reverse(result);
return result;
}
2. 资源分配扩展
当有多个资源(如多个会议室)时,问题变为区间图着色问题,可以使用贪心算法的最早结束时间策略,配合资源计数来解决。
实际应用场景
带权任务间隔调度算法在实际中有广泛应用:
- 会议室安排:选择最有价值的会议组合
- 作业调度:CPU任务调度,选择权重最高的任务组合
- 广告投放:在有限时间段选择最有价值的广告组合
- 课程安排:安排最有价值且不冲突的课程组合
性能优化技巧
- 预处理优化:提前计算并存储p(j)数组
- 记忆化搜索:使用递归+记忆化代替纯递归
- 迭代实现:使用迭代而非递归避免栈溢出
- 空间优化:只存储必要的DP状态,减少空间复杂度
与其他算法对比
-
与回溯算法对比:
- 回溯:时间复杂度O(2ⁿ),可以找到所有解
- 贪心:时间复杂度O(n log n),只能找到一种最优解
-
与动态规划对比:
- 动态规划:通常需要填表,可能时间复杂度较高
- 贪心:利用问题特性优化,通常更高效
-
与线性规划对比:
- 线性规划:更通用但实现复杂
- 贪心:针对特定问题,实现简单高效
常见错误与陷阱
- 排序标准错误:必须按结束时间排序,而不是开始时间或权重
- 权重处理不当:不能简单地选择权重最大的任务,需要考虑兼容性
- 边界条件处理:注意空任务集和全冲突任务集的特殊情况
- 索引处理错误:在实现p(j)时容易出现的off-by-one错误
测试用例设计
好的测试用例应包括:
public class WeightedIntervalSchedulingTest {
@Test
public void testEmptyTasks() {
Task[] tasks = {};
assertEquals(0, WeightedIntervalScheduling.schedule(tasks));
}
@Test
public void testSingleTask() {
Task[] tasks = {new Task(1, 3, 5)};
assertEquals(5, WeightedIntervalScheduling.schedule(tasks));
}
@Test
public void testAllTasksConflict() {
Task[] tasks = {
new Task(1, 5, 3),
new Task(2, 6, 4),
new Task(3, 7, 2)
};
assertEquals(4, WeightedIntervalScheduling.schedule(tasks));
}
@Test
public void testNoTasksConflict() {
Task[] tasks = {
new Task(1, 3, 5),
new Task(4, 6, 3),
new Task(7, 9, 2)
};
assertEquals(10, WeightedIntervalScheduling.schedule(tasks));
}
@Test
public void testMixedCase() {
Task[] tasks = {
new Task(1, 3, 5),
new Task(2, 5, 6),
new Task(4, 6, 5),
new Task(6, 7, 4),
new Task(5, 8, 11),
new Task(7, 9, 2)
};
assertEquals(16, WeightedIntervalScheduling.schedule(tasks));
}
}
总结
带权任务间隔调度问题展示了贪心算法在解决特定类型优化问题时的强大能力。通过合理的问题分析和算法设计,我们可以将看似复杂的O(2ⁿ)问题转化为O(n log n)的高效解决方案。关键在于:
- 识别问题的贪心选择性质
- 设计合适的排序和选择策略
- 结合动态规划避免重复计算
- 正确处理边界条件和特殊情况
Java实现时需要注意任务表示、排序、二分查找优化等细节,以确保算法的高效性和正确性。