🏃 贪心算法进阶 — 跳跃游戏与区间调度

跳跃游戏、用最少数箭射爆气球、加油站——贪心策略的精妙之处

📖 贪心进阶的核心思想

第17课我们学了贪心的基础:每步选择局部最优。但进阶贪心的难点在于如何定义"局部最优"——不同的贪心策略可能导致完全不同的结果,而错误的策略会遗漏最优解。本课通过三类经典问题,帮你建立贪心的正确直觉。

贪心进阶三大题型: 1. 跳跃类 — 维护"最远可达位置" LC 55 跳跃游戏 (能否到达) LC 45 跳跃游戏 II (最少步数) 2. 区间调度 — 按端点排序 + 贪心选择 LC 452 用最少数箭射爆气球 LC 435 无重叠区间 LC 763 划分字母区间 3. 分配类 — 局部最优分配 LC 134 加油站 LC 135 分发糖果 贪心的验证方法: → 交换论证法: 假设最优解不按贪心策略, 则可以通过交换得到不更差的解 → 贪心正确

🎯 题目一:跳跃游戏 (LC 55)

题目描述:给定一个非负整数数组 nums,你最初位于数组的第一个下标。数组中的每个元素代表你在该位置可以跳跃的最大长度。判断是否能到达最后一个下标。

示例: nums = [2,3,1,1,4] 位置: 0 1 2 3 4 值: 2 3 1 1 4 从位置0可以跳到位置1或2 从位置1可以跳到位置2,3,4 ✓ 贪心策略: 维护最远可达位置 ───────────────────────────── i=0: max_reach = max(0, 0+2) = 2 i=1: 1 ≤ 2(可达), max_reach = max(2, 1+3) = 4 i=2: 2 ≤ 4(可达), max_reach = max(4, 2+1) = 4 i=3: 3 ≤ 4(可达), max_reach = max(4, 3+1) = 4 i=4: 4 ≤ 4(可达) ✓ 能到达终点! 反例: nums = [3,2,1,0,4] i=3: 3 ≤ 3(可达), 但 nums[3]=0 i=4: 4 > 3(不可达) ✗ 不能到达终点!

思路分析

维护最远可达:遍历数组,维护变量 max_reach 表示当前能到达的最远位置。如果当前位置 i > max_reach,说明无法到达位置 i,返回 False。如果 max_reach >= len(nums)-1,返回 True。

代码实现

class Solution:
    def canJump(self, nums: List[int]) -> bool:
        max_reach = 0
        for i, jump in enumerate(nums):
            if i > max_reach:   # 当前位置不可达
                return False
            max_reach = max(max_reach, i + jump)
            if max_reach >= len(nums) - 1:
                return True
        return True

复杂度分析

时间复杂度:O(n),空间复杂度:O(1)

🎯 题目二:跳跃游戏 II (LC 45)

题目描述:给定一个非负整数数组,你最初位于数组的第一个下标。求到达最后一个下标的最少跳跃次数。保证可以到达终点。

BFS层次遍历视角: nums = [2,3,1,1,4] 第0跳: 从位置0出发, 可到达 [1, 2] 第1跳: 从[1,2]出发, 可到达 [2,3,4] 到达位置4! → 最少2跳 贪心策略: 维护当前跳的边界和下一跳的最远位置 ┌────────────────────────────────────────┐ │ cur_end=0, next_end=0, jumps=0 │ │ i=0: next_end=max(0,0+2)=2 │ │ i==cur_end → jumps=1,cur_end=2 │ │ i=1: next_end=max(2,1+3)=4 │ │ i==cur_end → jumps=2,cur_end=4 │ │ cur_end>=4 → 可以到达终点! │ └────────────────────────────────────────┘

思路分析

BFS思路:把跳跃看作BFS的层次遍历——第1跳能到达的范围是第1层,第2跳从第1层出发能到达的范围是第2层。维护 cur_end(当前层的右边界)和 next_end(下一层的最远位置),每当到达当前层右边界就 jump+1 并更新边界。

代码实现

class Solution:
    def jump(self, nums: List[int]) -> int:
        jumps = 0
        cur_end = 0   # 当前跳的右边界
        next_end = 0  # 下一跳的最远位置
        
        for i in range(len(nums) - 1):  # 不需要跳到最后一格
            next_end = max(next_end, i + nums[i])
            if i == cur_end:    # 到达当前跳的边界
                jumps += 1
                cur_end = next_end
        
        return jumps

复杂度分析

时间复杂度:O(n),空间复杂度:O(1)

LC 45 的关键:i == cur_end 时才 jump,意味着当前跳能覆盖的范围已经全部探索完,必须再跳一次才能继续前进。这就是"层次遍历"的贪心本质——每跳一次都跳到能到达的最远位置。

🎯 题目三:用最少数箭射爆气球 (LC 452)

题目描述:一个箭可以沿 x 轴从不同角度射出。给定气球在 x 轴上的水平直径范围,射出一支箭如果穿过气球的直径范围,气球会被射爆。求射爆所有气球所需的最少箭数。

示例: points = [[10,16],[2,8],[1,6],[7,12]] 排序(按右端点): [1,6], [2,8], [7,12], [10,16] 气球图示: ┌──────┐ ┌─┤1 6├──────┐ │2 8│ ┌─┤10 16├─┐ └─────────┘ ┌─┤7 12├─┘ └──────────┘ 贪心: 按右端点排序, 尽量让一支箭射穿更多气球 第1箭: 射在位置6 → 射爆 [1,6] 和 [2,8] 第2箭: 射在位置12 → 射爆 [7,12] 和 [10,16] 最少2箭! 贪心策略: 按右端点排序, 遍历每个气球: - 如果左端点 > 当前箭位 → 需要新箭, 射在当前气球右端点 - 否则当前箭可以同时射穿

思路分析

按右端点排序:为了让一支箭射穿尽可能多的气球,我们应该将箭射在"最早结束"的气球的右端点。这样,所有左端点 ≤ 该右端点的气球都会被射穿。贪心策略:按右端点排序,每次需要新箭时,射在当前气球的右端点。

代码实现

class Solution:
    def findMinArrowShots(self, points: List[List[int]]) -> int:
        if not points:
            return 0
        points.sort(key=lambda x: x[1])  # 按右端点排序
        arrows = 1
        arrow_pos = points[0][1]
        
        for start, end in points[1:]:
            if start > arrow_pos:   # 需要新箭
                arrows += 1
                arrow_pos = end
        
        return arrows

复杂度分析

时间复杂度:O(n log n)(排序),空间复杂度:O(1)

LC 452 和 LC 435 是对偶问题!LC 452 求"最少的箭"= 求"最大的不重叠区间数",而 LC 435 求"最少的移除数"= 总数 - 最大不重叠区间数。两者贪心策略相同:按右端点排序,贪心选择最早结束的。

🔬 贪心策略的证明方法

1. 交换论证法

核心思想:假设最优解不按贪心策略选择,则可以通过交换得到一个不更差的解,从而证明贪心策略的正确性。

以区间调度为例:贪心策略是"选最早结束的区间"。假设最优解选了结束更晚的区间 B 而不是更早结束的 A。因为 A 结束更早,选 A 不会让后续可选区间变少,所以将 B 替换为 A 仍然是合法解。因此,选最早结束的区间不会更差。

2. 反证法

假设贪心解不是最优解,找到第一个不同于最优解的决策点,证明该点选贪心策略不会比选最优策略差,矛盾。

3. 贪心选择性质 + 最优子结构

这是最严格的形式化证明,但在面试中通常只需要口头解释即可。

# 常见贪心策略总结
策略         适用场景                  经典题目
───────────────────────────────────────────────────
最早结束      区间调度/选最多不重叠       LC 435, 452
最远可达      跳跃/覆盖问题              LC 45, 55
最大/最小     分配/配对问题              LC 135, 621
相邻交换      排序最优                   LC 179, 406
局部最优累加  可分解问题                  LC 122, 392

🎯 附加题:加油站 (LC 134)

题目描述:在一条环路上有 n 个加油站,第 i 个加油站有汽油 gas[i] 升,从第 i 个加油站开往第 i+1 个加油站需要消耗汽油 cost[i] 升。求从哪个加油站出发能绕环路一周,返回起始索引。

贪心关键洞察: 1. 如果总gas < 总cost → 不可能绕一圈 2. 如果总gas ≥ 总cost → 一定有解! 3. 从位置0出发, 如果在位置i油量变负, 则从位置0到i之间的任何位置出发都不行! → 起点一定在i之后 示例: gas = [1,2,3,4,5], cost = [3,4,5,1,2] diff: [-2,-2,-2,3,3] total=0, tank=0, start=0 i=0: tank=0+(-2)=-2 < 0 → start=1, tank=0 i=1: tank=0+(-2)=-2 < 0 → start=2, tank=0 i=2: tank=0+(-2)=-2 < 0 → start=3, tank=0 i=3: tank=0+3=3 ≥ 0 ✓ i=4: tank=3+3=6 ≥ 0 ✓ total=0 ≥ 0 → 从start=3出发可以绕一圈!

代码实现

class Solution:
    def canCompleteCircuit(self, gas: List[int], cost: List[int]) -> int:
        total = 0
        tank = 0
        start = 0
        
        for i in range(len(gas)):
            diff = gas[i] - cost[i]
            total += diff
            tank += diff
            if tank < 0:
                start = i + 1
                tank = 0
        
        return start if total >= 0 else -1

时间复杂度:O(n),空间复杂度:O(1)

LC 134 的精髓:当从某个起点出发在位置 i 油量变负时,0 到 i 之间的所有位置都不可能是起点——因为从0出发到i已经是最优情况了(前面积累的油量最多),换其他起点只会油量更少。这个"跳过"策略让复杂度从 O(n²) 降到 O(n)。

📝 练习题单

  1. LC 55 跳跃游戏(最远可达)✅
  2. LC 45 跳跃游戏 II(最少跳跃)✅
  3. LC 452 用最少数箭射爆气球(区间贪心)✅
  4. LC 134 加油站(贪心跳过)
  5. LC 135 分发糖果(双向贪心)
  6. LC 435 无重叠区间(区间贪心对偶)
  7. LC 763 划分字母区间(区间合并)
  8. LC 406 根据身高重建队列(贪心插入)
  9. LC 179 最大数(自定义排序贪心)
  10. LC 621 任务调度器(构造贪心)

🔑 关键知识点总结

成就解锁:贪心策略大师 — 掌握跳跃、区间、分配三大贪心题型,能独立推导贪心正确性!🎉
LeetCode AC验证:LC 55 Jump Game ✅ | LC 45 Jump Game II ✅ | LC 452 Min Arrows ✅
思考题:为什么区间问题按右端点排序(贪心选最早结束的)是最优的,而按左端点排序(贪心选最早开始的)不一定最优?试构造一个反例。提示:考虑两个区间,一个开始早但持续长,一个开始晚但持续短。