【学习】《算法图解》第十章学习笔记:贪婪算法

一、贪婪算法概述

贪婪算法(Greedy Algorithm)是一种在每一步选择中都采取当前状态下最好或最优的选择,从而希望导致结果是最好或最优的算法。贪婪算法不从整体最优上加以考虑,它所做出的选择只是在某种意义上的局部最优选择。

(一)算法适用场景

贪婪算法适用于具有”贪心选择性质”的问题,即局部最优选择能导致全局最优解的问题。主要应用于:

  • 需要求解最优化问题
  • 问题具有贪心选择性质
  • 问题具有最优子结构性质

(二)算法基本特点

  1. 简单直接:思路简单,容易实现
  2. 局部最优选择:每步都选择当前看起来最优的解
  3. 不可回退:一旦做出选择,不再更改
  4. 不保证全局最优:在某些问题上可能无法得到全局最优解

二、算法步骤详解

(一)算法流程

贪婪算法的一般流程如下:

  1. 建立数学模型,定义最优化目标
  2. 将问题分解为若干个子问题
  3. 对每个子问题做出贪心选择(局部最优)
  4. 将贪心选择合并成最终解决方案

(二)图示说明

贪婪算法通常按照以下流程执行:

  1. 创建一个空结果集
  2. 按照某种顺序遍历所有可能的选择
  3. 对于每个选择,检查是否可以加入结果集
  4. 如果可以,则加入结果集
  5. 重复步骤2-4,直到问题解决

三、经典贪婪算法问题

(一)教室调度问题

1. 问题描述

有一系列的课程,每个课程都有开始时间和结束时间,如何安排才能使用同一个教室上最多的课?

2. 贪婪策略

按照课程的结束时间进行排序,每次选择结束最早且与已选课程不冲突的课程。

3. 代码实现

def schedule_classes(classes):
    # 按结束时间排序
    scheduled = []
    classes.sort(key=lambda x: x[1])  # 按结束时间排序
    
    # 选择第一个课程
    scheduled.append(classes[0])
    last_end_time = classes[0][1]
    
    # 遍历剩余课程
    for i in range(1, len(classes)):
        if classes[i][0] >= last_end_time:  # 如果开始时间晚于上一个结束时间
            scheduled.append(classes[i])
            last_end_time = classes[i][1]
    
    return scheduled

4. 正确性分析

这个贪婪策略能得到最优解,因为选择结束时间最早的课程,可以为后面的课程留出更多的时间。

(二)背包问题

1. 问题描述

有一个背包,最多能承受重量为W的物品。现在有n个物品,每个物品有重量和价值两个属性。如何选择物品放入背包,使得背包中物品的总价值最大?

2. 分数背包问题(可分割物品)

对于可分割的物品,贪婪算法可以得到最优解:

  • 计算每个物品的单位价值(价值/重量)
  • 按单位价值从高到低排序
  • 尽可能多地装入单位价值最高的物品
def fractional_knapsack(items, capacity):
    # 计算每个物品的单位价值
    for item in items:
        item['value_per_weight'] = item['value'] / item['weight']
    
    # 按单位价值排序
    items.sort(key=lambda x: x['value_per_weight'], reverse=True)
    
    total_value = 0
    remaining_capacity = capacity
    
    for item in items:
        if remaining_capacity >= item['weight']:
            # 可以完全装入
            total_value += item['value']
            remaining_capacity -= item['weight']
        else:
            # 只能装入一部分
            fraction = remaining_capacity / item['weight']
            total_value += item['value'] * fraction
            break
    
    return total_value

3. 0-1背包问题(不可分割物品)

对于0-1背包问题(物品不可分割),贪婪算法通常不能得到最优解,需要使用动态规划。

(三)集合覆盖问题

1. 问题描述

给定一个集合S和若干子集,选择尽可能少的子集,使得这些子集的并集等于S。

2. 贪婪策略

每次选择能覆盖最多尚未覆盖元素的子集。

def set_covering(universe, subsets):
    # 需要覆盖的元素
    elements_to_cover = set(universe)
    # 已选择的子集
    selected_subsets = []
    
    # 当还有元素未被覆盖时
    while elements_to_cover:
        # 选择能覆盖最多未覆盖元素的子集
        best_subset = None
        covered_elements = set()
        
        for subset in subsets:
            # 计算该子集能新覆盖的元素
            new_covered = set(subset) & elements_to_cover
            if len(new_covered) > len(covered_elements):
                best_subset = subset
                covered_elements = new_covered
        
        # 如果找不到能覆盖新元素的子集,则退出
        if not best_subset:
            break
            
        # 选择这个子集
        selected_subsets.append(best_subset)
        # 更新未覆盖元素
        elements_to_cover -= set(best_subset)
    
    return selected_subsets

3. 近似比

集合覆盖问题是NP完全问题,贪婪算法提供的是近似解,其近似比为ln(n),其中n是集合S的大小。

四、算法分析

(一)时间复杂度

贪婪算法的时间复杂度取决于具体问题和实现方式:

  • 教室调度问题:O(n log n),主要是排序的复杂度
  • 分数背包问题:O(n log n),主要是排序的复杂度
  • 集合覆盖问题:O(n × m),其中n是集合大小,m是子集数量

(二)空间复杂度

贪婪算法通常具有较低的空间复杂度,主要用于存储中间结果和最终解决方案:

  • 大多数情况下为O(n)
  • 有时可以优化到O(1),通过就地修改输入数据

(三)算法优缺点

优点:

  • 实现简单,思路直观
  • 计算速度快,效率高
  • 部分问题能得到最优解

缺点:

  • 不保证得到全局最优解
  • 适用范围有限
  • 有时难以证明算法正确性

五、贪婪算法与动态规划的比较

(一)相同点

  1. 都是通过组合子问题的解来构造原问题的解
  2. 都是求解最优化问题的方法
  3. 都需要问题具有最优子结构性质

(二)不同点

特性 贪婪算法 动态规划
子问题处理 每步只解决一个子问题 解决所有子问题
决策过程 做出选择后不再更改 根据之前所有结果做出选择
适用范围 具有贪心选择性质的问题 具有重叠子问题和最优子结构的问题
效率 通常效率更高,复杂度更低 通常需要更多的时间和空间
正确性 不总是得到最优解 保证得到最优解

六、贪婪算法的应用

(一)实际应用场景

  1. Huffman编码:构建最优前缀码,用于数据压缩
  2. Prim和Kruskal算法:寻找最小生成树
  3. Dijkstra算法:寻找单源最短路径
  4. 任务调度问题:最优分配资源
  5. 网络流量控制:最大化网络吞吐量

(二)算法变种和改进

  1. 随机贪心算法:引入随机性,避免陷入局部最优
  2. 启发式贪心算法:结合启发信息,提高解的质量
  3. 多阶段贪心算法:在不同阶段使用不同的贪心策略

七、如何判断问题是否适合使用贪婪算法

(一)贪心选择性质

问题的整体最优解可以通过一系列局部最优的选择来达到。换句话说,每一步的最优选择最终会导致全局最优解。

(二)最优子结构性质

问题的最优解包含其子问题的最优解。这意味着,一旦我们知道了子问题的最优解,就可以直接构造出原问题的最优解。

(三)验证方法

  1. 尝试使用反证法证明贪婪选择是安全的
  2. 尝试构造反例,看贪婪算法是否能得到最优解
  3. 与其他算法(如动态规划)的结果进行比较

八、总结

贪婪算法是一种简单而强大的算法设计范式,它在每一步都做出当前看起来最好的选择。虽然不能保证在所有问题上都能得到最优解,但在具有贪心选择性质的问题上,它能高效地得到最优解或接近最优的解。

理解贪婪算法的关键在于:

  1. 识别问题是否具有贪心选择性质
  2. 设计合适的贪心策略
  3. 证明贪心策略的正确性

贪婪算法的简单性和效率使其成为算法设计中的重要工具,尤其是在处理优化问题时。通过与动态规划、回溯等算法相结合,可以解决更加复杂的问题。

参考资料

  1. 《算法图解》第十章,Aditya Bhargava 著
  2. Introduction to Algorithms, Thomas H. Cormen et al.
  3. Algorithm Design Manual, Steven S. Skiena
  4. 维基百科:贪心算法
  5. Khan Academy: Greedy Algorithms