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

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

如果你学过 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

相关推荐
倒头就睡的小比特1 天前
算法竞赛C++常用的STL
c++·算法
小羊没烦恼!1 天前
初探性能优化——2个月到4小时的性能提升
java·开发语言·windows·算法·c#
猎头南楼1 天前
知识社区推荐系统实践:新用户冷启动与长短期兴趣建模的挑战 资深推荐算法工程师
人工智能·深度学习·算法·机器学习
旖旎夜光1 天前
力控面试题 01.01: 判定字符是否唯一(位运算) —— 题解
c++·学习·算法·leetcode·力控
wzdark1 天前
大规模并行计算中的负载均衡算法研究4
算法
Because_of_Her11 天前
并查集-听课笔记
笔记·算法·并查集
码流子1 天前
高速公路安全监测实践:碰撞监测预警+物联网底座,从感知到处置的闭环
大数据·人工智能·物联网·算法·架构
another heaven1 天前
【算法/C++ MD5算法能否逆解码?原理、C++实现与同类哈希算法对比】
c++·算法·哈希算法
wzdark1 天前
从算法设计模式看编程思维的抽象能力4
算法
2601_962218611 天前
万象生鲜系统称重自动多退少补算法解决生鲜非标品痛点
大数据·数据库·人工智能·python·算法