第13章:贪心算法的正确性

贪心算法是一种看似简单却极具技巧性的算法思想。对于资深工程师而言,难点不在于实现贪心策略,而在于如何证明贪心选择的正确性,以及识别何时贪心策略会失效。本章将深入探讨贪心算法的数学本质,帮助你建立严谨的贪心思维框架。

13.1 贪心选择性质的证明

13.1.1 贪心算法的本质

贪心算法的核心思想是:每一步都做出局部最优选择,期望最终得到全局最优解。这种策略之所以有效,依赖于问题满足两个关键性质:

  1. 贪心选择性质(Greedy Choice Property):局部最优选择能导向全局最优解
  2. 最优子结构(Optimal Substructure):问题的最优解包含子问题的最优解

让我们通过经典的跳跃游戏来理解这个概念。

13.1.2 跳跃游戏 I (#55)

def canJump(nums):
    """
    思路:维护能够到达的最远位置
    贪心选择:在可达范围内,选择能跳得最远的位置
    """
    max_reach = 0
    n = len(nums)

    for i in range(n):
        if i > max_reach:  # 当前位置不可达
            return False
        max_reach = max(max_reach, i + nums[i])
        if max_reach >= n - 1:  # 提前返回
            return True

    return True

正确性证明

  • 归纳基础:从位置0开始,max_reach = nums[0]
  • 归纳假设:对于位置i,max_reach表示从0到i所有位置能够到达的最远位置
  • 归纳步骤:如果i+1 ≤ max_reach,则i+1可达,更新max_reach = max(max_reach, i+1+nums[i+1])
  • 结论:如果存在到达末尾的路径,贪心策略一定能找到

13.1.3 跳跃游戏 II (#45)

def jump(nums):
    """
    思路:BFS层序遍历的贪心实现
    每一"层"表示用相同步数能到达的位置范围
    """
    n = len(nums)
    if n <= 1:
        return 0

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

    for i in range(n - 1):  # 注意不需要访问最后一个位置
        farthest = max(farthest, i + nums[i])

        if i == current_end:  # 到达当前层的边界
            jumps += 1
            current_end = farthest

            if current_end >= n - 1:
                break

    return jumps

关键洞察

  • 这个问题本质上是在求最短路径(最少跳跃次数)
  • 贪心策略等价于BFS,每次扩展都选择能到达最远的位置
  • 时间复杂度O(n),比显式BFS的O(n²)更优

13.1.4 贪心证明的一般框架

证明贪心算法正确性的常用方法:

  1. 交换论证(Exchange Argument) - 假设存在最优解OPT不包含贪心选择 - 通过交换操作将贪心选择加入OPT - 证明交换后的解不会变差

  2. 反证法(Contradiction) - 假设贪心解不是最优的 - 推导出矛盾

  3. 归纳法(Induction) - 证明每一步的贪心选择都保持最优性

13.2 区间调度与活动选择

区间调度问题是贪心算法的经典应用场景。这类问题的关键在于如何定义"贪心标准":是按开始时间、结束时间、还是区间长度?

13.2.1 无重叠区间 (#435)

给定一个区间集合,找到需要移除的最少区间数,使得剩余区间互不重叠。

def eraseOverlapIntervals(intervals):
    """
    贪心策略:保留结束时间最早的区间
    这样给后续区间留下最大空间
    """
    if not intervals:
        return 0

    # 按结束时间排序
    intervals.sort(key=lambda x: x[1])

    count = 0  # 需要移除的区间数
    prev_end = intervals[0][1]

    for i in range(1, len(intervals)):
        if intervals[i][0] < prev_end:  # 有重叠
            count += 1
        else:
            prev_end = intervals[i][1]

    return count

正确性证明(交换论证)

  1. 假设最优解OPT选择了区间A而非贪心选择的区间B(B的结束时间更早)
  2. 将OPT中的A替换为B,由于B结束更早,不会与OPT中其他区间产生新的冲突
  3. 因此替换后的解至少和OPT一样好,证明贪心选择正确

13.2.2 用最少数量的箭引爆气球 (#452)

def findMinArrowShots(points):
    """
    思路:将问题转化为最多不重叠区间数
    每个不重叠的区间组至少需要一支箭
    """
    if not points:
        return 0

    # 按结束位置排序
    points.sort(key=lambda x: x[1])

    arrows = 1
    prev_end = points[0][1]

    for i in range(1, len(points)):
        if points[i][0] > prev_end:  # 没有重叠,需要新的箭
            arrows += 1
            prev_end = points[i][1]
        # 如果有重叠,当前箭可以射穿两个气球

    return arrows

13.2.3 会议室 II (#253)

给定会议时间安排,求需要的最少会议室数量。

def minMeetingRooms(intervals):
    """
    方法1:扫描线算法
    将开始和结束时间分别处理
    """
    starts = sorted([i[0] for i in intervals])
    ends = sorted([i[1] for i in intervals])

    rooms = 0
    max_rooms = 0
    s = e = 0

    while s < len(starts):
        if starts[s] < ends[e]:
            rooms += 1  # 新会议开始,需要新房间
            max_rooms = max(max_rooms, rooms)
            s += 1
        else:
            rooms -= 1  # 有会议结束,释放房间
            e += 1

    return max_rooms

def minMeetingRooms_heap(intervals):
    """
    方法2:最小堆维护结束时间
    堆的大小就是需要的会议室数
    """
    import heapq

    if not intervals:
        return 0

    intervals.sort(key=lambda x: x[0])  # 按开始时间排序

    heap = []  # 存储会议结束时间
    heapq.heappush(heap, intervals[0][1])

    for i in range(1, len(intervals)):
        if intervals[i][0] >= heap[0]:  # 可以复用最早结束的会议室
            heapq.heappop(heap)
        heapq.heappush(heap, intervals[i][1])

    return len(heap)

13.2.4 区间问题的贪心策略总结

| 问题类型 | 贪心策略 | 排序关键字 |

问题类型 贪心策略 排序关键字
最多不重叠区间 选择结束最早的 结束时间
区间覆盖 选择覆盖最远的 开始时间
区间分组 扫描线或堆 开始时间
区间合并 排序后线性扫描 开始时间

13.3 哈夫曼编码与最优合并

哈夫曼编码展示了贪心算法在构造最优二叉树中的应用。核心思想是:频率越高的字符,编码越短。

13.3.1 连接木棍的最低费用 (#1167)

给定n根木棍,每次连接两根的费用为它们长度之和。求将所有木棍连成一根的最小费用。

def connectSticks(sticks):
    """
    贪心策略:每次选择最短的两根木棍连接
    这是经典的哈夫曼树构造问题
    """
    import heapq

    if len(sticks) <= 1:
        return 0

    heapq.heapify(sticks)  # 转换为最小堆
    total_cost = 0

    while len(sticks) > 1:
        # 取出最短的两根
        first = heapq.heappop(sticks)
        second = heapq.heappop(sticks)

        # 连接费用
        cost = first + second
        total_cost += cost

        # 将新木棍放回堆中
        heapq.heappush(sticks, cost)

    return total_cost

数学证明: 设木棍长度为 $w_1, w_2, ..., w_n$,在哈夫曼树中,每个叶节点的深度为 $d_i$。 总费用 = $\sum_{i=1}^{n} w_i \cdot d_i$

贪心选择保证了权重最小的节点深度最大,这正是哈夫曼编码的最优性质。

13.3.2 任务调度器 (#621)

给定一组任务和冷却时间n,相同任务之间必须间隔至少n个时间单位。求完成所有任务的最短时间。

def leastInterval(tasks, n):
    """
    关键洞察:最频繁的任务决定了总时间的下界
    """
    from collections import Counter

    # 统计任务频率
    freq = Counter(tasks)
    max_freq = max(freq.values())

    # 计算有多少任务具有最大频率
    max_count = sum(1 for f in freq.values() if f == max_freq)

    # 情况1:由最频繁任务决定
    # (max_freq - 1) * (n + 1) + max_count
    # 
    # 情况2:任务足够多,不需要空闲时间
    # len(tasks)

    return max(len(tasks), (max_freq - 1) * (n + 1) + max_count)

可视化理解

任务: A A A B B B C C D E, n = 2
频率: A:3, B:3, C:2, D:1, E:1

构造方式(按列填充):
A B C
A B C
A B D E

总时间 = 10

13.3.3 最优合并的一般模式

最优合并问题的共同特征:

  1. 每次合并的代价与参与合并的元素相关
  2. 目标是最小化总合并代价
  3. 贪心策略:优先合并代价最小的元素
def optimalMerge(elements, merge_cost_func):
    """
    通用的最优合并框架
    """
    import heapq

    heap = elements.copy()
    heapq.heapify(heap)
    total_cost = 0

    while len(heap) > 1:
        # 选择代价最小的两个元素
        a = heapq.heappop(heap)
        b = heapq.heappop(heap)

        # 计算合并代价
        cost = merge_cost_func(a, b)
        total_cost += cost

        # 将合并结果放回
        heapq.heappush(heap, a + b)  # 或其他合并方式

    return total_cost

13.4 贪心的反例与陷阱

并非所有看似贪心的问题都能用贪心解决。理解贪心算法的局限性同样重要。

13.4.1 根据身高重建队列 (#406)

给定一组人的身高和前面有多少人更高的信息,重建队列。

def reconstructQueue(people):
    """
    贪心策略:先排高的人,再插入矮的人
    关键洞察:矮的人不会影响高的人的k值
    """
    # 按身高降序、k值升序排序
    people.sort(key=lambda x: (-x[0], x[1]))

    result = []
    for person in people:
        # 在位置k插入当前人
        # 因为已经有k个更高的人在前面
        result.insert(person[1], person)

    return result

为什么这个贪心策略有效?

  1. 高个子先站好位置后,矮个子插入不会影响高个子的相对位置
  2. 每个人插入时,前面恰好有k个比他高的人
  3. 这种顺序保证了局部决策的独立性

13.4.2 贪心失效的经典例子

  1. 背包问题(不能用贪心)
def knapsack_greedy_wrong(items, capacity):
    """
    错误的贪心策略:按单位价值排序
    反例:
    items = [(weight=10, value=10), (weight=20, value=20), (weight=15, value=30)]
    capacity = 30
    贪心选择:item[2] + item[0] = 40
    最优解:item[1] + item[0] = 30
    """
    # 这是错误的方法!
    items.sort(key=lambda x: x[1]/x[0], reverse=True)
    # ... 贪心选择会导致次优解
  1. 最短路径(Dijkstra有条件限制)
def dijkstra_negative_edge_wrong(graph):
    """
    Dijkstra算法在有负权边时失效
    原因:贪心选择的"最短"可能通过负权边变得更短

    反例图:
    A --1--> B
    |        |
    10      -20
    |        |
    v        v
    C <------D

    从A到C:贪心选择A->C (10)
    实际最短:A->B->D->C (-8)
    """
    pass

13.4.3 判断是否能用贪心的技巧

  1. 检查贪心选择性质 - 问问自己:做出局部最优选择后,剩余问题是否仍是原问题的子问题? - 能否证明局部最优导向全局最优?

  2. 寻找反例 - 构造小规模测试用例 - 特别关注边界情况

  3. 与动态规划对比 - 如果需要考虑多个之前的状态,通常不能用贪心 - 如果决策依赖于未来的选择,通常不能用贪心

13.4.4 常见的贪心陷阱

class GreedyPitfalls:
    """
    总结常见的贪心算法陷阱
    """

    @staticmethod
    def trap1_local_not_global():
        """
        陷阱1:局部最优不等于全局最优
        例子:硬币找零问题
        """
        def coinChange_greedy_wrong(coins, amount):
            # 贪心:每次选最大面值
            # 反例:coins=[1,3,4], amount=6
            # 贪心:4+1+1=3枚
            # 最优:3+3=2枚
            pass

    @staticmethod
    def trap2_order_matters():
        """
        陷阱2:处理顺序影响结果
        例子:任务调度问题中的顺序选择
        """
        pass

    @staticmethod  
    def trap3_hidden_constraints():
        """
        陷阱3:隐含的约束条件
        例子:会议室分配中的时间冲突
        """
        pass

本章小结

贪心算法的优雅在于其简洁性,挑战在于正确性证明。作为资深工程师,你应该:

  1. 掌握证明技巧 - 交换论证:证明贪心选择至少和最优解一样好 - 归纳法:证明每步贪心选择都保持最优性 - 反证法:假设非贪心解更优,推出矛盾

  2. 识别问题模式 - 区间调度:结束时间优先 - 最优合并:哈夫曼树构造 - 活动选择:不重叠区间最大化

  3. 理解算法本质 - 贪心vs动态规划:无后效性vs有后效性 - 局部最优vs全局最优:需要严格证明 - 问题建模:将实际问题转化为已知模式

  4. 关键公式总结 - 区间调度:按结束时间排序,$O(n\log n)$ - 哈夫曼编码:总代价 = $\sum w_i \cdot d_i$ - 任务调度:时间 = $\max(\text{任务总数}, (\text{最大频率}-1) \times (n+1) + \text{最大频率任务数})$

常见陷阱与错误 (Gotchas)

1. 贪心选择的正确性陷阱

错误:凭直觉选择贪心策略,不加证明

# 错误示例:硬币找零
def coinChange_wrong(coins, amount):
    # 直觉:每次选最大面值
    coins.sort(reverse=True)
    count = 0
    for coin in coins:
        count += amount // coin
        amount %= coin
    return count if amount == 0 else -1
    # 反例:coins=[1,3,4], amount=6

正确做法:先尝试构造反例,再决定是否用贪心

2. 排序关键字选择错误

错误:区间问题随意选择排序标准

# 错误:按开始时间排序选择不重叠区间
intervals.sort(key=lambda x: x[0])  # 应该按结束时间

记忆技巧

  • 最多不重叠 → 结束时间(给后续留空间)
  • 区间覆盖 → 开始时间(覆盖更远)
  • 区间合并 → 开始时间(方便遍历)

3. 边界条件处理

常见错误

# 错误:跳跃游戏II中遍历到最后一个元素
for i in range(n):  # 应该是 range(n-1)
    # ...

调试技巧

  • 手动模拟小规模输入
  • 特别关注空输入、单元素、两元素的情况
  • 检查循环边界和数组访问

4. 优先队列使用错误

错误:忘记Python的heapq是最小堆

# 需要最大堆时的处理
import heapq
# 方法1:取负数
heapq.heappush(heap, -value)
max_value = -heapq.heappop(heap)

# 方法2:自定义比较(更复杂情况)
heap = [(-value, item) for value, item in items]
heapq.heapify(heap)

5. 贪心与DP的混淆

判断准则

  • 需要记录多个状态 → 用DP
  • 决策依赖未来 → 用DP
  • 只需局部最优 → 可能用贪心
  • 有明显的选择顺序 → 可能用贪心

6. 复杂度分析错误

常见误区

# 看似O(n),实际O(n²)
for person in people:
    result.insert(person[1], person)  # insert是O(n)

优化思路

  • 用堆代替排序后的线性搜索
  • 用双端队列优化插入操作
  • 预处理减少重复计算

记住:贪心算法的美在于简洁,但正确性需要严格证明。在面试中,先说明贪心策略,再简要说明为什么有效,会让你的解答更有说服力。