问题解构:LeetCode 1235 "规划兼职工作"
这是一个动态规划 结合二分查找的经典问题,属于"区间调度"问题的变种。题目要求在一系列兼职工作中进行选择,以最大化总收益。
核心要素:
- 每份工作由
[startTime, endTime, profit]表示。 - 你不能同时参与两份工作(即选择的工作时间区间不能重叠)。
- 目标是选择若干份互不重叠 的工作,使得这些工作的总收益最大。
解题思路 :
解决此问题的关键在于排序 和状态定义。一个高效的思路是:
- 排序 :将所有工作按照结束时间
endTime从小到大排序。这有助于我们快速找到"在前一份工作结束后,可以开始的下一份工作"。 - 动态规划定义 :
- 定义
dp[i]表示考虑前i份工作时,能获得的最大收益。 - 对于第
i份工作(排序后的),我们有两种选择:- 不选它 :那么最大收益就是
dp[i-1]。 - 选择它 :那么我们必须找到在它开始之前刚刚结束 的那份工作
j。最大收益就是profit[i] + dp[j]。
- 不选它 :那么最大收益就是
- 因此,状态转移方程为:
dp[i] = max(dp[i-1], profit[i] + dp[j])。
- 定义
- 寻找工作
j:由于我们已经按结束时间排序,可以使用二分查找 在0到i-1的范围内,快速找到最后一个结束时间<= startTime[i]的工作索引j。这是优化时间复杂度的关键。
C++ 代码实现(完整注释版)
cpp
class Solution {
public:
int jobScheduling(vector<int>& startTime, vector<int>& endTime, vector<int>& profit) {
int n = startTime.size();
// 1. 将工作打包,并按照结束时间排序
vector<vector<int>> jobs(n, vector<int>(3));
for (int i = 0; i < n; ++i) {
jobs[i] = {startTime[i], endTime[i], profit[i]};
}
// 按结束时间升序排序
sort(jobs.begin(), jobs.end(), [](const vector<int>& a, const vector<int>& b) {
return a[1] < b[1];
});
// 2. 动态规划数组,dp[i] 表示考虑前 i 份工作(排序后)的最大收益
vector<int> dp(n + 1, 0);
// 为了方便处理,让下标从1开始,dp[0]=0 表示没有工作时的收益为0
for (int i = 1; i <= n; ++i) {
int currStart = jobs[i-1][0];
int currProfit = jobs[i-1][2];
// 3. 二分查找:找到最后一个结束时间 <= 当前工作开始时间的工作索引 j
// jobs[0...j-1] 的结束时间都 <= currStart
int left = 0, right = i - 1; // 在 jobs[0...i-2] 中查找
while (left <= right) {
int mid = left + (right - left) / 2;
if (jobs[mid][1] <= currStart) {
left = mid + 1; // 继续向右找更晚结束的
} else {
right = mid - 1;
}
}
// 循环结束时,right 指向最后一个满足条件的索引
int j = right + 1; // 因为dp下标从1开始,所以需要+1。dp[j] 对应 jobs[0...right] 的最大收益
// 4. 状态转移:选择当前工作 或 不选当前工作
dp[i] = max(dp[i-1], currProfit + dp[j]);
}
// 5. 最终结果
return dp[n];
}
};
算法步骤详解
-
数据重组与排序:
- 将三个独立的数组合并成一个
jobs列表,每个元素是[start, end, profit]。 - 按照
end(结束时间)进行升序排序。排序是后续进行二分查找和动态规划的基础。
- 将三个独立的数组合并成一个
-
动态规划初始化:
- 创建
dp数组,长度为n+1。dp[i]表示处理完前i份工作(排序后)能获得的最大收益。 dp[0] = 0作为边界条件。
- 创建
-
核心循环(状态计算):
- 遍历每一份工作
i(从1到n):- 选项一:不选当前工作 。收益直接继承
dp[i-1]。 - 选项二:选择当前工作 。收益为
当前工作的利润+在它开始前能完成的所有工作的最大收益。 - 为了计算选项二,需要通过二分查找 找到"在它开始前能完成的所有工作中,最后结束的那一个"的索引
j。dp[j]就是这部分的最大收益。
- 选项一:不选当前工作 。收益直接继承
- 遍历每一份工作
-
二分查找细节:
- 在已排序的
jobs[0...i-2]中,查找最后一个满足jobs[mid][1] <= currStart的位置。 - 使用标准的二分查找模板,最终
right指向目标位置。因为dp索引从1开始,所以j = right + 1。
- 在已排序的
-
获取结果:
- 最终
dp[n]就是考虑所有n份工作后能获得的最大收益。
- 最终
举例说明
假设输入为:
startTime = [1,2,3,4,6]
endTime = [3,5,10,6,9]
profit = [20,20,100,70,60]
处理过程:
- 排序后 的工作列表
jobs为(按endTime排序):
[[1,3,20], [2,5,20], [4,6,70], [6,9,60], [3,10,100]] - 动态规划计算 :
i=1(工作1,3,20):前面没有工作,dp[1] = max(0, 20+0) = 20i=2(工作2,5,20):查找end<=2的工作,找到工作1。dp[2] = max(20, 20+20) = 40i=3(工作4,6,70):查找end<=4的工作,找到工作2。dp[3] = max(40, 70+40) = 110i=4(工作6,9,60):查找end<=6的工作,找到工作3。dp[4] = max(110, 60+110) = 170i=5(工作3,10,100):查找end<=3的工作,找到工作1。dp[5] = max(170, 100+20) = 170
- 最终结果 :
dp[5] = 170。
最优选择 :选择工作 [1,3,20]、[4,6,70] 和 [6,9,60],总收益为 20+70+60=150?等等,这里似乎计算有误。让我们仔细核对一下动态规划的过程。
实际上,根据我们的状态转移:
- 对于工作
[6,9,60],当i=4时,我们找到end<=6的工作是[4,6,70](即j=3),所以dp[4] = max(dp[3], 60 + dp[3]) = max(110, 60+110)=170。这意味着我们选择了[6,9,60]和它之前最优的组合(即[1,3,20]和[4,6,70]),但[4,6,70]和[6,9,60]时间上并不重叠(一个在6结束,一个在6开始),这是允许的。所以总收益是20+70+60=150,但dp[4]=170?这里出现了矛盾。
矛盾排查 :问题出在二分查找的索引转换上。在代码中,jobs 索引从0开始,dp 索引从1开始。当我们找到 right 后,j = right + 1。dp[j] 对应的是 jobs[0...right] 的最大收益。
- 对于
[6,9,60](i=4,jobs[3]),currStart=6。二分查找在jobs[0..2]中找end<=6的最后一个工作,是[4,6,70](jobs[2]),所以right=2,j=3。 dp[3]是考虑jobs[0], jobs[1], jobs[2]的最大收益,即[1,3,20],[2,5,20],[4,6,70]这三份工作的最优解。它们互不重叠吗?[1,3,20]和[4,6,70]不重叠,可以同时选,收益20+70=90。[2,5,20]与[1,3,20]重叠,与[4,6,70]不重叠,但[1,3,20]+[4,6,70]=90大于[2,5,20]+[4,6,70]=90。所以dp[3]应该是90。- 因此
dp[4] = max(dp[3]=90, 60 + dp[j]=60+90=150) = 150。这样就和手动计算一致了。
所以,修正后的最优选择确实是:[1,3,20], [4,6,70], [6,9,60],总收益 150。dp[5] 最终也是150。
复杂度分析
- 时间复杂度 :O(n log n) 。
- 排序需要
O(n log n)。 - 动态规划循环
n次,每次循环中进行一次二分查找O(log n)。因此总时间为O(n log n)。
- 排序需要
- 空间复杂度 :O(n) 。
- 用于存储
jobs组合数组和dp数组。
- 用于存储
关键点总结
- 排序是突破口 :按结束时间排序后,保证了在考虑第
i份工作时,所有可能在其之前结束的工作都已经被考虑过,并且可以通过二分查找快速定位。 - 状态定义 :
dp[i]表示考虑前i份工作 ,而非一定选择第i份工作。这涵盖了所有可能性。 - 二分查找优化 :这是将朴素动态规划 O(n²) 优化到 O(n log n) 的关键。它避免了为每个
i都向前线性扫描寻找不重叠的工作。 - 索引映射 :注意
jobs索引(从0开始)与dp索引(从1开始)之间的转换,这是代码实现中的一个常见技巧,能让边界条件处理更清晰。