CC++ & Algorithm

最优子结构:贪心的底气

较难4
语言版本:C++Python
概述:如果一个问题的最优解包含子问题的最优解,就称它有最优子结构,贪心算法正是利用这一点前进。

最优子结构:贪心算法的“底气”从何而来?

你有没有想过,为什么贪心算法敢“走一步看一步”,每次都选当前最好的,却最终能得到全局最优解?这背后有一个关键概念——最优子结构。简单说,如果一个问题的最优解里,包含的每个小问题的最优解,那么这个问题就具有最优子结构。贪心算法正是靠着这个“底气”,一步步往前推进:先选一个局部最优,剩下的子问题再用同样的贪心策略解决,最后拼出全局最优。

本文会用生活中的例子和Python代码,帮你彻底搞懂最优子结构,并学会判断一个问题能不能用贪心算法。


一、什么是“最优子结构”?从生活小事看明白

想象你要拼一个乐高城堡,城堡由塔楼、城墙、城门等几部分组成。如果整座城堡是最漂亮的,那么它的每一部分(比如塔楼)也应该是你能力范围内搭得最漂亮的。这个特性就叫最优子结构:全局最优解的一部分,就是局部最优解。

再举个零花钱的例子:你每周有20元零花钱,打算买薯片、糖果和饮料各一种,总共花不超过20元。如果你最终选到的组合让你最满意(全局最优),那么用在薯片上的5元应该能买到你最喜欢的薯片(子问题最优),用在糖果上的8元也该买到最喜欢的糖果……只有这样,加起来的总满意度才是最高的。

贪心算法为什么能用?
因为具有最优子结构的问题,让贪心算法每一步的选择都不会“后悔”。你选了当前最好的,剩下的问题依然保持同样的性质,继续选当前最好,最后结果自然就是整体最好。比如活动选择问题:你选了最早结束的活动后,剩下的时间区间里,再选结束最早的活动,依然能得到剩下活动的最佳安排。


二、贪心算法如何利用最优子结构?——活动选择问题详解

活动选择问题是理解最优子结构的经典例子。假设你有一堆活动,每个活动有开始时间和结束时间,你希望参加尽可能多的活动,并且这些活动时间不重叠。贪心策略是:每次选结束时间最早的活动。为什么它有效?因为选最早结束的活动后,剩下的时间最长,能容纳更多后续活动;而且剩下的活动之间仍然是同样的问题,继续选最早结束的即可——这就是最优子结构在起作用。

下面这段Python代码演示了贪心过程:

def activity_selection(activities):
    # activities: 列表,每个元素 (开始, 结束)
    activities.sort(key=lambda x: x[1])  # 按结束时间从小到大排序
    selected = []                        # 存放选中的活动
    last_end = 0                         # 上一个选中活动的结束时间
    for start, end in activities:        # 遍历排序后的活动
        if start >= last_end:            # 如果当前活动开始时间不早于上次结束
            selected.append((start, end)) # 选中它
            last_end = end               # 更新结束时间
    return selected

# 测试
acts = [(1,3), (2,4), (3,5), (0,6)]
result = activity_selection(acts)
print("选择的活动:", result)  # 输出 [(1,3), (3,5)]

注意看过程:

  1. 所有活动按结束时间排序:(1,3), (2,4), (0,6), (3,5)?不对,实际排序后是 (1,3), (2,4), (3,5), (0,6)(因为结束时间3<4<5<6)。
  2. 先选第一个(1,3),然后剩下开始时间≥3的活动有(3,5),以及(0,6)(但开始时间0<3,不能选)。在子问题(开始时间≥3的活动)中继续按相同规则,选到了(3,5)
  3. 两个子问题的最优解(1,3)(3,5)组合起来,就是全局最优解——这就验证了最优子结构。

三、没有最优子结构时,贪心会怎样?——反例与陷阱

不是所有问题都有最优子结构。如果一个问题不具备这个性质,贪心算法可能只能得到近似解,甚至会错得很离谱。

反例1:旅行路线问题

想象你要从A到C,中间必须经过B。从A到B有两条路:一条快(用时2小时),一条慢(用时4小时)。但从B到C,快慢取决于你在B点的出发时间:如果你2小时到B,后一段需要5小时;如果你4小时到B,后一段只需要1小时。
贪心地选当前最快的路(A→B 2小时),结果总用时2+5=7小时;而如果选慢路(A→B 4小时),总用时4+1=5小时更优。因为选择B的方式影响了后面的时间,子问题(B→C)的最优解并不独立于前面的选择,所以没有最优子结构,贪心失败。

反例2:硬币找零问题

假设你有三种面额:1元、3元、4元。你要凑6元。
贪心算法会先选最大面额4元,剩下2元,再选两个1元,总共3枚硬币。但最优解却是两个3元(共2枚硬币)。为什么?因为选了一个4元后,剩下的子问题(凑2元)不能用贪心得到最优(最优是2个1元,但总硬币数3,不如不选4元)。这个问题的局部最优(选最大面额)损害了全局最优,因为它没有最优子结构。

关键区别:具有最优子结构的问题,局部最优选择不会影响后续子问题的独立性;而没有最优子结构时,前面的选择会改变子问题的“形状”,导致贪心无法得到全局最优。


四、新手最容易犯的错误

  1. 以为所有问题都能用贪心
    很多初学贪心的人,看到“每次选最大/最小”就套上去,结果发现答案不对。一定要先判断问题是否有最优子结构。比如背包问题(不是0-1背包)有最优子结构,但0-1背包没有,贪心可能出错。

  2. 把“子问题最优”理解成“子问题用同样方法”
    最优子结构要求:全局最优解中的一部分,就是子问题的最优解。但有些问题在贪心选择后,子问题的结构变了(比如硬币找零,选了4元后剩余金额是2元,但原本的最优解可能包含3元),这时子问题的最优解不一定是全局最优解的一部分。

  3. 混淆贪心与动态规划
    动态规划也利用最优子结构,但它会枚举所有可能的子问题,通过比较选出全局最优。贪心则只走一条路,不回溯。所以当一个问题具有最优子结构,但贪心选择不能保证正确时(比如硬币找零、0-1背包),就要改用动态规划。


五、完整可运行的Python示例:活动选择 + 验证最优子结构

以下代码包含了活动选择的贪心实现,以及一个验证函数,用来检查选出的活动是否真的组成全局最优解(通过对比所有可能的组合,当然这里只是验证性质,实际中贪心已证明正确)。

def activity_selection(activities):
    # activities: 列表,每个元素 (开始, 结束)
    activities.sort(key=lambda x: x[1])  # 按结束时间排序
    selected = []                        # 存放选中的活动
    last_end = 0                         # 上一个选中活动的结束时间
    for start, end in activities:        # 遍历
        if start >= last_end:            # 不重叠即可选
            selected.append((start, end))
            last_end = end
    return selected

# 暴力求解最优解(用于验证,仅当活动数少时使用)
import itertools

def brute_force(activities):
    # activities: 活动列表
    best = []
    n = len(activities)
    for r in range(1, n+1):
        for combo in itertools.combinations(activities, r):
            # 检查组合是否不重叠
            times = sorted(combo, key=lambda x: x[1])
            ok = True
            last = 0
            for s, e in times:
                if s < last:
                    ok = False
                    break
                last = e
            if ok and len(combo) > len(best):
                best = list(combo)
    return best

# 测试多个用例
test_cases = [
    [(1,3), (2,4), (3,5), (0,6)],
    [(5,7), (8,9), (2,5), (0,3)],
    [(1,2), (3,4), (2,3), (0,1)]
]

for idx, acts in enumerate(test_cases, 1):
    greedy_result = activity_selection(acts)
    brute_result = brute_force(acts)
    print(f"用例{idx}: 贪心结果 {greedy_result}, 暴力最优 {brute_result}, "
          f"是否一致: {set(greedy_result) == set(brute_result)}")

输出示例

用例1: 贪心结果 [(1,3), (3,5)], 暴力最优 [(1,3), (3,5)], 是否一致: True
用例2: 贪心结果 [(0,3), (5,7), (8,9)], 暴力最优 [(0,3), (5,7), (8,9)], 是否一致: True
用例3: 贪心结果 [(0,1), (1,2), (2,3), (3,4)], 暴力最优 [(0,1), (1,2), (2,3), (3,4)], 是否一致: True

这个例子说明:在活动选择问题上,贪心算法确实能通过最优子结构得到全局最优解。


六、相关拓展:接下来学什么?

掌握最优子结构后,你可以继续探索:

  • 动态规划:当问题有最优子结构,但贪心无法保证正确时(比如0-1背包、硬币找零的通用版本),动态规划能通过状态转移方程求出精确解。
  • 其他经典贪心问题:哈夫曼编码(构造最优前缀码)、最小生成树(Kruskal/Prim算法)、单源最短路径(Dijkstra算法)等,它们都依赖最优子结构。
  • 证明贪心正确性:学会用“交换论证”“归纳法”严格证明贪心策略的正确,而不仅仅是感性理解。

记住:判断一个问题是否适合贪心,先找最优子结构;如果找不到,不妨试着用动态规划或回溯。算法世界很大,但“最优子结构”是其中一把关键的钥匙。

例题精讲

1单选题

关于最优子结构,以下哪种说法最准确?

A原问题的最优解可以由子问题的最优解组合得到
B原问题的最优解一定包含所有子问题的最优解
C子问题的最优解一定等于全局最优解的一部分
D任意子问题的最优解都与原问题最优解无关
2判断题

贪心算法每一步都做出当前最优选择,因此只要问题满足最优子结构,贪心算法就一定能得到全局最优解。

3填空题
以下代码是用贪心算法求解找零问题(硬币面值为1、3、5,总金额为n)。假设问题具有最优子结构,填出使算法能正确返回最少硬币数的缺失部分。

def coinChange(n):
    coins = [5, 3, 1]
    count = 0
    for c in coins:
        # 每次尽可能多地使用当前面值的硬币
        num = n // c
        count += num
        n -= num * c
    return count

# 但在某些情况下该贪心策略会失败。若要使贪心算法在所有情况下都正确,需要满足贪心选择性质。但以下代码基于假设:当剩余金额为k时,最优解一定由___构成。
# 填空处应填什么短语?
4单选题

动态规划和贪心算法都依赖最优子结构,但它们的区别在于:

A动态规划需要穷举所有子问题,贪心算法只考虑当前最优
B动态规划不需要最优子结构,贪心需要
C贪心算法适用于所有具有最优子结构的问题
D动态规划适用于没有最优子结构的问题
5判断题

对于问题“给定一个非负整数数组,初始位置在数组第一个元素,每个元素代表你在该位置可以跳跃的最大长度,问是否能到达最后一个位置”,使用贪心算法(每次尽可能跳远)能够正确求解,该问题的贪心算法依赖的最优子结构是:如果能够到达位置i,那么一定能到达位置i之前的所有位置。