力扣1235完整解法详解

问题解构:LeetCode 1235 "规划兼职工作"

这是一个动态规划 结合二分查找的经典问题,属于"区间调度"问题的变种。题目要求在一系列兼职工作中进行选择,以最大化总收益。

核心要素

  • 每份工作由 [startTime, endTime, profit] 表示。
  • 不能同时参与两份工作(即选择的工作时间区间不能重叠)。
  • 目标是选择若干份互不重叠 的工作,使得这些工作的总收益最大

解题思路

解决此问题的关键在于排序状态定义。一个高效的思路是:

  1. 排序 :将所有工作按照结束时间 endTime 从小到大排序。这有助于我们快速找到"在前一份工作结束后,可以开始的下一份工作"。
  2. 动态规划定义
    • 定义 dp[i] 表示考虑前 i 份工作时,能获得的最大收益。
    • 对于第 i 份工作(排序后的),我们有两种选择:
      • 不选它 :那么最大收益就是 dp[i-1]
      • 选择它 :那么我们必须找到在它开始之前刚刚结束 的那份工作 j。最大收益就是 profit[i] + dp[j]
    • 因此,状态转移方程为:dp[i] = max(dp[i-1], profit[i] + dp[j])
  3. 寻找工作 j :由于我们已经按结束时间排序,可以使用二分查找 0i-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];
    }
};

算法步骤详解

  1. 数据重组与排序

    • 将三个独立的数组合并成一个 jobs 列表,每个元素是 [start, end, profit]
    • 按照 end(结束时间)进行升序排序。排序是后续进行二分查找和动态规划的基础。
  2. 动态规划初始化

    • 创建 dp 数组,长度为 n+1dp[i] 表示处理完前 i 份工作(排序后)能获得的最大收益。
    • dp[0] = 0 作为边界条件。
  3. 核心循环(状态计算)

    • 遍历每一份工作 i(从1到n):
      • 选项一:不选当前工作 。收益直接继承 dp[i-1]
      • 选项二:选择当前工作 。收益为 当前工作的利润 + 在它开始前能完成的所有工作的最大收益
      • 为了计算选项二,需要通过二分查找 找到"在它开始前能完成的所有工作中,最后结束的那一个"的索引 jdp[j] 就是这部分的最大收益。
  4. 二分查找细节

    • 在已排序的 jobs[0...i-2] 中,查找最后一个满足 jobs[mid][1] <= currStart 的位置。
    • 使用标准的二分查找模板,最终 right 指向目标位置。因为 dp 索引从1开始,所以 j = right + 1
  5. 获取结果

    • 最终 dp[n] 就是考虑所有 n 份工作后能获得的最大收益。

举例说明

假设输入为:

复制代码
startTime = [1,2,3,4,6]
endTime   = [3,5,10,6,9]
profit    = [20,20,100,70,60]

处理过程

  1. 排序后 的工作列表 jobs 为(按endTime排序):
    [[1,3,20], [2,5,20], [4,6,70], [6,9,60], [3,10,100]]
  2. 动态规划计算
    • i=1 (工作1,3,20):前面没有工作,dp[1] = max(0, 20+0) = 20
    • i=2 (工作2,5,20):查找end<=2的工作,找到工作1。dp[2] = max(20, 20+20) = 40
    • i=3 (工作4,6,70):查找end<=4的工作,找到工作2。dp[3] = max(40, 70+40) = 110
    • i=4 (工作6,9,60):查找end<=6的工作,找到工作3。dp[4] = max(110, 60+110) = 170
    • i=5 (工作3,10,100):查找end<=3的工作,找到工作1。dp[5] = max(170, 100+20) = 170
  3. 最终结果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 + 1dp[j] 对应的是 jobs[0...right] 的最大收益。

  • 对于 [6,9,60] (i=4, jobs[3]),currStart=6。二分查找在 jobs[0..2] 中找 end<=6 的最后一个工作,是 [4,6,70] (jobs[2]),所以 right=2j=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],总收益 150dp[5] 最终也是150。


复杂度分析

  • 时间复杂度O(n log n)
    • 排序需要 O(n log n)
    • 动态规划循环 n 次,每次循环中进行一次二分查找 O(log n)。因此总时间为 O(n log n)
  • 空间复杂度O(n)
    • 用于存储 jobs 组合数组和 dp 数组。

关键点总结

  1. 排序是突破口 :按结束时间排序后,保证了在考虑第 i 份工作时,所有可能在其之前结束的工作都已经被考虑过,并且可以通过二分查找快速定位。
  2. 状态定义dp[i] 表示考虑前i份工作 ,而非一定选择第i份工作。这涵盖了所有可能性。
  3. 二分查找优化 :这是将朴素动态规划 O(n²) 优化到 O(n log n) 的关键。它避免了为每个 i 都向前线性扫描寻找不重叠的工作。
  4. 索引映射 :注意 jobs 索引(从0开始)与 dp 索引(从1开始)之间的转换,这是代码实现中的一个常见技巧,能让边界条件处理更清晰。
相关推荐
quan_泉1 小时前
DIDCTF 取证初学者
java·服务器·前端
i220818 Faiz Ul1 小时前
民谣网站|基于Springboot的民谣网站管理系统(源码+数据库+文档)
java·数据库·spring boot·后端·论文·毕设·民谣网站
z落落1 小时前
C# 继承基础详解(代码实战+权限规则)
java·开发语言
techdashen1 小时前
你想在 Rust 中实现动态库热重载?
开发语言·chrome·rust
不会C语言的男孩1 小时前
C++ Primer 第5章:语句
开发语言·c++
酉鬼女又兒1 小时前
零基础入门计算机网络:从基本概念到核心交换技术
开发语言·计算机网络·考研·职场和发展·php
爱喝水的鱼丶1 小时前
SAP-ABAP:SAP 简单报表输出开发系列(共6篇)第三篇:SAP ALV 报表样式定制:字段布局与交互功能配置
服务器·开发语言·学习·交互·sap·abap
chao1898441 小时前
基于SIFT和SURF特征的图像配准(MATLAB)
开发语言·matlab
摇滚侠1 小时前
JDBC 基础到高级一套通关!基础篇 00-15
java·开发语言·数据库