贪心算法精讲——贪心策略的“局部最优“如何通向全局最优?

一、引子:贪心为什么是"最难"的算法范式?

如果你学过 Dijkstra 最短路径算法,你就已经用过贪心了------每次从优先队列中取出距离最短的节点,这个"局部最优选择"最终导向了全局最短路径。但面试中不会考你背模板,而是考你设计新贪心策略的能力。

贪心与其他算法范式完全不同。DFS/BFS 有固定的遍历模板,DP 有状态转移方程的模式,二分查找有单调性条件。但贪心------没有统一模板。每道题都是独立的。

这篇文章要讲三件事:

  1. 贪心策略的数学基础:贪心选择性质、最优子结构、交换论证法

  2. 三题递进:LeetCode 55(可行性判断)→ 45(最优值求解)→ 435(区间调度)

  3. 贪心 vs DP 的决策边界:什么时候该用贪心,什么时候必须退回到 DP


二、贪心策略的数学基础

2.1 两个性质:贪心选择性质 + 最优子结构

贪心算法能工作的前提是问题同时满足两个性质:

最优子结构(Optimal Substructure):问题的最优解包含子问题的最优解。这个性质是 DP 和贪心共享的。

贪心选择性质(Greedy Choice Property):通过局部最优选择可以构造出全局最优解。这个性质是贪心独有的------DP 不需要它,因为 DP 会枚举所有可能的选择;贪心需要它,因为贪心只做一个选择。

维度 动态规划 贪心算法
选择方式 枚举所有可能的选择,取最优 只做一个"看起来最优"的选择
子问题求解 解所有子问题 解一个子问题
证明要求 最优子结构即可 最优子结构 + 贪心选择性质
代码复杂度 中等 ~ 复杂 简单 ~ 极简
时间复杂度 通常 O(N²) ~ O(N³) 通常 O(N) ~ O(N log N)
典型问题 背包、编辑距离 跳跃游戏、区间调度

关键洞察:贪心是 DP 的"特化"。当问题具有贪心选择性质时,我们不需要枚举所有选择。

2.2 交换论证法(Exchange Argument)

交换论证是证明贪心正确的数学方法,分两步:

  1. 假设存在一个最优解 OPT,它不一定等于贪心解 G

  2. 通过相邻交换证明:可以把 OPT 转换成 G,且解的质量不会下降

2.3 贪心 vs DP 的决策树

复制代码
问题有最优子结构吗?
├── 否 → 不能使用贪心或 DP
└── 是 → 有贪心选择性质吗?
    ├── 是 → 贪心 ✅(O(N) 或 O(N log N) 可解)
    └── 否 → DP ✅(需要枚举所有选择)

三、LeetCode 55:Jump Game(跳跃游戏)

问题描述

给定一个非负整数数组 nums,从下标 0 出发,判断是否能到达最后一个下标。

复制代码
示例 1:nums = [2, 3, 1, 1, 4] → true
示例 2:nums = [3, 2, 1, 0, 4] → false

贪心策略

维护 maxReach 记录当前能到达的最远位置。遍历数组,如果 i > maxReach 返回 false,否则更新 maxReach = max(maxReach, i + nums[i])

证明思路(交换论证)

用反证法:如果贪心选择(最远位置)到达不了终点,那么任何更近的位置也到达不了------因为贪心选择已经覆盖了所有位置的可达范围。

代码实现

复制代码
# Python
class Solution:
    def canJump(self, nums: List[int]) -> bool:
        maxReach = 0
        n = len(nums)
        for i in range(n):
            if i > maxReach:
                return False
            maxReach = max(maxReach, i + nums[i])
            if maxReach >= n - 1:
                return True
        return True

// Java
class Solution {
    public boolean canJump(int[] nums) {
        int maxReach = 0;
        int n = nums.length;
        for (int i = 0; i < n; i++) {
            if (i > maxReach) return false;
            maxReach = Math.max(maxReach, i + nums[i]);
            if (maxReach >= n - 1) return true;
        }
        return true;
    }
}

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

复杂度分析

维度
时间复杂度 O(N),一次遍历
空间复杂度 O(1)

如果用 DP 做?

复制代码
# Python DP 解法 O(N²)
def canJump_dp(nums):
    n = len(nums)
    dp = [False] * n
    dp[n - 1] = True
    for i in range(n - 2, -1, -1):
        for j in range(1, nums[i] + 1):
            if i + j < n and dp[i + j]:
                dp[i] = True
                break
    return dp[0]

当 N = 10^4 时,DP 需要约 10^8 次操作(可能超时),贪心只需要 10^4 次。这就是贪心的价值------不是"能不能解",而是"能不能高效地解"


四、LeetCode 45:Jump Game II(跳跃游戏 II)

问题描述

返回到达最后一个下标的最小跳跃次数。题目保证可到达。

复制代码
示例:nums = [2, 3, 1, 1, 4] → 输出 2

贪心策略:BFS 层序遍历

将跳跃过程看作 BFS 的层序遍历。维护两个变量:

  • curEnd:当前层能到达的最远边界

  • nextMax:下一层能到达的最远边界

遍历数组,当 i == curEnd 时,跳跃次数 +1,curEnd = nextMax

代码实现

复制代码
# Python
class Solution:
    def jump(self, nums: List[int]) -> int:
        n = len(nums)
        if n == 1:
            return 0
        jumps = 0
        curEnd = 0
        nextMax = 0
        for i in range(n - 1):
            nextMax = max(nextMax, i + nums[i])
            if i == curEnd:
                jumps += 1
                curEnd = nextMax
                if curEnd >= n - 1:
                    break
        return jumps

// Java
class Solution {
    public int jump(int[] nums) {
        int n = nums.length;
        if (n == 1) return 0;
        int jumps = 0, curEnd = 0, nextMax = 0;
        for (int i = 0; i < n - 1; i++) {
            nextMax = Math.max(nextMax, i + nums[i]);
            if (i == curEnd) {
                jumps++;
                curEnd = nextMax;
                if (curEnd >= n - 1) break;
            }
        }
        return jumps;
    }
}

// C++
class Solution {
public:
    int jump(vector<int>& nums) {
        int n = nums.size();
        if (n == 1) return 0;
        int jumps = 0, curEnd = 0, nextMax = 0;
        for (int i = 0; i < n - 1; i++) {
            nextMax = max(nextMax, i + nums[i]);
            if (i == curEnd) {
                jumps++;
                curEnd = nextMax;
                if (curEnd >= n - 1) break;
            }
        }
        return jumps;
    }
};

复杂度分析

维度
时间复杂度 O(N)
空间复杂度 O(1)

55 和 45 的关系

维度 55 题 45 题
问题类型 可行性判断 最优值求解
贪心策略 维护最远位置 BFS 层 + 边界
时间复杂度 O(N) O(N)
空间复杂度 O(1) O(1)

五、LeetCode 435:Non-overlapping Intervals(无重叠区间)

问题描述

求最少移除多少个区间,使剩余区间互不重叠。

复制代码
示例:intervals = [[1,2], [2,3], [3,4], [1,3]] → 输出 1

问题转化

等价于:最多能保留多少个互不重叠的区间?答案 = 总数 − 最多保留数。

贪心策略

按结束时间升序排序。遍历,如果当前区间开始时间 ≥ 上一个保留区间的结束时间,则保留;否则移除。

为什么按结束时间排序?

按结束时间排序才能给后续区间留出最大空间。

反例:[[1, 10], [2, 3], [4, 5]]

  • 按开始时间排序:先选 [1, 10],只能保留 1 个

  • 按结束时间排序:先选 [2, 3],再选 [4, 5],保留 2 个

证明思路

交换论证:设贪心选最早结束的区间 g1,最优解的第一个区间为 opt1g1.end ≤ opt1.end,将 opt1 替换为 g1 后,剩余空间更大或相等,不会减少解的个数。

代码实现

复制代码
# Python
class Solution:
    def eraseOverlapIntervals(self, intervals: List[List[int]]) -> int:
        if not intervals:
            return 0
        intervals.sort(key=lambda x: x[1])
        count = 0
        prevEnd = intervals[0][1]
        for i in range(1, len(intervals)):
            if intervals[i][0] < prevEnd:
                count += 1
            else:
                prevEnd = intervals[i][1]
        return count

// Java
class Solution {
    public int eraseOverlapIntervals(int[][] intervals) {
        if (intervals.length == 0) return 0;
        Arrays.sort(intervals, (a, b) -> a[1] - b[1]);
        int count = 0;
        int prevEnd = intervals[0][1];
        for (int i = 1; i < intervals.length; i++) {
            if (intervals[i][0] < prevEnd) count++;
            else prevEnd = intervals[i][1];
        }
        return count;
    }
}

// C++
class Solution {
public:
    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 = 0;
        int prevEnd = intervals[0][1];
        for (int i = 1; i < intervals.size(); i++) {
            if (intervals[i][0] < prevEnd) count++;
            else prevEnd = intervals[i][1];
        }
        return count;
    }
};

复杂度分析

维度
时间复杂度 O(N log N),排序占主导
空间复杂度 O(1)

变体题目

  • 452. Minimum Number of Arrows:本质是找不相交区间的数量

  • 56. Merge Intervals:合并重叠区间,按开始时间排序

  • 763. Partition Labels:划分字母区间,本质是合并重叠区间


六、总结:贪心的三种模式

模式一:可行性判断(55 题)

  • 维护一个"最远可达"边界,看能否覆盖目标

  • 时间复杂度 O(N),空间 O(1)

模式二:最优值求解(45 题)

  • BFS 层序遍历 + 贪心边界

  • 时间复杂度 O(N),空间 O(1)

模式三:区间调度(435 题)

  • 按结束时间排序,贪心选最早结束

  • 时间复杂度 O(N log N),空间 O(1)

通用框架

  1. 验证问题是否具有最优子结构

  2. 尝试构造反例,用交换论证法严格证明

  3. 如果贪心选择性质成立,设计贪心解

  4. 如果贪心选择性质不成立,退回到 DP

相关推荐
MuMuMu122314 分钟前
文旅户外场景智能回收终端工程难点解析:越华环保集团碳惠小屋耐候与供电系统实践
java·大数据·算法
玖玥拾37 分钟前
LeetCode 205 同构字符串
算法·leetcode
影视飓风TIM41 分钟前
C++哈希表:unordered_set / unordered_map底层原理
c++·算法·哈希算法·散列表
在所不辞兄43 分钟前
【零基础学智能仿真-06】决策树——让模型自动发现力学失效阈值
算法·决策树·机器学习
啥都想学点的研究生1 小时前
一篇文章讲清楚:C4.5决策树和CART决策树
算法·决策树·机器学习
DDXYcoder1 小时前
万字解析动态内存从编译到运行
java·前端·算法
阳明山水1 小时前
概念漂移分类与自适应策略解析
人工智能·深度学习·算法·机器学习·架构
欧特克_Glodon1 小时前
OpenCV计算机视觉开发入门与实践<二十九>:grabCut算法分割图像
opencv·算法·计算机视觉·图像分割·grabcut 算法
方方洛2 小时前
vllm教程-02-核心原理
人工智能·算法·vllm