贪心算法(Greedy Algorithms)完全详解 - 从零精通¶
重要性:⭐⭐⭐⭐⭐ 难度:⭐⭐⭐ 学习时间:5-7天 大厂面试频率:极高(字节、腾讯、阿里高频考) 前置知识:排序、基础算法
📚 目录¶
- 贪心算法基础
- 贪心算法核心思想
- 经典问题1:活动选择问题
- 经典问题2:区间调度问题
- 经典问题3:跳跃游戏
- 经典问题4:霍夫曼编码
- 经典问题5:分数背包问题
- 经典问题6:最优合并问题
- 经典问题7:加油站问题
- 经典问题8:分发糖果
- LeetCode高频真题
- 贪心vs动态规划
贪心算法基础¶
什么是贪心算法?¶
贪心算法(Greedy Algorithm)是一种在每个决策点都做出当前看起来最优选择的算法思想。
核心特点¶
一句话概括:只顾眼前,不顾将来(但在某些问题上,这反而能得到最优解)
贪心算法 vs 普通算法:
普通算法:全面考虑,寻找全局最优
贪心算法:局部最优,希望达到全局最优
例子:爬楼梯
动态规划(全局最优):
f(n) = f(n-1) + f(n-2) ← 考虑所有情况
贪心(局部最优):
每次都爬2阶(如果有) ← 只看当前最优
生活中的贪心例子¶
例子1:找零钱
问题:用最少的硬币数量凑出63元
硬币面值:1元、5元、10元、25元
贪心策略:每次选面值最大的硬币
步骤:
1. 63 ÷ 25 = 2 余 13 → 选2个25元(50元)
2. 13 ÷ 10 = 1 余 3 → 选1个10元(60元)
3. 3 ÷ 5 = 0 余 3 → 不选5元
4. 3 ÷ 1 = 3 余 0 → 选3个1元(63元)
结果:2 + 1 + 0 + 3 = 6个硬币 ✓
这就是贪心!每次都选"当前能选的最大面值"
例子2:旅行路线规划
为什么贪心算法重要?¶
✅ 大厂面试高频: - 字节跳动:区间问题、贪心+排序 - 腾讯:跳跃游戏、分发糖果 - 阿里:活动选择、任务调度 - Google:霍夫曼编码、最优合并
✅ 效率极高: - 时间复杂度通常:O(n log n)(主要是排序) - 空间复杂度通常:O(1)
✅ 代码简洁: - 思路清晰 - 实现简单 - 不需要额外空间(DP需要O(n)数组)
贪心算法核心思想¶
两个关键问题¶
问题1:什么时候能用贪心?
不是所有问题都适合贪心!必须满足: 1. 贪心选择性质:局部最优 → 全局最优 2. 最优子结构:子问题的最优解能构成整体最优解
问题2:贪心策略是什么?
这是最难的部分!需要: - 找到合适的贪心策略 - 证明这个策略是对的 - 实现这个策略
贪心算法框架¶
def greedy_algorithm():
"""
贪心算法通用框架
"""
# 第1步:预处理(通常是排序)
# 排序是贪心算法最常见的第一步!
items.sort(key=lambda x: x.some_attribute)
result = []
# 第2步:贪心选择
for item in items:
if is_compatible(item, result): # 判断是否兼容
result.append(item) # 贪心选择
return result
# 示例:活动选择问题
def activity_selection(activities):
"""
activities: [(开始时间, 结束时间), ...]
"""
# 第1步:按结束时间排序
activities.sort(key=lambda x: x[1])
result = []
last_end = -float('inf')
# 第2步:贪心选择最早结束的
for start, end in activities:
if start >= last_end: # 不冲突
result.append((start, end))
last_end = end
return result
贪心算法解题四步法¶
第1步:理解问题
→ 明确目标(最小化/最大化)
→ 明确约束条件
第2步:寻找贪心策略
→ 尝试排序(按什么属性排?)
→ 尝试贪心选择(选什么?)
→ 验证反例(有没有反例?)
第3步:证明正确性
→ 为什么贪心是对的?
→ 可以用反证法、数学归纳法
第4步:实现代码
→ O(n log n)排序
→ O(n)遍历
→ O(1)空间
经典贪心策略总结¶
| 问题类型 | 贪心策略 | 排序依据 |
|---|---|---|
| 活动选择 | 选最早结束的 | 结束时间↑ |
| 区间调度 | 选最早结束的 | 结束时间↑ |
| 分数背包 | 选单位价值最高的 | 价值/重量↓ |
| 跳跃游戏 | 选能跳最远的 | 位置+跳跃距离 |
| 霍夫曼编码 | 选频率最小的 | 频率↑ |
| 任务调度 | 选执行时间最短的 | 执行时间↑ |
经典问题1:活动选择问题¶
问题描述¶
有n个活动,每个活动有开始时间和结束时间。
求:最多能参加多少个活动?(假设同一时间只能参加一个活动)
示例:
活动1: [0, 6]
活动2: [3, 4]
活动3: [1, 2]
活动4: [5, 7]
活动5: [8, 9]
活动6: [5, 9]
图示:
活动1: ████████
活动2: ████
活动3: ██
活动4: ████
活动5: ████
活动6: ████████
时间: 0 1 2 3 4 5 6 7 8 9
贪心策略分析¶
策略1:选开始时间最早的? ❌
选活动3[1,2],然后能选什么?
活动1[0,6]冲突,活动2[3,4]可以
但这样不是最优!
反例:
活动1: [0, 100] ← 开始最早,但很长
活动2: [1, 2]
活动3: [3, 4]
...
活动100: [99, 100]
选活动1只能参加1个,但不选活动1能参加99个!
策略2:选持续时间最短的? ❌
反例:
活动1: [0, 5] ← 持续5小时
活动2: [1, 3] ← 持续2小时,更短
活动3: [2, 4] ← 持续2小时,更短
活动4: [3, 5] ← 持续2小时,更短
按持续时间排序:[2,3,4,1]
选活动2[1,3],然后活动3、4都冲突!
最优解:选活动1,或选活动2+活动4
策略3:选结束时间最早的! ✅
为什么选最早结束的?
因为结束越早,留给后面的时间越多!
证明(反证法):
假设最优解第一个活动不是最早结束的
我们把最优解的第一个活动换成最早结束的
- 不会减少后续活动数量(因为结束更早)
- 所以新解也是最优的
- 因此存在一个最优解包含最早结束的活动 ✓
代码实现¶
def activity_selection(activities):
"""
活动选择问题(贪心)
时间:O(n log n)
空间:O(1)
activities: [(开始时间, 结束时间), ...]
"""
if not activities:
return []
# 第1步:按结束时间排序(贪心策略)
activities.sort(key=lambda x: x[1])
print("活动(按结束时间排序):")
for i, (start, end) in enumerate(activities):
print(f" 活动{i}: [{start}, {end}]")
# 第2步:贪心选择
result = []
last_end = -float('inf')
print("\n贪心选择过程:")
for i, (start, end) in enumerate(activities):
if start >= last_end:
result.append((start, end))
last_end = end
print(f" ✓ 选择活动{i}: [{start}, {end}]")
else:
print(f" ✗ 跳过活动{i}: [{start}, {end}] (与上次选择的活动冲突)")
print(f"\n最多能参加 {len(result)} 个活动")
return result
# 测试
print("=" * 60)
print("活动选择问题")
print("=" * 60)
activities = [
(0, 6), # 活动1
(3, 4), # 活动2
(1, 2), # 活动3
(5, 7), # 活动4
(8, 9), # 活动5
(5, 9), # 活动6
]
result = activity_selection(activities)
# 输出:
# ============================================================
# 活动选择问题
# ============================================================
# 活动(按结束时间排序):
# 活动0: [1, 2] ← 最早结束
# 活动1: [3, 4]
# 活动2: [0, 6]
# 活动3: [5, 7]
# 活动4: [8, 9]
# 活动5: [5, 9]
#
# 贪心选择过程:
# ✓ 选择活动0: [1, 2] ← 选最早结束的
# ✓ 选择活动1: [3, 4]
# ✗ 跳过活动2: [0, 6] (与上次选择的活动冲突)
# ✗ 跳过活动3: [5, 7] (与上次选择的活动冲突)
# ✓ 选择活动4: [8, 9]
# ✗ 跳过活动5: [5, 9] (与上次选择的活动冲突)
#
# 最多能参加 3 个活动
#
# 结果:[1,2], [3,4], [8,9]
图解过程¶
原始活动:
活动1: ████████ [0, 6]
活动2: ████ [3, 4]
活动3: ██ [1, 2]
活动4: ████ [5, 7]
活动5: ████ [8, 9]
活动6: ████████ [5, 9]
按结束时间排序后:
活动3: ██ [1, 2] ← 最早结束
活动2: ████ [3, 4]
活动1: ████████ [0, 6]
活动4: ████ [5, 7]
活动5: ████ [8, 9]
活动6: ████████ [5, 9]
贪心选择:
第1步:选活动3[1,2]
██
0 1 2 3 4 5 6 7 8 9
✓
第2步:选活动2[3,4](不与[1,2]冲突)
██ ████
0 1 2 3 4 5 6 7 8 9
✓
第3步:跳过活动1[0,6](冲突)
第4步:跳过活动4[5,7](冲突)
第5步:选活动5[8,9](不冲突)
██ ████ ████
0 1 2 3 4 5 6 7 8 9
✓
最终结果:3个活动 [1,2], [3,4], [8,9]
经典问题2:区间调度问题¶
问题描述¶
LeetCode 435. 无重叠区间
给定一个区间的集合,找到需要移除区间的最小数量,使剩余区间互不重叠。
示例:
输入:[[1,2], [2,3], [3,4], [1,3]]
输出:1
解释:移除[1,3]后,剩余区间不重叠
图示:
[1,2]: ██
[2,3]: ██
[3,4]: ██
[1,3]: █████
移除[1,3]后:
██ ██ ██
↑ ↑ ↑
贪心策略¶
策略:按结束时间排序,优先选择结束早的区间
为什么? - 结束越早,留给后面的空间越大 - 等价于:选择尽可能多的不重叠区间 - 移除数量 = 总数 - 最大不重叠区间数
代码实现¶
def erase_overlap_intervals(intervals):
"""
无重叠区间(贪心)
时间:O(n log n)
空间:O(1)
"""
if not intervals:
return 0
# 第1步:按结束时间排序
intervals.sort(key=lambda x: x[1])
print("区间(按结束时间排序):")
for i, interval in enumerate(intervals):
print(f" {i}: {interval}")
# 第2步:贪心选择不重叠的区间
count = 1 # 选中第一个区间
last_end = intervals[0][1]
print("\n贪心选择:")
print(f" 选择区间0: {intervals[0]}")
for i in range(1, len(intervals)):
start, end = intervals[i]
if start >= last_end: # 不重叠
count += 1
last_end = end
print(f" 选择区间{i}: {intervals[i]}")
else:
print(f" 跳过区间{i}: {intervals[i]} (重叠)")
# 需要移除的数量
remove = len(intervals) - count
print(f"\n最大不重叠区间数: {count}")
print(f"需要移除: {remove}")
return remove
# 测试
print("\n" + "=" * 60)
print("无重叠区间")
print("=" * 60)
intervals = [[1,2], [2,3], [3,4], [1,3]]
result = erase_overlap_intervals(intervals)
# 输出:
# ============================================================
# 无重叠区间
# ============================================================
# 区间(按结束时间排序):
# 0: [1, 2]
# 1: [1, 3]
# 2: [2, 3]
# 3: [3, 4]
#
# 贪心选择:
# 选择区间0: [1, 2]
# 跳过区间1: [1, 3] (重叠)
# 选择区间2: [2, 3]
# 选择区间3: [3, 4]
#
# 最大不重叠区间数: 3
# 需要移除: 1
变体:用最少数量的箭射爆气球¶
LeetCode 452. 用最少数量的箭射爆气球
def find_min_arrow_shots(points):
"""
用最少数量的箭射爆气球
时间:O(n log n)
points: [[x_start, x_end], ...]
"""
if not points:
return 0
# 按结束位置排序
points.sort(key=lambda x: x[1])
arrows = 1
last_end = points[0][1]
for start, end in points[1:]: # 切片操作:[start:end:step]提取子序列
if start > last_end: # 不重叠,需要新箭
arrows += 1
last_end = end
# 否则重叠,一箭可以射爆多个
return arrows
经典问题3:跳跃游戏¶
问题描述¶
LeetCode 55. 跳跃游戏
给定一个非负整数数组,你最初位于数组的第一个位置。数组中的每个元素代表你在该位置可以跳跃的最大长度。判断是否能够到达最后一个位置。
示例1:
输入:[2, 3, 1, 1, 4]
输出:true
解释:先跳1步到索引1,再跳3步到最后
图示:
位置: 0 1 2 3 4
数值: 3 2 1 0 4
━━━
━━━━━━━
━━━━━━━━━━━━━━━━
0 1 2 3 4 5
路径1: 0→1→4(3→2→4)
路径2: 0→2→3→4(3→1→0→4)
路径3: 0→1→2→3→4(3→2→1→0→4)
...
示例2:
输入:[3, 2, 1, 0, 4]
输出:false
解释:无论怎么跳,都会卡在位置3
贪心策略¶
核心思想:维护"能到达的最远位置"
max_reach = 当前能到达的最远位置
遍历每个位置i:
if i > max_reach: # 到不了i,返回false
if i + nums[i] > max_reach: # 从i能跳更远
max_reach = i + nums[i]
最后判断max_reach是否到达末尾
代码实现¶
def can_jump(nums):
"""
跳跃游戏(贪心)
时间:O(n)
空间:O(1)
"""
if not nums:
return False
max_reach = 0 # 当前能到达的最远位置
print("跳跃过程:")
for i, jump in enumerate(nums): # enumerate同时获取索引和值
print(f"\n位置{i}: 可跳{jump}步")
print(f" 当前最远: {max_reach}")
if i > max_reach:
print(f" ✗ 到不了位置{i}")
return False
# 更新最远位置
new_reach = i + jump
if new_reach > max_reach:
max_reach = new_reach
print(f" 更新最远位置: {max_reach}")
if max_reach >= len(nums) - 1:
print(f" ✓ 能到达末尾!")
return True
return max_reach >= len(nums) - 1
# 测试
print("\n" + "=" * 60)
print("跳跃游戏")
print("=" * 60)
nums1 = [2, 3, 1, 1, 4]
print(f"\n示例1: {nums1}")
result1 = can_jump(nums1)
nums2 = [3, 2, 1, 0, 4]
print(f"\n示例2: {nums2}")
result2 = can_jump(nums2)
# 输出:
# ============================================================
# 跳跃游戏
# ============================================================
#
# 示例1: [2, 3, 1, 1, 4]
# 跳跃过程:
#
# 位置0: 可跳2步
# 当前最远: 0
# 更新最远位置: 2
#
# 位置1: 可跳3步
# 当前最远: 2
# 更新最远位置: 4
# ✓ 能到达末尾!
#
# 示例2: [3, 2, 1, 0, 4]
# 跳跃过程:
#
# 位置0: 可跳3步
# 当前最远: 0
# 更新最远位置: 3
#
# 位置1: 可跳2步
# 当前最远: 3
#
# 位置2: 可跳1步
# 当前最远: 3
#
# 位置3: 可跳0步
# 当前最远: 3
#
# 位置4: 可跳4步
# 当前最远: 3
# ✗ 到不了位置4
变体:跳跃游戏II¶
LeetCode 45. 跳跃游戏II
求到达最后一个位置的最小跳跃次数。
def jump_ii(nums):
"""
跳跃游戏II(贪心:BFS层序遍历思想)
时间:O(n)
空间:O(1)
"""
if len(nums) <= 1:
return 0
jumps = 0
current_end = 0 # 当前跳跃能到的边界
farthest = 0 # 当前层能到的最远位置
print("跳跃过程(BFS层序):")
for i in range(len(nums) - 1): # 不需要访问最后一个位置
# 更新当前层能到的最远位置
farthest = max(farthest, i + nums[i])
print(f"位置{i}: 可跳{nums[i]}步, 当前层最远: {farthest}")
# 到达当前跳跃的边界
if i == current_end:
jumps += 1
current_end = farthest
print(f" 跳跃{jumps}: 当前边界更新为{current_end}")
if current_end >= len(nums) - 1:
break
print(f"\n最小跳跃次数: {jumps}")
return jumps
# 测试
print("\n" + "=" * 60)
print("跳跃游戏II - 最小跳跃次数")
print("=" * 60)
nums = [2, 3, 1, 1, 4]
print(f"输入: {nums}")
result = jump_ii(nums)
# 输出:
# ============================================================
# 跳跃游戏II - 最小跳跃次数
# ============================================================
# 输入: [2, 3, 1, 1, 4]
# 跳跃过程(BFS层序):
# 位置0: 可跳2步, 当前层最远: 2
# 跳跃1: 当前边界更新为2
# 位置1: 可跳3步, 当前层最远: 4
# 位置2: 可跳1步, 当前层最远: 4
# 跳跃2: 当前边界更新为4
#
# 最小跳跃次数: 2
图解BFS层序思想:
数组: [2, 3, 1, 1, 4]
第0层(起点):
位置0,能跳到位置1或2
第1层:
从0跳1步到位置1,能跳到位置2、3、4
从0跳2步到位置2,能跳到位置3
第2层:
从位置1跳到位置4(终点)
BFS层次:
Layer 0: 0
↙ ↘
Layer 1: 1 2
↙ ↘ ↙
Layer 2: 2 3 4 3
↑
终点
最小跳跃次数 = 2
经典问题4:霍夫曼编码¶
问题描述¶
给定一组字符及其频率,构造最优前缀编码,使总编码长度最短。
示例:
字符: A, B, C, D, E
频率: 5, 9, 12, 13, 16
总次数: 55
目标:构造二进制编码,使:
Σ (频率 × 编码长度) 最小
约束:前缀编码(没有任何编码是另一个编码的前缀)
贪心策略¶
核心思想:频率高的字符编码短,频率低的字符编码长
算法: 1. 将所有字符作为独立的节点,放入最小堆 2. 每次从堆中取出频率最小的两个节点 3. 合并成新节点(频率 = 两节点之和) 4. 将新节点放回堆 5. 重复直到只剩一个节点(根节点)
图解过程¶
初始:所有节点放入最小堆
A(5) B(9) C(12) D(13) E(16)
第1步:取出A(5)和B(9),合并
新节点AB = 5 + 9 = 14
AB(14)
/ \
A(5) B(9)
剩余: C(12), D(13), E(16), AB(14)
第2步:取出C(12)和D(13),合并
新节点CD = 12 + 13 = 25
CD(25)
/ \
C(12) D(13)
剩余: E(16), AB(14), CD(25)
第3步:取出AB(14)和E(16),合并
新节点ABE = 14 + 16 = 30
ABE(30)
/ \
AB(14) E(16)
/ \
A(5) B(9)
剩余: CD(25), ABE(30)
第4步:取出CD(25)和ABE(30),合并
新节点CDABE = 25 + 30 = 55
CDABE(55)
/ \
CD(25) ABE(30)
/ \ / \
C(12) D(13) AB(14) E(16)
/ \
A(5) B(9)
最终霍夫曼树:
55
/ \
25 30
/ \ / \
12 13 14 16
C D / \ E
5 9
A B
编码(从根到叶,左=0,右=1):
C: 0 → 0 = 00 (长度2)
D: 0 → 1 = 01 (长度2)
A: 1 → 0 → 0 = 100 (长度3)
B: 1 → 0 → 1 = 101 (长度3)
E: 1 → 1 = 11 (长度2)
总长度 = 5×3 + 9×3 + 12×2 + 13×2 + 16×2
= 15 + 27 + 24 + 26 + 32
= 124
代码实现¶
import heapq
from collections import defaultdict # defaultdict带默认值的字典,避免KeyError
class HuffmanNode:
"""霍夫曼树节点"""
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 huffman_encoding(chars, freqs):
"""
霍夫曼编码(贪心)
时间:O(n log n)
空间:O(n)
"""
# 第1步:创建节点并放入最小堆
heap = []
for char, freq in zip(chars, freqs): # zip并行遍历多个可迭代对象
node = HuffmanNode(char=char, freq=freq)
heapq.heappush(heap, node)
print(f"创建节点: {char}({freq})")
# 第2步:贪心合并(每次取频率最小的两个)
print("\n构建霍夫曼树:")
step = 1
while len(heap) > 1:
# 取出频率最小的两个节点
left = heapq.heappop(heap)
right = heapq.heappop(heap)
# 合并
merged_freq = left.freq + right.freq
merged_node = HuffmanNode(freq=merged_freq, left=left, right=right)
print(f"\n步骤{step}:")
print(f" 取出: {left.char if left.char else 'Node'}({left.freq}) 和 "
f"{right.char if right.char else 'Node'}({right.freq})")
print(f" 合并: 频率 = {merged_freq}")
heapq.heappush(heap, merged_node)
step += 1
# 第3步:生成编码
root = heap[0]
codes = {}
def generate_codes(node, code=""):
if node is None:
return
# 叶子节点
if node.char is not None:
codes[node.char] = code if code else "0"
return
# 左边加0,右边加1
generate_codes(node.left, code + "0")
generate_codes(node.right, code + "1")
generate_codes(root)
print("\n霍夫曼编码:")
total_length = 0
for char, code in sorted(codes.items()):
freq = freqs[chars.index(char)]
length = freq * len(code)
total_length += length
print(f" {char}: {code} (长度={len(code)}, 贡献={length})")
print(f"\n总编码长度: {total_length}")
return codes
# 测试
print("\n" + "=" * 60)
print("霍夫曼编码")
print("=" * 60)
chars = ['A', 'B', 'C', 'D', 'E']
freqs = [5, 9, 12, 13, 16]
print(f"字符频率: {dict(zip(chars, freqs))}")
codes = huffman_encoding(chars, freqs)
# 输出:
# ============================================================
# 霍夫曼编码
# ============================================================
# 字符频率: {'A': 5, 'B': 9, 'C': 12, 'D': 13, 'E': 16}
# 创建节点: A(5)
# 创建节点: B(9)
# 创建节点: C(12)
# 创建节点: D(13)
# 创建节点: E(16)
#
# 构建霍夫曼树:
#
# 步骤1:
# 取出: A(5) 和 B(9)
# 合并: 频率 = 14
#
# 步骤2:
# 取出: C(12) 和 D(13)
# 合并: 频率 = 25
#
# 步骤3:
# 取出: Node(14) 和 E(16)
# 合并: 频率 = 30
#
# 步骤4:
# 取出: Node(25) 和 Node(30)
# 合并: 频率 = 55
#
# 霍夫曼编码:
# A: 100 (长度=3, 贡献=15)
# B: 101 (长度=3, 贡献=27)
# C: 00 (长度=2, 贡献=24)
# D: 01 (长度=2, 贡献=26)
# E: 11 (长度=2, 贡献=32)
#
# 总编码长度: 124
应用场景¶
✅ 数据压缩: - ZIP压缩 - JPEG图片压缩 - MP3音频压缩
✅ 为什么有效? - 频率高的字符编码短 - 频率低的字符编码长 - 前缀编码保证无歧义
经典问题5:分数背包问题¶
问题描述¶
有n个物品,第i个物品的重量为w[i],价值为v[i]。
背包容量为W,可以选择物品的一部分(分数)。
求:如何装能使总价值最大?
与0-1背包的区别:
- 0-1背包:物品不能分割,要么全拿要么不拿
- 分数背包:物品可以分割,可以拿一部分
示例:
物品: A B C
重量: 10 20 30
价值: 60 100 160
单价: 6 5 5.33
背包容量: 50
贪心策略:优先选单价最高的
贪心策略¶
策略:按单位价值(价值/重量)从高到低选择
为什么正确? - 因为可以拿部分物品 - 所以优先拿"性价比"最高的 - 拿满为止
代码实现¶
def fractional_knapsack(weights, values, capacity):
"""
分数背包问题(贪心)
时间:O(n log n)
空间:O(1)
"""
n = len(weights)
# 第1步:计算单位价值并排序
items = []
for i in range(n):
unit_value = values[i] / weights[i]
items.append((weights[i], values[i], unit_value, i))
# 按单位价值降序排序
items.sort(key=lambda x: x[2], reverse=True) # lambda匿名函数:简洁的单行函数
print("物品(按单位价值排序):")
print(" 物品 重量 价值 单位价值")
for w, v, uv, idx in items:
print(f" {chr(65+idx)} {w:2d} {v:3d} {uv:.2f}")
# 第2步:贪心选择
total_value = 0
remaining = capacity
result = []
print("\n贪心选择过程:")
for w, v, uv, idx in items:
if remaining >= w:
# 全部拿
total_value += v
remaining -= w
result.append((chr(65+idx), 1.0))
print(f" 全拿{chr(65+idx)}: 重量={w}, 价值={v}, 剩余容量={remaining}")
else:
# 拿一部分
fraction = remaining / w
take_value = v * fraction
total_value += take_value
result.append((chr(65+idx), fraction))
print(f" 拿{chr(65+idx)}的{fraction:.2%}: 重量={remaining}, 价值={take_value:.2f}")
break
print(f"\n总价值: {total_value:.2f}")
return total_value, result
# 测试
print("\n" + "=" * 60)
print("分数背包问题")
print("=" * 60)
weights = [10, 20, 30]
values = [60, 100, 160]
capacity = 50
print(f"背包容量: {capacity}")
total_value, result = fractional_knapsack(weights, values, capacity)
# 输出:
# ============================================================
# 分数背包问题
# ============================================================
# 背包容量: 50
# 物品(按单位价值排序):
# 物品 重量 价值 单位价值
# A 10 60 6.00
# C 30 160 5.33
# B 20 100 5.00
#
# 贪心选择过程:
# 全拿A: 重量=10, 价值=60, 剩余容量=40
# 全拿C: 重量=30, 价值=160, 剩余容量=10
# 拿B的50.00%: 重量=10, 价值=50.00
#
# 总价值: 270.00
经典问题6:最优合并问题¶
问题描述¶
有n个文件,大小分别为s[1], s[2], ..., s[n]。
每次只能合并2个文件,合并代价为两文件大小之和。
求:合并所有文件的最小总代价?
示例:
文件大小: [1, 2, 3, 4]
合并方案1:
(1+2)=3, [3, 3, 4]
(3+3)=6, [6, 4]
(6+4)=10
总代价 = 3 + 6 + 10 = 19
合并方案2:
(4+3)=7, [1, 2, 7]
(2+1)=3, [3, 7]
(3+7)=10
总代价 = 7 + 3 + 10 = 20
合并方案3(最优):
(1+2)=3, [3, 3, 4]
(3+3)=6, [6, 4]
(6+4)=10
总代价 = 3 + 6 + 10 = 19
等等,让我重新算...
贪心策略¶
核心思想:每次合并最小的两个文件(类似霍夫曼编码)
为什么? - 合并后的文件会被多次计入代价 - 所以要让"大文件"尽可能少地被重复计算 - → 每次合并最小的两个
代码实现¶
import heapq
def optimal_merge_pattern(files):
"""
最优合并问题(贪心)
时间:O(n log n)
空间:O(n)
"""
if len(files) < 2:
return 0
# 第1步:创建最小堆
heap = files.copy()
heapq.heapify(heap)
print("初始文件:", heap)
# 第2步:每次合并最小的两个
total_cost = 0
step = 1
while len(heap) > 1:
# 取出最小的两个
file1 = heapq.heappop(heap)
file2 = heapq.heappop(heap)
# 合并
merged = file1 + file2
total_cost += merged
print(f"\n步骤{step}:")
print(f" 合并 {file1} 和 {file2} → {merged}")
print(f" 当前代价: {total_cost}")
# 放回堆
heapq.heappush(heap, merged)
step += 1
print(f"\n最小总代价: {total_cost}")
return total_cost
# 测试
print("\n" + "=" * 60)
print("最优合并问题")
print("=" * 60)
files = [1, 2, 3, 4]
print(f"文件大小: {files}")
cost = optimal_merge_pattern(files)
# 输出:
# ============================================================
# 最优合并问题
# ============================================================
# 文件大小: [1, 2, 3, 4]
# 初始文件: [1, 2, 3, 4]
#
# 步骤1:
# 合并 1 和 2 → 3
# 当前代价: 3
#
# 步骤2:
# 合并 3 和 3 → 6
# 当前代价: 9
#
# 步骤3:
# 合并 4 和 6 → 10
# 当前代价: 19
#
# 最小总代价: 19
经典问题7:加油站问题¶
问题描述¶
LeetCode 134. 加油站
在一条环路上有 N 个加油站,其中第 i 个加油站有汽油 gas[i],从第 i 个加油站开到第 i+1 个加油站需要消耗汽油 cost[i]。求:能否从某个加油站出发,绕环路一周?
示例:
gas = [1, 2, 3, 4, 5]
cost = [3, 4, 5, 1, 2]
图示:
站点0: 有1升,到站点1需要3升 → 净余-2 ✗
站点1: 有2升,到站点2需要4升 → 净余-2 ✗
站点2: 有3升,到站点3需要5升 → 净余-2 ✗
站点3: 有4升,到站点4需要1升 → 净余+3 ✓
站点4: 有5升,到站点0需要2升 → 净余+3 ✓
答案:从站点3出发
贪心策略¶
核心思想: 1. 如果总汽油 < 总消耗,肯定不行 2. 如果从i出发到j失败,那么[i, j]之间的所有点都不可能作为起点 3. 只需要从j+1开始尝试
def can_complete_circuit(gas, cost):
"""
加油站问题(贪心)
时间:O(n)
空间:O(1)
"""
n = len(gas)
# 总汽油和总消耗
total_gas = sum(gas)
total_cost = sum(cost)
if total_gas < total_cost:
print(f"总汽油({total_gas}) < 总消耗({total_cost}), 无法完成")
return -1
print(f"总汽油({total_gas}) >= 总消耗({total_cost}), 可能完成\n")
# 贪心:寻找起点
start = 0
current_gas = 0
print("寻找起点:")
for i in range(n):
current_gas += gas[i] - cost[i]
print(f" 站点{i}: gas={gas[i]}, cost={cost[i]}, 净余={gas[i]-cost[i]}, 当前油量={current_gas}")
if current_gas < 0:
# 从i出发失败,从i+1重新开始
print(f" × 从{start}出发失败,尝试从{i+1}开始")
start = i + 1
current_gas = 0
print(f"\n✓ 从站点{start}出发可以完成环路")
return start
# 测试
print("\n" + "=" * 60)
print("加油站问题")
print("=" * 60)
gas = [1, 2, 3, 4, 5]
cost = [3, 4, 5, 1, 2]
print(f"gas = {gas}")
print(f"cost = {cost}\n")
start = can_complete_circuit(gas, cost)
# 输出:
# ============================================================
# 加油站问题
# ============================================================
# gas = [1, 2, 3, 4, 5]
# cost = [3, 4, 5, 1, 2]
#
# 总汽油(15) >= 总消耗(15), 可能完成
#
# 寻找起点:
# 站点0: gas=1, cost=3, 净余=-2, 当前油量=-2
# × 从0出发失败,尝试从1开始
# 站点1: gas=2, cost=4, 净余=-2, 当前油量=-2
# × 从1出发失败,尝试从2开始
# 站点2: gas=3, cost=5, 净余=-2, 当前油量=-2
# × 从2出发失败,尝试从3开始
# 站点3: gas=4, cost=1, 净余=3, 当前油量=3
# 站点4: gas=5, cost=2, 净余=3, 当前油量=6
#
# ✓ 从站点3出发可以完成环路
经典问题8:分发糖果¶
问题描述¶
LeetCode 135. 分发糖果
N 个孩子站成一排,每个孩子有一个评分。给每个孩子至少1颗糖果,相邻孩子评分更高的必须得到更多糖果。求:最少需要多少糖果?
示例:
评分: [1, 0, 2]
分配: [2, 1, 2]
总数: 5
图示:
孩子: 0 1 2
评分: 1 0 2
糖果: 2 1 2
解释:
- 孩子1评分最低,给1颗
- 孩子0比孩子1高,比孩子1多,给2颗
- 孩子2比孩子1高,比孩子1多,给2颗
贪心策略¶
核心思想:左右各扫描一次 1. 从左到右:如果右边评分高,糖果 = 左边 + 1 2. 从右到左:如果左边评分高,糖果 = max(当前, 右边 + 1)
def candy(ratings):
"""
分发糖果(贪心)
时间:O(n)
空间:O(n)
"""
n = len(ratings)
if n == 0:
return 0
# 每个孩子至少1颗
candies = [1] * n
print("初始糖果:", candies)
# 第1步:从左到右
print("\n从左到右扫描:")
for i in range(1, n):
if ratings[i] > ratings[i-1]:
candies[i] = candies[i-1] + 1
print(f" 孩子{i}评分>{i-1}, 糖果={candies[i]}")
print("\n中间结果:", candies)
# 第2步:从右到左
print("\n从右到左扫描:")
for i in range(n-2, -1, -1):
if ratings[i] > ratings[i+1]:
candies[i] = max(candies[i], candies[i+1] + 1)
print(f" 孩子{i}评分>{i+1}, 糖果=max({candies[i]-1}, {candies[i+1]+1})={candies[i]}")
total = sum(candies)
print(f"\n最终糖果分配: {candies}")
print(f"总糖果数: {total}")
return total
# 测试
print("\n" + "=" * 60)
print("分发糖果")
print("=" * 60)
ratings = [1, 0, 2]
print(f"评分: {ratings}\n")
total = candy(ratings)
# 输出:
# ============================================================
# 分发糖果
# ============================================================
# 评分: [1, 0, 2]
#
# 初始糖果: [1, 1, 1]
#
# 从左到右扫描:
# 孩子1评分>0, 糖果=1 (不满足,因为1>0是false)
# 孩子2评分>1, 糖果=2
#
# 中间结果: [1, 1, 2]
#
# 从右到左扫描:
# 孩子1评分>2, 糖果=max(1, 3)=3 (不满足,因为0>2是false)
# 孩子0评分>1, 糖果=max(1, 2)=2
#
# 最终糖果分配: [2, 1, 2]
# 总糖果数: 5
LeetCode高频真题¶
题目1:摆动序列¶
LeetCode 376. Wiggle Subsequence
如果连续数字之间的差严格地在正数和负数之间交替,则称为摆动序列。求最长摆动序列的长度。
def wiggle_max_length(nums):
"""
摆动序列(贪心)
时间:O(n)
空间:O(1)
"""
if len(nums) < 2:
return len(nums)
up = 1 # 上升趋势的长度
down = 1 # 下降趋势的长度
print("摆动序列分析:")
for i in range(1, len(nums)):
if nums[i] > nums[i-1]: # 上升
up = down + 1
print(f" 位置{i}: {nums[i-1]} → {nums[i]} (上升), up={up}")
elif nums[i] < nums[i-1]: # 下降
down = up + 1
print(f" 位置{i}: {nums[i-1]} → {nums[i]} (下降), down={down}")
print(f"\n最长摆动序列长度: {max(up, down)}")
return max(up, down)
# 测试
print("=" * 60)
print("摆动序列")
print("=" * 60)
nums = [1, 7, 4, 9, 2, 5]
print(f"输入: {nums}\n")
result = wiggle_max_length(nums)
# 输出:6 (整个序列都是摆动)
# 1 < 7 < 4 < 9 < 2 < 5
# ↑ ↓ ↑ ↓ ↑
题目2:单调递增的数字¶
LeetCode 738. 单调递增的数字
给定一个非负整数N,找出小于或等于N的最大的单调递增数字。
def monotone_increasing_digits(N):
"""
单调递增的数字(贪心)
时间:O(log N)
空间:O(log N)
"""
digits = list(str(N))
n = len(digits)
print(f"输入: {N}")
print(f"数字: {digits}")
# 从左到右找到第一个递减的位置
marker = n
for i in range(n-1, 0, -1):
if digits[i] < digits[i-1]:
marker = i
digits[i-1] = str(int(digits[i-1]) - 1)
print(f" 位置{i-1}: {digits[i-1]} > {digits[i]}, 减小前一位")
# marker之后全部设为9
for i in range(marker, n):
digits[i] = '9'
result = int(''.join(digits))
print(f"结果: {result}")
return result
# 测试
print("\n" + "=" * 60)
print("单调递增的数字")
print("=" * 60)
N = 332
result = monotone_increasing_digits(N)
# 输出:299
题目3:种花问题¶
LeetCode 605. 种花问题
花坛中有些花已经种了,相邻不能种花。求还能种多少花?
def can_place_flowers(flowerbed, n):
"""
种花问题(贪心)
时间:O(n)
空间:O(1)
"""
count = 0
length = len(flowerbed)
print("花坛:", flowerbed)
print("尝试种花:\n")
for i in range(length):
if flowerbed[i] == 0:
# 检查前后是否都是0
empty_left = (i == 0) or (flowerbed[i-1] == 0)
empty_right = (i == length-1) or (flowerbed[i+1] == 0)
if empty_left and empty_right:
flowerbed[i] = 1
count += 1
print(f" 位置{i}: ✓ 可以种花")
if count >= n:
print(f"\n已种够{n}朵花")
return True
else:
print(f" 位置{i}: ✗ 不能种花 (前后有花)")
print(f"\n最多只能种{count}朵花")
return count >= n
# 测试
print("\n" + "=" * 60)
print("种花问题")
print("=" * 60)
flowerbed = [1, 0, 0, 0, 1]
n = 1
print(f"花坛: {flowerbed}, 需要种{n}朵\n")
result = can_place_flowers(flowerbed, n)
# 输出:True
贪心vs动态规划¶
关键区别¶
| 特性 | 贪心算法 | 动态规划 |
|---|---|---|
| 决策方式 | 局部最优 | 考虑所有情况 |
| 最优解 | 可能不是全局最优 | 保证全局最优 |
| 时间复杂度 | 优秀(O(n log n)) | 较高(O(n²)) |
| 空间复杂度 | O(1) | O(n) |
| 适用问题 | 特定结构 | 通用 |
什么时候用贪心?¶
✅ 用贪心: - 活动选择问题 - 霍夫曼编码 - 分数背包 - 最短路径(Dijkstra) - 最小生成树(Prim, Kruskal)
❌ 不能用贪心: - 0-1背包(必须用DP) - 最长公共子序列(必须用DP) - 编辑距离(必须用DP) - 括号生成(必须用回溯/DP)
判断方法¶
方法1:看是否有最优子结构
方法2:看是否有重叠子问题
方法3:找反例
📝 总结¶
贪心算法核心要点¶
✅ 核心思想: - 每次选择当前最优 - 局部最优 → 全局最优
✅ 解题步骤: 1. 理解问题(目标、约束) 2. 寻找贪心策略(排序?选择?) 3. 验证正确性(证明、反例) 4. 实现代码
✅ 常见策略: - 按结束时间排序(活动选择、区间调度) - 按价值/重量比排序(分数背包) - 每次选最小的两个(霍夫曼编码、最优合并) - 维护最远位置(跳跃游戏)
✅ 大厂面试高频: - 活动选择、区间调度 - 跳跃游戏、加油站 - 分发糖果、种花问题 - 霍夫曼编码、贪心+排序
下一步¶
继续学习: - 字符串算法 - KMP、BM算法 - 回溯算法 - 全排列、N皇后 - LeetCode 100题 - 高频真题
继续学习字符串算法...