贪心算法详解
贪心算法(Greedy Algorithm)是一种在每一步选择中都采取在当前状态下最好或最优的选择,从而希望导致结果是全局最好或最优的算法。本文将从概念到实践,全面讲解贪心算法的思想、适用场景及常见题型。
一、什么是贪心算法?
1.1 贪心算法的定义
贪心算法是一种在每一步选择中都采取在当前状态下最好或最优的选择,从而希望导致结果是全局最好或最优的算法。
核心思想:
- 局部最优:每一步都做出当前看起来最优的选择
- 全局最优:希望通过局部最优达到全局最优
通俗理解:
想象你在玩一个游戏,每一步都选择当前能获得的最高分数,不考虑未来的影响。
- 有时候这样能得到最高分(贪心适用)
- 有时候这样反而得不到最高分(贪心不适用)
1.2 贪心算法的特点
| 特点 | 说明 |
|---|---|
| 贪心选择性质 | 全局最优解可以通过局部最优选择达到 |
| 无后效性 | 当前选择不影响之前的选择,只影响未来 |
| 不可回溯 | 一旦做出选择,不能回退修改 |
| 高效性 | 通常时间复杂度较低,实现简单 |
1.3 贪心 vs 其他算法
| 算法 | 策略 | 特点 |
|---|---|---|
| 贪心算法 | 每步选局部最优 | 快,但不保证全局最优 |
| 动态规划 | 考虑所有子问题 | 慢,但保证全局最优 |
| 回溯算法 | 尝试所有可能,可回退 | 最慢,但能找到最优解 |
| 分治算法 | 分解子问题,合并结果 | 适用于可分解的问题 |
二、贪心算法的核心思想
2.1 贪心选择性质
定义: 一个问题的全局最优解可以通过一系列局部最优选择来达到。
关键问题:
- 如何证明贪心选择是正确的?
- 什么样的题目适合用贪心?
2.2 贪心算法的解题步骤
1 | ┌─────────────────────────────────────────────────────────────────┐ |
2.3 常见的贪心策略
| 策略 | 适用场景 | 示例 |
|---|---|---|
| 选择最大/最小 | 资源分配、区间问题 | 最多活动选择、最小硬币 |
| 选择最早结束 | 区间调度问题 | 最多不重叠区间 |
| 选择最早开始 | 区间覆盖问题 | 最少区间覆盖 |
| 按某种顺序排序后贪心 | 大多数贪心问题 | 分发饼干、跳跃游戏 |
| 优先队列(堆) | 需要动态选择最优 | 合并 K 个有序链表 |
三、贪心算法的适用场景
3.1 什么情况下可以用贪心?
必须满足两个条件:
条件一:贪心选择性质
全局最优解可以通过局部最优选择达到。
示例:找零钱问题
- 硬币面额:1元、2元、5元
- 目标:凑出 11 元,使用最少硬币
- 贪心策略:每次选最大面额
- 选 5 元 → 剩余 6 元
- 选 5 元 → 剩余 1 元
- 选 1 元 → 完成
- 结果:3 枚硬币(最优)
条件二:最优子结构
问题的最优解包含子问题的最优解。
3.2 什么情况下不能用贪心?
示例:找零钱问题(特殊面额)
- 硬币面额:1元、3元、4元
- 目标:凑出 6 元
- 贪心策略:每次选最大面额
- 选 4 元 → 剩余 2 元
- 选 1 元 → 剩余 1 元
- 选 1 元 → 完成
- 结果:3 枚硬币
- 最优解:2 枚(3 + 3)
结论: 贪心不能保证全局最优,需要用动态规划。
3.3 贪心算法的经典应用场景
| 场景 | 说明 | 典型题目 |
|---|---|---|
| 区间调度 | 选择最多不重叠区间 | 活动选择问题 |
| 区间覆盖 | 用最小区间覆盖目标 | 跳跃游戏 |
| 资源分配 | 合理分配有限资源 | 分发饼干、任务调度 |
| 哈夫曼编码 | 数据压缩 | 构建最优前缀码 |
| 最小生成树 | Prim、Kruskal 算法 | 图论问题 |
| 单源最短路径 | Dijkstra 算法 | 图论问题 |
四、经典例题详解
4.1 例题一:分发饼干
题目:
假设你是一位很棒的家长,想要给你的孩子们一些小饼干。但是,每个孩子最多只能给一块饼干。
对每个孩子 i,都有一个胃口值 g[i],这是能让孩子们满足胃口的饼干的最小尺寸;并且每块饼干 j,都有一个尺寸 s[j] 。如果 s[j] >= g[i],我们可以将这个饼干 j 分配给孩子 i ,这个孩子会得到满足。你的目标是尽可能满足越多数量的孩子,并输出这个最大数值。
示例:
1 | 输入: g = [1,2,3], s = [1,1] |
解法:
贪心策略:
- 为了满足更多的孩子,应该让胃口小的孩子先吃
- 用最小的饼干满足最小的胃口
步骤:
- 对孩子胃口数组 g 排序(升序)
- 对饼干尺寸数组 s 排序(升序)
- 用双指针,尽量用最小的饼干满足当前孩子
代码实现:
1 | function findContentChildren(g, s) { |
时间复杂度: O(n log n) —— 主要是排序
空间复杂度: O(1) —— 只使用了常数额外空间
4.2 例题二:跳跃游戏
题目:
给定一个非负整数数组 nums,你最初位于数组的第一个位置。
数组中的每个元素代表你在该位置可以跳跃的最大长度。
判断你是否能够到达最后一个位置。
示例:
1 | 输入: nums = [2,3,1,1,4] |
解法一:贪心(从后向前)
贪心策略:
- 从后向前遍历,维护一个”能到达最后的位置”
- 如果当前位置能到达”目标位置”,则更新目标位置为当前位置
代码实现:
1 | function canJump(nums) { |
解法二:贪心(从前向后)
贪心策略:
- 维护当前能到达的最远位置
- 遍历过程中更新最远位置
代码实现:
1 | function canJump(nums) { |
时间复杂度: O(n)
空间复杂度: O(1)
4.3 例题三:跳跃游戏 II
题目:
给定一个长度为 n 的 0 索引整数数组 nums。初始位置为 nums[0]。
每个元素 nums[i] 表示从索引 i 向前跳转的最大长度。换句话说,如果你在 nums[i] 处,你可以跳转到任意 nums[i + j] 处:
- 0 <= j <= nums[i]
- i + j < n
返回到达 nums[n - 1] 的最小跳跃次数。生成的测试用例可以到达 nums[n - 1]。
示例:
1 | 输入: nums = [2,3,1,1,4] |
解法:
贪心策略:
- 在当前能跳的范围内,选择能跳得最远的那个位置
- 这样可以减少跳跃次数
代码实现:
1 | function jump(nums) { |
时间复杂度: O(n)
空间复杂度: O(1)
4.4 例题四:买卖股票的最佳时机 II
题目:
给你一个整数数组 prices ,其中 prices[i] 表示某支股票第 i 天的价格。
在每一天,你可以决定是否购买和/或出售股票。你在任何时候 最多 只能持有 一股 股票。你也可以先购买,然后在 同一天 出售。
返回 你能获得的 最大 利润 。
示例:
1 | 输入: prices = [7,1,5,3,6,4] |
解法:
贪心策略:
- 只要今天的价格比昨天高,就在昨天买入今天卖出
- 这样可以获得所有上涨的利润
证明:
- 假设第 i 天买入,第 j 天卖出(i < j,prices[j] > prices[i])
- 利润 = prices[j] - prices[i]
- = (prices[i+1] - prices[i]) + (prices[i+2] - prices[i+1]) + … + (prices[j] - prices[j-1])
- 所以每天上涨的利润加起来等于总利润
代码实现:
1 | function maxProfit(prices) { |
时间复杂度: O(n)
空间复杂度: O(1)
4.5 例题五:根据身高重建队列
题目:
假设有打乱顺序的一群人站成一个队列,数组 people 表示队列中一些人的属性(不一定按顺序)。每个 people[i] = [hi, ki] 表示第 i 个人的身高为 hi ,前面 正好 有 ki 个身高大于或等于 hi 的人。
请你重新构造并返回输入数组 people 所表示的队列。返回的队列应该格式化为数组 queue ,其中 queue[j] = [hj, kj] 是队列中第 j 个人的属性(queue[0] 是排在队列前面的人)。
示例:
1 | 输入: people = [[7,0],[4,4],[7,1],[5,0],[6,1],[5,2]] |
解法:
贪心策略:
- 先按身高降序排序,身高相同则按 k 升序排序
- 然后依次插入,每个人插入到 k 指定的位置
为什么这样是对的?
- 先处理高的人,他们的位置不会受矮的人影响
- 矮的人插入到 k 位置,前面正好有 k 个高的人
代码实现:
1 | function reconstructQueue(people) { |
时间复杂度: O(n²) —— 主要是 splice 操作
空间复杂度: O(n)
4.6 例题六:划分字母区间
题目:
给你一个字符串 s 。我们要把这个字符串划分为尽可能多的片段,同一字母最多出现在一个片段中。
返回一个表示每个字符串片段的长度的列表。
示例:
1 | 输入: s = "ababcbacadefegdehijhklij" |
解法:
贪心策略:
- 首先记录每个字母最后出现的位置
- 遍历字符串,维护当前片段的最远边界
- 当遍历位置等于最远边界时,可以划分
代码实现:
1 | function partitionLabels(s) { |
时间复杂度: O(n)
空间复杂度: O(1) —— 最多 26 个字母
4.7 例题七:合并区间
题目:
以数组 intervals 表示若干个区间的集合,其中单个区间为 intervals[i] = [starti, endi] 。请你合并所有重叠的区间,并返回一个不重叠的区间数组,该数组需恰好覆盖输入中的所有区间。
示例:
1 | 输入: intervals = [[1,3],[2,6],[8,10],[15,18]] |
解法:
贪心策略:
- 按区间左端点排序
- 遍历区间,如果当前区间与上一个合并后的区间重叠,则合并
- 否则,将当前区间加入结果
代码实现:
1 | function merge(intervals) { |
时间复杂度: O(n log n) —— 主要是排序
空间复杂度: O(n)
五、贪心算法 vs 动态规划
5.1 核心区别
| 特性 | 贪心算法 | 动态规划 |
|---|---|---|
| 选择策略 | 每步选局部最优 | 考虑所有可能,选全局最优 |
| 时间复杂度 | 通常 O(n) 或 O(n log n) | 通常 O(n²) 或更高 |
| 空间复杂度 | 通常 O(1) | 通常 O(n) 或更高 |
| 正确性保证 | 不一定全局最优 | 一定全局最优 |
| 适用场景 | 具有贪心选择性质的问题 | 具有最优子结构的问题 |
| 典型问题 | 区间调度、跳跃游戏 | 背包问题、最长子序列 |
5.2 如何区分用贪心还是动态规划?
方法一:举反例
尝试找一个反例,证明贪心不能得到最优解。
示例:找零钱
- 硬币:1, 3, 4
- 目标:6
- 贪心:4 + 1 + 1 = 3 枚
- 最优:3 + 3 = 2 枚
- 结论: 不能用贪心,用动态规划
方法二:问题特征
| 特征 | 适合贪心 | 适合动态规划 |
|---|---|---|
| 选择后不影响其他选择 | ✅ | ❌ |
| 需要记录历史状态 | ❌ | ✅ |
| 需要比较所有子问题 | ❌ | ✅ |
| 局部最优能导致全局最优 | ✅ | 不一定 |
方法三:经典问题对照
| 问题 | 贪心 | 动态规划 |
|---|---|---|
| 活动选择问题 | ✅ | ✅ |
| 背包问题 | ❌(分数背包可以) | ✅ |
| 最长递增子序列 | ❌ | ✅ |
| 跳跃游戏 | ✅ | ✅ |
| 股票买卖 II | ✅ | ✅ |
| 股票买卖 I | ❌ | ✅ |
5.3 对比示例
示例一:活动选择问题
问题: 选择最多不重叠的活动。
贪心解法:
1 | function maxActivities(activities) { |
贪心策略: 每次选结束时间最早的
正确性: 可以证明贪心选择性质成立
示例二:0-1 背包问题
问题: 物品不可分割,求最大价值。
贪心尝试: 每次选价值密度(价值/重量)最高的
反例:
1 | 背包容量:50 |
结论: 必须用动态规划
六、贪心算法的题型分类
6.1 按问题类型分类
类型一:区间问题
特点: 涉及时间区间或范围的处理
| 题目 | 策略 |
|---|---|
| 合并区间 | 按左端点排序,重叠则合并 |
| 插入区间 | 找到插入位置,合并重叠区间 |
| 用最少数量的箭引爆气球 | 按右端点排序,贪心选择 |
| 无重叠区间 | 按结束时间排序,选择最多 |
类型二:分配问题
特点: 资源分配,满足尽可能多的需求
| 题目 | 策略 |
|---|---|
| 分发饼干 | 排序后,小饼干满足小胃口 |
| 根据身高重建队列 | 高个子先排,矮个子插入 |
| 任务调度器 | 计算冷却时间,贪心填充 |
类型三:序列问题
特点: 对序列进行操作,达到某种目标
| 题目 | 策略 |
|---|---|
| 跳跃游戏 | 维护最远可达位置 |
| 买卖股票 II | 收集所有上涨利润 |
| 划分字母区间 | 记录最后位置,贪心划分 |
类型四:构造问题
特点: 构造满足条件的数据结构
| 题目 | 策略 |
|---|---|
| 重构字符串 | 按频率排序,交替放置 |
| reorganize string | 最大堆,每次选频率最高的 |
6.2 按贪心策略分类
| 策略 | 适用场景 | 示例 |
|---|---|---|
| 排序后贪心 | 大多数贪心问题 | 分发饼干、合并区间 |
| 双指针 | 两个数组/序列 | 分发饼干、盛最多水 |
| 维护最值 | 需要动态更新最优 | 跳跃游戏、股票买卖 |
| 优先队列 | 每次选最优 | 重构字符串、合并 K 个链表 |
| 区间处理 | 区间相关 | 合并区间、活动选择 |
七、常见面试题总结
7.1 必须掌握的面试题
| 题目 | 难度 | 类型 |
|---|---|---|
| 分发饼干 | 简单 | 分配问题 |
| 买卖股票的最佳时机 II | 简单 | 序列问题 |
| 跳跃游戏 | 中等 | 序列问题 |
| 跳跃游戏 II | 中等 | 序列问题 |
| 合并区间 | 中等 | 区间问题 |
| 根据身高重建队列 | 中等 | 构造问题 |
| 划分字母区间 | 中等 | 序列问题 |
| 无重叠区间 | 中等 | 区间问题 |
| 用最少数量的箭引爆气球 | 中等 | 区间问题 |
| 任务调度器 | 中等 | 分配问题 |
7.2 面试回答要点
Q:什么是贪心算法?
贪心算法是一种在每一步选择中都采取在当前状态下最好或最优的选择,从而希望导致结果是全局最好或最优的算法。它的核心特点是贪心选择性质和无后效性。
Q:贪心算法和动态规划的区别?
- 贪心:每步选局部最优,效率高但不保证全局最优
- 动态规划:考虑所有子问题,一定能得到全局最优,但效率较低
Q:如何判断一个问题能否用贪心?
- 尝试举反例,看贪心是否能得到最优解
- 检查是否具有贪心选择性质
- 检查是否具有最优子结构
- 参考经典问题,看是否属于贪心适用类型
Q:贪心算法的优缺点?
优点: 实现简单、时间复杂度低、代码简洁
缺点: 不一定得到全局最优、需要证明正确性
八、总结
8.1 贪心算法核心要点
| 要点 | 内容 |
|---|---|
| 核心思想 | 每步选局部最优,希望达到全局最优 |
| 关键性质 | 贪心选择性质、无后效性 |
| 解题步骤 | 建立模型 → 确定策略 → 证明正确性 → 实现 |
| 常见策略 | 排序后贪心、双指针、维护最值、优先队列 |
| 适用场景 | 区间问题、分配问题、序列问题 |
8.2 贪心 vs 动态规划决策树
1 | 遇到问题 |
8.3 学习建议
- 理解核心思想:局部最优 → 全局最优
- 掌握经典题目:分发饼干、跳跃游戏、合并区间
- 学会证明:能简单证明贪心选择的正确性
- 对比学习:与动态规划对比,理解适用场景
- 多做练习:贪心题目套路明显,多练就能掌握
贪心算法是算法面试中的常客,虽然实现简单,但关键在于判断是否能用贪心。记住:贪心选择性质是核心,举反例是检验方法!