目录

题目描述

1235. 规划兼职工作

题意分析

每份兼职由开始时间、结束时间和收益三元组描述,同一时刻只能干一份,问最多能挣多少。三个数组按下标一一对应,所以处理前要先把它们绑成一个整体。

「不能同时干两份」在这里的精确含义是:如果接下一份在 t 时刻结束的工作,下一份工作的开始时间必须不早于 t。题面明确允许一份工作结束的同一时刻开始另一份,这个等号会直接决定后面二分的写法,读题时必须先确认。

约束信号有两个。一是每份工作带权重,这意味着「多接几份」不等于「挣得多」,经典的区间贪心(按结束时间贪心地能接就接)在这里不成立;二是工作数量到 $5 \times 10^4$、时间值到 $10^9$,说明要按工作数做 $O(n \log n)$,不能按时间轴展开。

边界情形:所有工作两两重叠时答案就是单份最大收益;所有工作首尾相接时答案是全部收益之和;收益都是正数,所以不存在「接了反而更亏」的情况。

解法:按结束时间排序 + 动态规划 + 二分

核心思路

这是带权区间调度。按结束时间贪心只适用于每个区间价值相同的情况;例如一份收益 100 的长工作与两份各收益 60 的短工作冲突时,贪心选长工作会错过收益 120,因此需要动态规划。

先把工作按结束时间升序排列。对当前工作 (start, end, profit),所有结束时间不晚于 start 的工作一定构成前缀,可以用二分一次定位,而不必逐个回看。

定义 dp[i] 为只考虑排序后前 i 份工作能获得的最大收益,dp[0] = 0。处理第 i 份工作时设 pre 为此前结束时间 <= start 的工作数量,则:

\[dp[i] = \max(dp[i-1],\ dp[pre] + profit)\]

第一项表示不选当前工作,第二项表示选当前工作。最优方案对当前工作也只有这两种互斥情况;若选它,前面的选择只能来自可衔接前缀,而 dp[pre] 已是该前缀最优值,因此转移既不遗漏也不重复。由前缀归纳可知 dp[n] 就是全局最优解。

解题步骤

  1. 将开始时间、结束时间、收益绑定成工作三元组,按结束时间升序排序,并提取有序的 ends 数组。
  2. 建立长度为 n + 1dpdp[i] 对应前 i 份工作,当前工作因此是 jobs[i - 1]
  3. 在前 i - 1 份工作的结束时间中二分查找第一个 > start 的位置。该位置恰好是可衔接工作的数量 pre;使用 upper bound 才会保留 end == start 的合法衔接。
  4. max(dp[i - 1], dp[pre] + profit) 更新 dp[i],最终返回 dp[n]

例如工作依结束时间排列为 (1,3,50)、(2,4,10)、(3,5,40)、(3,6,70)。处理 (3,5,40) 时,结束时间 <= 3 的工作只有第一份,所以 pre = 1,得到 max(50, 50 + 40) = 90;处理 (3,6,70) 时同样接在第一份之后,最终收益为 120。

代码实现

import java.util.Arrays;

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)$。排序需要 $O(n \log n)$,n 次状态转移各做一次 $O(\log n)$ 二分。
  • 空间复杂度:$O(n)$。工作数组、结束时间数组和 dp 数组都占线性空间。

关键点总结

  • 排序的目的不是方便遍历,而是让每份工作可衔接的候选集合变成一个完整前缀。
  • dp[i] 是前 i 份工作的最优值,不要求必须选择第 i 份,因此转移必须同时比较“选”和“不选”。
  • pre 表示工作数量,可直接作为 dp 下标;它不是上一份工作的数组下标。
  • 题目允许 end == start,所以要找第一个 end > start 的位置,而不是第一个 end >= start 的位置。
  • 面试时先用反例否定无权区间贪心,再给出状态、转移和二分边界,推导会比直接背公式更完整。

易错点总结

  • 三个输入数组必须先绑定成三元组再排序,分别排序会破坏同一份工作的对应关系。
  • 二分必须使用排序后的 ends,并且只搜索当前工作之前的前缀。
  • 若用 lower bound 查找 end >= start,会错误排除首尾相接的工作。例如 [1,3][3,5] 应当可以同时选择。
  • 转移不能只计算 dp[pre] + profit,否则相当于强制选择当前工作,可能覆盖更优的 dp[i - 1]
  • 线性回扫寻找 pre 虽然正确,但整体会退化为 $O(n^2)$,无法通过 $5 \times 10^4$ 的数据规模。

相似题目

题目 难度 考察点
300. 最长递增子序列 中等 同样是「排序后选不冲突子集」,但用二分维护尾值
435. 无重叠区间 中等 无权重版本,交换论证成立,贪心即可
646. 最长数对链 中等 无权重且要求严格衔接,贪心与 DP 两种写法都能过
1353. 最多可以参加的会议数目 中等 每份工作只占一天,模型退化成堆维护的贪心