04 · 贪心算法(Greedy Algorithms)

📅 预计 90 分钟 | ⭐ 本章主题:每一步都选当下看起来最好的
📌 中英术语见文末


4.1 贪心思想

贪心 = 只管眼前,不求长远

想象你在登山,目标是山顶。贪心的做法是:每一步都朝当前最陡的上坡走,从不回头。这种"每步都选当下最优"的策略,就是贪心算法(greedy algorithm)的核心。

更生活化的例子:食堂打菜,菜盘子依次经过你面前,规则是"每人只能端走一份,端了就不能换"。贪心策略就是——只要面前这道菜比你手里的好,就换。它不看后面还会来什么菜

贪心算法的一般形态:把问题分解成一系列局部决策,每一步都选择当前状态下的最优选项,做完一步就进入下一个状态,不回溯。它最大的特点是"短视",所以:

  • 好处:实现简单、速度快(通常 O(n log n) 或 O(n));
  • 风险:不保证全局最优。登山时只挑当前最陡的坡,可能爬上一个小土包就下不来了。
# 贪心思想的雏形:从 1 开始,每次尽量多走,直到超过目标
# 这只是一个示意,真正的问题需要证明贪心正确
def greedy_sum(target):
    s = 0
    while s + 1 <= target:      # 每次只加 1(局部最优的最小步?)
        s += 1
    return s

print(greedy_sum(5))
# 输出: 5

⚠️ 常见错误

  1. 以为贪心对所有最优化问题都适用:贪心只在满足特定条件时才保证全局最优,很多问题贪心会给出错误答案(见 4.4 零钱反例)。
  2. 把"贪心"和"暴力枚举"混为一谈:贪心每步只做一次选择、不回退;枚举是把所有可能都试一遍,完全不同。
  3. 不证明就下结论:写出来很快,但"为什么这样最优"必须用贪心选择性质论证,否则就是猜。

4.2 贪心的两个要素 ⭐

什么时候敢用贪心?

一个最优化问题能用贪心解决,需要同时满足两个性质:

  1. 贪心选择性质(greedy-choice property):可以通过做出一系列局部最优的选择来构造全局最优解。也就是说,存在一个最优解,它的第一步就是贪心选择——选完这一步,剩下的子问题仍可用贪心继续。
  2. 最优子结构(optimal substructure):问题的最优解包含其子问题的最优解。大问题拆成子问题后,只要子问题各自最优,合起来就是大问题最优。

判断流程(画决策树很直观):

  • 每一步选完,剩下的问题还是同一类、规模更小的问题吗?→ 对应最优子结构;
  • 存在一个最优解以"当前贪心选择"为第一步吗?→ 对应贪心选择性质。

验证方法:先用小数据手算一遍,如果贪心答案和穷举最优答案完全一致,说明很可能正确;再尝试构造反例——找一个贪心会错的数据。找不到反例 + 能证明两步性质,才敢放心用。

💡 记忆口诀:贪心两件套——"第一步敢不敢这么选"(贪心选择性质)+"子问题最优拼起来就是全局最优"(最优子结构)。

⚠️ 常见错误

  1. 只有最优子结构,没有贪心选择性质:最优子结构是贪心、分治、动态规划共有的前提,单独有它不足以证明贪心。
  2. 用"经验上感觉对"代替证明:贪心正确性必须论证或找反例,不能靠直觉。
  3. 不检查局部最优组合后是否仍整体最优:局部最优的叠加有时并不等于全局最优。

4.3 活动选择问题 ⭐

一个会议室,怎么安排最多的活动?

问题:有一间会议室,n 个活动各占一个区间 [start, end),一个时间只能开一个活动,求最多能安排几个活动。

贪心策略:每次选"结束时间最早"且与已选不冲突的活动。直觉:结束越早,给后面留的时间越多。

def activity_selection(start, end):
    """start、end 已按结束时间升序排列"""
    n = len(start)
    count = 1                          # 第一个活动必选
    last_end = end[0]
    for i in range(1, n):
        if start[i] >= last_end:       # 与上一个已选活动不冲突
            count += 1
            last_end = end[i]
    return count

# 活动区间:(1,2) (3,4) (0,6) (5,7) (8,9) (5,9),已按结束时间排序
start = [1, 3, 0, 5, 8, 5]
end   = [2, 4, 6, 7, 9, 9]
print(activity_selection(start, end))
# 输出: 4  → 选 (1,2)、(3,4)、(5,7)、(8,9)

为什么贪心正确:设最优解中结束最早的活动为 e1。按"结束时间最早"贪心选出的活动 g1 结束时间 ≤ e1,用 g1 替换 e1 不改变活动个数,还让剩余时间更宽松。于是存在一个最优解以 g1 为第一步,剩余子问题同构——贪心选择性质 + 最优子结构同时满足。

复杂度:需要先按结束时间排序 O(n log n),然后一趟 O(n),总 O(n log n)

# 未排序时的完整流程:先排序再贪心
activities = [(1, 2), (0, 6), (3, 4), (5, 7), (8, 9), (5, 9)]
activities.sort(key=lambda x: x[1])        # 按结束时间排序
sel = []
last_end = -1
for s, e in activities:
    if s >= last_end:
        sel.append((s, e))
        last_end = e
print(sel)
# 输出: [(1, 2), (3, 4), (5, 7), (8, 9)]

⚠️ 常见错误

  1. 按"开始时间最早"或"持续时间最短"贪心:这两个策略对活动选择都不保证最优,必须按结束时间最早
  2. 忘排序就贪心:核心是"结束最早的先处理",必须先排序。
  3. 边界判断写反:区间不冲突的条件是 start[i] >= last_end,写成 > 会漏掉"无缝衔接"的活动。

4.4 零钱问题

贪心不一定对,要看币制

问题:给定若干面额的硬币,用最少硬币凑出金额 M。经典做法是"从最大面额开始,能塞多少塞多少":

def coin_change_greedy(coins, amount):
    coins = sorted(coins, reverse=True)   # 从大到小
    total = 0
    for c in coins:
        total += amount // c              # 尽量多用大面额
        amount %= c
        if amount == 0:
            break
    return total if amount == 0 else -1   # -1 表示凑不出来

print(coin_change_greedy([25, 10, 5, 1], 63))
# 输出: 6  → 25×2 + 10×1 + 1×3 = 6 枚

但对某些币制,贪心是错的。反例:币值 {1, 5, 11},凑 15:

  • 贪心:先拿 11,剩 4 → 再拿 4 个 1,共 5 枚
  • 最优:3 个 5,共 3 枚

贪心被 11 带偏了。结论:

  • 当币制满足某种"规范"条件(如人民币的 1/5/10 递进),贪心正确;
  • 币制任意时,贪心不保证最优,需要用动态规划(第 05 章)保证正确。
coins = [1, 5, 11]
amount = 15
coins.sort(reverse=True)
used, rem = 0, amount
for c in coins:
    used += rem // c
    rem %= c
print(f"贪心: {used} 枚")      # 输出: 贪心: 5 枚(错误)
# 正确答案是 3 枚:5 + 5 + 5

💡 记忆口诀:零钱贪心要看币制——规范币制贪心对,任意币制会翻车,翻车就上动态规划。

⚠️ 常见错误

  1. 默认贪心永远正确{1, 5, 11} 凑 15 是教科书反例,务必记住。
  2. 没检查是否恰好凑满:贪心可能凑不出精确金额,要处理返回 -1 的情形。
  3. 把零钱问题写成"排序后能拿就拿"就完事:要能回答"什么时候贪心正确",才算真懂。

4.5 区间调度

最少移除多少区间,让剩下不重叠

问题变体:给定若干区间,移除最少数量的区间,使剩余区间互不重叠;等价于"保留最多的互不重叠区间"。和活动选择本质相同——保留尽可能多的不重叠区间,答案就是 n 减保留数。

def erase_overlap(intervals):
    intervals.sort(key=lambda x: x[1])     # 按结束时间排序
    keep = 0
    last_end = float("-inf")
    for s, e in intervals:
        if s >= last_end:                 # 不重叠就保留
            keep += 1
            last_end = e
    return len(intervals) - keep          # 要移除的数量

print(erase_overlap([[1, 2], [2, 3], [3, 4], [1, 3]]))
# 输出: 1  → 移除 [1,3],剩三个互不重叠

同族问题:合并区间(把所有重叠区间并成一个大区间)也用贪心——按起点排序后,一趟合并。

def merge_intervals(intervals):
    intervals.sort()                      # 按起点排序
    res = []
    for s, e in intervals:
        if res and s <= res[-1][1]:       # 与当前结果最后一个重叠
            res[-1][1] = max(res[-1][1], e)  # 扩展右端点
        else:
            res.append([s, e])
    return res

print(merge_intervals([[1, 3], [2, 6], [8, 10], [15, 18]]))
# 输出: [[1, 6], [8, 10], [15, 18]]

⚠️ 常见错误

  1. 合并区间按结束排序:合并必须先按起点排序,否则相邻区间找不对。
  2. 把"移除最少"和"保留最多"搞混:两者互补,答案相差常数,别算错。
  3. 边界"重叠"定义不一致[1,2][2,3] 是接壤不重叠(s >= last_end 放行);要包含端点需统一约定。

4.6 哈夫曼编码 ⭐

压缩数据:给高频字符发短码,低频字符发长码

背景:ASCII 里每个字符固定占 8 位。如果一篇文档里 e 出现几百万次、z 出现几次,让高频字符的编码更短、低频的更长,总长度就能大幅压缩。

关键约束是前缀码(prefix code):任何一个字符的编码,都不能是另一个字符编码的前缀——否则解码时会歧义。哈夫曼编码(Huffman coding)用贪心构造一棵最省总长度的前缀树:

  • 把所有字符按频率建一个最小堆
  • 每轮弹出频率最小的两个,合并成一个"新节点"(频率相加),重新入堆;
  • 重复直到堆里只剩一个节点,就是哈夫曼树;
  • 从根到叶子,左分支记 0、右分支记 1,得到每个字符的编码。

贪心点在于"每次合并当前频率最小的两个"——让频率低的字符待在树更深处(编码更长),频率高的字符待在更浅处(编码更短)。

import heapq

def huffman(freq):
    heap = [[w, [sym, ""]] for sym, w in freq.items()]
    heapq.heapify(heap)
    while len(heap) > 1:
        lo = heapq.heappop(heap)          # 频率最小的
        hi = heapq.heappop(heap)          # 频率次小的
        for pair in lo[1:]:
            pair[1] = "0" + pair[1]
        for pair in hi[1:]:
            pair[1] = "1" + pair[1]
        heapq.heappush(heap, [lo[0] + hi[0]] + lo[1:] + hi[1:])
    return sorted(heapq.heappop(heap)[1:])

freq = {"a": 45, "b": 13, "c": 12, "d": 16, "e": 9, "f": 5}
print(huffman(freq))
# 输出: [['a', '0'], ['b', '101'], ['c', '100'], ['d', '111'], ['e', '1101'], ['f', '1100']]
# 哈夫曼编码本身不唯一,但平均码长总是最优

这段代码里 heap 每个元素是 [权重, [符号, 编码], ...],合并时给两组的编码分别加上 01 前缀。注意哈夫曼编码不唯一(并列频率的合并顺序不同结果不同),但平均码长最优

复杂度:每轮从堆里取最小 O(log n),共 n-1 轮,总 O(n log n)

⚠️ 常见错误

  1. 说编码结果唯一:哈夫曼编码不唯一,但任何一棵哈夫曼树的平均码长都最优。
  2. 破坏前缀码约束:直接按频率排序取前几位做编码可能产生前缀冲突,必须通过构造树保证前缀性。
  3. 堆里存了 (权重, 符号) 元组就够了:不行,合并后要带整棵子树,通常存 [权重, 节点列表]。

4.7 贪心 vs 动态规划

长得像,但一个"不回看",一个"回头看"

贪心与动态规划(DP)都要求最优子结构,都常用于最优化问题,但决策方式本质不同:

对比项 贪心 动态规划
决策依据 只看当前局部最优 综合比较所有子问题解
是否回溯 否,一路向前 否,但会重复利用子问题解
需要重叠子问题 不依赖 依赖(否则没有 DP 的价值)
正确性 需额外证明贪心选择性质 靠状态转移保证全局最优
典型复杂度 O(n) ~ O(n log n) 看状态数 × 转移成本
失败情形 局部最优 ≠ 全局最优 不适用的问题也能硬写但低效

同一个问题两种解法的对比——零钱问题

  • 贪心(币制规范时):从大到小,O(k) 时间,但任意币制可能错;
  • 动态规划:枚举所有凑法取最小,正确性有保证,但复杂度更高(O(amount × 币种数))。
# 零钱问题的动态规划解法(正确性有保证,见第 05 章详述)
def coin_change_dp(coins, amount):
    INF = float("inf")
    dp = [INF] * (amount + 1)
    dp[0] = 0
    for m in range(1, amount + 1):
        for c in coins:
            if c <= m:
                dp[m] = min(dp[m], dp[m - c] + 1)
    return dp[amount] if dp[amount] != INF else -1

print(coin_change_dp([1, 5, 11], 15))
# 输出: 3  → 5 + 5 + 5(贪心给 5,是错误的)

选择指引:能证明贪心性质 → 用贪心(快);证明不了、但问题有最优子结构和重叠子问题 → 用动态规划(稳)。分治则是"子问题不重叠"的那一类,与 DP 不冲突。

💡 记忆口诀:贪心是"走一步看一步",DP 是"把每一步的最优都记下来拼起来";证明不了贪心就上 DP。

⚠️ 常见错误

  1. 贪心错了也不换 DP:遇到反例(如 {1,5,11} 凑 15)就应改用 DP,别硬撑。
  2. 认为 DP 一定比贪心慢很多:DP 慢在状态多,但很多问题状态空间可控,是保证正确性的可靠手段。
  3. 分治、贪心、DP 三者边界混淆:子问题独立不重叠 → 分治;每步局部最优且可证 → 贪心;子问题重叠且要比较多种选择 → DP。

🧠 记忆口诀

  • 贪心一句话:走一步看一步,选当下最优,不回头。
  • 两个要素:贪心选择性质(第一步敢这么选)+ 最优子结构(子问题最优拼全局最优)。
  • 活动选择:按结束时间最早排,能排就排。
  • 零钱反例{1,5,11} 凑 15,贪心 5 枚、最优 3 枚。
  • 区间调度:保留最多不重叠 = 结束最早优先;合并区间按起点排。
  • 哈夫曼:最小堆每次合并两个最小频率,左 0 右 1 造前缀码。
  • 贪心 vs DP:能证明用贪心,证明不了用 DP。

📌 中英术语表(本讲)

中文 English 说明
贪心算法 greedy algorithm 每步选当前局部最优
贪心选择性质 greedy-choice property 存在最优解以贪心选择为第一步
最优子结构 optimal substructure 最优解包含子问题最优解
局部最优 local optimum 当前步骤下的最优
全局最优 global optimum 整个问题的最优
活动选择问题 activity-selection problem 最多互不重叠活动
零钱问题 coin change problem 最少硬币凑出金额
反例 counterexample 推翻策略错误的例子
区间调度 interval scheduling 区间不重叠的取舍
前缀码 prefix code 任意码不是另一码的前缀
哈夫曼编码 Huffman coding 频率高的码短,频率低的码长
最小堆 min-heap 每次可取最小元素的结构
回溯 backtrack 撤销之前的决策
动态规划 dynamic programming 用子问题最优解递推全局最优

⭐ 本章考点清单

  1. 贪心思想:每步局部最优、不回溯、不保证全局最优
  2. 贪心的两个性质:贪心选择性质 + 最优子结构
  3. 活动选择:按结束时间最早排序,O(n log n)
  4. 零钱问题反例 {1,5,11} 凑 15:贪心 5 枚 vs 最优 3 枚
  5. 区间调度:移除最少 = 保留最多不重叠
  6. 合并区间:按起点排序一趟合并
  7. 哈夫曼编码:最小堆每次合并两个最小频率,前缀码,O(n log n)
  8. 哈夫曼编码不唯一但平均码长最优
  9. 贪心 vs 动态规划对比:正确性保证、复杂度、适用条件
  10. 判断流程:可证贪心则用贪心,否则用 DP