一、贪心算法核心思想
贪心算法(Greedy Algorithm)是一种在每一步选择中都采取当前状态下最优或最有利的选择,从而希望导致结果是全局最优的算法策略。
贪心算法的基本特征:
局部最优选择:每一步都选择当前看起来最好的选项
不可回溯性:一旦做出选择,就不再重新考虑
高效性:通常时间复杂度较低,实现简单
二、贪心算法的优缺点分析
优点:
时间复杂度低:通常是O(n log n)或O(n)
实现简单直观:代码通常简短易懂
空间效率高:多数情况下只需要常数额外空间
实时性好:适合在线处理和实时系统
缺点:
不保证全局最优:局部最优不一定导致全局最优
适用范围有限:只适用于具有贪心选择性质的问题
证明困难:需要严谨的数学证明
对输入敏感:不同输入可能需要不同的贪心策略
三、贪心算法的证明方法
方法一:反证法 - 例1:部分背包问题
反证法证明思路:
1、假设存在最优解A与贪心解B不同
2、找到第一个选择不同的物品,证明贪心选择不会更差
3、逐步调整,最终证明贪心解B至少与A一样好
问题:n个物品,重量w和价值v,背包容量C,可分割物品
# 读取物品数量n和背包容量t n, t = map(int, input().split()) # 存储物品信息:每个物品是(质量, 价值, 单位价值) golds = [] for i in range(n): m, v = map(int, input().split()) # 读取物品的质量和价值 p = v / m # 计算单位价值(价值/质量比) golds.append((m, v, p)) # 将物品信息添加到列表 # 按照单位价值从高到低排序(贪心策略:优先选择性价比高的物品) golds.sort(key=lambda x: x[2], reverse=True) # 初始化总价值和剩余容量 total_value = 0.0 remaining = t # 背包剩余容量 # 遍历排序后的物品 for m, v, p in golds: if remaining <= 0: # 如果背包已满,结束 break if m <= remaining: # 如果当前物品可以完整放入背包 total_value += v # 获得完整价值 remaining -= m # 减少剩余容量 else: # 如果只能放入部分 total_value += p * remaining # 按比例获得部分价值 remaining = 0 # 背包容量用完 # 输出结果,保留两位小数 print(f'{total_value:.2f}')方法二:归纳法 - 例2:活动选择问题(凌乱的yyy)
归纳法证明:
1、基础:只有一个活动时,贪心选择最优
2、归纳:假设前k个活动的选择是最优的
3、递推:第k+1个活动选择结束最早的,证明仍然最优
问题:选择最大数量的互不重叠活动
# 读取活动数量 n = int(input()) # 存储活动信息:每个活动是(开始时间, 结束时间) contest = [] for i in range(n): a, b = map(int, input().split()) # 读取活动的开始时间和结束时间 contest.append((a, b)) # 将活动添加到列表 # 按照结束时间升序排序 # 贪心策略:优先选择结束时间早的活动,给后面的活动留下更多时间 contest.sort(key=lambda x: x[1]) # 初始化计数和上一个选择的活动的结束时间 count = 0 # 最多能选择的活动数量 last_end = -1 # 上一个选择的活动的结束时间,初始化为-1确保第一个活动可以被选择 # 遍历排序后的活动 for start, end in contest: # 如果当前活动的开始时间不早于上一个选择的活动的结束时间 # 说明两个活动不冲突,可以选择当前活动 if start >= last_end: count += 1 # 选择当前活动 last_end = end # 更新上一个选择的活动的结束时间 # 输出最多能选择的活动数量 print(count)方法三:交换论证法 - 例3:排队接水问题
交换论证证明:
假设最优解中有两个相邻的人i,j,且t_i > t_j,交换i和j的位置,总等待时间不会增加,因此升序排列是最优的。
问题:n个人排队接水,如何安排使总等待时间最小
# 读取排队人数 n = int(input()) # 读取每个人的接水时间 time = list(map(int, input().split())) # 创建(编号, 时间)的元组列表 times = [] for i in range(n): t = time[i] times.append((i + 1, t)) # i+1作为编号(从1开始) # 按照接水时间升序排序,如果时间相同则按编号升序排序 # 贪心策略:让接水时间短的人先接水,可以减少总等待时间 times.sort(key=lambda x: (x[1], x[0])) # 输出接水顺序(编号) for i, t in times: print(i, end=' ') print() # 换行 # 计算总等待时间 waits = 0.0 # 使用浮点数以确保除法精度 for i in range(n): wait = 0 # 第i个人的等待时间 # 第i个人需要等待前面所有人的接水时间 for j in range(i): wait += times[j][1] waits += wait # 累加到总等待时间 # 计算平均等待时间 waits_mean = waits / n # 输出结果,保留两位小数 print(f'{waits_mean:.2f}')四、经典贪心问题详解
例4:分卷子问题(最小合并代价)
问题:合并多堆卷子,每次合并两堆,代价为两堆之和,求最小总代价
import heapq def min_merge_cost_greedy(volumes): """ 分卷子问题贪心解法(哈夫曼树应用) 贪心策略:总是合并最小的两堆 参数: volumes: 各堆卷子的数量列表 返回: total_cost: 最小合并总代价 merge_sequence: 合并顺序记录 """ if len(volumes) <= 1: return 0, [] # 使用最小堆 min_heap = volumes[:] heapq.heapify(min_heap) total_cost = 0 merge_sequence = [] # 贪心合并:每次合并最小的两堆 while len(min_heap) > 1: # 取出最小的两个元素 first = heapq.heappop(min_heap) second = heapq.heappop(min_heap) # 合并代价 merge_cost = first + second total_cost += merge_cost merge_sequence.append((first, second, merge_cost)) # 将合并结果放回堆中 heapq.heappush(min_heap, merge_cost) return total_cost, merge_sequence # 示例使用 volumes = [3, 4, 2, 6, 1] min_cost, sequence = min_merge_cost_greedy(volumes) print(f"最小合并代价: {min_cost}") print(f"合并顺序: {sequence}")例5:合并果子问题
问题:类似分卷子,但可能有额外约束
import heapq # 导入堆队列算法模块(最小堆) # 读取水果数量 n = int(input()) # 读取每个水果的重量,转换为列表 fruits = list(map(int, input().split())) # 将列表转换为最小堆(堆化) # 堆是一种特殊的二叉树结构,最小堆的根节点总是最小的元素 heapq.heapify(fruits) # 初始化总消耗体力值 total_cost = 0 # 当堆中还有不止一个水果时继续合并 while len(fruits) > 1: # 取出堆中最小的两个水果(堆顶元素) a = heapq.heappop(fruits) # 弹出并返回最小元素 b = heapq.heappop(fruits) # 弹出并返回次小元素 # 合并这两个水果需要的体力值 = 它们的重量之和 cost = a + b # 累加到总消耗体力值 total_cost += cost # 将合并后的新水果(重量为cost)放回堆中 heapq.heappush(fruits, cost) # 将新元素推入堆,保持堆性质 # 输出合并所有水果需要的最小总体力值 print(total_cost)例6:哈夫曼编码问题
问题:给定字符频率,设计最优二进制编码
class HuffmanNode: """哈夫曼树节点类""" def __init__(self, freq, char=None): self.freq = freq # 频率 self.char = char # 字符(叶子节点才有) self.left = None # 左子节点 self.right = None # 右子节点 def __lt__(self, other): """用于堆比较:按频率比较""" return self.freq < other.freq def build_huffman_tree(freq_dict): """ 构建哈夫曼树 贪心策略:每次合并频率最小的两个节点 参数: freq_dict: 字符频率字典 {字符: 频率} 返回: root: 哈夫曼树的根节点 """ if not freq_dict: return None # 创建叶子节点并加入最小堆 heap = [] for char, freq in freq_dict.items(): node = HuffmanNode(freq, char) heapq.heappush(heap, node) # 贪心构建哈夫曼树 while len(heap) > 1: # 取出频率最小的两个节点 left = heapq.heappop(heap) right = heapq.heappop(heap) # 创建新内部节点 merged_freq = left.freq + right.freq merged_node = HuffmanNode(merged_freq) merged_node.left = left merged_node.right = right # 放回堆中 heapq.heappush(heap, merged_node) return heap[0] if heap else None def generate_huffman_codes(root, current_code="", codes={}): """ 生成哈夫曼编码 参数: root: 哈夫曼树根节点 current_code: 当前路径编码 codes: 存储编码的字典 返回: codes: 字符到编码的映射 """ if root is None: return codes # 如果是叶子节点,记录编码 if root.char is not None: codes[root.char] = current_code if current_code else "0" else: # 递归遍历左子树(编码加'0') generate_huffman_codes(root.left, current_code + "0", codes) # 递归遍历右子树(编码加'1') generate_huffman_codes(root.right, current_code + "1", codes) return codes def calculate_compression_rate(original_text, freq_dict, huffman_codes): """ 计算哈夫曼编码的压缩率 参数: original_text: 原始文本 freq_dict: 字符频率 huffman_codes: 哈夫曼编码 返回: compression_rate: 压缩率 """ # 原始比特数(假设8-bit ASCII) original_bits = len(original_text) * 8 if original_text else 0 # 编码后比特数 encoded_bits = 0 for char, freq in freq_dict.items(): code_length = len(huffman_codes.get(char, "")) encoded_bits += freq * code_length if original_bits == 0: return 0 compression_rate = 1 - (encoded_bits / original_bits) return compression_rate # 完整示例 def huffman_coding_example(text): """哈夫曼编码完整示例""" if not text: return None # 1. 统计字符频率 freq_dict = {} for char in text: freq_dict[char] = freq_dict.get(char, 0) + 1 # 2. 构建哈夫曼树 root = build_huffman_tree(freq_dict) # 3. 生成编码 codes = generate_huffman_codes(root) # 4. 计算压缩率 compression_rate = calculate_compression_rate(text, freq_dict, codes) # 5. 编码文本 encoded_text = ''.join(codes[char] for char in text) return { 'frequency': freq_dict, 'huffman_tree': root, 'codes': codes, 'encoded_text': encoded_text, 'compression_rate': compression_rate, 'original_length': len(text) * 8, 'encoded_length': len(encoded_text) } # 使用示例 text = "this is an example for huffman encoding" result = huffman_coding_example(text) print(f"字符频率: {result['frequency']}") print(f"哈夫曼编码: {result['codes']}") print(f"压缩率: {result['compression_rate']:.2%}")五、贪心算法的适用条件与判断
适用条件:
1、贪心选择性质
1)局部最优选择能导致全局最优解
2)可以通过数学证明验证
2、最优子结构
1)问题的最优解包含子问题的最优解
2)子问题独立且可递归求解
3、无后效性
1)当前决策不影响后续决策的最优性
2)决策序列的代价可加性
贪心算法选择决策树:
开始
↓
问题是否可分解为子问题? → No → 不适合贪心
↓ Yes
子问题是否具有最优子结构? → No → 考虑动态规划
↓ Yes
能否做出局部最优选择? → No → 考虑回溯/分支限界
↓ Yes
局部最优是否保证全局最优? → No → 可能需要其他方法
↓ Yes
✓ 适合使用贪心算法
六、贪心 vs 动态规划对比
| 对比维度 | 贪心算法 | 动态规划 |
|---|---|---|
| 决策方式 | 局部最优,不可回溯 | 考虑所有可能,可回溯 |
| 时间复杂度 | 通常O(n log n)或O(n) | 通常O(n²)或更高 |
| 空间复杂度 | 通常O(1)或O(n) | 通常O(n²)需要存储状态 |
| 解的质量 | 可能不是全局最优 | 保证全局最优 |
| 适用问题 | 活动选择、哈夫曼编码、最小生成树 | 0-1背包、最长公共子序列 |
| 实现难度 | 相对简单 | 相对复杂 |
七、贪心算法的实战技巧
技巧1:预处理排序
def greedy_with_sorting(data): """大多数贪心算法需要先排序""" # 关键:确定正确的排序标准 sorted_data = sorted(data, key=custom_key_function) # ... 贪心处理逻辑技巧2:使用优先队列
def greedy_with_heap(data): """需要频繁获取最小/最大值时使用堆""" import heapq heapq.heapify(data) while condition: current = heapq.heappop(data) # 获取当前最优 # ... 处理逻辑 heapq.heappush(data, new_item) # 更新堆技巧3:边界条件处理
def robust_greedy(data): """健壮的贪心算法实现""" if not data: # 空输入处理 return default_value # 特殊输入处理 if len(data) == 1: return handle_single_case(data[0]) # 正常贪心逻辑 try: result = greedy_logic(data) except Exception as e: # 异常处理 return fallback_solution(data) return result八、常见陷阱与避免方法
陷阱1:错误的贪心策略
# 错误示例:0-1背包问题用贪心(单位价值排序) def wrong_knapsack_greedy(weights, values, capacity): items = sorted(zip(weights, values), key=lambda x: x[1]/x[0], reverse=True) total_value = 0 for w, v in items: if capacity >= w: capacity -= w total_value += v return total_value # 可能不是最优解! # 正确:0-1背包应用动态规划陷阱2:忽略证明
❌ 直接实现看似合理的贪心策略
✅ 先用数学证明或构造反例验证
陷阱3:过度优化
❌ 追求过于复杂的贪心策略
✅ 保持简单,必要时用其他算法补充
九、总结与最佳实践
贪心算法是算法工具箱中的重要工具。正确使用时,它能提供高效的解决方案。总结关键点:
先证明后实现:确保问题具有贪心选择性质
从简单开始:先实现基础版本,再优化
充分测试:用边界用例和随机数据测试
保持灵活性:当贪心失败时,考虑动态规划等其他方法
总结:
掌握经典的贪心问题模板
理解各种证明方法的适用场景
在实际问题中积累经验,培养对贪心适用性的直觉
(本文例题来自洛谷)