1. 贪心算法训练营 Day24 学习概览
今天进入代码随想录算法训练营的第24天,我们将重点攻克贪心算法part02的系列题目。贪心算法作为算法面试中的高频考点,其核心在于"局部最优导致全局最优"的解题思路。在今天的训练中,我们将通过5道经典LeetCode题目(122.买卖股票的最佳时机II、55.跳跃游戏、45.跳跃游戏II、1005.K次取反后最大化的数组和、134.加油站)来掌握贪心算法的实际应用技巧。
提示:贪心算法最难的不是代码实现,而是如何证明局部最优策略能导致全局最优解。建议每道题先自己思考贪心策略的可行性,再看解析验证。
2. 贪心算法核心思想回顾
2.1 贪心算法的三大特性
贪心算法之所以能高效解决某些问题,依赖于问题本身具备的特定性质:
- 贪心选择性质:每一步的局部最优选择能导致最终的全局最优解
- 最优子结构:问题的最优解包含子问题的最优解
- 无后效性:某个状态以前的过程不会影响以后的状态,只与当前状态有关
2.2 贪心与动态规划的差异
虽然贪心和DP都用于优化问题,但它们的解题思路有本质区别:
- 贪心算法:每步选择都采取当前状态下最优,不可回退
- 动态规划:会保存以前的运算结果,并根据之前结果对当前进行选择,有回退功能
以背包问题为例:
- 0-1背包:只能用动态规划(物品不可分割)
- 分数背包:可以用贪心算法(物品可分割)
3. 买卖股票的最佳时机II(LeetCode 122)
3.1 问题重述
给定一个数组 prices,其中 prices[i] 表示某支股票第 i 天的价格。你可以无限次地完成交易(买入和卖出一支股票),但必须在再次购买前出售掉之前的股票。计算能获得的最大利润。
示例:
输入:prices = [7,1,5,3,6,4]
输出:7
解释:在第2天买入,第3天卖出,利润4;第4天买入,第5天卖出,利润3。总利润7。
3.2 贪心策略解析
这道题的贪心策略非常巧妙:收集所有的上坡利润。具体来说:
- 将总利润分解为每天之间的利润(price[i] - price[i-1])
- 只累加正利润,忽略负利润
- 最终总和就是最大利润
python复制def maxProfit(prices):
profit = 0
for i in range(1, len(prices)):
diff = prices[i] - prices[i-1]
if diff > 0:
profit += diff
return profit
3.3 正确性证明
为什么这样能获得最大利润?考虑三种情况:
- 持续上涨:每天买卖等同于在最低点买入最高点卖出
- 持续下跌:不进行任何交易
- 波动行情:只在上涨段获利,避开下跌段
这种策略实际上捕捉了所有的价格上涨区间,而价格下跌区间被自然避开。
4. 跳跃游戏(LeetCode 55)
4.1 问题描述
给定一个非负整数数组 nums,你最初位于数组的第一个下标。数组中的每个元素代表你在该位置可以跳跃的最大长度。判断你是否能够到达最后一个下标。
示例:
输入:nums = [2,3,1,1,4]
输出:true
解释:从位置0跳1步到位置1,然后跳3步到最后一个位置。
4.2 贪心策略设计
这道题的贪心策略是:维护一个最远可到达位置:
- 初始化最远位置为0
- 遍历数组,更新最远位置为max(当前最远,i + nums[i])
- 如果最远位置≥最后下标,返回true
- 如果i > 最远位置,说明无法继续前进,返回false
python复制def canJump(nums):
max_reach = 0
for i in range(len(nums)):
if i > max_reach:
return False
max_reach = max(max_reach, i + nums[i])
if max_reach >= len(nums) - 1:
return True
return True
4.3 边界情况处理
需要注意几种特殊情况:
- 数组长度为1:直接返回true
- 首元素为0且数组长度>1:直接返回false
- 中间遇到0:需要检查之前的最远距离是否能跳过这个0
5. 跳跃游戏II(LeetCode 45)
5.1 问题升级
这是跳跃游戏的进阶版,现在假设你总是可以到达数组的最后一个位置,求到达终点的最小跳跃次数。
示例:
输入:nums = [2,3,1,1,4]
输出:2
解释:跳到位置1(跳1步),然后跳到末尾(跳3步)。
5.2 贪心策略优化
我们需要采用分步贪心的策略:
- 维护当前步能到达的最远位置(current_end)
- 维护下一步能到达的最远位置(max_reach)
- 当遍历到current_end时,必须再跳一步,并更新current_end = max_reach
python复制def jump(nums):
jumps = 0
current_end = 0
max_reach = 0
for i in range(len(nums) - 1): # 不需要处理最后一个元素
max_reach = max(max_reach, i + nums[i])
if i == current_end:
jumps += 1
current_end = max_reach
return jumps
5.3 算法正确性分析
这种策略之所以能得到最小步数,是因为:
- 每次都是在当前步的范围内,选择能跳最远的位置作为下一步的起点
- 只有在必须跳的时候(到达当前步的边界)才增加步数
- 确保每一步都最大化覆盖范围,从而最小化总步数
6. K次取反后最大化的数组和(LeetCode 1005)
6.1 问题描述
给定一个整数数组nums和一个整数k,你可以进行以下操作恰好k次:选择数组中的一个元素并将其取反。返回可能的最大数组和。
示例:
输入:nums = [4,2,3], k = 1
输出:5
解释:选择2取反,数组变为[4,-2,3],和为5。
6.2 贪心策略制定
这个问题需要分阶段采用不同的贪心策略:
- 优先翻转负数:从小到大翻转负数(绝对值大的负数优先)
- 处理剩余的k:如果还有剩余翻转次数,反复翻转最小的元素
具体步骤:
- 将数组按绝对值从大到小排序
- 遍历数组,优先翻转负数
- 如果k还有剩余且为奇数,翻转最小的元素一次
python复制def largestSumAfterKNegations(nums, k):
nums.sort(key=lambda x: -abs(x))
for i in range(len(nums)):
if nums[i] < 0 and k > 0:
nums[i] = -nums[i]
k -= 1
if k % 2 == 1:
nums[-1] = -nums[-1]
return sum(nums)
6.3 策略有效性验证
这种策略的有效性基于:
- 对于负数,取反能直接增加总和(相当于加上两倍绝对值)
- 对于正数,只有在k为奇数时才需要取反(偶数次取反相互抵消)
- 排序确保我们总是处理影响最大的元素
7. 加油站问题(LeetCode 134)
7.1 问题描述
在一条环路上有n个加油站,其中第i个加油站有汽油gas[i],从第i个加油站到第i+1个加油站消耗cost[i]。你有一辆油箱无限大的车,从某个加油站出发时油箱为空。判断是否能绕环路一周,如果可以返回出发的加油站下标,否则返回-1。
示例:
输入:gas = [1,2,3,4,5], cost = [3,4,5,1,2]
输出:3
解释:
从3号站出发,油箱:
0 + 4 = 4 → 4 - 1 = 3
3 + 5 = 8 → 8 - 2 = 6
6 + 1 = 7 → 7 - 3 = 4
4 + 2 = 6 → 6 - 4 = 2
2 + 3 = 5 → 5 - 5 = 0
成功绕行一周。
7.2 贪心算法实现
这个问题的最优解法是一次遍历贪心法:
- 计算总gas和总cost,如果总gas < 总cost,直接返回-1
- 维护当前油量current_tank和起始站start
- 遍历每个站,更新current_tank += gas[i] - cost[i]
- 如果current_tank < 0,重置start为i+1,current_tank归零
- 最后返回start
python复制def canCompleteCircuit(gas, cost):
if sum(gas) < sum(cost):
return -1
start = 0
current_tank = 0
for i in range(len(gas)):
current_tank += gas[i] - cost[i]
if current_tank < 0:
start = i + 1
current_tank = 0
return start
7.3 算法正确性证明
这个算法的正确性基于两个关键观察:
- 如果总gas ≥ 总cost,必定存在解(题目保证唯一解)
- 如果从A不能到达B,那么A和B之间的任何站都不能作为起点
- 因为A能到达中间站说明A出发时有剩余油量
- 如果直接从中间站出发,油量更少,更不可能到达B
8. 贪心算法解题模板与技巧
8.1 通用解题步骤
通过今天的五道题目,我们可以总结出贪心算法的一般解题步骤:
- 问题转化:将问题分解为一系列子问题/步骤
- 贪心选择:确定每个步骤的局部最优选择标准
- 可行性验证:证明局部最优能导致全局最优
- 实现优化:寻找高效的数据结构实现贪心策略
8.2 常见贪心问题类型
- 分配问题:如分发饼干、任务调度
- 区间问题:如合并区间、安排会议室
- 买卖问题:如股票买卖系列
- 跳跃游戏:如今天的两道跳跃问题
- 加油站问题:环形路线上的资源分配
8.3 调试与验证技巧
在实际解题中,我总结了几条验证贪心策略有效性的方法:
- 反证法:假设存在更优解,推导矛盾
- 数学归纳法:证明每个步骤的选择保持最优性
- 交换论证:比较不同策略的优劣
- 极端案例测试:构造边界情况验证算法鲁棒性
9. 贪心算法常见误区
9.1 误区一:贪心策略想当然
很多初学者容易犯的错误是,没有严格证明贪心策略的正确性就匆忙实现。例如在跳跃游戏II中,可能会错误地认为每次都跳最远就是最优解(实际上这样会导致跳跃次数不是最小)。
经验:每想出一个贪心策略,至少要用2-3个测试案例验证,包括常规情况和边界情况。
9.2 误区二:忽视问题前提条件
贪心算法能适用的前提是问题具有贪心选择性质。例如在背包问题中,只有分数背包可以用贪心,0-1背包就不行。在今天的加油站问题中,总gas≥总cost的前提是关键。
9.3 误区三:实现细节出错
即使策略正确,实现细节也可能影响最终结果。例如:
- 在跳跃游戏II中,循环范围应该是len(nums)-1而不是len(nums)
- 在K次取反问题中,最后的k处理需要考虑奇偶性
- 在加油站问题中,current_tank的更新顺序很重要
10. 进阶练习建议
为了巩固今天的贪心算法知识,建议尝试以下LeetCode题目:
-
- 摆动序列(中等)
-
- 根据身高重建队列(中等)
-
- 用最少数量的箭引爆气球(中等)
-
- 无重叠区间(中等)
-
- 划分字母区间(中等)
在解决这些问题时,建议:
- 先自己思考贪心策略,不要直接看答案
- 写出策略的伪代码,验证其正确性
- 实现代码后,用多个测试案例验证
- 对比讨论区的优秀解法,学习更优思路
贪心算法的精妙之处在于,一旦找到正确的贪心策略,代码实现往往非常简洁高效。这种"四两拨千斤"的特点,正是它在算法竞赛和面试中备受青睐的原因。
