一、贪心算法之区间调度问题

运用贪心算法来做时间管理

  • 贪心算法可以认为是动态规划算法的一个特例,相比动态规划,使用贪心算法需要满足更多的条件(贪心选择性质),但是效率比动态规划要高。

  • 比如说一个算法问题使用暴力解法需要指数级时间,如果能使用动态规划消除重叠子问题,就可以降到多项式级别的时间,如果满足贪心选择性质,那么可以进一步降低时间复杂度,达到线性级别的。

  • 什么是贪心选择性质呢,简单说就是:每一步都做出一个局部最优的选择,最终的结果就是全局最优。注意哦,这是一种特殊性质,其实只有一小部分问题拥有这个性质。

问题概述

image.png

贪心解法

正确的思路可以分为以下三步:

  1. 从区间集合 intvs 中选择一个区间 x,这个 x 是在当前所有区间中结束最早的(end 最小)。
  2. 把所有与 x 区间相交的区间从区间集合 intvs 中删除。
  3. 重复步骤 1 和 2,直到 intvs 为空为止。之前选出的那些 x 就是最大不相交子集。

image.png

应用举例

image.png
image.png

二、剪视频剪出贪心算法

  1. ![image.png](https://cdn.nlark.com/yuque/0/2021/png/21447592/1631336443085-8ab1025f-fe49-4319-80e6-501ce8d4a933.png#clientId=ub7f48804-e4d3-4&from=paste&height=568&id=uc15d8128&margin=%5Bobject%20Object%5D&name=image.png&originHeight=677&originWidth=703&originalType=binary&ratio=1&size=184480&status=done&style=none&taskId=u4fcb7136-f978-4b48-99ea-a9880fec7cc&width=590)

思路分析

给定一个目标区间和若干小区间,如何裁剪和组合小区间拼凑出目标区间?
最少需要几个小区间?

  • 前文多次说过,区间问题肯定按照区间的起点或者终点进行排序。因为排序之后更容易找到相邻区间之间的联系,如果是求最值的问题,可以使用贪心算法进行求解。
  • 至于到底如何排序,这个就要因题而异了,我做这道题的思路是先按照起点升序排序,如果起点相同的话按照终点降序排序。
    1. 要用若干短视频凑出完成视频[0, T],至少得有一个短视频的起点是 0。
    • 这个很好理解,如果没有一个短视频是从 0 开始的,那么区间[0, T]肯定是凑不出来的。
    1. 如果有几个短视频的起点都相同,那么一定应该选择那个最长(终点最大)的视频。
    • 这一条就是贪心的策略,因为题目让我们计算最少需要的短视频个数,如果起点相同,那肯定是越长越好,不要白不要,多出来了大不了剪辑掉嘛。

代码实现

实现上述思路需要我们用两个变量curEnd和nextEnd来进行:
经典动态规划 之 贪心类型问题 - 图5
image.png

复杂度

这段代码的时间复杂度是多少呢?虽然代码中有一个嵌套的 while 循环,但这个嵌套 while 循环的时间复杂度是O(N)。因为当i递增到n时循环就会结束,所以这段代码只会执行O(N)次。
但是别忘了我们对clips数组进行了一次排序,消耗了O(NlogN)的时间,所以本算法的总时间复杂度是O(NlogN)。

三、贪心思想:玩跳跃游戏

LeetCode 两道经典的贪心算法:跳跃游戏 I 和跳跃游戏 II。这两道题可以使用动态规划或者算法和贪心算法进行求解,通过实践,你就能更深刻地理解贪心和动规的区别和联系了。

  • 贪心算法可以理解为一种特殊的动态规划问题,拥有一些更特殊的性质,可以进一步降低动态规划算法的时间复杂度。

跳跃游戏 I

image.png

有关动态规划的问题,大多是让你求最值的,比如最长子序列,最小编辑距离,最长公共子串。 那么贪心算法作为特殊的动态规划也一样,一般也是让你求最值。这题表面不是求最值,但可以改改:请问通过题目中的跳跃规则,最多能跳多远?如果能够越过最后一格,返回 true,否则返回 false。

  • 所以说,这道题肯定可以用动态规划求解的。但是由于它比较简单,下一道题再用动态规划和贪心思路进行对比,现在直接上贪心的思路:每一步都计算一下从当前位置最远能够跳到哪里,然后和一个全局最优的最远位置farthest做对比,通过每一步的最优解,更新全局最优解,这就是贪心。
  • 如果某一个作为 起跳点 的格子可以跳跃的距离是 3,那么表示后面 3 个格子都可以作为 起跳点
  • 可以对每一个能作为 起跳点 的格子都尝试跳一次,把 能跳到最远的距离 不断更新
  • 如果可以一直跳到最后,就成功了

image.png

跳跃游戏 II

image.png
现在的问题是:保证你一定可以跳到最后一格,请问你最少要跳多少次,才能跳过去?

动态规划的思路

  • 自顶向下的递归 —— dp函数这么定义:

// 定义:从索引 p 跳到最后一格,至少需要 dp(nums, p) 个跳跃数
int dp(vector<int>& nums, int p);

  • base case:当p超过最后一格时,不需要跳跃:

if (p >= nums.size() - 1) return 0;

  • 备忘录消除 重叠子问题 ,取最小值为最终答案

image.png

  • 该算法的时间复杂度是 递归深度 × 每次递归需要的时间复杂度,即 O(N^2),在 LeetCode 上是无法通过所有用例的,会超时。

贪心的思路

贪心算法比动态规划多了一个性质:贪心选择性质。什么样的问题满足贪心选择性质。

  • 刚才的动态规划思路:穷举所有子问题,然后取其中最小的作为结果。核心的代码中的for循环中陷入递归计算子问题,这就是动态规划时间复杂度高的原因。

真的需要「递归地」计算出每一个子问题的结果,然后求最值吗?只需判断哪一个选择最有「潜力」即可

  • 显然应该跳 2 步调到索引 2,因为nums[2]的可跳跃区域涵盖了索引区间[3..6],比其他的都大。如果想求最少的跳跃次数,那么往索引 2 跳必然是最优的选择。
  • 这就是贪心选择性质,我们不需要「递归地」计算出所有选择的具体结果然后比较求最值,而只需要做出那个最有「潜力」,看起来最优的选择即可

image.png
image.png

经典动态规划 之 贪心类型问题 - 图13

  • i和end标记了可以选择的跳跃步数,farthest标记了所有可选择跳跃步数[i..end]中能够跳到的最远距离,jumps记录了跳跃次数。
  • 本算法的时间复杂度 O(N),空间复杂度 O(1),可以说是非常高效,动态规划都被吊起来打了。
  • 贪心算法的实际应用还挺多,比如赫夫曼编码也是一个经典的贪心算法应用。更多时候运用贪心算法可能不是求最优解,而是求次优解以节约时间,比如经典的旅行商问题。
  • 不过我们常见的贪心算法题目,就像本文的题目,大多一眼就能看出来,大不了就先用动态规划求解,如果动态规划都超时,说明该问题存在贪心选择性质无疑了。

四、当老司机学会了贪心算法

image.png

暴力解法

  • 时间复杂度是 O(N^2)

image.png
暴力解法是否有重复计算的部分?是否可以抽象出「状态」,是否对同一个「状态」重复计算了多次?

  • 变化的量就是「状态」:「起点」和「当前油箱的油量」,但这两个状态的组合有不下 O(N^2) 种,显然没有任何优化的空间。所以说这道题肯定不是通过简单的剪枝来优化暴力解法的效率,而是需要我们发现一些隐藏较深的规律,从而减少一些冗余的计算

图像解法

把站点和与其相连的路看做一个整体,将gas[i] - cost[i]作为经过站点i的油量变化值:这样,题目描述的场景就被抽象成了一个环形数组,数组中的第i个元素就是gas[i] - cost[i]。

有了这个环形数组,我们需要判断这个环形数组中是否能够找到一个起点start,使得从这个起点开始的累加和一直大于等于 0

如何判断是否存在这样一个起点start?又如何计算这个起点start的值呢?

  • 将 0 作为起点肯定不行,因为sum在变化的过程中小于 0 了,不符合「累加和一直大于等于 0」的要求。

那如果 0 不能作为起点,谁可以作为起点呢?

  • 看图说话,图像的最低点最有可能可以作为起点:如果把这个「最低点」作为起点,就是说将这个点作为坐标轴原点,就相当于把图像「最大限度」向上平移了

不过,经过平移后图像一定全部在 x 轴以上吗?

  • 不一定,因为还有无解的情况:如果sum(gas[…]) < sum(cost[…]),总油量小于总的消耗,那肯定是没办法环游所有站点的

image.png

贪心解法

用贪心思路解决这道题的关键在于以下这个结论: 当选择站点i作为起点「恰好」无法走到 j,则i和j中间的任意站点k都不可能作为起点

回想一下我们开头说的暴力解法是怎么做的?
如果我发现从i出发无法走到j,那么显然i不可能是起点。现在,我们发现了一个新规律,可以推导出什么?

  • 如果我发现从i出发无法走到j,那么i以及i, j之间的所有站点都不可能作为起点。

看到冗余计算了吗?看到优化的点了吗?
这就是贪心思路的本质,如果找不到重复计算,那就通过问题中一些隐藏较深的规律,来减少冗余计算
image.png