cv.cm

编码 · 算法

贪心算法详解:原理、代码与示例

阅读约 6 分钟

简短回答

贪心算法一步步构造解,每一步都选择当下看起来最好的选项,并且不回头修改之前的选择。它快速而简单,但只有在具有贪心选择性质的问题上才是正确的,即局部最优的选择总是某个最优解的一部分。经典的正确贪心算法包括区间调度、哈夫曼编码、Dijkstra 算法以及 Kruskal 和 Prim 最小生成树算法。

贪心算法解决什么问题

贪心算法针对的是优化问题:安排最多的活动、用最少的硬币、搭建成本最低的网络、把文件压缩成最少的比特。贪心算法不会穷举所有组合,而是先对选项排序或设定优先级,然后在每一步都坚定地选择最好的那个。时间复杂度通常是 O(n log n),往往就是排序的开销。

难点不在代码,而在证明。许多看上去自然的贪心规则其实是错的,所以你要么证明规则正确,要么用暴力解法对它进行测试。

用大白话讲思路

  1. 找一条给选项排序的规则,例如“结束时间最早的优先”或“最轻的边优先”。
  2. 按这条规则对选项排序,或把它们放进优先队列。
  3. 依次检查各选项,只要约束仍然允许,就选取它。
  4. 绝不撤销已做的选择。

当两个性质同时成立时,贪心算法才是正确的。贪心选择性质:存在某个最优解包含第一步的贪心选择。最优子结构:做出该选择后,剩下的部分是同一问题的更小实例。常见的证明方法是交换论证:取任意一个最优解,证明把其中某个选择换成贪心选择并不会使结果变差。

实例:区间调度

你只有一间会议室,收到以下申请(开始, 结束):A (1, 4)、B (3, 5)、C (0, 6)、D (5, 7)、E (3, 9)、F (5, 9)、G (6, 10)、H (8, 11)。要预订尽可能多的互不重叠的会议。正确的贪心规则是:在开始时间不早于上一场已预订会议结束时间的会议中,始终选择结束最早的那个。

会议(按结束时间排序)开始, 结束开始时间不早于上次结束?决定上次结束
A1, 4是(尚未预订任何会议)预订4
B3, 5否(3 < 4)跳过4
C0, 6否跳过4
D5, 7是预订7
E3, 9否跳过7
F5, 9否跳过7
G6, 10否跳过7
H8, 11是预订11

结果:A、D、H,共三场会议,这已是最大值。原理:结束最早的会议为其他所有会议留下最多的时间,所以把任意最优日程中的第一场会议换成它,永远不会引起冲突。

Python 实现

def max_meetings(meetings):
    """meetings: list of (name, start, end). Returns the booked names."""
    booked, last_end = [], float("-inf")
    for name, start, end in sorted(meetings, key=lambda m: m[2]):
        if start >= last_end:
            booked.append(name)
            last_end = end
    return booked

requests = [("A", 1, 4), ("B", 3, 5), ("C", 0, 6), ("D", 5, 7),
            ("E", 3, 9), ("F", 5, 9), ("G", 6, 10), ("H", 8, 11)]
print(max_meetings(requests))  # ['A', 'D', 'H']

第二个经典例子是分数背包:当可以只取物品的一部分时,按单位重量的价值从高到低取物品是最优的。

def fractional_knapsack(items, capacity):
    """items: list of (value, weight). Returns the best total value."""
    total = 0.0
    for value, weight in sorted(items, key=lambda i: i[0] / i[1], reverse=True):
        take = min(weight, capacity)
        total += value * take / weight
        capacity -= take
        if capacity == 0:
            break
    return total

print(fractional_knapsack([(60, 10), (100, 20), (120, 30)], 50))  # 240.0

贪心失效时:反例

问题看似合理的贪心规则反例正确的方法
零钱兑换,硬币面值 1、3、4先取面值最大的硬币金额 6:贪心得 4 + 1 + 1(3 枚),最优为 3 + 3(2 枚)动态规划
0/1 背包(物品不可拆分)先取单位重量价值最高的容量 50,物品 (60, 10)、(100, 20)、(120, 30):贪心取 60 + 100 = 160,最优为 100 + 120 = 220动态规划
区间调度先选最短的会议(0, 5)、(4, 7)、(6, 11):贪心只取 (4, 7),最优是取另外两个结束最早的优先
含负权边的最短路径Dijkstra 的离起点最近的节点优先任何一张存在负权边、会让已确定节点变得更便宜的图Bellman-Ford

时间与空间复杂度

算法时间原因
区间调度O(n log n)按结束时间排序;扫描为 O(n)。
分数背包O(n log n)按单位重量的价值排序。
哈夫曼编码O(n log n)n − 1 次合并,每次涉及堆操作。
Kruskal 最小生成树O(E log E)对边排序,加上近乎常数时间的并查集。
Dijkstra 最短路径O((V + E) log V)基于堆的优先队列。

空间复杂度通常是 O(n),用于排序后的副本或堆。如果输入已经有序,许多贪心算法只需 O(n)。

何时使用贪心及实际应用

  • 当你能证明贪心选择性质,或可以接受快速的近似解时,使用贪心。否则请使用动态规划或回溯。
  • 压缩:哈夫曼编码通过反复合并出现频率最低的两个符号,构造出最优前缀码;DEFLATE(ZIP、gzip、PNG)等格式内部就用到了它。
  • 网络:最小生成树(Kruskal、Prim)和最短路径路由(Dijkstra)。
  • 调度:最早截止时间优先,以及预订房间或机器。
  • 近似算法:对集合覆盖问题,贪心规则“选择覆盖最多未覆盖元素的集合”并不最优,但可以保证与最优解相差不超过一个对数因子。
  • 面试题:跳跃游戏、加油站、分发饼干、无重叠区间、用最少数量的箭引爆气球、任务调度器。

常见问题

用简单的话说,什么是贪心算法?

它是一种总是选择当下看起来最好的选项且从不重新考虑的算法。只有当这些局部选择总能带来全局最优答案时,它才是正确的。

如何证明贪心算法是正确的?

常用方法是交换论证:从任意一个最优解出发,证明可以把它的第一个选择换成贪心选择而不使结果变差,然后对剩余的问题重复这一过程。

该选贪心还是动态规划?

如果觉得某条简单规则可行,先试试贪心,再去找反例。如果找到反例,就说明该问题需要动态规划,它会对每个子问题比较所有选择。

Dijkstra 算法是贪心算法吗?

是的。它总是确定离起点最近的未确定节点。由于边权非负,这个贪心选择是正确的。

全部编码教程