CC++ & Algorithm

贪心算法思想:每次选最好的

困难6
语言版本:C++Python
概述:贪心算法就像吃自助餐时每次拿最爱吃的菜,通过每一步都选当前最优解,最终得到整体不错的方案。

贪心算法:每次选当前最好的策略

什么是贪心算法?

想象你正在吃自助餐——餐台上摆着鸡腿、薯条、蛋糕、水果……你每次只能拿一种食物,而且只能拿一次。贪心算法的思路很简单:每一轮都从剩下的食物中选你最喜欢的那一样。第一轮最爱鸡腿,就拿鸡腿;第二轮最爱薯条,就拿薯条;第三轮最爱蛋糕,就拿蛋糕。这样一步步选下去,虽然最后拿到的可能不是营养最均衡的组合,但每一步你都选了当时觉得最好的。

在编程中,贪心算法就是每一步都选择当前看起来最优的方案,希望最后能得到整个问题的最优解。它从不回头考虑前面的选择是否会影响后续,眼睛只盯着当下的利益。

贪心算法最大的优点是简单、高效——通常只需要遍历一次数据,不需要复杂的回溯或递归。很多实际问题都可以用贪心快速得到一个“足够好”的答案,甚至在一些问题上它就是全局最优解。

贪心算法的两大条件(什么时候能用)

不是所有问题都适合用贪心。只有满足以下两个条件时,贪心才可能得到正确的最优解:

  1. 最优子结构:一个问题的最优解包含其子问题的最优解。也就是说,如果你把大问题拆成小问题,先解决小问题,再把小问题的最优解拼起来,就能得到大问题的最优解。
  2. 贪心选择性质:每一步的局部最优选择,最终能导致全局最优解。换句话说,你不需要考虑以后的选择,现在选最好的就行,以后的选择可以独立进行。

如果问题只满足第一条但不满足第二条,贪心可能失败(比如我们后面会看到的硬币找零反例)。所以在实际使用贪心前,最好先想一想:“我选当前最好的,以后还有机会弥补吗?”

生活中的贪心例子(不只是找零钱)

例子1:排队打饭选最短的队伍

食堂打饭时,你看到三条队伍分别排了5人、3人、7人。贪心选择:站到人数最少(当前最好)的队伍,即3人那队。虽然以后可能有更多人加入,或者队伍前进速度不一样,但至少当下这是最快的选择。

例子2:周末选最喜欢的活动

周末有多个活动,时间可能冲突。你想参加尽可能多的活动。贪心策略:每次选结束时间最早的活动(这样能留出更多时间给后面的活动)。比如活动时间:画画(9:00-10:00)、打球(9:30-10:30)、看书(10:00-11:00)。按结束时间排序:画画(10:00)、打球(10:30)、看书(11:00)。先选画画(结束最早),然后从10:00开始,剩下能选的是看书(10:00开始)。贪心得到画画和看书(2个),而如果先选打球,就只剩1个活动。这个例子中贪心恰好得到了最优解。

例子3:找零钱(原文例子,已保留)

假设你要用最少硬币凑出11元,有1元、2元、5元、10元硬币。贪心做法:每次选面值最大的硬币。先选10元(剩下1元),再选1元(剩下0元)。共2枚。如果选5元+5元+1元则需要3枚。贪心在这里成功了。

但是,如果硬币面额改为1元、3元、4元,要凑出6元:贪心先选4元(剩下2元),然后选1元+1元(共3枚),而最优解是3元+3元(2枚)。所以贪心不总是正确。只有硬币面额满足某种规律(比如货币系统中后面面额是前面面额的倍数)时,贪心才保证最优。人民币的面额(1,2,5,10,20,50,100)就满足这种规律,所以日常找零用贪心没问题。

Python实现:两个经典例子

例1:找零钱(扩展原文代码)

下面的代码演示了用贪心算法求最少硬币数(假设硬币面额满足贪心正确性,如人民币面额)。注意代码里添加了详细的注释和错误处理。

def greedy_change(coins, amount):
    """
    贪心找零:使用面额从大到小排序的硬币凑成amount元
    :param coins: list[int] 硬币面额列表(不要求排序)
    :param amount: int 需要凑的金额
    :return: list[int] 选择的硬币列表
    """
    # 从大到小排序,确保先选最大面额
    coins.sort(reverse=True)  # 降序排列
    result = []               # 存储选中的硬币
    for coin in coins:
        # 能取多少个当前面额的硬币
        while amount >= coin:
            amount -= coin         # 减去已选的面额
            result.append(coin)   # 记录选择的硬币
    # 如果最后amount不为0,说明无法凑出(但本题假定硬币组合能凑出)
    if amount != 0:
        print("警告:无法用给定硬币凑出目标金额")
    return result

# 测试
coins = [10, 5, 2, 1]      # 人民币常用面额
money = 11                 # 需要找零11元
change = greedy_change(coins, money)
print("找零硬币:", change)   # 输出 [10, 1]
print("硬币数量:", len(change))  # 输出 2

新手常见错误

  • 忘记对硬币排序,直接循环,会导致选不到最大面额,结果不是贪心。
  • 认为贪心在所有硬币组合下都正确。实际需要先验证问题是否满足贪心选择性质。

例2:活动选择问题(选出最多的不冲突活动)

这是贪心算法的经典问题。假设你有以下一些活动,每个活动有开始时间和结束时间,你想参加尽可能多的活动,且不能同时参加两个(时间冲突)。贪心策略:每次选结束时间最早的活动,然后抛弃所有与它冲突的活动,继续选下一个结束最早的。

# 活动选择:选最多不冲突的活动
def activity_selection(activities):
    """
    :param activities: list of (start, end) 每个活动用元组(开始, 结束)表示
    :return: list of int 选中的活动编号(按原始输入顺序)
    """
    # 按结束时间从小到大排序(关键!)
    # 同时保留每个活动原来的索引,方便后续输出
    indexed = [(i, start, end) for i, (start, end) in enumerate(activities)]
    indexed.sort(key=lambda x: x[2])  # 按结束时间排序

    selected = []            # 存放选中的活动编号
    last_end_time = 0        # 上一个选中活动的结束时间,初始为0

    for idx, start, end in indexed:
        # 如果当前活动的开始时间 >= 上一个活动的结束时间,则不冲突
        if start >= last_end_time:
            selected.append(idx)      # 选中这个活动
            last_end_time = end       # 更新结束时间

    return selected

# 测试:一些活动
activities = [
    (9, 10),   # 活动0: 9点到10点
    (9, 11),   # 活动1: 9点到11点
    (10, 12),  # 活动2: 10点到12点
    (11, 12),  # 活动3: 11点到12点
]
chosen = activity_selection(activities)
print("选中的活动编号:", chosen)  # 输出 [0, 3](先选活动0,然后活动3)
# 还可以改为打印活动内容
for idx in chosen:
    s, e = activities[idx]
    print(f"活动{idx}: {s}:00 - {e}:00")

注意:这里我们先排序再选择,排序需要O(n log n),但后续贪心只需要O(n)。对于学生来说,可以把这个例子想象成周末选自己喜欢的兴趣班,每节课不能冲突。

完整可运行示例:找零钱 + 活动选择(整合在一个脚本中)

下面是一个完整的Python脚本,包含了上述两个例子,并添加了错误提示。你可以直接复制运行。

# 完整示例:贪心算法两个经典应用

# ----- 1. 找零钱问题 -----
def greedy_change(coins, amount):
    # coins: 硬币面额列表(不需要排序)
    # amount: 需要凑的金额
    coins.sort(reverse=True)  # 降序排序
    result = []               # 记录选中的硬币
    for coin in coins:
        while amount >= coin:
            amount -= coin
            result.append(coin)
    if amount != 0:
        print("注意:无法用给定硬币凑出目标金额")
    return result

# 测试找零钱
coins = [5, 2, 10, 1]         # 人民币常用面额,故意乱序
money = 11
change = greedy_change(coins, money)
print("找零问题:")
print("硬币面额:", sorted(coins, reverse=True))
print("目标金额:", money)
print("方案:", change)
print("数量:", len(change))
print()

# ----- 2. 活动选择问题 -----
def activity_selection(activities):
    # 给每个活动加原始索引
    indexed = [(i, s, e) for i, (s, e) in enumerate(activities)]
    indexed.sort(key=lambda x: x[2])  # 按结束时间升序
    selected = []
    last_end = 0
    for idx, start, end in indexed:
        if start >= last_end:
            selected.append(idx)
            last_end = end
    return selected

# 测试活动选择
activities = [
    ("画画", 9, 10),
    ("打球", 9, 11),
    ("看书", 10, 12),
    ("游泳", 11, 12),
]
# 提取时间和名称
time_list = [(s, e) for _, s, e in activities]
name_list = [n for n, _, _ in activities]
chosen_idx = activity_selection(time_list)
print("活动选择问题:")
print("可选活动:")
for i, (n, s, e) in enumerate(activities):
    print(f"  {i}: {n}  {s}:00-{e}:00")
print("贪心选择的活动:")
for idx in chosen_idx:
    n, s, e = activities[idx]
    print(f"  {n}  {s}:00-{e}:00")

新手容易犯的错误

  1. 没排序就开始贪心:比如找零钱时,如果硬币列表是乱的,不排序直接循环,会先处理小面额,导致结果不是贪心(因为贪心要求“每次选当前最好的”,必须从大到小)。
  2. 想当然地认为贪心永远正确:前面硬币反例已经证明,贪心不是万能的。遇到新问题时,一定要先问自己:“我这一步选最好的,会影响后面的选择吗?”如果可以证明不影响,才能用贪心。
  3. 忽略问题约束:比如活动选择中,如果活动时间不是整数或者有特殊规则,贪心策略需要调整。
  4. 只写代码不验证边界:比如找零时amount为0,硬币列表为空等情况,应该做相应处理。

相关知识点指引

  • 动态规划:当贪心失效时,可以尝试动态规划。动态规划会考虑所有可能性,找到全局最优解,但效率较低。找零钱问题中,如果硬币面额不满足贪心条件,就可以用动态规划。
  • 排序算法:贪心经常需要先排序,所以掌握排序(如快速排序、归并排序)对理解贪心很有帮助。
  • 证明方法:学习如何证明贪心算法的正确性(如“交换论证法”、“反证法”),能帮你判断一个新问题是否可以用贪心。

现在拿起笔,想想你身边还有哪些“每次选当前最好”就能解决问题的场景?比如:买零食时先用大面额的钱、做作业时先做容易的、玩游戏时先打小怪攒经验……试着用代码实现一下,你会更深刻地理解贪心算法!

例题精讲

1单选题

下列关于贪心算法的描述,正确的是?

A贪心算法通过枚举所有可能解来找到全局最优
B贪心算法每一步都选择当前看起来最优的方案,能保证得到全局最优解
C贪心算法每一步都选择当前最优的方案,但可能无法得到全局最优解
D贪心算法必须依赖动态规划才能求解
2判断题

在活动选择问题中,按结束时间最早的原则选择活动,是贪心策略且能保证得到最优解。

3填空题
以下是用贪心算法解决找零问题的代码(假设有无限数量的1、5、10、20、50、100元纸币),请补充空白处,使函数返回最少纸币张数。

def min_bills(amount):
    bills = [100, 50, 20, 10, 5, 1]
    count = 0
    for bill in bills:
        count += amount // bill
        amount = ___  # 更新剩余金额
    return count
4单选题

以下哪个经典问题适合用贪心算法求解(且能保证全局最优)?

A0-1背包问题
B最短路径问题(非负权图)
C旅行商问题
D八皇后问题
5判断题

贪心算法通常比动态规划算法的时间复杂度更低,因此在所有问题上都应该优先使用贪心。