贪心算法:区间调度与跳跃游戏的完整套路

前言

贪心算法是最"直觉"的算法。很多人问:什么时候用贪心,什么时候用DP? 答案是:能用贪心就用贪心(更简单),不能用贪心才用DP(更复杂)

我并没有能力让你看完就能证明所有贪心的正确性,我只是想让你理解贪心的本质、知道贪心和DP的区别、掌握经典贪心问题的套路。

摘要

从"跳跃游戏能否到达终点"问题出发,剖析贪心算法的核心思想与证明方法。通过贪心与DP的对比、局部最优到全局最优的推导、以及区间调度问题的详细分析,揭秘贪心算法的适用场景与解题套路。配合LeetCode高频题目,给出贪心算法的完整攻略。


一、从跳跃游戏说起

周一早上,哈吉米遇到一道题:

LeetCode 55 - 跳跃游戏

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

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

判断你是否能够到达最后一个下标。

示例:
输入:nums = [2,3,1,1,4]
输出:true
解释:可以先跳 1 步,从下标 0 到达下标 1, 然后再从下标 1 跳 3 步到达最后一个下标。

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

哈吉米:"这不是DP吗?"

南北绿豆:"可以用DP,但贪心更简单。"


二、贪心 vs DP

南北绿豆:"先看两种思路的区别。"

2.1 DP思路

dp[i]:能否到达位置i

dp[0] = true(起点)
dp[i] = 是否存在j < i,使得dp[j]==true 且 j+nums[j]>=i

遍历所有位置,判断能否到达

时间复杂度:O(n²)

2.2 贪心思路

维护一个变量maxReach:当前能到达的最远位置

遍历数组:
  如果i > maxReach,说明到不了i,返回false
  否则,更新maxReach = max(maxReach, i + nums[i])

如果maxReach >= n-1,返回true

时间复杂度:O(n)

2.3 对比图示

flowchart TB
    A["跳跃游戏"]
    B["DP思路<br/>判断每个位置<br/>能否到达"]
    C["贪心思路<br/>维护最远<br/>可达位置"]
    D["O(n²)"]
    E["O(n)"]
    
    A --> B
    A --> C
    B --> D
    C --> E
    
    style C fill:#e1ffe1
    style E fill:#e1ffe1

哈吉米:"贪心更简单,为什么不都用贪心?"

阿西噶阿西:"因为不是所有问题贪心都正确。"


三、什么是贪心算法

南北绿豆:"贪心算法的本质:每一步都做当前看起来最优的选择,希望最终得到全局最优解。"

3.1 生活化场景

场景1:找零钱(贪心成功)

你要找给顾客99元
现有硬币:[50, 20, 10, 5, 1]

贪心策略:每次选面额最大的
50 + 20 + 20 + 5 + 1 + 1 + 1 + 1 = 997个硬币)✓

这是最优解吗?是的!

场景2:找零钱(贪心失败的反例)

硬币:[25, 10, 1]
要找30元

贪心策略:
25 + 1 + 1 + 1 + 1 + 1 = 306个硬币)

最优解:
10 + 10 + 10 = 303个硬币)✓

贪心失败!

哈吉米:"所以贪心不一定对?"

南北绿豆:"对,贪心需要证明正确性。如果局部最优推导不出全局最优,就不能用贪心,要用DP。"


四、贪心的正确性证明

阿西噶阿西:"证明贪心正确性有两种方法。"

4.1 方法1:反证法

证明:假设贪心解不是最优解,推导出矛盾。

示例:区间调度问题(后面会讲)

4.2 方法2:归纳法

证明

  1. 第一步贪心选择是正确的
  2. 假设前k步贪心选择正确,证明第k+1步也正确

哈吉米:"听起来很数学。"

南北绿豆:"实际做题时,多数情况靠直觉+测试。如果贪心AC了,说明正确;如果WA了,就换DP。"


五、例题1:跳跃游戏

5.1 思路分析

南北绿豆:"回到最开始的跳跃游戏。"

贪心策略:维护最远可达位置maxReach

nums = [2,3,1,1,4]

i=0:maxReach = max(0, 0+2) = 2
i=11 <= 2,可达,maxReach = max(2, 1+3) = 4
i=22 <= 4,可达,maxReach = max(4, 2+1) = 4
...
maxReach=4 >= 4(最后一个位置),返回true

图示

nums = [2, 3, 1, 1, 4]
index: 0  1  2  3  4

从0出发,最远到2:
[====]
 0 1 2

从1出发,最远到4:
   [==========]
   1 2 3 4

能到达最后一个位置 ✓

5.2 代码实现

Java版本

public boolean canJump(int[] nums) {
    int maxReach = 0;
    
    for (int i = 0; i < nums.length; i++) {
        // 如果当前位置到不了,返回false
        if (i > maxReach) {
            return false;
        }
        
        // 更新最远可达位置
        maxReach = Math.max(maxReach, i + nums[i]);
        
        // 如果已经能到达最后一个位置,提前返回
        if (maxReach >= nums.length - 1) {
            return true;
        }
    }
    
    return true;
}

C++版本

bool canJump(vector<int>& nums) {
    int maxReach = 0;
    
    for (int i = 0; i < nums.size(); i++) {
        if (i > maxReach) {
            return false;
        }
        
        maxReach = max(maxReach, i + nums[i]);
        
        if (maxReach >= nums.size() - 1) {
            return true;
        }
    }
    
    return true;
}

Python版本

def canJump(nums):
    maxReach = 0
    
    for i in range(len(nums)):
        if i > maxReach:
            return False
        
        maxReach = max(maxReach, i + nums[i])
        
        if maxReach >= len(nums) - 1:
            return True
    
    return True

六、例题2:跳跃游戏II

6.1 题目

LeetCode 45 - 跳跃游戏 II

给定一个长度为 n 的 0 索引整数数组 nums。初始位置为 nums[0]。

每个元素 nums[i] 表示从索引 i 向前跳转的最大长度。

返回到达 nums[n - 1] 的最小跳跃次数。

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

输入:nums = [2,3,0,1,4]
输出:2

6.2 思路分析

南北绿豆:"这题要求最少跳跃次数,稍微复杂点。"

贪心策略在当前能到达的范围内,选择能跳得最远的位置

阿西噶阿西:"维护两个变量:

  • curEnd:当前跳跃能到达的最远位置
  • maxReach:下一跳能到达的最远位置"

示例nums = [2,3,1,1,4]

第0跳:从0出发
  curEnd = 0
  maxReach = 0 + nums[0] = 2
  
遍历[0,curEnd]范围,更新maxReach:
  i=0:maxReach = max(2, 0+2) = 2
  到达curEnd,跳跃次数+1
  
第1跳:从[1,2]范围出发
  curEnd = 2
  
遍历[1,2]范围:
  i=1:maxReach = max(2, 1+3) = 4
  i=2:maxReach = max(4, 2+1) = 4
  到达curEnd,跳跃次数+1
  
maxReach=4 >= 4,到达终点
答案:2次跳跃

执行过程表格

inums[i]curEndmaxReach操作跳跃次数
0202i==curEnd,跳跃+11
1324-1
2124i==curEnd,跳跃+12
3144maxReach>=终点,结束2

6.3 代码实现

Java版本

public int jump(int[] nums) {
    int jumps = 0;
    int curEnd = 0;      // 当前跳跃能到达的最远位置
    int maxReach = 0;    // 下一跳能到达的最远位置
    
    for (int i = 0; i < nums.length - 1; i++) {
        // 更新下一跳能到达的最远位置
        maxReach = Math.max(maxReach, i + nums[i]);
        
        // 到达当前跳跃的边界,需要跳跃
        if (i == curEnd) {
            jumps++;
            curEnd = maxReach;
        }
    }
    
    return jumps;
}

C++版本

int jump(vector<int>& nums) {
    int jumps = 0;
    int curEnd = 0;
    int maxReach = 0;
    
    for (int i = 0; i < nums.size() - 1; i++) {
        maxReach = max(maxReach, i + nums[i]);
        
        if (i == curEnd) {
            jumps++;
            curEnd = maxReach;
        }
    }
    
    return jumps;
}

Python版本

def jump(nums):
    jumps = 0
    curEnd = 0
    maxReach = 0
    
    for i in range(len(nums) - 1):
        maxReach = max(maxReach, i + nums[i])
        
        if i == curEnd:
            jumps += 1
            curEnd = maxReach
    
    return jumps

哈吉米:"每次在可达范围内选能跳最远的,这就是贪心。"


七、例题3:无重叠区间

7.1 题目

LeetCode 435 - 无重叠区间

给定一个区间的集合 intervals ,其中 intervals[i] = [starti, endi] 。
返回需要移除区间的最小数量,使剩余区间互不重叠 。

示例:
输入:intervals = [[1,2],[2,3],[3,4],[1,3]]
输出:1
解释:移除 [1,3] 后,剩下的区间没有重叠。

输入:intervals = [[1,2],[1,2],[1,2]]
输出:2
解释:你需要移除两个 [1,2] 来使剩下的区间没有重叠。

7.2 思路分析

南北绿豆:"这是经典的区间调度问题。"

问题转换

  • 移除最少的区间 = 保留最多的不重叠区间
  • 转换成:从所有区间中选择最多的不重叠区间

贪心策略按结束时间排序,每次选结束最早的区间

为什么选结束最早的?

阿西噶阿西:"因为结束越早,后面留给其他区间的空间越多。"

示例

区间:[[1,4], [2,3], [3,5]]

策略1:选[1,4]
  → 后面只能选[5,...],空间很少 ✗

策略2:选[2,3](结束最早)
  → 后面可以选[3,5][4,...],空间多 ✓

7.3 执行过程演示

示例intervals = [[1,2],[2,3],[3,4],[1,3]]

第1步:按结束时间排序

原始:[[1,2],[2,3],[3,4],[1,3]]
排序:[[1,2],[2,3],[1,3],[3,4]]
      end=2  end=2  end=3  end=4

第2步:贪心选择

区间结束时间操作已选说明
[1,2]2选择1个第一个,直接选
[2,3]3选择2个2>=2,不重叠
[1,3]3跳过2个1<3,重叠
[3,4]4选择3个3>=3,不重叠

结果:保留3个区间,移除1个

7.4 代码实现

Java版本

public int eraseOverlapIntervals(int[][] intervals) {
    if (intervals.length == 0) return 0;
    
    // 按结束时间排序
    Arrays.sort(intervals, (a, b) -> a[1] - b[1]);
    
    int count = 1; // 至少保留第一个区间
    int end = intervals[0][1];
    
    for (int i = 1; i < intervals.length; i++) {
        // 如果不重叠,保留
        if (intervals[i][0] >= end) {
            count++;
            end = intervals[i][1];
        }
    }
    
    // 移除的区间数 = 总数 - 保留的
    return intervals.length - count;
}

C++版本

int eraseOverlapIntervals(vector<vector<int>>& intervals) {
    if (intervals.empty()) return 0;
    
    sort(intervals.begin(), intervals.end(), 
         [](const vector<int>& a, const vector<int>& b) {
             return a[1] < b[1];
         });
    
    int count = 1;
    int end = intervals[0][1];
    
    for (int i = 1; i < intervals.size(); i++) {
        if (intervals[i][0] >= end) {
            count++;
            end = intervals[i][1];
        }
    }
    
    return intervals.size() - count;
}

Python版本

def eraseOverlapIntervals(intervals):
    if not intervals:
        return 0
    
    # 按结束时间排序
    intervals.sort(key=lambda x: x[1])
    
    count = 1
    end = intervals[0][1]
    
    for i in range(1, len(intervals)):
        if intervals[i][0] >= end:
            count += 1
            end = intervals[i][1]
    
    return len(intervals) - count

八、例题4:用最少数量的箭引爆气球

8.1 题目

LeetCode 452 - 用最少数量的箭引爆气球

有一些球形气球贴在一堵用 XY 平面表示的墙面上。
墙面上的气球记录在整数数组 points ,其中points[i] = [xstart, xend] 表示气球的直径在 xstartxend之间。

你可以沿着 x 轴由左向右发射箭来戳破气球。
如果气球的 xstartxxend,该气球会被引爆 。

求所需箭的最小数量。

示例:
输入:points = [[10,16],[2,8],[1,6],[7,12]]
输出:2
解释:气球可以用2支箭来爆破:
-x = 6处射出箭,击破气球[2,8][1,6]-x = 11处射出箭,击破气球[10,16][7,12]

8.2 思路分析

南北绿豆:"这题和无重叠区间类似,但要求的是最少的箭。"

贪心策略按结束时间排序,尽量让一支箭射穿更多气球

阿西噶阿西:"关键:相交的区间用一支箭,不相交的需要新箭。"

8.3 代码实现

Java版本

public int findMinArrowShots(int[][] points) {
    if (points.length == 0) return 0;
    
    // 按结束位置排序
    Arrays.sort(points, (a, b) -> Integer.compare(a[1], b[1]));
    
    int arrows = 1;
    int end = points[0][1];
    
    for (int i = 1; i < points.length; i++) {
        // 如果不相交,需要新箭
        if (points[i][0] > end) {
            arrows++;
            end = points[i][1];
        }
    }
    
    return arrows;
}

C++版本

int findMinArrowShots(vector<vector<int>>& points) {
    if (points.empty()) return 0;
    
    sort(points.begin(), points.end(),
         [](const vector<int>& a, const vector<int>& b) {
             return a[1] < b[1];
         });
    
    int arrows = 1;
    int end = points[0][1];
    
    for (int i = 1; i < points.size(); i++) {
        if (points[i][0] > end) {
            arrows++;
            end = points[i][1];
        }
    }
    
    return arrows;
}

Python版本

def findMinArrowShots(points):
    if not points:
        return 0
    
    points.sort(key=lambda x: x[1])
    
    arrows = 1
    end = points[0][1]
    
    for i in range(1, len(points)):
        if points[i][0] > end:
            arrows += 1
            end = points[i][1]
    
    return arrows

九、贪心算法总结

9.1 贪心 vs DP

对比项贪心算法动态规划
策略每步选局部最优考虑所有可能
是否回溯不回溯(一条路走到黑)会考虑之前的状态
正确性需要证明一定正确(穷举)
复杂度通常O(n)或O(nlogn)通常O(n²)或更高
适用场景局部最优=全局最优有重叠子问题

9.2 何时用贪心

南北绿豆

  1. 问题有贪心选择性质:局部最优能推导出全局最优
  2. 问题有最优子结构:子问题的最优解能组成全局最优解
  3. 能证明正确性:或者AC了就说明正确

9.3 常见贪心策略

阿西噶阿西

问题类型贪心策略典型题目
区间调度按结束时间排序,选结束早的LeetCode 435、452
跳跃问题维护最远可达位置LeetCode 55、45
分配问题排序后贪心分配分发饼干、分发糖果
最值问题每次选最大/最小数组拆分、最大子数组和

9.4 识别技巧

南北绿豆

  • 看到最少、最多、最大、最小,先考虑贪心
  • 看到区间、调度、选择,想贪心
  • 如果贪心WA了,再考虑DP

哈吉米:"先试贪心,不行再DP。"


参考资料

  • 《算法导论》- Thomas H. Cormen
  • 《算法竞赛进阶指南》- 李煜东
  • LeetCode题解 - 贪心算法专题