跳转至

贪心算法(Greedy Algorithms)完全详解 - 从零精通

重要性:⭐⭐⭐⭐⭐ 难度:⭐⭐⭐ 学习时间:5-7天 大厂面试频率:极高(字节、腾讯、阿里高频考) 前置知识:排序、基础算法


📚 目录

  1. 贪心算法基础
  2. 贪心算法核心思想
  3. 经典问题1:活动选择问题
  4. 经典问题2:区间调度问题
  5. 经典问题3:跳跃游戏
  6. 经典问题4:霍夫曼编码
  7. 经典问题5:分数背包问题
  8. 经典问题6:最优合并问题
  9. 经典问题7:加油站问题
  10. 经典问题8:分发糖果
  11. LeetCode高频真题
  12. 贪心vs动态规划

贪心算法基础

什么是贪心算法?

贪心算法(Greedy Algorithm)是一种在每个决策点都做出当前看起来最优选择的算法思想。

核心特点

一句话概括只顾眼前,不顾将来(但在某些问题上,这反而能得到最优解)

Text Only
贪心算法 vs 普通算法:

普通算法:全面考虑,寻找全局最优
贪心算法:局部最优,希望达到全局最优

例子:爬楼梯

动态规划(全局最优):
  f(n) = f(n-1) + f(n-2)  ← 考虑所有情况

贪心(局部最优):
  每次都爬2阶(如果有)    ← 只看当前最优

生活中的贪心例子

例子1:找零钱

Text Only
问题:用最少的硬币数量凑出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:旅行路线规划

Text Only
问题:从北京到上海,想最快到达

贪心策略:每次都选择当前最快的交通方式

北京 →(飞机1.5小时)→ 上海
总时间:1.5小时 ✓

虽然可能不是最便宜,但是是最快的(贪心的目标)

为什么贪心算法重要?

大厂面试高频: - 字节跳动:区间问题、贪心+排序 - 腾讯:跳跃游戏、分发糖果 - 阿里:活动选择、任务调度 - Google:霍夫曼编码、最优合并

效率极高: - 时间复杂度通常:O(n log n)(主要是排序) - 空间复杂度通常:O(1)

代码简洁: - 思路清晰 - 实现简单 - 不需要额外空间(DP需要O(n)数组)


贪心算法核心思想

两个关键问题

问题1:什么时候能用贪心?

不是所有问题都适合贪心!必须满足: 1. 贪心选择性质:局部最优 → 全局最优 2. 最优子结构:子问题的最优解能构成整体最优解

问题2:贪心策略是什么?

这是最难的部分!需要: - 找到合适的贪心策略 - 证明这个策略是对的 - 实现这个策略

贪心算法框架

Python
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

贪心算法解题四步法

Text Only
第1步:理解问题
  → 明确目标(最小化/最大化)
  → 明确约束条件

第2步:寻找贪心策略
  → 尝试排序(按什么属性排?)
  → 尝试贪心选择(选什么?)
  → 验证反例(有没有反例?)

第3步:证明正确性
  → 为什么贪心是对的?
  → 可以用反证法、数学归纳法

第4步:实现代码
  → O(n log n)排序
  → O(n)遍历
  → O(1)空间

经典贪心策略总结

问题类型 贪心策略 排序依据
活动选择 选最早结束的 结束时间↑
区间调度 选最早结束的 结束时间↑
分数背包 选单位价值最高的 价值/重量↓
跳跃游戏 选能跳最远的 位置+跳跃距离
霍夫曼编码 选频率最小的 频率↑
任务调度 选执行时间最短的 执行时间↑

经典问题1:活动选择问题

问题描述

Text Only
有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:选开始时间最早的?

Text Only
选活动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:选持续时间最短的?

Text Only
反例:
活动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:选结束时间最早的!

Text Only
为什么选最早结束的?
因为结束越早,留给后面的时间越多!

证明(反证法):
假设最优解第一个活动不是最早结束的
我们把最优解的第一个活动换成最早结束的
- 不会减少后续活动数量(因为结束更早)
- 所以新解也是最优的
- 因此存在一个最优解包含最早结束的活动 ✓

代码实现

Python
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]

图解过程

Text Only
原始活动:
活动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. 无重叠区间

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

Text Only
示例:
输入:[[1,2], [2,3], [3,4], [1,3]]
输出:1
解释:移除[1,3]后,剩余区间不重叠

图示:
[1,2]: ██
[2,3]:   ██
[3,4]:     ██
[1,3]: █████

移除[1,3]后:
██ ██ ██
↑  ↑  ↑

贪心策略

策略:按结束时间排序,优先选择结束早的区间

为什么? - 结束越早,留给后面的空间越大 - 等价于:选择尽可能多的不重叠区间 - 移除数量 = 总数 - 最大不重叠区间数

代码实现

Python
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. 用最少数量的箭射爆气球

Python
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. 跳跃游戏

给定一个非负整数数组,你最初位于数组的第一个位置。数组中的每个元素代表你在该位置可以跳跃的最大长度。判断是否能够到达最后一个位置。

Text Only
示例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

贪心策略

核心思想:维护"能到达的最远位置"

Text Only
max_reach = 当前能到达的最远位置

遍历每个位置i:
  if i > max_reach:  # 到不了i,返回false
  if i + nums[i] > max_reach:  # 从i能跳更远
    max_reach = i + nums[i]

最后判断max_reach是否到达末尾

代码实现

Python
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

求到达最后一个位置的最小跳跃次数

Python
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层序思想

Text Only
数组: [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:霍夫曼编码

问题描述

给定一组字符及其频率,构造最优前缀编码,使总编码长度最短。

Text Only
示例:
字符: A, B, C, D, E
频率: 5, 9, 12, 13, 16
总次数: 55

目标:构造二进制编码,使:
Σ (频率 × 编码长度) 最小

约束:前缀编码(没有任何编码是另一个编码的前缀)

贪心策略

核心思想:频率高的字符编码短,频率低的字符编码长

算法: 1. 将所有字符作为独立的节点,放入最小堆 2. 每次从堆中取出频率最小的两个节点 3. 合并成新节点(频率 = 两节点之和) 4. 将新节点放回堆 5. 重复直到只剩一个节点(根节点)

图解过程

Text Only
初始:所有节点放入最小堆

      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

代码实现

Python
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:分数背包问题

问题描述

Text Only
有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

贪心策略:优先选单价最高的

贪心策略

策略:按单位价值(价值/重量)从高到低选择

为什么正确? - 因为可以拿部分物品 - 所以优先拿"性价比"最高的 - 拿满为止

代码实现

Python
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:最优合并问题

问题描述

Text Only
有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

等等,让我重新算...

贪心策略

核心思想:每次合并最小的两个文件(类似霍夫曼编码)

为什么? - 合并后的文件会被多次计入代价 - 所以要让"大文件"尽可能少地被重复计算 - → 每次合并最小的两个

代码实现

Python
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]。求:能否从某个加油站出发,绕环路一周?

Text Only
示例:
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开始尝试

Python
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颗糖果,相邻孩子评分更高的必须得到更多糖果。求:最少需要多少糖果?

Text Only
示例:
评分: [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)

Python
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

如果连续数字之间的差严格地在正数和负数之间交替,则称为摆动序列。求最长摆动序列的长度。

Python
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的最大的单调递增数字。

Python
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. 种花问题

花坛中有些花已经种了,相邻不能种花。求还能种多少花?

Python
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:看是否有最优子结构

Text Only
贪心:子问题的最优解能直接构成整体最优解
DP:需要组合多个子问题的解

方法2:看是否有重叠子问题

Text Only
贪心:无重叠子问题
DP:有重叠子问题,需要记忆化

方法3:找反例

Text Only
如果能找到一个反例,说明不能用贪心

例子:0-1背包 vs 分数背包
分数背包:可以拿部分 → 贪心(按单价)✓
0-1背包:不能分割 → 贪心失败 ✗


📝 总结

贪心算法核心要点

核心思想: - 每次选择当前最优 - 局部最优 → 全局最优

解题步骤: 1. 理解问题(目标、约束) 2. 寻找贪心策略(排序?选择?) 3. 验证正确性(证明、反例) 4. 实现代码

常见策略: - 按结束时间排序(活动选择、区间调度) - 按价值/重量比排序(分数背包) - 每次选最小的两个(霍夫曼编码、最优合并) - 维护最远位置(跳跃游戏)

大厂面试高频: - 活动选择、区间调度 - 跳跃游戏、加油站 - 分发糖果、种花问题 - 霍夫曼编码、贪心+排序

下一步

继续学习: - 字符串算法 - KMP、BM算法 - 回溯算法 - 全排列、N皇后 - LeetCode 100题 - 高频真题


继续学习字符串算法...