第 26 章

贪心:局部最优的赌注

第二十六章:贪心 — 局部最优的赌注

你在一个加油站环形公路上,油箱空空如也。每个加油站能加的油和到下一站的耗油量不同。你能不能绕一圈回到起点?如果能,应该从哪个站出发?

这就是 LeetCode #134(加油站)的设定。一个直觉性的做法是:从某个站出发,如果油箱在某处变负了,说明从这个站到变负的那个站之间的任何站出发都不行——直接跳到下一个站重新开始。这个"跳过中间所有站"的决策看似鲁莽,但它是正确的。这就是贪心算法的精髓:做出局部最优的选择,赌它能导向全局最优

贪心算法(Greedy Algorithm)是一种在每一步都选择当前看起来最好的选项的策略。它不像动态规划那样考虑所有子问题的组合,也不像回溯那样尝试所有可能性——它只看眼前,做出决定,永不回头。

这种"短视"的策略为什么有时能得到最优解?答案是:当问题具有贪心选择性质(greedy-choice property)和最优子结构(optimal substructure)时,贪心就是正确的。但问题的另一面是:当这两个性质不满足时,贪心会得到错误答案,而且它的错误往往很隐蔽——代码能跑,结果看起来合理,但就是不是最优解。

贪心的历史可以追溯到图论的早期。1930 年代 Kruskal 和 Prim 为最小生成树设计的算法就是贪心的典范(我们在第 18 章已经详细讨论)。1952 年 David Huffman 在 MIT 读研究生时发明的 Huffman 编码,开创了信息论中贪心方法的先河。今天,gzip、JPEG、MP3 这些无处不在的压缩格式都直接或间接地依赖 Huffman 编码。

这一章我们从贪心的核心思想和正确性证明出发,通过经典问题(活动选择、区间调度、Huffman 编码、背包问题)建立对贪心的深度理解,最后给出"什么时候用贪心、什么时候用 DP"的决策框架。


Level 1 · 你需要知道的

26.1 贪心的核心思想

26.1.1 什么是贪心

贪心算法的定义可以用一句话概括:

在每一步做出局部最优选择,希望这些局部最优选择能组合成全局最优解。

关键特征:

  1. 不回头:一旦做出选择,不会撤销
  2. 不考虑未来:只根据当前信息做决定
  3. 没有子问题组合:不像 DP 那样合并子问题的解

贪心是最"简单"的算法范式——实现通常很短,时间复杂度通常很优。难点在于:判断贪心策略是否正确

26.1.2 贪心 vs 动态规划 vs 回溯

            考虑所有可能性?    利用子问题重叠?    做出不可逆选择?
回溯/暴力:     是               否                 否(会回退)
动态规划:     是(隐式地)       是                 否
贪心:         否               不需要              是

一个直觉:

26.2 活动选择问题

26.2.1 问题定义

给定 n 个活动,每个活动有开始时间 s[i] 和结束时间 f[i]。一个人同一时间只能参加一个活动。问:最多能参加多少个不重叠的活动?

这是贪心最经典的入门问题,也叫"区间调度问题"(Interval Scheduling)。

26.2.2 贪心策略:按结束时间排序

直觉:每次选择结束时间最早的活动。为什么?因为一个活动越早结束,留给后续活动的时间就越多。

def activity_selection(activities):
    """
    活动选择问题:贪心法
    activities: [(start, end), ...]
    返回最大不重叠活动集合
    """
    # 按结束时间排序
    sorted_acts = sorted(activities, key=lambda x: x[1])

    selected = [sorted_acts[0]]
    last_end = sorted_acts[0][1]

    for start, end in sorted_acts[1:]:
        if start >= last_end:  # 不重叠
            selected.append((start, end))
            last_end = end

    return selected


# 示例
activities = [(1, 4), (3, 5), (0, 6), (5, 7), (3, 9), (5, 9),
              (6, 10), (8, 11), (8, 12), (2, 14), (12, 16)]
result = activity_selection(activities)
print(f"最多参加 {len(result)} 个活动: {result}")
# 最多参加 4 个活动: [(1, 4), (5, 7), (8, 11), (12, 16)]

时间复杂度: O(n log n)(排序) + O(n)(遍历) = O(n log n)

26.2.3 为什么其他贪心策略不行?

只有"结束时间最早"这个策略是正确的。为什么?我们在 Level 2 中严格证明。

26.3 区间调度问题

区间调度是活动选择的泛化。常见的三种变体:

26.3.1 最大不重叠区间数(等价于活动选择)

def max_non_overlapping(intervals):
    """LeetCode #435 的逆问题:最大不重叠区间数"""
    if not intervals:
        return 0
    intervals.sort(key=lambda x: x[1])
    count = 1
    last_end = intervals[0][1]
    for start, end in intervals[1:]:
        if start >= last_end:
            count += 1
            last_end = end
    return count

26.3.2 最少移除使区间不重叠(LeetCode #435)

def erase_overlap_intervals(intervals):
    """
    LeetCode #435: 无重叠区间
    返回最少需要移除的区间数,使剩余区间互不重叠
    """
    if not intervals:
        return 0
    # 贪心策略:最大不重叠 = 总数 - 需要移除的
    max_keep = max_non_overlapping(intervals)
    return len(intervals) - max_keep


# 示例
intervals = [[1,2],[2,3],[3,4],[1,3]]
print(erase_overlap_intervals(intervals))  # 1(移除[1,3])

26.3.3 最少箭引爆气球(LeetCode #452)

def find_min_arrow_shots(points):
    """
    LeetCode #452: 用最少数量的箭引爆气球
    每个 point 是气球的水平区间 [x_start, x_end]
    一支箭可以引爆所有包含该 x 坐标的气球
    """
    if not points:
        return 0

    # 按右端点排序
    points.sort(key=lambda x: x[1])

    arrows = 1
    arrow_pos = points[0][1]  # 箭射在第一个气球的右端点

    for start, end in points[1:]:
        if start > arrow_pos:  # 当前气球不被之前的箭覆盖
            arrows += 1
            arrow_pos = end

    return arrows


# 示例
points = [[10,16],[2,8],[1,6],[7,12]]
print(find_min_arrow_shots(points))  # 2

关键洞察: 这三个问题的核心都是"按右端点排序 + 贪心选择"。它们看起来不同,但数学本质相同。

26.4 跳跃游戏

26.4.1 LeetCode #55: 跳跃游戏(能否到达终点)

给定数组 nums,nums[i] 表示从位置 i 最多能跳 nums[i] 步。判断能否到达最后一个位置。

def can_jump(nums):
    """
    LeetCode #55: 跳跃游戏
    贪心:维护能到达的最远位置
    """
    max_reach = 0
    for i in range(len(nums)):
        if i > max_reach:
            return False  # 当前位置超出了能到达的范围
        max_reach = max(max_reach, i + nums[i])
        if max_reach >= len(nums) - 1:
            return True
    return True


# 示例
print(can_jump([2, 3, 1, 1, 4]))  # True
print(can_jump([3, 2, 1, 0, 4]))  # False

26.4.2 LeetCode #45: 跳跃游戏 II(最少跳几次)

def jump(nums):
    """
    LeetCode #45: 跳跃游戏 II
    贪心:在每一步的可达范围内,选择能跳最远的位置
    """
    if len(nums) <= 1:
        return 0

    jumps = 0
    current_end = 0    # 当前跳跃能到达的最远边界
    farthest = 0       # 下一步能到达的最远位置

    for i in range(len(nums) - 1):
        farthest = max(farthest, i + nums[i])

        if i == current_end:  # 必须跳了
            jumps += 1
            current_end = farthest
            if current_end >= len(nums) - 1:
                break

    return jumps


# 示例
print(jump([2, 3, 1, 1, 4]))  # 2 (0→1→4)

贪心策略解释: 把数组想象成一个个"台阶"。从位置 0 开始,你能跳到的范围是 [1, nums[0]]。在这个范围内,找到能让你跳得最远的位置——那就是下一步应该跳到的地方。这就像 BFS 的层级扩展:每一"跳"对应 BFS 的一层。

26.5 贪心的常见模式总结

模式 排序依据 贪心选择 典型问题
区间调度 右端点 选最早结束的 活动选择、#435、#452
区间覆盖 左端点 选覆盖范围最大的 跳跃游戏、区间覆盖
分配匹配 双方排序 最小满足最小 分发饼干#455
拓扑贪心 依赖关系 选入度为0的 任务调度
反悔贪心 优先队列 选当前最优,可反悔 超级丑数、IPO

Level 2 · 它是怎么运行的

26.6 贪心正确性证明

贪心算法最大的难点不是实现,而是证明正确性。两种经典证明方法:

26.6.1 交换论证(Exchange Argument)

思路:假设存在一个最优解 OPT 不同于贪心解 G。证明可以把 OPT 中的某个选择"交换"为贪心的选择,得到的解不会更差。反复交换后,OPT 被变换为 G,说明 G 也是最优的。

活动选择问题的证明:

设贪心算法选择的活动序列为 G = {g₁, g₂, ..., gₖ}(按结束时间排序)。 设任意最优解为 OPT = {o₁, o₂, ..., oₘ}(按结束时间排序)。

我们要证明 k = m(即贪心选的数量和最优解一样多)。

Step 1: 证明 f(g₁) ≤ f(o₁)(贪心的第一个活动结束不比最优解的晚)

Step 2: 用 g₁ 替换 o₁ 得到 OPT' = {g₁, o₂, ..., oₘ}

Step 3: 对 OPT' 的剩余活动 {o₂, ..., oₘ} 递归应用同样的论证

Step 4: 归纳得 k ≥ m。又因为 OPT 是最优解(m 最大),所以 k ≤ m。故 k = m。

def prove_greedy_optimal():
    """
    交换论证的形式化(伪代码)
    """
    # 假设 OPT ≠ G
    # 找到第一个不同的位置 j: o_j ≠ g_j
    # 因为 f(g_j) ≤ f(o_j)(贪心总是选结束最早的)
    # 构造 OPT' = OPT 中 o_j 换成 g_j
    # OPT' 仍合法且大小不变
    # 重复直到 OPT' = G
    # 结论:|G| = |OPT|
    pass

26.6.2 反证法(Proof by Contradiction)

思路:假设贪心解不是最优的,推出矛盾。

跳跃游戏 II (#45) 的证明:

设贪心解跳了 k 次,设存在更优解跳了 j < k 次。

贪心算法的性质:在第 t 跳时,它的跳跃范围 [current_end_t] 是所有 t 跳方案能达到的最远位置。

证明(归纳法):

如果更优解在 j 跳后到达终点,而贪心在每一步都到得更远(或一样远),那贪心在 j 步也一定到达了终点。这与"贪心需要 k > j 步"矛盾。

26.7 Huffman 编码

26.7.1 问题背景

假设你要压缩一个只含 a、b、c、d、e 五种字符的文件。如果用固定长度编码:

但如果字符出现频率不同呢?比如 a 出现 45 次,b 出现 13 次,c 出现 12 次,d 出现 16 次,e 出现 9 次,f 出现 5 次。

如果给高频字符短编码、低频字符长编码,总 bits 数可以更少。这就是 Huffman 编码 的核心思想——一种前缀码(prefix-free code),保证任何字符的编码不是另一个字符编码的前缀,从而可以唯一解码。

26.7.2 Huffman 算法

贪心策略:每次合并频率最低的两个节点

import heapq
from collections import Counter


class HuffmanNode:
    """Huffman 树节点"""
    def __init__(self, char=None, freq=0, left=None, right=None):
        self.char = char
        self.freq = freq
        self.left = left
        self.right = right

    def __lt__(self, other):
        return self.freq < other.freq  # 用于堆排序


def build_huffman_tree(text):
    """
    构建 Huffman 树
    贪心策略:每次合并频率最低的两个节点
    """
    # 统计字符频率
    freq = Counter(text)
    if len(freq) == 0:
        return None
    if len(freq) == 1:
        char = list(freq.keys())[0]
        return HuffmanNode(char=char, freq=freq[char])

    # 创建最小堆
    heap = [HuffmanNode(char=c, freq=f) for c, f in freq.items()]
    heapq.heapify(heap)

    # 贪心合并
    while len(heap) > 1:
        # 取出频率最低的两个
        left = heapq.heappop(heap)
        right = heapq.heappop(heap)

        # 合并为新节点
        merged = HuffmanNode(
            freq=left.freq + right.freq,
            left=left,
            right=right
        )
        heapq.heappush(heap, merged)

    return heap[0]  # 根节点


def generate_codes(root):
    """从 Huffman 树生成编码表"""
    codes = {}

    def traverse(node, code):
        if node is None:
            return
        if node.char is not None:  # 叶子节点
            codes[node.char] = code if code else '0'
            return
        traverse(node.left, code + '0')
        traverse(node.right, code + '1')

    traverse(root, '')
    return codes


def huffman_encode(text):
    """Huffman 编码"""
    tree = build_huffman_tree(text)
    codes = generate_codes(tree)

    encoded = ''.join(codes[c] for c in text)
    return encoded, codes, tree


def huffman_decode(encoded, tree):
    """Huffman 解码"""
    decoded = []
    node = tree
    for bit in encoded:
        if bit == '0':
            node = node.left
        else:
            node = node.right
        if node.char is not None:  # 到达叶子
            decoded.append(node.char)
            node = tree  # 回到根
    return ''.join(decoded)


# 完整示例
text = "aaaaaaaaaaaaaabbbbbbbbbbcccccccccddddddddddddeeeeeffff"
encoded, codes, tree = huffman_encode(text)

print("字符编码表:")
for char, code in sorted(codes.items()):
    print(f"  '{char}': {code}")

print(f"\n原始文本长度: {len(text)} 字符")
print(f"固定编码: {len(text) * 3} bits (3 bits/char)")
print(f"Huffman 编码: {len(encoded)} bits")
print(f"压缩率: {len(encoded) / (len(text) * 8) * 100:.1f}%")

# 验证解码正确性
decoded = huffman_decode(encoded, tree)
assert decoded == text, "解码错误!"
print(f"解码验证: {'通过' if decoded == text else '失败'}")

输出示例:

字符编码表:
  'a': 0
  'b': 110
  'c': 101
  'd': 10
  'e': 1110
  'f': 1111

原始文本长度: 53 字符
固定编码: 159 bits (3 bits/char)
Huffman 编码: 116 bits
压缩率: 27.4%

26.7.3 Huffman 编码与 gzip 的关系

gzip 的压缩流程:

  1. LZ77:用滑动窗口找重复字符串,替换为(距离, 长度)对
  2. Huffman 编码:对 LZ77 的输出进行 Huffman 编码

gzip 实际使用两种 Huffman 编码模式:

# gzip 的 Deflate 算法核心流程(简化)
def deflate_simplified(data):
    """
    模拟 gzip 的 Deflate 压缩(极简版)
    """
    # Step 1: LZ77 — 找重复,生成 (literal/length, distance) 序列
    lz77_output = lz77_compress(data)

    # Step 2: Huffman — 对 LZ77 输出进行编码
    # 分别为 literal/length 和 distance 各建一棵 Huffman 树
    huffman_encoded = huffman_encode_lz77(lz77_output)

    return huffman_encoded


def lz77_compress(data, window_size=32768, lookahead_size=258):
    """LZ77 压缩(简化版)"""
    output = []
    i = 0
    while i < len(data):
        best_length = 0
        best_distance = 0

        # 在滑动窗口中找最长匹配
        search_start = max(0, i - window_size)
        for j in range(search_start, i):
            length = 0
            while (i + length < len(data) and
                   length < lookahead_size and
                   data[j + length] == data[i + length]):
                length += 1
            if length > best_length:
                best_length = length
                best_distance = i - j

        if best_length >= 3:  # 匹配长度 >= 3 才值得用引用
            output.append(('match', best_distance, best_length))
            i += best_length
        else:
            output.append(('literal', data[i]))
            i += 1

    return output

26.8 分数背包 vs 0-1 背包

这是理解"什么时候贪心有效,什么时候无效"的最好例子。

26.8.1 分数背包(Fractional Knapsack)— 贪心有效

物品可以"切割"——可以取一部分。

def fractional_knapsack(capacity, items):
    """
    分数背包:贪心法
    items: [(weight, value), ...]
    可以取物品的一部分
    """
    # 按单位重量价值排序(从高到低)
    items_with_ratio = [(v / w, w, v) for w, v in items]
    items_with_ratio.sort(reverse=True)

    total_value = 0
    remaining = capacity

    for ratio, weight, value in items_with_ratio:
        if remaining <= 0:
            break
        if weight <= remaining:
            # 全部拿走
            total_value += value
            remaining -= weight
        else:
            # 拿一部分
            total_value += ratio * remaining
            remaining = 0

    return total_value


# 示例
items = [(10, 60), (20, 100), (30, 120)]  # (weight, value)
capacity = 50
print(f"最大价值: {fractional_knapsack(capacity, items)}")
# 最大价值: 240.0
# 取全部物品1(60) + 全部物品2(100) + 2/3物品3(80) = 240

为什么贪心正确? 因为物品可以分割,所以"单位价值最高"的策略不会浪费空间。每一份容量都被最有价值的物品填满。

26.8.2 0-1 背包 — 贪心无效

物品不可分割——要么拿,要么不拿。

# 反例:贪心策略失败
items = [(10, 60), (20, 100), (30, 120)]  # (weight, value)
capacity = 50

# 贪心(按单位价值排序):
# 物品1: ratio=6, 拿 (weight=10, value=60)
# 物品2: ratio=5, 拿 (weight=20, value=100)
# 物品3: ratio=4, 剩余30>=30, 拿 (weight=30, value=120)
# 但 10+20+30=60 > 50!物品3放不进去
# 贪心结果:只拿物品1和物品2,value=160

# 最优解:物品2+物品3,weight=50,value=220
# 贪心差了很多!

def knapsack_01_dp(capacity, items):
    """0-1 背包:只能用 DP"""
    n = len(items)
    dp = [[0] * (capacity + 1) for _ in range(n + 1)]

    for i in range(1, n + 1):
        weight, value = items[i - 1]
        for w in range(capacity + 1):
            dp[i][w] = dp[i - 1][w]  # 不选第 i 个
            if w >= weight:
                dp[i][w] = max(dp[i][w], dp[i - 1][w - weight] + value)

    return dp[n][capacity]


print(f"0-1背包最优解: {knapsack_01_dp(50, items)}")  # 220

为什么贪心失败? 因为物品不可分割,选了"单位价值高但重量轻"的物品后,可能剩余容量只够放"单位价值低但总价值高"的物品的一小部分——而 0-1 背包不允许取一部分。贪心无法处理这种"组合效应"。

26.9 贪心适用条件速查

贪心能用的信号:

  1. 问题要求"最多/最少/最优"
  2. 每一步的选择不影响已做选择的有效性
  3. 能找到一个简单的排序准则
  4. 问题具有"交换不会更优"的性质

贪心不能用的信号:

  1. 当前选择会影响未来可选项的"质量"
  2. 需要考虑选择之间的"组合"
  3. 存在"选 A 导致后面只能选 B,但选 C 导致后面能选 D+E"的情况
  4. 问题的最优解不满足"子问题无关性"

Level 2(续) · 更多实战

26.10 分发饼干(LeetCode #455)

def find_content_children(g, s):
    """
    LeetCode #455: 分发饼干
    g[i]: 第 i 个孩子的胃口值
    s[j]: 第 j 块饼干的大小
    每个孩子最多能得到一块饼干,饼干 j 能满足孩子 i 当且仅当 s[j] >= g[i]
    返回最多能满足的孩子数
    
    贪心策略:用最小的能满足的饼干去满足胃口最小的孩子
    """
    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


# 示例
print(find_content_children([1, 2, 3], [1, 1]))  # 1
print(find_content_children([1, 2], [1, 2, 3]))  # 2

正确性直觉: 如果一块大饼干能满足一个小胃口的孩子,那用一块刚好够的小饼干去满足这个孩子,把大饼干留给大胃口的孩子,结果不会更差。这就是交换论证的直觉形式。

26.11 加油站(LeetCode #134)

def can_complete_circuit(gas, cost):
    """
    LeetCode #134: 加油站
    gas[i]: 第 i 个加油站能加的油
    cost[i]: 从第 i 站到第 i+1 站的耗油
    返回能绕一圈的起始加油站编号,不能则返回 -1
    
    贪心策略:
    1. 如果总油量 >= 总消耗,一定有解
    2. 如果从 start 出发到 i 油箱变负,则 start 到 i 之间任何站都不行
       (因为从 start 到中间某站 j 的油量 >= 0,从 j 出发少了这部分油更不行)
    3. 所以直接从 i+1 重新开始
    """
    total_tank = 0
    current_tank = 0
    start = 0

    for i in range(len(gas)):
        net = gas[i] - cost[i]
        total_tank += net
        current_tank += net

        if current_tank < 0:
            # 从 start 到 i 都不行,从 i+1 重新开始
            start = i + 1
            current_tank = 0

    return start if total_tank >= 0 else -1


# 示例
gas = [1, 2, 3, 4, 5]
cost = [3, 4, 5, 1, 2]
print(can_complete_circuit(gas, cost))  # 3

为什么"跳过中间所有站"是正确的?

设从 start 出发,到达位置 j(start ≤ j ≤ i)时油箱有 tank_j ≥ 0。到位置 i 时 tank_i < 0。

如果改从 j 出发(j 在 start 和 i 之间),那到位置 i 时的油量 = tank_i - tank_j ≤ tank_i < 0(因为 tank_j ≥ 0)。

所以从 start 到 i 之间任何位置出发,到 i 时油量都是负的。只有 i+1 之后的站有可能是正确起点。

26.12 Huffman 正确性证明

定理: Huffman 算法构建的前缀码是最优的(总编码长度最短)。

证明(交换论证):

设字母表大小为 n,字符按频率排序 f₁ ≤ f₂ ≤ ... ≤ fₙ。

引理 1: 最优树中频率最低的两个字符一定在最深层且互为兄弟。

反证:假设最优树 T 中,频率最低的 x 不在最深层。设最深层有节点 z(频率 ≥ x)。交换 x 和 z 的位置,总代价变化 = (f_x - f_z)(depth_z - depth_x)。因为 f_x ≤ f_z 且 depth_z > depth_x,所以变化 ≤ 0,即不会增加代价。与 T 最优矛盾(除非代价不变,说明 x 也可以在最深层)。

引理 2: 合并频率最低的两个字符,得到的子问题的最优解对应原问题的最优解。

设 x, y 是频率最低的两个,合并为 z(f_z = f_x + f_y)。设 T 是原问题的最优树,T' 是把 T 中 x,y 的父节点替换为叶子 z 得到的树。

cost(T) = cost(T') + f_x + f_y

因为 T' 是 n-1 个字符问题的某个解,如果它不是最优的,设 T'' 是 n-1 字符的最优解,则把 T'' 中的 z 展开为 x,y 得到的树代价 = cost(T'') + f_x + f_y < cost(T') + f_x + f_y = cost(T),与 T 最优矛盾。

所以 T' 是 n-1 字符的最优解,归纳得证。


Level 3 · 规范怎么定义的

26.13 Huffman 1952 年论文

David A. Huffman 在 1952 年发表了《A Method for the Construction of Minimum-Redundancy Codes》(Proceedings of the IRE)。这篇论文源于他在 MIT 读研时 Robert Fano 的课上的一次作业——Fano 给学生两个选择:做期末考试,或者找到最优的二进制编码方法。Huffman 选择了后者,在数月苦思后发现了这个贪心算法。

讽刺的是,Fano 本人之前提出的 Shannon-Fano 编码(自顶向下分割)并不是最优的,而他的学生从底向上合并的方法却是最优的。这个故事完美诠释了"自底向上贪心"有时比"自顶向下分治"更好。

Huffman 论文的核心贡献:

  1. 证明了前缀码的最优性可以通过贪心构造达到
  2. 给出了 O(n log n) 的构造算法
  3. 证明了 Huffman 编码的平均码长满足 H(X) ≤ L < H(X) + 1,其中 H(X) 是信源熵

这个 H(X) ≤ L < H(X) + 1 的界来自 Shannon 1948 年的信息论奠基论文。它告诉我们:Huffman 编码最多比理论极限(熵)多 1 bit/symbol。现代的算术编码(Arithmetic Coding)可以逼近熵,但 Huffman 编码因为简单高效,至今仍广泛使用。

26.14 贪心与拟阵(Matroid)理论

26.14.1 什么是拟阵

拟阵(matroid)是一种组合结构,由 Hassler Whitney 在 1935 年提出,用于抽象"独立性"的概念。一个拟阵 M = (S, I) 由有限集 S 和独立集族 I ⊆ 2^S 构成,满足:

  1. 空集独立:∅ ∈ I
  2. 遗传性(hereditary):如果 A ∈ I 且 B ⊆ A,则 B ∈ I
  3. 交换性(exchange property):如果 A, B ∈ I 且 |A| < |B|,则存在 x ∈ B\A 使得 A ∪ {x} ∈ I

26.14.2 贪心在拟阵上总是最优

定理(Rado 1957,Edmonds 1971): 如果问题的可行解构成一个拟阵,且目标是最大化权重,那么按权重从大到小贪心地加入元素(只要保持独立)就得到最优解。

这个定理解释了为什么以下贪心算法是正确的:

Kruskal 最小生成树(第 18 章):

活动选择(本章):

26.14.3 拟阵视角下的贪心通用框架

def greedy_on_matroid(elements, weight_fn, is_independent):
    """
    拟阵上的贪心算法(通用框架)
    
    elements: 基础集合 S
    weight_fn: 权重函数 w: S → R
    is_independent: 判断子集是否独立的函数
    
    返回最大权重独立集
    """
    # 按权重从大到小排序
    sorted_elements = sorted(elements, key=weight_fn, reverse=True)

    result = set()
    for e in sorted_elements:
        # 如果加入 e 后仍然独立,就加入
        if is_independent(result | {e}):
            result.add(e)

    return result


# 示例:Kruskal 算法
def kruskal_via_matroid(n, edges):
    """用拟阵框架实现 Kruskal"""

    class UnionFind:
        def __init__(self, n):
            self.parent = list(range(n))
            self.rank = [0] * n

        def find(self, x):
            if self.parent[x] != x:
                self.parent[x] = self.find(self.parent[x])
            return self.parent[x]

        def union(self, x, y):
            px, py = self.find(x), self.find(y)
            if px == py:
                return False
            if self.rank[px] < self.rank[py]:
                px, py = py, px
            self.parent[py] = px
            if self.rank[px] == self.rank[py]:
                self.rank[px] += 1
            return True

    uf = UnionFind(n)
    # 按权重从小到大(最小生成树)
    edges.sort(key=lambda e: e[2])
    mst = []
    for u, v, w in edges:
        if uf.find(u) != uf.find(v):
            uf.union(u, v)
            mst.append((u, v, w))
    return mst

26.15 Kruskal 贪心正确性的拟阵证明

在第 18 章中我们用"剪切性质"证明了 Kruskal 算法的正确性。这里我们用拟阵理论给出另一个视角。

图拟阵定义:

验证三条公理:

  1. 空集是森林 ✓
  2. 森林的子集仍是森林 ✓
  3. 交换性:设 A, B 是两个森林且 |A| < |B|。A 有 n-1-|A| 个连通分量,B 有 n-1-|B| 个。因为 |A| < |B|,A 的连通分量更多。必存在一条边 e ∈ B\A 连接了 A 中的两个不同分量,则 A ∪ {e} 仍无环 ✓

Rado 定理应用: 对于最小生成树,我们想最小化总权重。等价于最大化"负权重"。按 Rado 定理,贪心(按权重从小到大选,跳过成环的)得到的就是最大权重独立集 = 最小生成树。

26.16 贪心 vs DP 的理论边界

从计算复杂度角度:

从问题结构角度(CLRS 教材的分类):

这解释了一个重要现象:能用贪心解的问题一定能用 DP 解(DP 更通用),但能用 DP 解的问题不一定能用贪心解(贪心更特殊)。


Level 4 · 边界与陷阱

26.17 面试题精选

26.17.1 LeetCode #455: 分发饼干

(已在 26.10 详细讲解)

关键点:排序 + 双指针。时间 O(n log n + m log m),空间 O(1)。

26.17.2 LeetCode #435: 无重叠区间

(已在 26.3.2 详细讲解)

关键点:转化为"最大不重叠区间数",答案 = 总数 - 最大不重叠。

26.17.3 LeetCode #452: 用最少数量的箭引爆气球

(已在 26.3.3 详细讲解)

关键点:等价于"最大不重叠区间数"(注意边界:相切也算重叠)。

26.17.4 LeetCode #134: 加油站

(已在 26.11 详细讲解)

关键点:总量判断可行性 + 局部重置找起点。

26.17.5 更多面试贪心题

def lemonade_change(bills):
    """
    LeetCode #860: 柠檬水找零
    顾客按顺序付 5/10/20 美元,柠檬水 5 美元
    能否给每个顾客正确找零?
    
    贪心:优先用大面额找零
    """
    five = ten = 0
    for bill in bills:
        if bill == 5:
            five += 1
        elif bill == 10:
            if five == 0:
                return False
            five -= 1
            ten += 1
        else:  # 20
            # 优先用 10+5 找零(贪心:保留更多5块)
            if ten > 0 and five > 0:
                ten -= 1
                five -= 1
            elif five >= 3:
                five -= 3
            else:
                return False
    return True


def partition_labels(s):
    """
    LeetCode #763: 划分字母区间
    把字符串划分为尽可能多的片段,使每个字母只出现在一个片段中
    
    贪心:每个片段的右边界取所有已包含字符的最远出现位置
    """
    # 记录每个字符最后出现的位置
    last = {c: i for i, c in enumerate(s)}

    partitions = []
    start = end = 0

    for i, c in enumerate(s):
        end = max(end, last[c])  # 扩展右边界
        if i == end:  # 到达当前片段的右边界
            partitions.append(end - start + 1)
            start = end + 1

    return partitions


def reorganize_string(s):
    """
    LeetCode #767: 重构字符串
    重新排列字符使得相邻字符不同,如不可能返回空串
    
    贪心 + 优先队列:每次放频率最高的(且和上一个不同的)字符
    """
    from collections import Counter
    import heapq

    freq = Counter(s)
    # 最大堆(用负数模拟)
    heap = [(-count, char) for char, count in freq.items()]
    heapq.heapify(heap)

    result = []
    prev_count, prev_char = 0, ''

    while heap:
        count, char = heapq.heappop(heap)
        result.append(char)

        # 把上一个字符放回堆(如果还有剩余)
        if prev_count < 0:
            heapq.heappush(heap, (prev_count, prev_char))

        prev_count = count + 1  # 用了一个,计数+1(负数所以是+1)
        prev_char = char

    return ''.join(result) if len(result) == len(s) else ''

26.18 什么时候贪心,什么时候 DP

这是面试中最常被追问的元问题。以下是决策框架:

Step 1: 问题是否有"贪心选择性质"?

测试方法:假设你做了一个贪心选择(如"选最小的"、"选最早结束的"),能否证明存在一个包含这个选择的最优解?

Step 2: 构造反例

# 尝试构造反例的通用方法:
# 1. 设计只有 2-3 个元素的小 case
# 2. 看贪心选择是否导致错误

# 例:硬币找零
# 面额 [1, 5, 10, 25],金额 30
# 贪心:25+5 = 2 枚 ✓
# 面额 [1, 3, 4],金额 6
# 贪心:4+1+1 = 3 枚 ✗(最优:3+3 = 2 枚)
# 结论:一般硬币找零不能贪心(但特定面额系统可以)

Step 3: 经典问题分类

问题 贪心 or DP 原因
活动选择/区间调度 贪心 选最早结束的不影响后续选择质量
分数背包 贪心 可分割→单位价值排序
0-1 背包 DP 不可分割→选 A 影响剩余容量→影响后续选择
最长公共子序列 DP 选一个字符影响后续匹配
最短路径(无负权) 贪心(Dijkstra) 已确定的最短路不会被更长路径改善
最短路径(有负权) DP(Bellman-Ford) 负权边可能使已确定的路径变得不最优
哈夫曼编码 贪心 合并最低频满足拟阵条件
硬币找零(一般) DP 面额组合有"干扰"效应
跳跃游戏 贪心 每一步能到的范围只取决于已走过的位置
编辑距离 DP 每一步操作相互依赖

面试回答模板: "这个问题我先考虑能不能贪心。贪心的前提是'局部最优能导向全局最优'。让我试试 [具体贪心策略]... [如果能证明/构造反例] 所以这个问题应该用 [贪心/DP]。"

26.19 贪心的边界 Case

贪心看似对但实际错的陷阱

# 陷阱 1:旅行商问题(TSP)
# "每次去最近的未访问城市" — 贪心!
# 但这是 NP-hard 问题,贪心不能保证最优。
# 反例:4个城市在正方形顶点,贪心走边,最优走对角线。

# 陷阱 2:最长递增子序列(LIS)
# "贪心选当前能选的最小的" — 不对!
# LIS 需要 DP(O(n²))或 贪心+二分(O(n log n),但这个"贪心"本质是 DP)

# 陷阱 3:任务分配
# "给最快的人分最重的任务" — 不一定对!
# 取决于具体的目标函数(最小化最大完成时间 vs 最小化总完成时间)

贪心正确但难以证明的情况

def max_profit_job_scheduling(startTime, endTime, profit):
    """
    LeetCode #1235: 规划兼职工作
    看起来像贪心(按结束时间排序选最赚的),但实际需要 DP。
    
    这个问题是"带权区间调度"(Weighted Interval Scheduling),
    权重不同使得简单贪心失效。
    """
    import bisect

    jobs = sorted(zip(endTime, startTime, profit))
    n = len(jobs)
    dp = [0] * (n + 1)

    for i in range(1, n + 1):
        end, start, p = jobs[i - 1]
        # 二分查找最后一个结束时间 <= 当前开始时间的 job
        k = bisect.bisect_right([j[0] for j in jobs[:i]], start)
        dp[i] = max(dp[i - 1], dp[k] + p)

    return dp[n]

26.20 与本站其他章节的联系


本章小结

贪心算法是最"赌"的算法范式——它在每一步做出不可逆的局部最优选择,赌这些选择能组合成全局最优。这个赌注有时对(活动选择、Huffman 编码、Kruskal),有时错(0-1 背包、TSP、一般硬币找零)。

区分贪心是否正确的能力,是算法素养的核心。本章的关键外带:

  1. 贪心三要素:贪心选择性质 + 最优子结构 + 无后效性
  2. 证明方法:交换论证(最常用)和反证法
  3. 核心模式:区间调度按右端点、Huffman 合最小、背包按单位价值
  4. 决策框架:先尝试贪心→构造反例→不行就用 DP
  5. 数学基础:拟阵理论给出了"贪心何时正确"的完整理论框架

Huffman 在 1952 年用一个研究生作业改变了信息论;Bayer 在 1972 年用 B-Tree 改变了数据库;O'Neil 在 1996 年用 LSM-Tree 改变了存储系统。这些影响深远的贡献,核心思想往往简洁到令人惊叹——这正是贪心的美学:用最简单的规则,解决最复杂的问题

本章评分
4.6  / 5  (5 评分)

💬 留言讨论