LeetCode 1235. 规划兼职工作
题目描述


题意分析
每份工作由开始时间、结束时间和收益组成,选择若干时间互不重叠的工作,使总收益最大。上一份工作结束的时刻可以直接开始下一份,因此两份工作能衔接的条件是前者结束时间不大于后者开始时间。
解法:按结束时间排序 + 动态规划 + 二分
核心思路
[!blue]
工作收益不同,尽早结束只能为后续留下更多时间,不能保证总收益最大。需要比较“选择当前工作”和“保留之前方案”两种情况。
先将每份工作的三个属性绑定,按结束时间排序。定义
dp[i]为只考虑排序后前i份工作时的最大收益,dp[0] = 0表示没有工作可选。当前工作是jobs[i - 1],如果不选它,答案就是dp[i - 1]。如果选择当前工作,它一定是所选方案中最后结束的工作。其他工作都必须在它开始前结束;这些候选按结束时间排序后恰好构成一个前缀。设前缀有
pre份工作,最优收益就是dp[pre] + 当前收益。这里使用前缀的最优安排,而不是把前缀中的所有收益直接相加,因为候选之间仍可能重叠。所有方案都属于选或不选当前工作这两类,因此
dp[i] = max(dp[i - 1], dp[pre] + 当前收益)。选择当前工作后,若此前安排不是该前缀的最优方案,就能替换成dp[pre]而不影响衔接,所以这个转移不会遗漏最优解。在当前工作之前的下标范围
[0, i - 1)内,二分第一个结束时间严格大于当前开始时间的位置。它前面的工作都可以衔接,所以返回的位置恰好等于数量pre,可直接用作 DP 下标。没有可衔接工作时pre = 0,也能通过dp[0]自然处理。
解题步骤
- 将开始时间、结束时间和收益组成工作记录,按结束时间排序,并提取有序的
ends。- 创建长度为
n + 1的dp,初始dp[0] = 0。- 依次处理前
i份工作,在此前的i - 1份中二分得到可衔接前缀长度pre。- 比较
dp[i - 1]和dp[pre] + 当前工作收益,保存较大值。- 返回
dp[n]。
代码实现
class Solution {
public int jobScheduling(int[] startTime, int[] endTime, int[] profit) {
int n = startTime.length;
int[][] jobs = new int[n][3];
for (int i = 0; i < n; i++) {
jobs[i][0] = startTime[i];
jobs[i][1] = endTime[i];
jobs[i][2] = profit[i];
}
Arrays.sort(jobs, (a, b) -> Integer.compare(a[1], b[1]));
int[] ends = new int[n];
for (int i = 0; i < n; i++) {
ends[i] = jobs[i][1];
}
int[] dp = new int[n + 1];
for (int i = 1; i <= n; i++) {
int[] job = jobs[i - 1];
// 查第一个结束晚于开始的位置,得到可衔接前缀数量
int pre = upperBound(ends, i - 1, job[0]);
// 比较不选与选择当前工作,不能强制覆盖历史最优
dp[i] = Math.max(dp[i - 1], dp[pre] + job[2]);
}
return dp[n];
}
private int upperBound(int[] ends, int right, int target) {
int left = 0;
while (left < right) {
int mid = left + (right - left) / 2;
if (ends[mid] <= target) {
left = mid + 1;
} else {
right = mid;
}
}
return left;
}
}
import "sort"
type Job struct {
start int
end int
profit int
}
func jobScheduling(startTime []int, endTime []int, profit []int) int {
n := len(startTime)
jobs := make([]Job, n)
for i := 0; i < n; i++ {
jobs[i] = Job{start: startTime[i], end: endTime[i], profit: profit[i]}
}
sort.Slice(jobs, func(i, j int) bool {
return jobs[i].end < jobs[j].end
})
ends := make([]int, n)
for i := 0; i < n; i++ {
ends[i] = jobs[i].end
}
dp := make([]int, n+1)
for i := 1; i <= n; i++ {
job := jobs[i-1]
// 查第一个结束晚于开始的位置,得到可衔接前缀数量
pre := sort.Search(i-1, func(idx int) bool {
return ends[idx] > job.start
})
// 比较不选与选择当前工作,不能强制覆盖历史最优
take := dp[pre] + job.profit
if take > dp[i-1] {
dp[i] = take
} else {
dp[i] = dp[i-1]
}
}
return dp[n]
}
复杂度分析
- 时间复杂度:$O(n\log n)$,
n为工作数量,排序及每份工作的一次二分占主导。- 空间复杂度:$O(n)$,工作、结束时间和 DP 数组。
关键点总结
[!green]
- 按结束时间排序,是为了让所有可衔接的前置工作形成连续前缀。
- 等于开始时间的结束值允许衔接,所以查找第一个严格更大的结束时间。
dp[i]是前i份工作的最优收益,不要求选中第i份工作。
易错点总结
[!yellow]
- 分开排序三个数组会拆散工作数据。
- 只算选择当前,可能覆盖更优历史结果。
- 把二分返回数量再减一,错用了前缀 DP 索引。
相似题目
| 题目 | 难度 | 关联与区别 |
|---|---|---|
| 435. 无重叠区间 | 中等 | 原题所有区间收益相同可按结束时间贪心,本题收益不同,需比较选与不选的加权DP。 |
| 1031. 两个无重叠子数组的最大和 | 中等 | 原题只选两个固定长度窗口,本题任务区间与收益一般化,需要二分前一个不冲突任务。 |
转载与许可
许可
本博客所有文章除特别声明外,均采用 CC BY-NC-SA 4.0 许可协议,转载请注明出处!