文章目录
- 简介
- [121. 买卖股票的最佳时机](#121. 买卖股票的最佳时机)
- [55. 跳跃游戏](#55. 跳跃游戏)
- [45. 跳跃游戏 II](#45. 跳跃游戏 II)
- [763. 划分字母区间](#763. 划分字母区间)
- 个人学习总结
简介
本篇博客深入剖析 LeetCode 热题 100 中贪心算法板块的经典题目,涵盖了买卖股票的最佳时机、跳跃游戏系列及划分字母区间。文章侧重于讲解如何通过贪心策略将复杂的遍历问题简化为单次扫描,通过维护动态边界(如最低价格、最远覆盖距离)来高效获取最优解,旨在帮助读者掌握贪心思维在解决最值与区间覆盖问题中的应用技巧。
121. 买卖股票的最佳时机
问题描述
给定一个数组 prices ,它的第 i 个元素 prices[i] 表示一支给定股票第 i 天的价格。
你只能选择 某一天 买入这只股票,并选择在 未来的某一个不同的日子 卖出该股票。设计一个算法来计算你所能获取的最大利润。
返回你可以从这笔交易中获取的最大利润。如果你不能获取任何利润,返回 0 。
示例:
java
示例 1:
输入:[7,1,5,3,6,4]
输出:5
解释:在第 2 天(股票价格 = 1)的时候买入,在第 5 天(股票价格 = 6)的时候卖出,最大利润 = 6-1 = 5 。
注意利润不能是 7-1 = 6, 因为卖出价格需要大于买入价格;同时,你不能在买入前卖出股票。
标签提示: 动态规划、数组
解题思想
这道题的目的是寻找数组中两个数字的最大差值(后面的数字减去前面的数字)。暴力解法是双层循环遍历所有买卖组合,但时间复杂度高达O(N^2)。
更优的解法是贪心算法(或称一次遍历法)。核心思想在于:对于一个特定的卖出点(当天价格),要想获得最大利润,必然要在它之前的历史中选择最低价格作为买入点。
因此,我们在遍历数组时,只需要维护一个变量记录历史最低价格,并实时计算"当前价格减去历史最低价格"的利润,更新最大利润即可。这确保了我们永远不会错过任何一个潜在的获利机会。
解题步骤
- 初始化变量:
- currMin:记录遍历过程中遇到的最低价格,初始化为Integer.MAX_VALUE(表示无穷大)。
- ans:记录当前能获得的最大利润,初始化为 0。
- 遍历价格数组:
- 对于当天的价格 prices[i]:
- 更新最低价:如果 prices[i] 比 currMin 还小,说明遇到了更便宜的买入时机,更新 currMin = prices[i]。
- 计算并更新利润:否则(即当前价格不是最低价),假设我们在 currMin 那天买入,今天卖出。计算利润 prices[i] - currMin。如果这个利润比 ans 大,就更新 ans。
- 返回结果:
- 遍历结束后,ans 中存储的就是最大利润。如果全程没有利润(数组一直递减),ans 会保持为 0。
实现代码
java
class Solution {
// 每次都是以当前最低购入价格时购入(遍历过得最低)
// 计算后面抛出能够获得的最高利润
public int maxProfit(int[] prices) {
int currMin = Integer.MAX_VALUE, ans = 0;
for(int i = 0; i < prices.length; i ++){
if(prices[i] < currMin){
currMin = prices[i];
}else{
int tmp = prices[i] - currMin;
ans = Math.max(ans, tmp);
}
}
return ans;
}
}
复杂度分析
- 时间复杂度:O(N)。
只需要对价格数组进行一次遍历,每次循环内的操作都是常数时间 O(1)。 - 空间复杂度:O(1)。
只使用了 currMin 和 ans 两个变量,没有使用额外的线性空间。
55. 跳跃游戏
问题描述
给你一个非负整数数组 nums ,你最初位于数组的 第一个下标 。数组中的每个元素代表你在该位置可以跳跃的最大长度。
判断你是否能够到达最后一个下标,如果可以,返回 true ;否则,返回 false 。
示例:
java
示例 1:
输入:nums = [2,3,1,1,4]
输出:true
解释:可以先跳 1 步,从下标 0 到达下标 1, 然后再从下标 1 跳 3 步到达最后一个下标。
示例 2:
输入:nums = [3,2,1,0,4]
输出:false
解释:无论怎样,总会到达下标为 3 的位置。但该下标的最大跳跃长度是 0 , 所以永远不可能到达最后一个下标。
标签提示: 贪心、数组、动态规划
解题思想
这道题的核心在于贪心算法(Greedy Algorithm)。我们不需要去具体计算每一步应该跳几步才能到达终点,那样会陷入复杂的排列组合或回溯中。
我们可以换个角度思考:"覆盖范围" 。
只要我们能确定一个"当前能到达的最远位置",并且在遍历过程中,这个位置始终能够覆盖到我们当前所在的索引,那么我们就能不断向前推进。
- 如果在遍历到某一点 i 时,发现 i 已经超过了之前所有跳跃能达到的最远位置,说明中间出现了断层,无法到达 i,自然也无法到达终点。
- 只要没有断层,我们不断更新"最远位置",看最终是否能覆盖到最后一个索引。
解题步骤
- 初始化:
定义变量 maxL,初始化为 0,表示当前能够到达的最远索引。 - 遍历数组:
使用循环从第一个元素开始遍历数组(索引 i 从 0 到 nums.length - 1)。 - 检查可达性:
在循环中,首先判断当前索引 i 是否在可达范围内:
如果 i > maxL,说明前面的跳跃步数无法覆盖到位置 i,中间断开了,直接返回 false。 - 更新最远距离:
既然能够到达位置 i,那么从位置 i 出发,我们可以跳到 i + nums[i] 的位置。
使用 Math.max(maxL, i + nums[i]) 来更新 maxL,扩大我们的覆盖范围。 - 提前结束判断:
如果在遍历过程中,maxL 已经大于或等于数组的长度(说明可以直接跳出去或到达终点),可以直接返回 true。 - 循环结束:
如果顺利遍历完数组且没有返回 false,说明一直有路可走,最终返回 true。
实现代码
java
class Solution {
// 不是去寻找最快跳到终点来限制自己,而是能够跳到的范围(不管怎么到达)包含终点
public boolean canJump(int[] nums) {
int maxL = 0;
for(int i = 0; i < nums.length; i ++){
if(i > maxL){
return false;
}
maxL = Math.max(maxL, i + nums[i]);
if(maxL >= nums.length){
return true;
}
}
return true;
}
}
复杂度分析
- 时间复杂度:O(N)。
只需要对数组进行一次遍历,每个元素只被访问一次。 - 空间复杂度:O(1)。
只使用了 maxL 这一个额外变量,没有使用额外的线性空间。
45. 跳跃游戏 II
问题描述
给定一个长度为 n 的 0 索引整数数组 nums。初始位置在下标 0。
每个元素 nums[i] 表示从索引 i 向后跳转的最大长度。换句话说,如果你在索引 i 处,你可以跳转到任意 (i + j) 处:
- 0 <= j <= nums[i] 且
- i + j < n
返回到达 n - 1 的最小跳跃次数。测试用例保证可以到达 n - 1。
示例:
java
示例 1:
输入: nums = [2,3,1,1,4]
输出: 2
解释: 跳到最后一个位置的最小跳跃数是 2。
从下标为 0 跳到下标为 1 的位置,跳 1 步,然后跳 3 步到达数组的最后一个位置。
示例 2:
输入: nums = [2,3,0,1,4]
输出: 2
标签提示: 贪心、数组、动态规划
解题思想
这道题要求用最少的步数跳到数组末尾。虽然可以使用 BFS(广度优先搜索)将每个点看作图的节点来求最短路径,但那样空间复杂度较高。
更优的解法是贪心算法,其本质类似于层序遍历。
核心思想是:"能不跳就不跳,必须跳才跳" 。
我们不需要具体记录跳到了哪个位置,而是关注"当前步数下能覆盖的右边界"。在遍历数组时,我们不断探索当前覆盖范围内能跳到的最远位置。一旦遍历指针走到了当前覆盖的边界,说明我们用当前的步数已经无法再往前走了,此时必须增加步数(跳一次),并将覆盖范围更新为刚才探索到的最远位置。
解题步骤
- 初始化状态:定义当前跳跃步数所覆盖的右边界 end,以及在此范围内下一跳能覆盖的最远位置 maxL。
- 贪心扩展:在当前覆盖范围内遍历元素,计算每个位置作为起跳点能到达的最远距离,并不断更新 maxL。此举旨在确保每次步数增加时,都能获得最大的覆盖增量。
- 边界触发跳跃:当遍历指针触及当前覆盖范围的右边界 i == end 时,说明必须进行下一次跳跃以继续前进。此时步数加 1,并将当前覆盖范围更新为 maxL。
- 终止判定:每次更新右边界后,立即判断新边界是否已包含数组末尾。若满足条件,则表明已用最少的步数抵达终点,直接返回当前步数。
实现代码
java
class Solution {
// 不只是判断能否跳到,而是得最少得步数跳到,那么就得能不跳就不跳,必须跳才跳
public int jump(int[] nums) {
if(nums.length <= 1){
return 0;
}
int n = nums.length;
int end = 0; // 当前步数下的边界,初始为0
int maxL = 0; // 能够跳到最远的地方
int step = 0; // 当前使用步数
for(int i = 0; i < n; i ++){
// 更新最远距离
maxL = Math.max(maxL, i + nums[i]);
// 判断是否到边界
if(i == end){
end = maxL; // 更新边界
step ++; //增加步数
if(end >= n - 1){
return step;
}
}
}
return step;
}
}
复杂度分析
- 时间复杂度:O(N)。
只需要对数组进行一次遍历。虽然内部有 if 判断,但每个元素最多被访问一次。 - 空间复杂度:O(1)。
只使用了 end、maxL 和 step 几个变量,没有使用额外的数组或递归栈。
763. 划分字母区间
问题描述
给你一个字符串 s 。我们要把这个字符串划分为尽可能多的片段,同一字母最多出现在一个片段中。例如,字符串 "ababcc" 能够被分为 ["abab", "cc"],但类似 ["aba", "bcc"] 或 ["ab", "ab", "cc"] 的划分是非法的。
注意,划分结果需要满足:将所有划分结果按顺序连接,得到的字符串仍然是 s 。
返回一个表示每个字符串片段的长度的列表。
示例:
java
示例 1:
输入:s = "ababcbacadefegdehijhklij"
输出:[9,7,8]
解释:
划分结果为 "ababcbaca"、"defegde"、"hijhklij" 。
每个字母最多出现在一个片段中。
像 "ababcbacadefegde", "hijhklij" 这样的划分是错误的,因为划分的片段数较少。
示例 2:
输入:s = "eccbbbbdec"
输出:[10]
标签提示: 贪心、哈希表、双指针、字符串
解题思想
这道题的核心思想是贪心算法结合区间合并的思想。
题目要求同一个字母只能出现在同一个片段中,这意味着如果一个字符在某个位置出现,那么它最后出现的位置之前的所有字符都必须归属于同一个片段。因此,我们可以将问题转化为寻找若干个不相交的闭区间。
解题的关键在于维护一个"当前片段的右边界"。在遍历字符串的过程中,如果遇到了某个字符,且该字符最后出现的位置比当前的右边界更远,我们就必须将右边界扩展过去,以确保该字符的所有出现都在当前片段内。当遍历指针到达这个右边界时,说明该片段内的所有字符都不会再出现在后面,此时即可进行分割。
解题步骤
- 统计最远位置:
首先遍历一次字符串,利用哈希表或数组记录每个字符在字符串中最后出现的位置索引。这一步是为了确定每个字符的"覆盖范围"。 - 确定分割边界:
再次遍历字符串,维护两个指针:start(当前片段的起始位置)和 end(当前片段的结束边界)。
在遍历过程中,不断检查当前字符的最远出现位置。如果该位置大于当前的 end,则更新 end。这意味着为了包含当前字符,当前片段必须延伸到该位置。 - 执行分割逻辑:
当遍历指针 i 正好移动到当前边界 end 时,说明在 [start, end] 范围内的所有字符,其最后出现位置都没有超出这个范围。此时找到了一个满足条件的片段。
记录该片段的长度(end - start + 1),并将 start 更新为 end + 1,开始寻找下一个片段。
实现代码
java
class Solution {
// 有点类似于区间合并(覆盖区间),同一字母只能出现在一个片段中,那么一个字符的首尾即为一个区间
// 那么可以记住当前字母出现的尾部(通过一次遍历就可以确定)
// 同一字母尾部为一个结尾区间(待定),然后判断区间内的字母尾部是否更远(更新),当没有了就为一个片段
public List<Integer> partitionLabels(String s) {
List<Integer> ans = new ArrayList<>();
// 记录出现字母的末尾
int n = s.length();
int[] last = new int[26];
for(int i = 0; i < n; i ++){
last[s.charAt(i) - 'a'] = i;
}
// 遍历,寻找分割点
int start = 0; // 当前开始位置
int end = 0; // 当前结束边界
for(int i = 0; i < n; i ++){
char c = s.charAt(i);
end = Math.max(end, last[c - 'a']); // 判断包含字符尾部是否超出边界(超出则更新)
// 当判断到单签边界,则可以分割
if(i == end){
ans.add(end - start + 1);
start = end + 1;
}
}
return ans;
}
}
复杂度分析
- 时间复杂度:O(N)。
需要遍历两次字符串:第一次统计每个字符的最后出现位置,第二次进行贪心分割。由于字符集大小固定(26个字母),哈希数组查找为 O(1),因此总时间与字符串长度 N 成正比。 - 空间复杂度:O(1)。
使用了一个长度为 26 的数组来存储最后出现位置,这是常数级的空间开销。不考虑输出结果列表的空间,额外空间复杂度为 O(1)。
个人学习总结
通过对贪心算法的专项学习,我深刻体会到"局部最优推导全局最优"的核心思想。贪心算法的优势在于能将看似需要回溯或动态规划的高复杂度问题,在特定条件下简化为 O(N) 的一次遍历。解题的关键在于准确定义贪心选择性质,例如维护历史极值(股票最低价)或最大覆盖范围(跳跃边界)。这种思维方式极大地提升了代码的执行效率,是解决区间覆盖与极值问题的有力武器。