630. 课程表 III : 经典贪心运用题

题目描述

这是 LeetCode 上的 630. 课程表 III ,难度为 困难

Tag : 「贪心」

这里有 n n 门不同的在线课程,按从 1 1 n n  编号。给你一个数组 c o u r s e s courses ,其中 c o u r s e s [ i ] = [ d u r a t i o n i , l a s t D a y i ] courses[i] = [durationi, lastDayi] 表示第 i i 门课将会 持续 上 d u r a t i o n i duration_i 天课,并且必须在不晚于 l a s t D a y i lastDay_i 的时候完成。

你的学期从第 1 1 天开始。且不能同时修读两门及两门以上的课程。

返回你最多可以修读的课程数目。

示例 1:

输入:courses = [[100, 200], [200, 1300], [1000, 1250], [2000, 3200]]

输出:3

解释:
这里一共有 4 门课程,但是你最多可以修 3 门:
首先,修第 1 门课,耗费 100 天,在第 100 天完成,在第 101 天开始下门课。
第二,修第 3 门课,耗费 1000 天,在第 1100 天完成,在第 1101 天开始下门课程。
第三,修第 2 门课,耗时 200 天,在第 1300 天完成。
第 4 门课现在不能修,因为将会在第 3300 天完成它,这已经超出了关闭日期。
复制代码

示例 2:

输入:courses = [[1,2]]

输出:1
复制代码

示例 3:

输入:courses = [[3,2],[4,3]]

输出:0
复制代码

提示:

  • 1 < = c o u r s e s . l e n g t h < = 1 0 4 1 <= courses.length <= 10^4
  • 1 < = d u r a t i o n i , l a s t D a y i < = 1 0 4 1 <= durationi, lastDayi <= 10^4

贪心 + 优先队列(堆)

这是一道很好的题目。

题目是要我们构造出一种可行的排列,排列中每个课程的实际结束时间满足「最晚完成时间」要求,求可行排序的最大长度(每个课程对答案的贡献都是 1 1 )。

这容易引导我们往「泛化背包」方面进行思考:简单来说,对于某个物品(课程)而言,在不同条件下成本不同,在时间轴 [ 1 , c o u r s e s [ i ] [ 1 ] c o u r s e s [ i ] [ 0 ] ] [1, courses[i][1] - courses[i][0]] 上该物品可被选,成本为其持续时间,在比该范围大的数轴上无法被选,成本为正无穷。因此某一段特定的时间轴上,问题可抽象成有条件限制的组合优化问题。

由于数据范围为 1 0 4 10^4 ,泛化背包做法需要记录的维度大于一维,不予考虑。

再然后容易想到「二分」,显然在以最大选择数量 a n s ans 为分割点的数组上具有「二段性」:

  • 使用数量小于等于 a n s ans 的课程能够构造出合法排序(考虑在最长合法序列上做减法即可);
  • 使用数量大于 a n s ans 的课程无法构造出合法排列。

此时二分范围为 [ 0 , n ] [0, n] ,问题转化为:如何在 O ( n ) O(n) 检查是否可构造出某个长度 l e n len 的合法排列(实现 check 方法)。

常规的线性扫描做法无法确定是否存在某个长度的合法排列,因此二分不予考虑。

我们需要运用「贪心」思维考虑可能的方案。

具体的,我们先根据「结束时间」对 c o u r s e s courses 排升序,从前往后考虑每个课程,处理过程中维护一个总时长 s u m sum ,对于某个课程 c o u r s e s [ i ] courses[i] 而言,根据如果学习该课程,是否满足「最晚完成时间」要求进行分情况讨论:

  • 学习该课程后,满足「最晚完成时间」要求,即 s u m + c o u r s e s [ i ] [ 0 ] < = c o u r s e s [ i ] [ 1 ] sum + courses[i][0] <= courses[i][1] ,则进行学习;

  • 学习该课程后,不满足「最晚完成时间」要求,此时从过往学习的课程中找出「持续时间」最长的课程进行「回退」操作(这个持续时长最长的课程有可能是当前课程)。

其中「记录当前已选课程」和「从过往学习的课程中找出持续时间最长的课程」操作可以使用优先队列(大根堆)实现。

可用「归纳法 + 反证法」证明该做法能够取到最优排列之一,定义最优排列为「总课程数最大,且总耗时最少」的合法排列。

  1. 在课程数量相同的前提下,该做法得到的排列总耗时最少

    这点可通过「反证法」来证明:当不满足「最后完成时间」时,我们总是弹出「持续时间」最长的课程来进行回退,因此在所有课程对答案的贡献都是 1 1 的前提下,该做法能够确保总耗时最少。即当堆中元素数量被调整为 x x 时,必然是由元素数量为 x + 1 x + 1 时,将持续时间最长的课程弹出所得来。

  2. 该做法能够确保取得最大课程数量

    在得证第 1 1 点后,可用「归纳法」进行证明第 2 2 点:只考虑一个课程的情况下(假定 c o u r s e s [ 0 ] [ 0 ] < c o u r s e s [ 0 ] [ 1 ] courses[0][0] < courses[0][1] ),选该课程会比不选更好。 将 c o u r s e s [ 0 ] courses[0] 的最优解排列记为 s [ 0 ] s[0] ,当确定了 s [ 0 ] s[0] 后再考虑如何处理 c o u r s e s [ 1 ] courses[1] 来得到 s [ 1 ] s[1] 。 首先可知 s [ 1 ] s[1] 只有两种情况:

    • s [ 1 ] = s [ 0 ] s[1] = s[0] :意味着 c o u r s e s [ 1 ] courses[1] 不参与到最优解排列当中;
    • s [ 1 ] s [ 0 ] s[1] \neq s[0] ,但两个最优解长度相同:意味着 s [ 1 ] s[1] 是由 c o u r s e s [ i ] courses[i] 替换了 s [ 0 ] s[0] 中的某个课程而来,且基于证明 1 1 可以得证,被替换的课程持续时间比 c o u r s e s [ i ] courses[i] 要长;
    • s [ 1 ] s [ 0 ] s[1] \neq s[0] ,且 s [ 1 ] s[1] 长度比 s [ 0 ] s[0] 1 1 :意味着 s [ i ] s[i] 是由 c o u r s e s [ i ] courses[i] 最接追加到 s [ 0 ] s[0] 而来。

    综上,我们证明了,如果已知某个边界情况的最优解,那么由边界的最优解可推导出在此基础上多考虑一个课程时的最优解。即以上分析可以推广到任意的 s [ i 1 ] s[i - 1] s [ i ] s[i]

    需要注意,在推广到任意的 s [ i 1 ] s[i - 1] s [ i ] s[i] 时,还需要证明在已知 s [ i 1 ] s[i - 1] 时,多考虑一个 c o u r s e s [ i ] courses[i] 不会出现 s [ i ] s[i] 的和 s [ i 1 ] s[i - 1] 的长度差超过 1 1 的情况。 这个基于已得证的第 1 1 点,再用反证法可证:如果在 s [ i 1 ] s[i - 1] 的基础上多考虑一个 c o u r s e s [ i ] courses[i] 能够使得总长度增加超过 1 1 ,说明存在一个「之前没有被选的课程」+「课程 c o u r s e s [ i ] courses[i] 」的持续时间比「被替换的课程」短。 那么使用这个「之前没有被选的课程」直接替换「被替换的课程」可得到长度与 s [ i 1 ] s[i - 1] 相同,且总耗时更短的排列方案,这与 s [ i 1 ] s[i - 1] 本身是最优排列冲突。

代码:

class Solution {
    public int scheduleCourse(int[][] courses) {
        Arrays.sort(courses, (a,b)->a[1]-b[1]);
        PriorityQueue<Integer> q = new PriorityQueue<>((a,b)->b-a);
        int sum = 0;
        for (int[] c : courses) {
            int d = c[0], e = c[1];
            sum += d;
            q.add(d);
            if (sum > e) sum -= q.poll();
        }
        return q.size();
    }
}
复制代码
  • 时间复杂度: O ( n log n ) O(n\log{n})
  • 空间复杂度: O ( n ) O(n)

最后

这是我们「刷穿 LeetCode」系列文章的第 No.630 篇,系列开始于 2021/01/01,截止于起始日 LeetCode 上共有 1916 道题目,部分是有锁题,我们将先把所有不带锁的题目刷完。

在这个系列文章里面,除了讲解解题思路以外,还会尽可能给出最为简洁的代码。如果涉及通解还会相应的代码模板。

为了方便各位同学能够电脑上进行调试和提交代码,我建立了相关的仓库:github.com/SharingSour…

在仓库地址里,你可以看到系列文章的题解链接、系列文章的相应代码、LeetCode 原题链接和其他优选题解。

猜你喜欢

转载自juejin.im/post/7041398079219728420