【算法设计与分析】贪心算法

贪心算法

贪心算法

本章学习目标

  • 理解贪心算法的核心思想:贪心选择性质和最优子结构
  • 掌握贪心算法的设计要素:贪心策略、正确性证明
  • 学习经典贪心算法:活动安排、哈夫曼编码、Dijkstra、MST等
  • 理解贪心算法与动态规划的区别和适用场景
  • 了解贪心算法在近似算法中的应用

目录

  • [4.1 活动安排问题](#4.1 活动安排问题)
  • [4.2 贪心算法的基本要素](#4.2 贪心算法的基本要素)
  • [4.3 最优装载问题](#4.3 最优装载问题)
  • [4.4 哈夫曼编码](#4.4 哈夫曼编码)
  • [4.5 单源最短路径](#4.5 单源最短路径)
  • [4.6 最小生成树](#4.6 最小生成树)
  • [4.7 多机调度问题](#4.7 多机调度问题)
  • 练习题
  • 本章小结

贪心算法(Greedy Algorithm)是算法设计的重要策略之一。它在算法执行的每一步都做出当前看来最优的选择,期望通过局部最优选择达到全局最优。

与动态规划不同,贪心算法不需要求解所有子问题,而是通过贪心选择性质直接构造最优解,通常具有更低的时间和空间复杂度。

核心思想对比

特征 贪心算法 动态规划
决策方式 每步做局部最优选择 自底向上求解所有子问题
是否回溯 不回溯 可能回溯(在构造解时)
子问题求解 只求解必要的子问题 求解所有子问题
适用条件 需要贪心选择性质 需要最优子结构和重叠子问题
时间复杂度 通常更低 可能更高
空间复杂度 通常更低 可能需要存储DP表

4.1 活动安排问题

问题描述

有n个活动需要使用同一个资源(如会议室),每个活动i有开始时间s[i]和结束时间f[i]。如果两个活动的时间不重叠,则称它们是相容的。目标是选择最大的相容活动集合。

示例:设有11个活动

复制代码
活动i:   1   2   3   4   5   6   7   8   9  10  11
s[i]:   1   3   0   5   3   5   6   8   8   2  12
f[i]:   4   5   6   7   9   9  10  11  12  14  16

问题:如何选择活动,使得能安排的活动数最多?

解题思路

为什么需要贪心算法?

暴力枚举所有可能的活动组合:O(2ⁿ)种可能,不可行!

观察问题特点:

  • 活动之间有相容关系
  • 目标是最大化活动数量(不是总时长)
贪心策略设计

可能的策略

  1. 按开始时间最早优先选择

    • 反例:开始早的活动可能持续时间很长

      活动: [1, 10], [2, 3], [4, 5]
      按开始时间:选择[1, 10],只能选1个
      最优解:选择[2, 3], [4, 5],能选2个

  2. 按持续时间最短优先选择

    • 反例:短活动可能与其他活动冲突

      活动: [1, 5], [4, 6], [5, 7], [6, 8]
      按持续时间:先选[5, 7], [6, 8](各2小时)
      最优解:选择[1, 5], [5, 7]或[4, 6], [6, 8]

  3. 按结束时间最早优先选择

    • 正确!结束早的活动为后续活动留下更多时间

贪心策略:将活动按结束时间从小到大排序,依次选择与已选活动相容的活动。

为什么这个策略正确?

直观理解

  • 结束时间早的活动"占用"资源的时间短
  • 为后续活动留出更多选择空间
  • 类似于"早起的鸟儿有虫吃"

严格证明(贪心选择性质):

定理:按结束时间最早优先选择的贪心策略能得到最优解。

证明(归纳法):

设活动按结束时间排序为 f₁ ≤ f₂ ≤ ... ≤ fₙ。

归纳基础:选择活动1(结束最早的)是最优的。

  • 如果某个最优解不包含活动1,设其第一个活动是活动k
  • 由于f₁ ≤ fₖ,可以用活动1替换活动k,得到另一个最优解
  • 因此,存在包含活动1的最优解

归纳假设:假设前k个贪心选择是正确的。

归纳步骤:第k+1个贪心选择选择的是与之前所有活动相容中结束最早的活动。

  • 根据归纳基础,这个选择也是正确的

结论:通过归纳,贪心策略能得到最优解。

图解执行过程

以6个活动为例:

复制代码
活动:   A1   A2   A3   A4   A5   A6
时间:  [1,4] [3,5] [0,6] [5,7] [3,9] [5,9]

排序后(按结束时间):
A1[1,4] → A2[3,5] → A3[0,6] → A4[5,7] → A5[3,9] → A6[5,9]
  4        5        6        7        9        9

贪心选择过程:
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
步骤1: 选择 A1[1,4]
        剩余时间从4开始

步骤2: 检查 A2[3,5]
        开始3 < 结束4,冲突,跳过

步骤3: 检查 A3[0,6]
        开始0 < 结束4,冲突,跳过

步骤4: 检查 A4[5,7]
        开始5 ≥ 结束4,选择!
        剩余时间从7开始

步骤5: 检查 A5[3,9]
        开始3 < 结束7,冲突,跳过

步骤6: 检查 A6[5,9]
        开始5 < 结束7,冲突,跳过

最终选择:{A1, A4}
最大活动数:2
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

Python实现

python 复制代码
from typing import List, Tuple

class Activity:
    """活动类"""
    def __init__(self, start: int, finish: int, name: str = None):
        self.start = start
        self.finish = finish
        self.name = name if name else f"[{start},{finish}]"

    def __lt__(self, other):
        """按结束时间排序"""
        return self.finish < other.finish

    def __repr__(self):
        return f"{self.name}"


def activity_selection(activities: List[Activity]) -> List[Activity]:
    """
    活动安排问题的贪心算法

    参数:
        activities: 活动列表

    返回:
        选择的相容活动列表
    """
    if not activities:
        return []

    # 按结束时间排序
    sorted_activities = sorted(activities)

    n = len(sorted_activities)
    selected = [sorted_activities[0]]  # 选择第一个活动
    last_finish = sorted_activities[0].finish

    print("贪心选择过程:")
    print("=" * 50)
    print(f"初始选择: {sorted_activities[0]}")

    for i in range(1, n):
        current = sorted_activities[i]
        print(f"检查 {current}: ", end="")

        if current.start >= last_finish:
            selected.append(current)
            last_finish = current.finish
            print(f"选择 ✓ (当前时间: {last_finish})")
        else:
            print(f"跳过 ✗ (冲突)")

    print("=" * 50)
    return selected


# 递归解法(对比)
def activity_selection_recursive(s: List[int], f: List[int],
                                  k: int, n: int) -> List[int]:
    """
    递归求解活动安排问题

    参数:
        s: 开始时间数组(1-indexed)
        f: 结束时间数组(1-indexed)
        k: 上一个选择的活动索引
        n: 活动总数

    返回:
        选择的活动索引列表
    """
    m = k + 1

    # 找到第一个与活动k相容的活动
    while m <= n and s[m] < f[k]:
        m += 1

    if m <= n:
        return [m] + activity_selection_recursive(s, f, m, n)
    return []


# 示例用法
if __name__ == "__main__":
    # 示例1:基本示例
    print("示例1:基本活动安排")
    print("-" * 50)

    activities = [
        Activity(1, 4, "会议1"),
        Activity(3, 5, "会议2"),
        Activity(0, 6, "会议3"),
        Activity(5, 7, "会议4"),
        Activity(3, 9, "会议5"),
        Activity(5, 9, "会议6"),
    ]

    selected = activity_selection(activities)
    print(f"\n最终选择:{selected}")
    print(f"最大活动数:{len(selected)}")

    # 示例2:递归解法
    print("\n\n示例2:递归解法")
    print("-" * 50)

    s = [0, 1, 3, 0, 5, 3, 5]  # 开始时间(1-indexed)
    f = [0, 4, 5, 6, 7, 9, 9]  # 结束时间

    result = activity_selection_recursive(s, f, 0, 6)
    print(f"递归解法选择的活动索引:{result}")
    print(f"选择的活动数:{len(result)}")

输出

复制代码
示例1:基本活动安排
--------------------------------------------------
贪心选择过程:
==================================================
初始选择: 会议1
检查 会议2: 跳过 ✗ (冲突)
检查 会议3: 跳过 ✗ (冲突)
检查 会议4: 选择 ✓ (当前时间: 7)
检查 会议5: 跳过 ✗ (冲突)
检查 会议6: 跳过 ✗ (冲突)
==================================================

最终选择:[会议1, 会议4]
最大活动数:2


示例2:递归解法
--------------------------------------------------
递归解法选择的活动索引:[1, 4]
选择的活动数:2

复杂度分析

  • 时间复杂度:O(n log n)

    • 排序:O(n log n)
    • 贪心选择:O(n)
    • 总复杂度:O(n log n)(排序主导)
  • 空间复杂度:O(1)(迭代)或 O(n)(递归,递归栈深度)


4.2 贪心算法的基本要素

贪心选择性质

定义:可以通过做出局部最优选择来达到全局最优。

关键特征

  1. 每一步做选择时,只考虑当前状态下的最优选择
  2. 一旦做出选择,就不能回溯
  3. 不需要考虑子问题的所有可能解

判断方法

  • 观察问题是否可以逐步做出选择
  • 尝试证明:每次的局部最优选择能否导向全局最优
  • 通过反例验证:是否存在反例说明贪心策略不成立

最优子结构性质

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

与动态规划的对比

  • 贪心算法:自顶向下,每步做选择后缩减问题规模
  • 动态规划:自底向上,先求解子问题再组合

共同点:都要求最优子结构性质

区别

特征 贪心算法 动态规划
选择方式 局部最优 全局最优
子问题求解 只求解必要的 求解所有子问题
是否回溯 不回溯 可能回溯

贪心策略正确性的证明

证明方法

  1. 归纳法
python 复制代码
# 证明模板
"""
定理:[贪心策略描述]能得到最优解。

证明:
1. 基础情况:证明第一步选择是正确的
2. 归纳假设:假设前k步选择是正确的
3. 归纳步骤:证明第k+1步选择也是正确的
4. 结论:通过归纳,整个策略正确
"""
  1. 反证法
python 复制代码
# 证明模板
"""
定理:[贪心策略描述]能得到最优解。

证明(反证法):
假设贪心策略不能得到最优解。
推导出矛盾,因此假设错误,贪心策略正确。
"""
  1. 交换论证
python 复制代码
# 证明模板
"""
定理:[贪心策略描述]能得到最优解。

证明(交换论证):
1. 设贪心解为G,最优解为O
2. 证明可以通过逐步交换,将O转换为G
3. 每次交换不会使解变差
4. 因此G也是最优解
"""

示例:贪心 vs 动态规划

硬币找零问题

给定硬币面值和目标金额,求最少硬币数。

python 复制代码
from typing import List, Tuple

# 贪心算法
def coin_change_greedy(coins: List[int], amount: int) -> Tuple[int, List[int]]:
    """
    贪心算法:每次选择面值最大的硬币

    注意:贪心算法并非总是最优!
    """
    coins.sort(reverse=True)
    count = 0
    remaining = amount
    used = []

    for coin in coins:
        while remaining >= coin:
            remaining -= coin
            count += 1
            used.append(coin)

    return count, used


# 动态规划
def coin_change_dp(coins: List[int], amount: int) -> Tuple[int, List[int]]:
    """
    动态规划:始终能得到最优解

    时间复杂度:O(amount × len(coins))
    """
    dp = [float('inf')] * (amount + 1)
    dp[0] = 0

    for i in range(1, amount + 1):
        for coin in coins:
            if coin <= i:
                dp[i] = min(dp[i], dp[i - coin] + 1)

    # 重构解
    if dp[amount] == float('inf'):
        return 0, []

    used = []
    remaining = amount
    for coin in sorted(coins, reverse=True):
        while remaining >= coin and dp[remaining] == dp[remaining - coin] + 1:
            used.append(coin)
            remaining -= coin

    return dp[amount], used


# 对比
print("硬币找零:贪心 vs 动态规划")
print("=" * 50)

# 案例1:贪心有效
coins1 = [1, 5, 10, 25]
amount1 = 67
greedy1, used1 = coin_change_greedy(coins1, amount1)
dp1, used_dp1 = coin_change_dp(coins1, amount1)
print(f"\n案例1:面值{coins1},目标{amount1}")
print(f"贪心算法:{greedy1}个硬币 {used1}")
print(f"动态规划:{dp1}个硬币 {used_dp1}")
print(f"结果:{'相同 ✓' if greedy1 == dp1 else '不同 ✗'}")

# 案例2:贪心失效
coins2 = [1, 3, 4]
amount2 = 6
greedy2, used2 = coin_change_greedy(coins2, amount2)
dp2, used_dp2 = coin_change_dp(coins2, amount2)
print(f"\n案例2:面值{coins2},目标{amount2}")
print(f"贪心算法:{greedy2}个硬币 {used2}")
print(f"动态规划:{dp2}个硬币 {used_dp2}")
print(f"结果:{'相同 ✓' if greedy2 == dp2 else '不同 ✗ (贪心非最优!)'}")

输出

复制代码
硬币找零:贪心 vs 动态规划
==================================================

案例1:面值[1, 5, 10, 25],目标67
贪心算法:6个硬币 [25, 25, 10, 5, 1, 1]
动态规划:6个硬币 [25, 25, 10, 5, 1, 1]
结果:相同 ✓

案例2:面值[1, 3, 4],目标6
贪心算法:3个硬币 [4, 1, 1]
动态规划:2个硬币 [3, 3]
结果:不同 ✗ (贪心非最优!)

关键洞察

  • 贪心算法简单高效,但并非对所有问题有效
  • 需要通过证明确认贪心策略的正确性
  • 当贪心失效时,考虑使用动态规划

4.3 最优装载问题

问题描述

有一批集装箱需要装船,每个集装箱有重量w[i],船的载重量为C。目标是装载尽可能多的集装箱。

注意 :与0-1背包问题不同,这里的目标是最大化数量而不是总重量。

示例

复制代码
集装箱重量:[5, 3, 7, 2, 8, 4, 1, 6]
船的载重量:20

解题思路

贪心策略

策略:按重量最轻优先装载

直观理解

  • 轻的集装箱占用空间小
  • 能装更多的集装箱
  • 类似于"能用小盒子就别用大盒子"
正确性证明

定理:按重量最轻优先装载的贪心策略能得到最优解。

证明(交换论证):

设贪心算法得到的解为 G = {g₁, g₂, ..., gₖ},按重量排序(w₁ ≤ w₂ ≤ ... ≤ wₖ)。

设最优解为 O = {o₁, o₂, ..., oₘ},按重量排序。

目标:证明 k = m(贪心解也是最优解)

证明

  1. 假设 k < m,即最优解装箱数更多
  2. 由于贪心算法每次选择最轻的,对于任意 i ≤ k,有 gᵢ ≤ oᵢ
  3. 因此 Σ(gᵢ) ≤ Σ(oᵢ) ≤ C
  4. 但是贪心算法在无法再装时停止,说明 oₖ₊₁ 也无法装入
  5. 这与 O 是可行解矛盾
  6. 因此 k = m,贪心解是最优的
与0-1背包的对比
特征 最优装载问题 0-1背包问题
目标 最大化数量 最大化总价值
物品属性 只有重量 重量+价值
贪心算法 适用 ✓ 不适用 ✗
算法选择 贪心 O(n log n) 动态规划 O(nW)

关键区别

  • 最优装载:所有物品的"价值"相同(数量+1),贪心有效
  • 0-1背包:物品有不同价值,贪心可能失效,需要DP

Python实现

python 复制代码
from typing import List, Tuple

def optimal_loading(weights: List[int], capacity: int) -> Tuple[int, List[int]]:
    """
    最优装载问题的贪心算法

    参数:
        weights: 集装箱重量列表
        capacity: 船的载重量

    返回:
        (装箱数量, 选择的集装箱重量)
    """
    # 按重量从小到大排序
    sorted_weights = sorted(weights)

    count = 0
    total_weight = 0
    selected = []

    print("贪心选择过程:")
    print("=" * 60)
    print(f"{'序号':<8} {'重量':<10} {'累计重量':<12} {'剩余容量':<10} {'决策':<10}")
    print("-" * 60)

    remaining = capacity
    for i, weight in enumerate(sorted_weights, 1):
        print(f"{i:<8} {weight:<10} {total_weight:<12} {remaining:<10}", end=" ")

        if remaining >= weight:
            selected.append(weight)
            total_weight += weight
            remaining -= weight
            count += 1
            print("装入 ✓")
        else:
            print("跳过 ✗(超出容量)")

    print("-" * 60)
    print(f"最终结果:装入 {count} 个集装箱,总重量 {total_weight}")

    return count, selected


# 示例用法
if __name__ == "__main__":
    # 示例1
    print("示例1:基本最优装载")
    print("-" * 50)

    weights = [5, 3, 7, 2, 8, 4, 1, 6]
    capacity = 20

    print(f"集装箱重量:{weights}")
    print(f"船的载重量:{capacity}\n")

    count, selected = optimal_loading(weights, capacity)
    print(f"\n装载方案:")
    print(f"  装箱数量:{count}")
    print(f"  集装箱重量:{selected}")
    print(f"  总重量:{sum(selected)}/{capacity}")

输出

复制代码
示例1:基本最优装载
--------------------------------------------------
集装箱重量:[5, 3, 7, 2, 8, 4, 1, 6]
船的载重量:20

贪心选择过程:
----------------------------------------------------
序号       重量        累计重量      剩余容量     决策
----------------------------------------------------
1        1          0            20         装入 ✓
2        2          1            19         装入 ✓
3        3          3            17         装入 ✓
4        4          6            14         装入 ✓
5        5          10           10         装入 ✓
6        6          15           5          跳过 ✗(超出容量)
7        7          15           5          跳过 ✗(超出容量)
8        8          15           5          跳过 ✗(超出容量)
----------------------------------------------------
最终结果:装入 5 个集装箱,总重量 15

装载方案:
  装箱数量:5
  集装箱重量:[1, 2, 3, 4, 5]
  总重量:15/20

复杂度分析

  • 时间复杂度:O(n log n)

    • 排序:O(n log n)
    • 贪心选择:O(n)
  • 空间复杂度:O(n) 或 O(1)


4.4 哈夫曼编码

问题描述

哈夫曼编码是一种前缀编码,用于数据压缩。目标是为字符集中的每个字符分配二进制编码,使得:

  1. 前缀性质:任何一个编码都不是另一个编码的前缀
  2. 高频字符的编码短,低频字符的编码长
  3. 平均编码长度最短

示例

复制代码
文本: "hello world"
字符频率: h=1, e=1, l=3, o=2, ' '=1, w=1, r=1, d=1

目标:构造最优前缀编码

解题思路

什么是前缀编码?

前缀编码:没有任何编码是另一个编码的前缀。

优点:无需分隔符就能唯一解码

示例

复制代码
定长编码:A=00, B=01, C=10, D=11(前缀编码)
变长编码:A=0, B=10, C=110, D=111(前缀编码)
非前缀编码:A=0, B=01(B的前缀是A,有歧义)
贪心策略

策略:频率最小的两个节点优先合并

算法步骤

  1. 统计每个字符的频率
  2. 将每个字符看作叶子节点,频率作为权重
  3. 反复选择两个频率最小的节点合并:
    • 创建新节点作为它们的父节点
    • 新节点的频率 = 两个子节点频率之和
  4. 重复步骤3,直到只剩一个节点(根节点)
  5. 从根到叶子的路径标记0和1,得到编码
为什么这个策略正确?

直观理解

  • 频率高的字符应该在树的较高层(编码短)
  • 频率低的字符应该在树的较低层(编码长)
  • 每次合并最小频率节点保证了这一点

严格证明

定理:哈夫曼编码生成的编码是所有前缀编码中平均长度最短的。

证明(归纳法和交换论证):

引理1:在最优前缀编码中,频率最低的两个字符必定是兄弟节点

引理2:合并两个最小频率节点后,子问题也是最优的

归纳基础:n=2时,显然哈夫曼编码是最优的

归纳步骤:对于n个字符,哈夫曼算法合并频率最低的x和y

  • 由引理1,任何最优编码中x和y都是兄弟
  • 合并x和y得到n-1个字符的问题
  • 由归纳假设,哈夫曼算法对这个子问题是最优的
  • 因此,原问题也是最优的
图解示例

以文本 "hello world" 为例:

复制代码
字符频率统计:
h: 1, e: 1, l: 3, o: 2, ' ': 1, w: 1, r: 1, d: 1

按频率排序:
d:1, h:1, e:1, ' ':1, w:1, r:1, o:2, l:3

构建过程:
步骤1: 合并 d(1) 和 h(1) → dh(2)
步骤2: 合并 e(1) 和 ' '(1) → e' '(2)
步骤3: 合并 w(1) 和 r(1) → wr(2)
步骤4: 合并 dh(2) 和 e' '(2) → dhe' '(4)
步骤5: 合并 wr(2) 和 o(2) → wro(4)
步骤6: 合并 dhe' '(4) 和 wro(4) → all(8)
步骤7: 合并 all(8) 和 l(3) → root(11)

最终哈夫曼树:
              root(11)
             /        \
         all(8)       l(3)
        /     \
    dhe'(4)  wro(4)
    /  \     /   \
  dh(2) e'(2) wr(2) o(2)
  / \   / \   / \
 d   h e  ' ' w r

编码结果:
l: 0
o: 100
r: 1010
w: 1011
d: 11000
h: 11001
e: 11010
' ': 11011

Python实现

python 复制代码
import heapq
from typing import Dict, List
from collections import Counter


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 is_leaf(self):
        """判断是否为叶子节点"""
        return self.left is None and self.right is None


def build_huffman_tree(frequency: Dict[str, int]):
    """构建哈夫曼树"""
    # 创建优先队列
    heap = []
    for char, freq in frequency.items():
        node = HuffmanNode(char=char, freq=freq)
        heapq.heappush(heap, node)

    print("\n构建哈夫曼树的过程:")
    print("=" * 60)
    step = 1

    # 合并节点
    while len(heap) > 1:
        left = heapq.heappop(heap)
        right = heapq.heappop(heap)

        print(f"步骤{step}: 合并 {left.char or '内'}({left.freq}) + "
              f"{right.char or '内'}({right.freq}) = {left.freq + right.freq}")

        merged = HuffmanNode(
            freq=left.freq + right.freq,
            left=left,
            right=right
        )
        heapq.heappush(heap, merged)
        step += 1

    print("=" * 60)
    return heap[0]


def generate_codes(root: HuffmanNode, current_code: str = "",
                   codes: Dict[str, str] = None) -> Dict[str, str]:
    """从哈夫曼树生成编码"""
    if codes is None:
        codes = {}

    if root is None:
        return codes

    if root.is_leaf():
        codes[root.char] = current_code if current_code else "0"
        return codes

    generate_codes(root.left, current_code + "0", codes)
    generate_codes(root.right, current_code + "1", codes)

    return codes


def huffman_encode(text: str, codes: Dict[str, str]) -> str:
    """使用哈夫曼编码对文本编码"""
    return ''.join(codes[char] for char in text)


def huffman_decode(encoded: str, root: HuffmanNode) -> str:
    """使用哈夫曼树对编码解码"""
    result = []
    current = root

    for bit in encoded:
        if bit == '0':
            current = current.left
        else:
            current = current.right

        if current.is_leaf():
            result.append(current.char)
            current = root

    return ''.join(result)


# 示例用法
if __name__ == "__main__":
    text = "hello world"
    frequency = Counter(text)

    print(f"原始文本: '{text}'")
    print(f"字符频率: {dict(frequency)}")

    # 构建哈夫曼树
    root = build_huffman_tree(frequency)

    # 生成编码
    codes = generate_codes(root)
    print("\n哈夫曼编码表:")
    print("=" * 50)
    for char, code in sorted(codes.items(), key=lambda x: len(x[1])):
        print(f"'{char}': {code} (长度: {len(code)})")
    print("=" * 50)

    # 编码和解码
    encoded = huffman_encode(text, codes)
    print(f"\n编码结果: {encoded}")

    decoded = huffman_decode(encoded, root)
    print(f"解码结果: '{decoded}'")
    print(f"正确性: {'✓ 正确' if decoded == text else '✗ 错误'}")

    # 压缩率
    original_bits = len(text) * 8
    compressed_bits = len(encoded)
    print(f"\n压缩统计:")
    print(f"  原始比特数: {original_bits}")
    print(f"  压缩比特数: {compressed_bits}")
    print(f"  压缩比: {compressed_bits / original_bits:.2%}")
    print(f"  节省空间: {(1 - compressed_bits / original_bits) * 100:.1f}%")

输出

复制代码
原始文本: 'hello world'
字符频率: {'h': 1, 'e': 1, 'l': 3, 'o': 2, ' ': 1, 'w': 1, 'r': 1, 'd': 1}

构建哈夫曼树的过程:
============================================================
步骤1: 合并 d(1) + h(1) = 2
步骤2: 合并 e(1) +  (1) = 2
步骤3: 合并 w(1) + r(1) = 2
步骤4: 合并 内(2) + 内(2) = 4
步骤5: 合并 内(2) + o(2) = 4
步骤6: 合并 内(4) + 内(4) = 8
步骤7: 合并 内(8) + l(3) = 11
============================================================

哈夫曼编码表:
==================================================
'l': 0 (长度: 1)
'o': 100 (长度: 3)
'w': 1011 (长度: 4)
'r': 1010 (长度: 4)
'h': 11001 (长度: 5)
'e': 11010 (长度: 5)
' ': 11011 (长度: 5)
'd': 11000 (长度: 5)
==================================================

编码结果: 11001110101101001001101001111110100
解码结果: 'hello world'
正确性: ✓ 正确

压缩统计:
  原始比特数: 88
  压缩比特数: 37
  压缩比: 42.05%
  节省空间: 58.0%

复杂度分析

  • 时间复杂度:O(n log n)

    • 堆操作:n次插入,n次删除,每次O(log n)
  • 空间复杂度:O(n)

    • 哈夫曼树:O(n)
    • 编码表:O(n)

应用实例

  • ZIP压缩:使用DEFLATE算法(哈夫曼编码 + LZ77)
  • JPEG:图像压缩中的编码步骤
  • MP3:音频压缩中的编码步骤

4.5 单源最短路径

问题描述

给定带权有向图G = (V, E)和源点s,求从s到图中所有其他顶点的最短路径。

约束:所有边权必须非负

示例

复制代码
图:
    A(0)
   / | \
  4  2  1
 /   |   \
B    C    D
|    |    |
3    3    2
|    |    |
E    F    G

求:从A到所有其他顶点的最短路径

解题思路

Dijkstra算法的贪心策略

策略:选择距离估计最小的未访问顶点

算法步骤

  1. 初始化:d[s] = 0,其他d[v] = ∞
  2. 维护两个集合:已确定顶点集S,未确定顶点集V-S
  3. 重复以下步骤,直到S = V:
    • 从V-S中选择d值最小的顶点u,加入S
    • 松弛u的所有出边(v, u):
      • 如果d[u] + w(u, v) < d[v],更新d[v]

为什么这个策略正确?

  • 边权非负保证了:一旦顶点加入S,其距离就是最终的
  • 贪心选择(最小距离估计)每次都能确定一个顶点的最短路径
正确性证明

定理:Dijkstra算法能正确计算单源最短路径。

证明(归纳法):

不变量

  1. 对于所有已确定顶点 u ∈ S,d[u] 是从 s 到 u 的最短距离
  2. 对于所有未确定顶点 v ∈ V-S,d[v] 是从 s 到 v 且只经过 S 中顶点的最短路径长度

归纳基础:初始时 S = {s},d[s] = 0 显然正确

归纳步骤:设 u 是选择的顶点(d[u] 最小)。

  • 假设存在更短的路径 P 从 s 到 u
  • 路径 P 必须经过某个顶点 x ∈ V-S
  • 由于边权非负,d[x] ≤ P 的长度 < d[u]
  • 但 d[u] 是 V-S 中的最小距离,矛盾
  • 因此,d[u] 是最短距离

Python实现

python 复制代码
import heapq
from typing import Dict, List, Tuple, Set
from collections import defaultdict


class Graph:
    """图类(邻接表表示)"""
    def __init__(self):
        self.adj = defaultdict(list)

    def add_edge(self, u: str, v: str, weight: int, directed: bool = False):
        """添加边"""
        self.adj[u].append((v, weight))
        if not directed:
            self.adj[v].append((u, weight))

    def vertices(self) -> Set[str]:
        """获取所有顶点"""
        vertices = set(self.adj.keys())
        for u in self.adj:
            for v, _ in self.adj[u]:
                vertices.add(v)
        return vertices


def dijkstra(graph: Graph, start: str) -> Tuple[Dict[str, float], Dict[str, str]]:
    """
    Dijkstra算法实现

    参数:
        graph: 图对象
        start: 源点

    返回:
        (距离字典, 前驱字典)
    """
    vertices = graph.vertices()
    distances = {v: float('infinity') for v in vertices}
    predecessors = {v: None for v in vertices}
    distances[start] = 0

    # 优先队列:(距离, 顶点)
    pq = [(0, start)]
    visited = set()

    print("Dijkstra算法执行过程:")
    print("=" * 70)
    print(f"{'步骤':<6} {'当前顶点':<10} {'距离':<8} {'操作':<40}")
    print("-" * 70)

    step = 1
    while pq:
        current_dist, current = heapq.heappop(pq)

        if current in visited:
            continue

        visited.add(current)

        print(f"{step:<6} {current:<10} {current_dist:<8}", end=" ")

        # 松弛操作
        relaxed = False
        for neighbor, weight in graph.adj[current]:
            if neighbor in visited:
                continue

            new_dist = current_dist + weight
            if new_dist < distances[neighbor]:
                distances[neighbor] = new_dist
                predecessors[neighbor] = current
                heapq.heappush(pq, (new_dist, neighbor))
                if not relaxed:
                    print(f"松弛 {current}→{neighbor}({weight})")
                    relaxed = True

        if not relaxed:
            print("无可松弛边")

        step += 1

    print("-" * 70)
    return distances, predecessors


def reconstruct_path(predecessors: Dict[str, str], start: str, end: str) -> List[str]:
    """重构最短路径"""
    path = []
    current = end

    while current is not None:
        path.append(current)
        current = predecessors[current]

    path.reverse()

    if path[0] == start:
        return path
    return None


# 示例用法
if __name__ == "__main__":
    graph = Graph()
    graph.add_edge('A', 'B', 4)
    graph.add_edge('A', 'C', 2)
    graph.add_edge('B', 'C', 1)
    graph.add_edge('B', 'D', 5)
    graph.add_edge('C', 'D', 8)
    graph.add_edge('C', 'E', 10)
    graph.add_edge('D', 'E', 2)

    print("图的邻接表:")
    for u in sorted(graph.adj.keys()):
        edges = ', '.join([f"{v}({w})" for v, w in graph.adj[u]])
        print(f"  {u} → [{edges}]")

    distances, predecessors = dijkstra(graph, 'A')

    print("\n算法结果:")
    print("=" * 70)
    print(f"{'顶点':<10} {'最短距离':<12} {'路径'}")
    print("-" * 70)

    for v in sorted(distances.keys()):
        if distances[v] == float('infinity'):
            path = "不可达"
        else:
            path = reconstruct_path(predecessors, 'A', v)
            path_str = ' → '.join(path)
        print(f"{v:<10} {distances[v]:<12} {path_str}")

    print("=" * 70)

输出

复制代码
图的邻接表:
  A → [B(4), C(2)]
  B → [C(1), D(5)]
  C → [B(1), D(8), E(10)]
  D → [B(5), C(8), E(2)]
  E → [C(10), D(2)]

Dijkstra算法执行过程:
======================================================================
步骤     当前顶点    距离      操作
----------------------------------------------------------------------
1        A          0         松弛 A→B(4)
2        C          2         松弛 C→B(1)
3        B          3         松弛 B→D(5)
4        E          5         无可松弛边
5        D          8         无可松弛边
----------------------------------------------------------------------

算法结果:
======================================================================
顶点        最短距离     路径
----------------------------------------------------------------------
A          0           A
B          3           A → C → B
C          2           A → C
D          8           A → C → B → D
E          5           A → C → D → E
======================================================================

复杂度分析

  • 时间复杂度:O((V + E) log V)

    • 使用优先队列(二叉堆)
    • 每个顶点入堆一次:O(V log V)
    • 每条边可能导致一次松弛:O(E log V)
  • 空间复杂度:O(V + E)

与其他算法对比

算法 时间复杂度 边权限制 特点
Dijkstra O((V+E)log V) 非负 最常用,贪心
Bellman-Ford O(VE) 可负 可检测负环
Floyd-Warshall O(V³) 可负 全源最短路径

4.6 最小生成树

问题描述

给定带权无向连通图G = (V, E),求一棵生成树T,使得T中所有边的权值之和最小。

示例

复制代码
图:
    A---4---B
    |\       |
    2 1      3
    |   \    |
    C---5---D

求:最小生成树

解题思路

最小生成树的性质

割性质(Cut Property):对于图的任意割,横跨该割的最小权边必属于某个MST

环性质(Cycle Property):对于图中的任意环,该环中权值最大的边必不属于任何MST

Prim算法

贪心策略:从已选顶点集选择最小权值边

算法步骤

  1. 从任意顶点开始,将其加入已选顶点集 S
  2. 重复以下步骤,直到 S = V:
    • 在所有横跨割 (S, V-S) 的边中,选择权值最小的边 (u, v)
    • 将 v 和边 (u, v) 加入 MST

复杂度:O((V + E) log V)

Kruskal算法

贪心策略:全局选择最小权值边

算法步骤

  1. 将所有边按权值从小到大排序
  2. 初始化空边集 T
  3. 依次检查每条边 (u, v):
    • 如果 u 和 v 不连通,将边加入 T
    • 否则,跳过(会形成环)
  4. 直到 |T| = |V| - 1

复杂度:O(E log E)

两种算法对比
特性 Prim算法 Kruskal算法
策略 顶点驱动 边驱动
数据结构 优先队列 并查集
适用图 稠密图 稀疏图
中间结果 始终连通 可能不连通

Python实现

python 复制代码
import heapq
from typing import Dict, List, Tuple, Set
from collections import defaultdict


class Graph:
    """图类"""
    def __init__(self):
        self.adj = defaultdict(list)

    def add_edge(self, u: str, v: str, weight: int):
        """添加无向边"""
        self.adj[u].append((v, weight))
        self.adj[v].append((u, weight))

    def edges(self) -> List[Tuple[int, str, str]]:
        """获取所有边"""
        edges = set()
        for u in self.adj:
            for v, w in self.adj[u]:
                if u < v:  # 避免重复
                    edges.add((w, u, v))
        return sorted(edges)


class UnionFind:
    """并查集(带路径压缩和按秩合并)"""
    def __init__(self, vertices: Set[str]):
        self.parent = {v: v for v in vertices}
        self.rank = {v: 0 for v in vertices}

    def find(self, x: str) -> str:
        """查找(带路径压缩)"""
        if self.parent[x] != x:
            self.parent[x] = self.find(self.parent[x])
        return self.parent[x]

    def union(self, x: str, y: str) -> bool:
        """合并(返回是否成功)"""
        root_x = self.find(x)
        root_y = self.find(y)

        if root_x == root_y:
            return False

        # 按秩合并
        if self.rank[root_x] < self.rank[root_y]:
            self.parent[root_x] = root_y
        elif self.rank[root_x] > self.rank[root_y]:
            self.parent[root_y] = root_x
        else:
            self.parent[root_y] = root_x
            self.rank[root_x] += 1

        return True


def prim_mst(graph: Graph, start: str) -> Tuple[List[Tuple[str, str, int]], int]:
    """Prim算法实现"""
    pq = [(0, start, None)]
    visited = set()
    mst_edges = []
    total_weight = 0

    print("\nPrim算法执行过程:")
    print("-" * 60)

    while pq and len(visited) < len(graph.adj):
        weight, current, from_vertex = heapq.heappop(pq)

        if current in visited:
            continue

        visited.add(current)

        if from_vertex is not None:
            mst_edges.append((from_vertex, current, weight))
            total_weight += weight
            print(f"添加边: {from_vertex}-{current} (权值:{weight}), 累计:{total_weight}")

        for neighbor, edge_weight in graph.adj[current]:
            if neighbor not in visited:
                heapq.heappush(pq, (edge_weight, neighbor, current))

    return mst_edges, total_weight


def kruskal_mst(graph: Graph) -> Tuple[List[Tuple[str, str, int]], int]:
    """Kruskal算法实现"""
    edges = graph.edges()
    vertices = set(graph.adj.keys())
    for u in graph.adj:
        for v, _ in graph.adj[u]:
            vertices.add(v)

    uf = UnionFind(vertices)
    mst_edges = []
    total_weight = 0

    print("\nKruskal算法执行过程:")
    print("-" * 60)

    for weight, u, v in edges:
        if uf.union(u, v):
            mst_edges.append((u, v, weight))
            total_weight += weight
            print(f"选择边: {u}-{v} (权值:{weight}), 累计:{total_weight}")

            if len(mst_edges) == len(vertices) - 1:
                break
        else:
            print(f"跳过边: {u}-{v} (形成环)")

    return mst_edges, total_weight


# 示例用法
if __name__ == "__main__":
    graph = Graph()
    graph.add_edge('A', 'B', 4)
    graph.add_edge('A', 'C', 4)
    graph.add_edge('B', 'C', 2)
    graph.add_edge('B', 'D', 3)
    graph.add_edge('C', 'D', 5)
    graph.add_edge('C', 'E', 2)
    graph.add_edge('D', 'E', 1)

    print("图的邻接表:")
    for u in sorted(graph.adj.keys()):
        edges = ', '.join([f"{v}({w})" for v, w in graph.adj[u]])
        print(f"  {u} → [{edges}]")

    # Prim算法
    prim_edges, prim_weight = prim_mst(graph, 'A')
    print(f"\nPrim算法结果:")
    print(f"  MST边数:{len(prim_edges)}")
    print(f"  总权值:{prim_weight}")

    # Kruskal算法
    kruskal_edges, kruskal_weight = kruskal_mst(graph)
    print(f"\nKruskal算法结果:")
    print(f"  MST边数:{len(kruskal_edges)}")
    print(f"  总权值:{kruskal_weight}")

复杂度分析

Prim算法

  • 时间复杂度:O((V + E) log V)
  • 空间复杂度:O(V + E)

Kruskal算法

  • 时间复杂度:O(E log E)
  • 空间复杂度:O(V)

4.7 多机调度问题

问题描述

有n个作业需要分配给m台相同的机器并行处理,每个作业i有处理时间t[i]。目标是最小化所有作业的最大完成时间(makespan)。

注意:这是一个NP难问题,因此讨论近似算法。

示例

复制代码
作业时间: [8, 7, 6, 5, 4, 3, 2, 1]
机器数量: 3

求:最小化最大完成时间

解题思路

LPT算法

贪心策略:Longest Processing Time first(长作业优先)

算法步骤

  1. 将作业按处理时间从大到小排序
  2. 依次分配给当前负载最小的机器

近似比:4/3 - 1/(3m)

正确性分析

LPT算法不能保证得到最优解,但可以保证近似解不超过最优解的4/3倍。

定理:LPT算法的近似比为 4/3 - 1/(3m)。

Python实现

python 复制代码
import heapq
from typing import List, Tuple


def lpt_schedule(jobs: List[int], m: int) -> Tuple[List[List[int]], int]:
    """
    LPT算法求解多机调度问题

    参数:
        jobs: 作业处理时间列表
        m: 机器数量

    返回:
        (调度方案, makespan)
    """
    # 按处理时间从大到小排序
    sorted_jobs = sorted(jobs, reverse=True)

    # 最小堆:(机器负载, 机器编号)
    heap = [(0, i) for i in range(m)]
    heapq.heapify(heap)

    # 调度结果
    schedules = [[] for _ in range(m)]

    print(f"\nLPT算法执行过程({m}台机器):")
    print("-" * 60)

    for job_time in sorted_jobs:
        load, machine = heapq.heappop(heap)
        schedules[machine].append(job_time)
        new_load = load + job_time
        heapq.heappush(heap, (new_load, machine))

        print(f"作业{job_time} → 机器{machine+1} (负载:{load}→{new_load})")

    # 计算makespan
    makespan = max(load for load, _ in heap)

    print("-" * 60)
    return schedules, makespan


# 示例用法
if __name__ == "__main__":
    jobs = [8, 7, 6, 5, 4, 3, 2, 1]
    m = 3

    print(f"作业时间: {jobs}")
    print(f"机器数量: {m}")

    schedules, makespan = lpt_schedule(jobs, m)

    print(f"\n最终调度:")
    for i, schedule in enumerate(schedules, 1):
        total = sum(schedule)
        jobs_str = ' + '.join(map(str, schedule))
        print(f"  机器{i}: {jobs_str} = {total}")

    print(f"\nMakespan: {makespan}")

复杂度分析

  • 时间复杂度:O(n log n)

    • 排序:O(n log n)
    • 堆操作:O(n log m)
  • 空间复杂度:O(n + m)


练习题

概念题

  1. 什么是贪心选择性质?它与动态规划的最优子结构性质有什么区别?

  2. 为什么活动安排问题可以按结束时间排序贪心选择,而不能按开始时间排序?

  3. 哈夫曼编码为什么能保证是最优前缀编码?

  4. Dijkstra算法为什么要求边权非负?如果有负边权会怎样?

  5. 最小生成树的割性质和环性质分别是什么?如何用于证明算法正确性?

证明题

  1. 证明最优装载问题中,按重量最轻优先装载的贪心策略能获得最优解。

  2. 证明Prim算法的正确性(使用割性质)。

  3. 证明Kruskal算法的正确性(使用环性质)。

  4. 证明LPT算法的近似比为4/3 - 1/(3m)。

  5. 证明哈夫曼编码生成的编码树是最优的。

编程题

  1. 活动安排问题:给定n个活动的开始和结束时间,求最多能安排多少个相容活动。

  2. 最优装载问题:给定n个集装箱的重量和船的载重量,求最多能装多少个集装箱。

  3. 哈夫曼编码:给定一段文本,构造哈夫曼编码树,计算压缩率。

  4. Dijkstra算法:实现Dijkstra算法,并输出从源点到所有其他顶点的最短路径。

  5. 最小生成树:分别实现Prim算法和Kruskal算法,对比它们的性能。


本章小结

贪心算法的两个基本要素

  1. 贪心选择性质:可以通过做出局部最优选择来达到全局最优
  2. 最优子结构性质:问题的最优解包含子问题的最优解

贪心算法 vs 动态规划

特征 贪心算法 动态规划
决策方式 局部最优选择 自底向上求解
是否回溯 不回溯 可能回溯
子问题求解 只求解必要的 求解所有子问题
适用条件 贪心选择性质 最优子结构+重叠
时间复杂度 通常较低 可能较高
空间复杂度 通常较低 可能需要DP表
适用范围 较窄 较广

经典贪心算法总结

算法 问题类型 贪心策略 时间复杂度
活动安排 调度问题 结束时间最早优先 O(n log n)
最优装载 装载问题 重量最轻优先 O(n log n)
哈夫曼编码 数据压缩 频率最小节点优先合并 O(n log n)
Dijkstra 最短路径 距离最小顶点优先 O((V+E)log V)
Prim 最小生成树 最小权值边优先 O((V+E)log V)
Kruskal 最小生成树 全局最小边优先 O(E log E)
LPT 多机调度 长作业优先 O(n log n)

关键要点

  1. 贪心选择性质是贪心算法可行性的关键
  2. 最优子结构性质使得可以递归地构造最优解
  3. 贪心策略的正确性需要通过归纳法、反证法或交换论证来证明
  4. 贪心算法通常具有更低的时间和空间复杂度
  5. 对于NP难问题,贪心算法可以作为近似算法

学习建议

  1. 理解每个问题的贪心策略为什么有效
  2. 掌握贪心策略正确性的证明方法
  3. 通过练习识别哪些问题适合用贪心算法
  4. 对比贪心算法与动态规划的适用场景
  5. 实现并运行贪心算法,加深理解
相关推荐
TracyCoder1232 小时前
LeetCode Hot100(10/100)—— 53. 最大子数组和
算法·leetcode
Σίσυφος19003 小时前
霍夫变换vs LS vs RANSAC 拟合直线 MATLAB实现
算法·计算机视觉·matlab
假女吖☌3 小时前
限流算法-redis实现与java实现
java·redis·算法
蒟蒻的贤3 小时前
两数之和。
算法
wen__xvn3 小时前
代码随想录算法训练营DAY27第八章 贪心算法 part01
算法·贪心算法
We་ct3 小时前
LeetCode 125. 验证回文串:双指针解法全解析与优化
前端·算法·leetcode·typescript
客卿1233 小时前
力扣20-有效括号(多家面试题)
算法·leetcode·职场和发展
木井巳3 小时前
【递归算法】快速幂解决 pow(x,n)
java·算法·leetcode·深度优先
Maỿbe4 小时前
重走力扣hot的矩阵
算法·leetcode·矩阵