Part 7.1:贪心算法——局部最优通向全局最优的艺术

引言:当”自私”成为一种智慧

想象你正在玩一个闯关游戏,每一关都有多条路径可选,每条路径上都有不同数量的金币。你的目标是收集尽可能多的金币。此时,你会采用什么策略?

一种直觉的做法是:在每一关都选择当前能拿到最多金币的那条路。这种”走一步看一步,每步都选最好”的策略,就是贪心算法 (Greedy Algorithm) 的核心思想。

但问题来了:这种”目光短浅”的策略,真的能保证最终拿到最多的金币吗?答案是:不一定。这正是贪心算法最迷人也最具挑战性的地方——它简单、高效、直观,但并非对所有问题都有效。

为什么贪心算法如此重要?

面试价值

  • 贪心算法是大厂面试的高频考点(Google、字节、腾讯等)
  • 能够快速判断一个问题是否适合用贪心求解,是算法思维成熟的标志
  • 许多看似复杂的问题,用贪心思想能得到优雅的 O(n log n) 甚至 O(n) 解法

学术与工程价值

  • 贪心算法是许多经典算法的基础(如 Dijkstra、Prim、Kruskal)
  • 在资源调度、任务分配、网络路由等实际工程问题中广泛应用
  • 理解贪心算法,能帮助你更好地理解”局部最优”与”全局最优”的关系

本文将带你系统学习贪心算法的核心思想、适用条件、证明方法,以及如何判断一个问题能否用贪心求解。

背景知识:算法设计的三大范式

在深入贪心算法之前,我们需要了解算法设计的三大主要范式:

1. 暴力枚举 (Brute Force)

  • 思想:尝试所有可能的解,选择最优的那个
  • 优点:一定能找到正确答案
  • 缺点:时间复杂度通常是指数级 O(2^n) 或 O(n!)
  • 例子:旅行商问题的暴力解法

2. 动态规划 (Dynamic Programming)

  • 思想:将问题分解为子问题,保存子问题的解,避免重复计算
  • 优点:能解决很多复杂问题,时间复杂度通常是多项式级
  • 缺点:需要额外的空间存储子问题的解
  • 例子:背包问题、最长公共子序列

3. 贪心算法 (Greedy Algorithm)

  • 思想:每一步都做出当前看起来最好的选择
  • 优点:实现简单,效率高,通常是 O(n log n) 或 O(n)
  • 缺点:不是对所有问题都有效,需要满足特定条件
  • 例子:找零钱问题、活动选择问题

核心区别:动态规划会”回头看”(考虑之前的所有选择),而贪心算法”不回头”(只关注当前最优)。

详细算法原理

直觉:贪心的本质

贪心算法的核心思想可以用一句话概括:

在每一步选择中,都采取当前状态下最好或最优的选择,从而希望导致结果是全局最好或最优的。

用更通俗的话说:走一步看一步,每步都选最好的,希望最后整体也是最好的。

这就像你在爬山时,每次都选择当前看起来最陡峭的上坡路(局部最优),希望最终能到达山顶(全局最优)。但问题是,这种策略可能让你爬到一个小山包上,而错过了真正的山顶。

严谨原理:贪心算法的两大性质

一个问题能够用贪心算法求解,必须满足两个关键性质:

1. 贪心选择性质 (Greedy Choice Property)

定义:一个问题的全局最优解可以通过一系列局部最优选择得到。

通俗理解:你可以放心地做出当前看起来最好的选择,而不用担心这个选择会影响后续的最优性。

数学表述:设问题的一个最优解为 S = {s₁, s₂, …, sₙ},如果存在一个贪心选择 g,使得 S’ = {g, s₂, …, sₙ} 也是最优解,则该问题具有贪心选择性质。

2. 最优子结构 (Optimal Substructure)

定义:一个问题的最优解包含其子问题的最优解。

通俗理解:如果你已经做出了一个贪心选择,那么剩下的问题也可以用同样的贪心策略求解。

数学表述:设问题 P 的最优解为 OPT(P),如果 OPT(P) 包含子问题 P’ 的最优解 OPT(P’),则该问题具有最优子结构。

注意:最优子结构是动态规划和贪心算法的共同特征,但贪心选择性质是贪心算法独有的。

关键图示

图示:贪心算法 vs 动态规划的决策树

贪心算法的决策过程

起点 → [选择A] → 子问题1 → [选择C] → 子问题2 → ... → 终点
        ↓ (只考虑当前最优)
     放弃B

特点:每一步只保留一个分支(当前最优),不回溯。

动态规划的决策过程

起点 → [选择A] → 子问题1 → [选择C] → ...
     ↘         ↗
       [选择B] → 子问题2 → [选择D] → ...
        (保存所有可能的状态)

特点:保留所有可能的状态,通过比较选择最优。

步骤分解:贪心算法的设计流程

设计一个贪心算法通常遵循以下步骤:

Step 1:将问题分解为子问题

  • 明确问题的目标和约束
  • 思考如何将问题分解为一系列决策

Step 2:确定贪心策略

  • 在每一步,定义什么是”最优选择”
  • 这是贪心算法设计的核心,也是最难的部分

Step 3:证明贪心选择性质

  • 证明局部最优选择不会影响全局最优
  • 常用方法:反证法、交换论证法

Step 4:证明最优子结构

  • 证明做出贪心选择后,剩余问题仍然可以用贪心求解

Step 5:实现算法

  • 将贪心策略转化为代码
  • 通常需要排序或使用优先队列

动画式案例:找零钱问题

问题描述:假设有面值为 1、5、10、25 的硬币,要找给顾客 41 元,如何用最少的硬币?

贪心策略:每次都选择不超过剩余金额的最大面值硬币。

步骤变化

初始状态

  • 剩余金额:41
  • 已用硬币:[]

第1步:选择面值 25 的硬币

  • 剩余金额:41 - 25 = 16
  • 已用硬币:[25]

第2步:选择面值 10 的硬币

  • 剩余金额:16 - 10 = 6
  • 已用硬币:[25, 10]

第3步:选择面值 5 的硬币

  • 剩余金额:6 - 5 = 1
  • 已用硬币:[25, 10, 5]

第4步:选择面值 1 的硬币

  • 剩余金额:1 - 1 = 0
  • 已用硬币:[25, 10, 5, 1]

最终结果:使用 4 枚硬币

注意:这个贪心策略对于美国硬币系统是正确的,但对于任意硬币系统不一定正确!例如,如果硬币面值是 [1, 3, 4],找零 6 元,贪心会选 [4, 1, 1](3枚),但最优解是 [3, 3](2枚)。

伪代码

标准化贪心算法框架

// 贪心算法的通用框架
function GreedyAlgorithm(problem):
    // 1. 初始化
    solution = empty_set
    remaining_problem = problem
    
    // 2. 循环直到问题解决
    while remaining_problem is not solved:
        // 3. 做出贪心选择
        choice = select_best_choice(remaining_problem)
        
        // 4. 检查选择的可行性
        if choice is feasible:
            // 5. 将选择加入解集
            solution.add(choice)
            
            // 6. 更新剩余问题
            remaining_problem = update_problem(remaining_problem, choice)
        else:
            // 如果无可行选择,返回失败
            return FAILURE
    
    // 7. 返回解
    return solution

// 关键函数:选择当前最优
function select_best_choice(problem):
    // 根据贪心策略,从候选集中选择最优的一个
    candidates = get_all_candidates(problem)
    best = candidates[0]
    
    for each candidate in candidates:
        if is_better(candidate, best):
            best = candidate
    
    return best

Python 实现

案例1:找零钱问题(完整实现)

def coin_change_greedy(coins, amount):
    """
    使用贪心算法解决找零钱问题
    
    参数:
        coins: 硬币面值列表(必须是降序排列)
        amount: 需要找零的金额
    
    返回:
        (硬币数量, 使用的硬币列表) 或 None(无法找零)
    """
    # 确保硬币按降序排列
    coins = sorted(coins, reverse=True)
    
    result = []  # 存储使用的硬币
    remaining = amount  # 剩余需要找零的金额
    
    # 贪心策略:每次选择最大的硬币
    for coin in coins:
        # 计算当前面值的硬币可以使用多少枚
        count = remaining // coin
        
        if count > 0:
            # 添加到结果中
            result.extend([coin] * count)
            # 更新剩余金额
            remaining -= coin * count
        
        # 如果已经找完零,提前退出
        if remaining == 0:
            break
    
    # 检查是否成功找零
    if remaining == 0:
        return len(result), result
    else:
        return None  # 无法精确找零

# 测试案例
print("=== 找零钱问题 ===")
coins = [25, 10, 5, 1]
amount = 41

result = coin_change_greedy(coins, amount)
if result:
    count, used_coins = result
    print(f"找零 {amount} 元需要 {count} 枚硬币")
    print(f"使用的硬币:{used_coins}")
else:
    print(f"无法精确找零 {amount} 元")

# 输出:
# 找零 41 元需要 4 枚硬币
# 使用的硬币:[25, 10, 5, 1]

案例2:活动选择问题

def activity_selection(activities):
    """
    活动选择问题:选择最多的互不冲突的活动
    
    参数:
        activities: 活动列表,每个活动是 (start_time, end_time)
    
    返回:
        选中的活动列表
    """
    # 贪心策略:按结束时间排序,优先选择结束早的活动
    activities = sorted(activities, key=lambda x: x[1])
    
    selected = []  # 已选择的活动
    last_end_time = 0  # 上一个活动的结束时间
    
    for start, end in activities:
        # 如果当前活动的开始时间 >= 上一个活动的结束时间
        # 说明不冲突,可以选择
        if start >= last_end_time:
            selected.append((start, end))
            last_end_time = end
    
    return selected

# 测试案例
print("\n=== 活动选择问题 ===")
activities = [
    (1, 4),   # 活动1: 1点到4点
    (3, 5),   # 活动2: 3点到5点
    (0, 6),   # 活动3: 0点到6点
    (5, 7),   # 活动4: 5点到7点
    (3, 9),   # 活动5: 3点到9点
    (5, 9),   # 活动6: 5点到9点
    (6, 10),  # 活动7: 6点到10点
    (8, 11),  # 活动8: 8点到11点
    (8, 12),  # 活动9: 8点到12点
    (2, 14),  # 活动10: 2点到14点
    (12, 16)  # 活动11: 12点到16点
]

selected = activity_selection(activities)
print(f"最多可以选择 {len(selected)} 个活动")
print(f"选中的活动:{selected}")

# 输出:
# 最多可以选择 4 个活动
# 选中的活动:[(1, 4), (5, 7), (8, 11), (12, 16)]

时间复杂度推导过程

找零钱问题

分析

  1. 排序硬币:O(k log k),其中 k 是硬币种类数
  2. 遍历每种硬币:O(k)
  3. 每种硬币的计算:O(1)

总时间复杂度:O(k log k)

由于硬币种类 k 通常是常数(如 4 种),实际上可以认为是 **O(1)**。

活动选择问题

分析

  1. 排序活动:O(n log n),其中 n 是活动数量
  2. 遍历活动:O(n)
  3. 每个活动的判断:O(1)

总时间复杂度O(n log n)

排序是瓶颈操作。

空间复杂度推导过程

找零钱问题

分析

  • 存储结果列表:最坏情况下需要 O(amount) 个硬币(全是面值1)
  • 其他变量:O(1)

总空间复杂度O(amount)

活动选择问题

分析

  • 存储选中的活动:最多 O(n) 个
  • 排序可能需要额外空间:O(n)(取决于排序算法)

总空间复杂度O(n)

优势 / 劣势 / 易错点

优势

  1. 实现简单:贪心算法的代码通常非常简洁,易于理解和实现
  2. 效率高:时间复杂度通常是 O(n log n) 或 O(n),远优于动态规划的 O(n²) 或 O(n³)
  3. 空间占用小:通常只需要 O(1) 或 O(n) 的额外空间
  4. 直观:贪心策略往往符合人的直觉,容易想到

劣势

  1. 适用范围窄:只对满足贪心选择性质和最优子结构的问题有效
  2. 难以证明正确性:判断一个问题是否适合用贪心求解,需要严格的数学证明
  3. 容易出错:看似合理的贪心策略可能给出错误答案
  4. 无法处理所有情况:对于某些问题,贪心只能给出近似解,而非最优解

易错点

  1. 错误的贪心策略

    # 错误示例:背包问题用贪心(应该用动态规划)
    # 贪心策略:按价值排序
    # 这对 0-1 背包是错误的!
  2. 忘记排序

    # 错误:直接遍历,没有按贪心策略排序
    for activity in activities:  # 应该先排序!
        ...
  3. 边界条件处理不当

    # 错误:没有检查剩余金额是否为0
    if remaining == 0:  # 必须检查!
        return result
  4. 贪心策略选择错误

    # 活动选择问题
    # 错误策略1:选择持续时间最短的活动
    # 错误策略2:选择开始时间最早的活动
    # 正确策略:选择结束时间最早的活动

适用场景

适合用贪心的问题

  1. 活动选择问题:选择最多的互不冲突的活动
  2. 霍夫曼编码:构建最优前缀编码
  3. 最小生成树:Kruskal 和 Prim 算法
  4. 单源最短路径:Dijkstra 算法(非负权图)
  5. 分数背包问题:可以拆分物品的背包问题
  6. 区间覆盖问题:用最少的区间覆盖所有点

不适合用贪心的问题

  1. 0-1 背包问题:物品不可拆分,需要用动态规划
  2. **旅行商问题 (TSP)**:需要考虑全局路径,贪心只能给出近似解
  3. 最长公共子序列:需要考虑多种可能性,用动态规划
  4. 任意硬币系统的找零:贪心不一定给出最优解

判断方法

如何判断一个问题是否适合用贪心?

  1. 尝试构造反例:如果能找到贪心策略失败的例子,说明不适合
  2. 证明贪心选择性质:能否证明局部最优导致全局最优?
  3. 检查最优子结构:做出贪心选择后,剩余问题是否独立?
  4. 参考经典问题:该问题是否与已知的贪心问题相似?

扩展算法 & 进阶方向

1. 贪心算法的证明方法

交换论证法 (Exchange Argument)

  • 证明任何最优解都可以通过一系列交换变成贪心解
  • 常用于活动选择、区间调度等问题

反证法

  • 假设贪心解不是最优解,推导出矛盾
  • 适用于简单的贪心问题

2. 近似算法

当贪心无法给出最优解时,可以用于设计近似算法:

  • 集合覆盖问题:贪心给出 O(log n) 近似比
  • 旅行商问题:贪心给出 2-近似解

3. 在线算法

贪心思想在在线算法中的应用:

  • 缓存替换算法:LRU、LFU
  • 负载均衡:最少连接数策略

4. 经典贪心问题

霍夫曼编码 (Huffman Coding)

  • 构建最优前缀编码树
  • 应用于数据压缩

区间调度问题

  • 变种:带权重的活动选择
  • 变种:最少会议室问题

分数背包问题

  • 与 0-1 背包的对比
  • 贪心策略:按性价比排序

总结

贪心算法是一种强大而优雅的算法设计范式。它的核心思想简单直观:每一步都做出当前最优的选择,希望最终达到全局最优

核心要点

两大性质:贪心选择性质 + 最优子结构
设计流程:分解问题 → 确定策略 → 证明正确性 → 实现算法
时间复杂度:通常是 O(n log n)(排序)或 O(n)(遍历)
适用条件:必须满足贪心选择性质,否则可能得到错误答案
判断方法:构造反例、证明性质、参考经典问题

与动态规划的对比

特性贪心算法动态规划
决策方式局部最优全局最优
是否回溯不回溯需要回溯
时间复杂度通常更低通常更高
适用范围较窄较广
正确性需要证明一般正确

掌握了贪心算法,你就拥有了一把解决优化问题的利器。在下一篇文章中,我们将深入学习贪心算法的经典应用——区间调度问题,看看如何用贪心思想解决实际的资源分配问题。


💡 思考题

  1. 为什么 Dijkstra 算法是贪心算法,而 Bellman-Ford 不是?
  2. 如何证明活动选择问题的贪心策略是正确的?
  3. 设计一个贪心算法,判断一个字符串是否可以通过删除某些字符变成回文串。

📚 推荐阅读

  • 《算法导论》第16章:贪心算法
  • 《算法设计手册》:贪心算法专题

Part 7.1:贪心算法——局部最优通向全局最优的艺术
https://hjjjkh.github.io/posts/9c483780
作者
李国强
发布于
2025年11月15日
许可协议