力扣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 :由于我们已经按结束时间排序,可以使用二分查找 在 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];
    }
};

算法步骤详解

  1. 数据重组与排序:

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

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

    • 遍历每一份工作 i(从1到n):
      • 选项一:不选当前工作 。收益直接继承 dp[i-1]。
      • 选项二:选择当前工作 。收益为 当前工作的利润 + 在它开始前能完成的所有工作的最大收益。
      • 为了计算选项二,需要通过二分查找 找到"在它开始前能完成的所有工作中,最后结束的那一个"的索引 j。dp[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 + 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 数组。

关键点总结

  1. 排序是突破口 :按结束时间排序后,保证了在考虑第 i 份工作时,所有可能在其之前结束的工作都已经被考虑过,并且可以通过二分查找快速定位。
  2. 状态定义 :dp[i] 表示考虑前i份工作 ,而非一定选择第i份工作。这涵盖了所有可能性。
  3. 二分查找优化 :这是将朴素动态规划 O(n²) 优化到 O(n log n) 的关键。它避免了为每个 i 都向前线性扫描寻找不重叠的工作。
  4. 索引映射 :注意 jobs 索引(从0开始)与 dp 索引(从1开始)之间的转换,这是代码实现中的一个常见技巧,能让边界条件处理更清晰。
相关推荐
一水鉴天1 小时前
映射、哈希表与哈斯图:计算机科学的三种基线 20261003(元宝)
开发语言·人工智能
Frank_refuel1 小时前
C++11之一场名为“搬家”的 C++ 之旅
开发语言·c++
Wang's Blog2 小时前
Java 项目实战: 外卖平台优化-Nginx配置文件结构与块层级
java·开发语言·nginx
2601_962071573 小时前
类变量和全局变量的查找路径有什么区别?
开发语言·python
\光辉岁月/4 小时前
5.java-数组
java·开发语言
Java后端的Ai之路4 小时前
Python进阶探索29_eval内置函数
开发语言·python·探索·eval·内置函数
谢亮_vipxieliang4 小时前
Spring 事务失效的常见场景
java·开发语言·数据库·spring boot
longlongzihan4 小时前
从LeetCode 70. 爬楼梯看动态规划:如何精准推导状态转移方程?
c++·算法·leetcode·动态规划
时间的拾荒人4 小时前
Qt 多线程详解:从 QThread 到实战
开发语言·qt·面试
郑州光合科技余经理4 小时前
海外版外卖加盟:总站与分站配送规则怎么分开管
java·开发语言·前端·后端·uni-app·php·ai编程