代码随想录算法训练营第三十五天:贪心第二步

代码随想录算法训练营第三十五天:贪心第二步

本周小结!(贪心算法系列一)

#周一

本周正式开始了贪心算法,在关于贪心算法,你该了解这些!​**(opens new window)** 中,我们介绍了什么是贪心以及贪心的套路。

贪心的本质是选择每一阶段的局部最优,从而达到全局最优。

有没有啥套路呢?

不好意思,贪心没套路,就刷题而言,如果感觉好像局部最优可以推出全局最优,然后想不到反例,那就试一试贪心吧!

而严格的数据证明一般有如下两种:

  • 数学归纳法
  • 反证法

数学就不在讲解范围内了,感兴趣的同学可以自己去查一查资料。

正是因为贪心算法有时候会感觉这是常识,本就应该这么做! 所以大家经常看到网上有人说这是一道贪心题目,有人说这不是。

这里说一下我的依据:如果找到局部最优,然后推出整体最优,那么就是贪心,大家可以参考哈。

#周二

贪心算法:分发饼干​**(opens new window)** 中讲解了贪心算法的第一道题目。

这道题目很明显能看出来是用贪心,也是入门好题。

我在文中给出局部最优:大饼干喂给胃口大的,充分利用饼干尺寸喂饱一个,全局最优:喂饱尽可能多的小孩

很多录友都是用小饼干优先先喂饱小胃口的。

后来我想一想,虽然结果是一样的,但是大家的这个思考方式更好一些。

因为用小饼干优先喂饱小胃口的 这样可以尽量保证最后省下来的是大饼干(虽然题目没有这个要求)!

所以还是小饼干优先先喂饱小胃口更好一些,也比较直观。

一些录友不清楚贪心算法:分发饼干​**(opens new window)** 中时间复杂度是怎么来的?

就是快排O(nlog n),遍历O(n),加一起就是还是O(nlogn)。

#周三

接下来就要上一点难度了,要不然大家会误以为贪心算法就是常识判断一下就行了。

贪心算法:摆动序列​**(opens new window)** 中,需要计算最长摇摆序列。

其实就是让序列有尽可能多的局部峰值。

局部最优:删除单调坡度上的节点(不包括单调坡度两端的节点),那么这个坡度就可以有两个局部峰值。

整体最优:整个序列有最多的局部峰值,从而达到最长摆动序列。

在计算峰值的时候,还是有一些代码技巧的,例如序列两端的峰值如何处理。

这些技巧,其实还是要多看多用才会掌握。

#周四

贪心算法:最大子序和​**(opens new window)** 中,详细讲解了用贪心的方式来求最大子序列和,其实这道题目是一道动态规划的题目。

贪心的思路为局部最优:当前"连续和"为负数的时候立刻放弃,从下一个元素重新计算"连续和",因为负数加上下一个元素 "连续和"只会越来越小。从而推出全局最优:选取最大"连续和"

代码很简单,但是思路却比较难。还需要反复琢磨。

针对贪心算法:最大子序和​**(opens new window)** 文章中给出的贪心代码如下;

cpp 复制代码
class Solution {
public:
int maxSubArray(vector<int>& nums) {
int result = INT32_MIN;
int count = 0;
for (int i = 0; i < nums.size(); i++) {
count += nums[i];
if (count > result) { // 取区间累计的最大值(相当于不断确定最大子序终止位置)
result = count;
}
if (count <= 0) count = 0; // 相当于重置最大子序起始位置,因为遇到负数一定是拉低总和
}
return result;
}
};

不少同学都来问,如果数组全是负数这个代码就有问题了,如果数组里有int最小值这个代码就有问题了。

大家不要脑洞模拟哈,可以亲自构造一些测试数据试一试,就发现其实没有问题。

数组都为负数,result记录的就是最大的负数,如果数组里有int最小值,那么最终result就是int最小值。

#总结

本周我们讲解了贪心算法的理论基础​**(opens new window)** ,了解了贪心本质:局部最优推出全局最优。

然后讲解了第一道题目分发饼干​**(opens new window)** ,还是比较基础的,可能会给大家一种贪心算法比较简单的错觉,因为贪心有时候接近于常识。

其实我还准备一些简单的贪心题目,甚至网上很多都质疑这些题目是不是贪心算法。这些题目我没有立刻发出来,因为真的会让大家感觉贪心过于简单,而忽略了贪心的本质:局部最优和全局最优两个关键点。

所以我在贪心系列难度会有所交替,难的题目在于拓展思路,简单的题目在于分析清楚其贪心的本质,后续我还会发一些简单的题目来做贪心的分析。

摆动序列​**(opens new window)** 中大家就初步感受到贪心没那么简单了。

本周最后是最大子序和​**(opens new window)** ,这道题目要用贪心的方式做出来,就比较有难度,都知道负数加上正数之后会变小,但是这道题目依然会让很多人搞混淆,其关键在于:不能让"连续和"为负数的时候加上下一个元素,而不是 不让"连续和"加上一个负数。这块真的需要仔细体会!

122.买卖股票的最佳时机 II

力扣题目链接(opens new window)

给定一个数组,它的第 i 个元素是一支给定股票第 i 天的价格。

设计一个算法来计算你所能获取的最大利润。你可以尽可能地完成更多的交易(多次买卖一支股票)。

注意:你不能同时参与多笔交易(你必须在再次购买前出售掉之前的股票)。

示例 1:

  • 输入: [7,1,5,3,6,4]
  • 输出: 7
  • 解释: 在第 2 天(股票价格 = 1)的时候买入,在第 3 天(股票价格 = 5)的时候卖出, 这笔交易所能获得利润 = 5-1 = 4。随后,在第 4 天(股票价格 = 3)的时候买入,在第 5 天(股票价格 = 6)的时候卖出, 这笔交易所能获得利润 = 6-3 = 3 。

示例 2:

  • 输入: [1,2,3,4,5]
  • 输出: 4
  • 解释: 在第 1 天(股票价格 = 1)的时候买入,在第 5 天 (股票价格 = 5)的时候卖出, 这笔交易所能获得利润 = 5-1 = 4 。注意你不能在第 1 天和第 2 天接连购买股票,之后再将它们卖出。因为这样属于同时参与了多笔交易,你必须在再次购买前出售掉之前的股票。

示例 3:

  • 输入: [7,6,4,3,1]
  • 输出: 0
  • 解释: 在这种情况下, 没有交易完成, 所以最大利润为 0。

提示:

  • 1 <= prices.length <= 3 * 10 ^ 4
  • 0 <= prices[i] <= 10 ^ 4

#算法公开课

《代码随想录》算法视频公开课 ****(opens new window)****​ 贪心算法也能解决股票问题!LeetCode:122.买卖股票最佳时机 II ****(opens new window)****​ ,相信结合视频在看本篇题解,更有助于大家对本题的理解

#思路

本题首先要清楚两点:

  • 只有一只股票!
  • 当前只有买股票或者卖股票的操作

想获得利润至少要两天为一个交易单元。

#贪心算法

这道题目可能我们只会想,选一个低的买入,再选个高的卖,再选一个低的买入...循环反复。

如果想到其实最终利润是可以分解的,那么本题就很容易了!

如何分解呢?

假如第 0 天买入,第 3 天卖出,那么利润为:prices[3] - prices[0]。

相当于(prices[3] - prices[2]) + (prices[2] - prices[1]) + (prices[1] - prices[0])。

此时就是把利润分解为每天为单位的维度,而不是从 0 天到第 3 天整体去考虑!

那么根据 prices 可以得到每天的利润序列:(prices[i] - prices[i - 1])...(prices[1] - prices[0])。

如图:

一些同学陷入:第一天怎么就没有利润呢,第一天到底算不算的困惑中。

第一天当然没有利润,至少要第二天才会有利润,所以利润的序列比股票序列少一天!

从图中可以发现,其实我们需要收集每天的正利润就可以,收集正利润的区间,就是股票买卖的区间,而我们只需要关注最终利润,不需要记录区间

那么只收集正利润就是贪心所贪的地方!

局部最优:收集每天的正利润,全局最优:求得最大利润

局部最优可以推出全局最优,找不出反例,试一试贪心!

对应 C++代码如下:

cpp 复制代码
class Solution {
public:
int maxProfit(vector<int>& prices) {
int result = 0;
for (int i = 1; i < prices.size(); i++) {
result += max(prices[i] - prices[i - 1], 0);
}
return result;
}
};
  • 时间复杂度:O(n)
  • 空间复杂度:O(1)

#动态规划

动态规划将在下一个系列详细讲解,本题解先给出我的 C++代码(带详细注释),想先学习的话,可以看本篇:122.买卖股票的最佳时机II(动态规划)(opens new window)

cpp 复制代码
class Solution {
public:
int maxProfit(vector<int>& prices) {
// dp[i][1]第i天持有的最多现金
// dp[i][0]第i天持有股票后的最多现金
int n = prices.size();
vector<vector<int>> dp(n, vector<int>(2, 0));
dp[0][0] -= prices[0]; // 持股票
for (int i = 1; i < n; i++) {
// 第i天持股票所剩最多现金 = max(第i-1天持股票所剩现金, 第i-1天持现金-买第i天的股票)
dp[i][0] = max(dp[i - 1][0], dp[i - 1][1] - prices[i]);
// 第i天持有最多现金 = max(第i-1天持有的最多现金,第i-1天持有股票的最多现金+第i天卖出股票)
dp[i][1] = max(dp[i - 1][1], dp[i - 1][0] + prices[i]);
}
return max(dp[n - 1][0], dp[n - 1][1]);
}
};
  • 时间复杂度: O ( n ) O(n) O(n)
  • 空间复杂度: O ( n ) O(n) O(n)

#总结

股票问题其实是一个系列的,属于动态规划的范畴,因为目前在讲解贪心系列,所以股票问题会在之后的动态规划系列中详细讲解。

可以看出有时候,贪心往往比动态规划更巧妙,更好用,所以别小看了贪心算法

本题中理解利润拆分是关键点! 不要整块的去看,而是把整体利润拆为每天的利润。

一旦想到这里了,很自然就会想到贪心了,即:只收集每天的正利润,最后稳稳的就是最大利润了。

55. 跳跃游戏

力扣题目链接(opens new window)

给定一个非负整数数组,你最初位于数组的第一个位置。

数组中的每个元素代表你在该位置可以跳跃的最大长度。

判断你是否能够到达最后一个位置。

示例 1:

  • 输入: [2,3,1,1,4]
  • 输出: true
  • 解释: 我们可以先跳 1 步,从位置 0 到达 位置 1, 然后再从位置 1 跳 3 步到达最后一个位置。

示例 2:

  • 输入: [3,2,1,0,4]
  • 输出: false
  • 解释: 无论怎样,你总会到达索引为 3 的位置。但该位置的最大跳跃长度是 0 , 所以你永远不可能到达最后一个位置。

#算法公开课

《代码随想录》算法视频公开课 ****(opens new window)****​ 贪心算法,怎么跳跃不重要,关键在覆盖范围 | LeetCode:55.跳跃游戏 ****(opens new window)****​ ,相信结合视频在看本篇题解,更有助于大家对本题的理解

#思路

刚看到本题一开始可能想:当前位置元素如果是 3,我究竟是跳一步呢,还是两步呢,还是三步呢,究竟跳几步才是最优呢?

其实跳几步无所谓,关键在于可跳的覆盖范围!

不一定非要明确一次究竟跳几步,每次取最大的跳跃步数,这个就是可以跳跃的覆盖范围。

这个范围内,别管是怎么跳的,反正一定可以跳过来。

那么这个问题就转化为跳跃覆盖范围究竟可不可以覆盖到终点!

每次移动取最大跳跃步数(得到最大的覆盖范围),每移动一个单位,就更新最大覆盖范围。

贪心算法局部最优解:每次取最大跳跃步数(取最大覆盖范围),整体最优解:最后得到整体最大覆盖范围,看是否能到终点

局部最优推出全局最优,找不出反例,试试贪心!

如图:

i 每次移动只能在 cover 的范围内移动,每移动一个元素,cover 得到该元素数值(新的覆盖范围)的补充,让 i 继续移动下去。

而 cover 每次只取 max(该元素数值补充后的范围, cover 本身范围)。

如果 cover 大于等于了终点下标,直接 return true 就可以了。

C++代码如下:

cpp 复制代码
class Solution {
public:
bool canJump(vector<int>& nums) {
int cover = 0;
if (nums.size() == 1) return true; // 只有一个元素,就是能达到
for (int i = 0; i <= cover; i++) { // 注意这里是小于等于cover
cover = max(i + nums[i], cover);
if (cover >= nums.size() - 1) return true; // 说明可以覆盖到终点了
}
return false;
}
};
  • 时间复杂度: O(n)
  • 空间复杂度: O(1)

#总结

这道题目关键点在于:不用拘泥于每次究竟跳几步,而是看覆盖范围,覆盖范围内一定是可以跳过来的,不用管是怎么跳的。

大家可以看出思路想出来了,代码还是非常简单的。

一些同学可能感觉,我在讲贪心系列的时候,题目和题目之间貌似没有什么联系?

是真的就是没什么联系,因为贪心无套路!没有个整体的贪心框架解决一系列问题,只能是接触各种类型的题目锻炼自己的贪心思维!

45.跳跃游戏 II

力扣题目链接(opens new window)

给定一个非负整数数组,你最初位于数组的第一个位置。

数组中的每个元素代表你在该位置可以跳跃的最大长度。

你的目标是使用最少的跳跃次数到达数组的最后一个位置。

示例:

  • 输入: [2,3,1,1,4]
  • 输出: 2
  • 解释: 跳到最后一个位置的最小跳跃数是 2。从下标为 0 跳到下标为 1 的位置,跳 1 步,然后跳 3 步到达数组的最后一个位置。

说明: 假设你总是可以到达数组的最后一个位置。

#算法公开课

《代码随想录》算法视频公开课 ****(opens new window)****​ 贪心算法,最少跳几步还得看覆盖范围 | LeetCode: 45.跳跃游戏 II ****(opens new window)****​ ,相信结合视频在看本篇题解,更有助于大家对本题的理解

#思路

本题相对于55.跳跃游戏​**(opens new window)** 还是难了不少。

但思路是相似的,还是要看最大覆盖范围。

本题要计算最少步数,那么就要想清楚什么时候步数才一定要加一呢?

贪心的思路,局部最优:当前可移动距离尽可能多走,如果还没到终点,步数再加一。整体最优:一步尽可能多走,从而达到最少步数。

思路虽然是这样,但在写代码的时候还不能真的能跳多远就跳多远,那样就不知道下一步最远能跳到哪里了。

所以真正解题的时候,要从覆盖范围出发,不管怎么跳,覆盖范围内一定是可以跳到的,以最小的步数增加覆盖范围,覆盖范围一旦覆盖了终点,得到的就是最少步数!

这里需要统计两个覆盖范围,当前这一步的最大覆盖和下一步最大覆盖

如果移动下标达到了当前这一步的最大覆盖最远距离了,还没有到终点的话,那么就必须再走一步来增加覆盖范围,直到覆盖范围覆盖了终点。

如图:

图中覆盖范围的意义在于,只要红色的区域,最多两步一定可以到!(不用管具体怎么跳,反正一定可以跳到)

#方法一

从图中可以看出来,就是移动下标达到了当前覆盖的最远距离下标时,步数就要加一,来增加覆盖距离。最后的步数就是最少步数。

这里还是有个特殊情况需要考虑,当移动下标达到了当前覆盖的最远距离下标时

  • 如果当前覆盖最远距离下标不是是集合终点,步数就加一,还需要继续走。
  • 如果当前覆盖最远距离下标就是是集合终点,步数不用加一,因为不能再往后走了。

C++代码如下:(详细注释)

cpp 复制代码
// 版本一
class Solution {
public:
int jump(vector<int>& nums) {
if (nums.size() == 1) return 0;
int curDistance = 0;    // 当前覆盖最远距离下标
int ans = 0;            // 记录走的最大步数
int nextDistance = 0;   // 下一步覆盖最远距离下标
for (int i = 0; i < nums.size(); i++) {
nextDistance = max(nums[i] + i, nextDistance);  // 更新下一步覆盖最远距离下标
if (i == curDistance) {                         // 遇到当前覆盖最远距离下标
ans++;                                  // 需要走下一步
curDistance = nextDistance;             // 更新当前覆盖最远距离下标(相当于加油了)
if (nextDistance >= nums.size() - 1) break;  // 当前覆盖最远距到达集合终点,不用做ans++操作了,直接结束
}
}
return ans;
}
};
  • 时间复杂度: O(n)
  • 空间复杂度: O(1)

#方法二

依然是贪心,思路和方法一差不多,代码可以简洁一些。

针对于方法一的特殊情况,可以统一处理,即:移动下标只要遇到当前覆盖最远距离的下标,直接步数加一,不考虑是不是终点的情况。

想要达到这样的效果,只要让移动下标,最大只能移动到 nums.size - 2 的地方就可以了。

因为当移动下标指向 nums.size - 2 时:

  • 如果移动下标等于当前覆盖最大距离下标, 需要再走一步(即 ans++),因为最后一步一定是可以到的终点。(题目假设总是可以到达数组的最后一个位置),如图:
  • 如果移动下标不等于当前覆盖最大距离下标,说明当前覆盖最远距离就可以直接达到终点了,不需要再走一步。如图:

代码如下:

cpp 复制代码
// 版本二
class Solution {
public:
int jump(vector<int>& nums) {
int curDistance = 0;    // 当前覆盖的最远距离下标
int ans = 0;            // 记录走的最大步数
int nextDistance = 0;   // 下一步覆盖的最远距离下标
for (int i = 0; i < nums.size() - 1; i++) { // 注意这里是小于nums.size() - 1,这是关键所在
nextDistance = max(nums[i] + i, nextDistance); // 更新下一步覆盖的最远距离下标
if (i == curDistance) {                 // 遇到当前覆盖的最远距离下标
curDistance = nextDistance;         // 更新当前覆盖的最远距离下标
ans++;
}
}
return ans;
}
};
  • 时间复杂度: O(n)
  • 空间复杂度: O(1)

可以看出版本二的代码相对于版本一简化了不少!

其精髓在于控制移动下标 i 只移动到 nums.size() - 2 的位置,所以移动下标只要遇到当前覆盖最远距离的下标,直接步数加一,不用考虑别的了。

#总结

相信大家可以发现,这道题目相当于55.跳跃游戏​**(opens new window)** 难了不止一点。

但代码又十分简单,贪心就是这么巧妙。

理解本题的关键在于:以最小的步数增加最大的覆盖范围,直到覆盖范围覆盖了终点,这个范围内最少步数一定可以跳到,不用管具体是怎么跳的,不纠结于一步究竟跳一个单位还是两个单位。

相关推荐
向宇it10 分钟前
【unity小技巧】Unity 四叉树算法实现空间分割、物体存储并进行查询和碰撞检测
开发语言·算法·游戏·unity·游戏引擎
无限大.10 分钟前
冒泡排序(结合动画进行可视化分析)
算法·排序算法
我真的太难了啊15 分钟前
学习QT第二天
开发语言·qt·学习
伏虎山真人18 分钟前
QT程序开机自启方案
开发语言·qt
lsx20240627 分钟前
Ruby 模块(Module)
开发语言
走向自由30 分钟前
Leetcode 最长回文子串
数据结构·算法·leetcode·回文·最长回文
豆包MarsCode36 分钟前
我用豆包MarsCode IDE 做了一个 CSS 权重小组件
开发语言·前端·javascript·css·ide·html
铅华尽37 分钟前
Java---JDBC案例--手机信息管理系统
java·开发语言·智能手机
nuo5342021 小时前
The 2024 ICPC Kunming Invitational Contest
c语言·数据结构·c++·算法
luckilyil1 小时前
Leetcode 每日一题 11. 盛最多水的容器
算法·leetcode