核心算法 / 08

贪心算法

核心思想: 每步选局部最优,期望达到全局最优。


什么是贪心

贪心算法(Greedy Algorithm)的思路非常直觉化:在每一步决策时,都选择当前看起来最好的选项,不回头、不犹豫。如果问题本身具有特殊性质,这种”只看眼前”的策略最终就能得到全局最优解。

贪心 vs 动态规划

对比项 贪心 动态规划
决策方式 每步只选当前最优,不回头 考虑所有可能的子问题组合
时间复杂度 通常更低 通常更高
适用条件 需要贪心选择性质 只需最优子结构
实现难度 代码简单,难在证明正确性 代码稍复杂,难在状态定义

什么时候能用贪心

需要同时满足两个性质:

  1. 贪心选择性质:局部最优选择能导出全局最优。
  2. 最优子结构:问题的最优解包含子问题的最优解。

简单说就是——”每一步选最好的,最后结果也是最好的”。如果你发现某个问题”只看眼前”会漏掉更优解,那就该考虑 DP 或回溯了。


贪心的解题思路

  1. 将问题分解为子问题——明确每一步要做什么决策。
  2. 找到贪心策略——通常需要先排序,然后按某种规则逐个处理。
  3. 证明正确性——面试中简要说明”为什么局部最优能推出全局最优”即可,不需要严格数学证明。

贪心题的套路:排序 + 扫描 + 维护一个关键变量


经典题目详解

跳跃游戏(LC 55)

题意:数组中每个元素表示你在该位置能跳的最大长度,判断能否到达最后一个位置。

贪心思路:从左到右遍历,维护一个”最远可达位置” max_reach。如果当前位置超过了 max_reach,说明到不了这里,直接返回 False

def canJump(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)
    return True
  • 时间复杂度:O(n)
  • 空间复杂度:O(1)

跳跃游戏 II(LC 45)

题意:保证能到达终点,求最少跳跃次数。

贪心思路:把每一跳能覆盖的范围看作一个”层”,类似 BFS 的层序遍历。每次都跳到能让下一层覆盖最远的位置。

def jump(nums: list[int]) -> int:
    jumps = 0        # 跳跃次数
    cur_end = 0      # 当前这一跳能覆盖的右边界
    max_reach = 0    # 在当前层中能达到的最远位置
    for i in range(len(nums) - 1):
        max_reach = max(max_reach, i + nums[i])
        if i == cur_end:
            # 到达当前层的边界,必须跳一次
            jumps += 1
            cur_end = max_reach
    return jumps
  • 时间复杂度:O(n)
  • 空间复杂度:O(1)

买卖股票的最佳时机 II(LC 122)

题意:可以多次买卖股票(但同一时间最多持有一股),求最大利润。

贪心思路:只要明天比今天贵,就今天买、明天卖。把所有上涨的差价都吃掉。

def maxProfit(prices: list[int]) -> int:
    profit = 0
    for i in range(1, len(prices)):
        # 只要有涨幅就收入囊中
        if prices[i] > prices[i - 1]:
            profit += prices[i] - prices[i - 1]
    return profit
  • 时间复杂度:O(n)
  • 空间复杂度:O(1)

分发饼干(LC 455)

题意:每个孩子有一个胃口值 g[i],每块饼干有大小 s[j]。饼干 j 能满足孩子 i 当且仅当 s[j] >= g[i]。求最多能满足几个孩子。

贪心思路:排序后,用最小的饼干去满足胃口最小的孩子,尽量不浪费大饼干。

def findContentChildren(g: list[int], s: list[int]) -> int:
    g.sort()  # 孩子胃口排序
    s.sort()  # 饼干大小排序
    child = 0
    cookie = 0
    while child < len(g) and cookie < len(s):
        if s[cookie] >= g[child]:
            # 这块饼干能满足这个孩子
            child += 1
        # 无论能否满足,饼干都要往后看
        cookie += 1
    return child
  • 时间复杂度:O(n log n),瓶颈在排序
  • 空间复杂度:O(1)(原地排序)

合并区间(LC 56)

题意:给定若干区间,合并所有重叠的区间。

贪心思路:按左端点排序后,依次检查当前区间是否和上一个区间重叠。重叠就合并(取右端点的较大值),不重叠就直接加入结果。

def merge(intervals: list[list[int]]) -> list[list[int]]:
    intervals.sort(key=lambda x: x[0])  # 按左端点排序
    merged = []
    for interval in intervals:
        if merged and interval[0] <= merged[-1][1]:
            # 与上一个区间重叠,合并
            merged[-1][1] = max(merged[-1][1], interval[1])
        else:
            # 不重叠,直接加入
            merged.append(interval)
    return merged
  • 时间复杂度:O(n log n)
  • 空间复杂度:O(n)(存储结果)

划分字母区间(LC 763)

题意:将字符串划分为尽可能多的片段,使得每个字母最多出现在一个片段中。

贪心思路:先记录每个字母最后出现的位置。然后从左到右扫描,维护当前片段的右边界(当前片段内所有字母的最远出现位置)。当扫描到右边界时,就完成一个片段的划分。

def partitionLabels(s: str) -> list[int]:
    # 记录每个字符最后出现的位置
    last = {ch: i for i, ch in enumerate(s)}

    result = []
    start = 0   # 当前片段起点
    end = 0     # 当前片段的右边界
    for i, ch in enumerate(s):
        end = max(end, last[ch])  # 扩展右边界
        if i == end:
            # 到达边界,切一刀
            result.append(end - start + 1)
            start = i + 1
    return result
  • 时间复杂度:O(n)
  • 空间复杂度:O(1)(字母表大小固定为 26)

经典题目列表

按难度分组,建议按顺序刷:

入门

题号 题目 核心思路
455 分发饼干 排序 + 双指针贪心
860 柠檬水找零 模拟 + 贪心找零
1005 K 次取反后最大化的数组和 排序 + 贪心翻转最小值

进阶

题号 题目 核心思路
55 跳跃游戏 维护最远可达位置
45 跳跃游戏 II 层级 BFS 思想
122 买卖股票的最佳时机 II 累加所有正差价
56 合并区间 排序 + 贪心合并
763 划分字母区间 记录最远位置 + 滑动边界

挑战

题号 题目 核心思路
134 加油站 累计亏损 + 起点重置
435 无重叠区间 按右端点排序,经典区间调度

学习建议

  1. 先学思想:理解算法核心思路比背代码重要。
  2. 多画图:画出贪心每一步的选择过程,帮助理解为什么局部最优能推出全局最优。
  3. 归纳模板:每类贪心题(区间类、跳跃类、分配类)总结自己的解题模板。
  4. 重复练习:同类题目做 5 道以上形成肌肉记忆。
  5. 对比 DP:遇到贪心不确定的题,先试贪心再想 DP,体会两者的区别。

小结

  • 贪心的难点不在编码,而在证明正确性。面试中能简要说清楚”为什么这样选是对的”就够了。
  • 面试中常见的贪心题型:
    • 区间调度:合并区间、无重叠区间、会议室问题
    • 跳跃类:跳跃游戏系列
    • 分配类:分发饼干、任务分配
  • 记住一个口诀:排序是贪心的好朋友,大部分贪心题第一步都是排序。