贪心算法在计算机领域是一个很实用的方法,它背后的贪心思想能帮助我们解决很多实际问题。今天就来聊聊贪心思想的核心,也就是从局部最优到全局最优的转化条件以及证明方法。

一、贪心思想初认识

贪心思想其实很好理解,就好比你去超市买东西,每次都挑最划算的商品,也就是当下对你来说最有利的选择。在计算机算法里,贪心算法每一步都选择当前看起来最优的解决方案,希望通过每一步的最优选择,最终能得到全局的最优结果。

举个简单的例子,假如你是个硬币找零的收银员,顾客给了你一张大钞,你要找给他零钱。为了让找零的硬币数量最少,你肯定会优先选择面值大的硬币。比如顾客要找零 63 元,你会先给一张 50 元的,再给一张 10 元的,最后给 3 个 1 元的。这就是贪心思想在找零问题上的应用,每次都选当前能使用的最大面值硬币,这就是局部最优选择。

示例代码(Python 技术栈)

# 定义硬币面值列表,从大到小排序
coin_values = [50, 10, 5, 1]

def greedy_change(amount):
    coins_used = []
    index = 0
    while amount > 0:
        if coin_values[index] <= amount:
            # 如果当前面值的硬币可以使用,就使用它
            coins_used.append(coin_values[index])
            amount -= coin_values[index]
        else:
            # 否则尝试下一个较小面值的硬币
            index += 1
    return coins_used

# 测试找零 63 元
change = greedy_change(63)
print("找零所需硬币:", change)
print("硬币数量:", len(change))

在这个例子中,每次选择硬币时,我们都优先选择最大面值的硬币,这就是局部最优选择。通过不断地进行局部最优选择,最终得到了找零所需的硬币组合,这就是我们希望达到的全局最优结果。

二、局部最优到全局最优的转化条件

贪心思想要从局部最优转化为全局最优,可不是随便就能行得通的,需要满足一些特定条件。

1. 贪心选择性质

贪心选择性质就是说,问题的全局最优解可以通过一系列局部最优选择来得到。也就是说,每一步的局部最优选择都不会影响最终的全局最优解。还是拿找零问题来说,每次选择最大面值的硬币,这个局部最优选择不会让最终找零的硬币数量变多,反而能保证是最少的,这就满足了贪心选择性质。

2. 最优子结构性质

最优子结构性质是指,问题的最优解包含了子问题的最优解。在找零问题中,当我们找零 63 元时,找零 13 元(63 元减去 50 元)这个子问题的最优解也是基于相同的贪心策略(优先选大面值硬币)得到的。也就是说,大问题的最优解是由小问题的最优解组合而成的。

再举个例子,假如有一个任务调度问题,有一系列任务,每个任务都有开始时间和结束时间,我们要在有限的时间内安排尽可能多的任务。我们可以使用贪心算法,每次选择结束时间最早的任务。这里的贪心选择性质就是,选择结束时间最早的任务不会影响最终能安排的任务总数;最优子结构性质就是,在安排了一个任务后,剩下任务的调度问题也是一个子问题,并且它的最优解也是基于同样的贪心策略(选结束时间最早的任务)得到的。

三、证明方法

要证明贪心算法能从局部最优得到全局最优,常用的方法有两种。

1. 数学归纳法

数学归纳法是一种很常用的证明方法。它的基本思路是先证明当问题规模为最小的时候,贪心算法得到的是最优解。然后假设当问题规模为 n 时,贪心算法得到的是最优解,接着证明当问题规模为 n + 1 时,贪心算法依然能得到最优解。

还是以任务调度问题为例。

  • 基础步骤:当只有一个任务时,显然选择这个任务就是最优解,因为没有其他选择。
  • 归纳假设:假设当有 n 个任务时,按照选择结束时间最早的任务的贪心策略,能安排的任务数量最多。
  • 归纳步骤:当有 n + 1 个任务时,我们先选择结束时间最早的任务 T。去掉任务 T 后,剩下的 n 个任务就变成了一个子问题。根据归纳假设,对于这 n 个任务,贪心策略能得到最优解。而加上任务 T 后,整体的安排依然是最优的,因为选择任务 T 不会和其他任务冲突,并且能让我们在有限的时间内安排尽可能多的任务。

2. 交换论证法

交换论证法的思路是,假设存在一个最优解和贪心算法得到的解不一样,然后通过交换这两个解中的某些元素,证明可以把最优解转化为贪心算法得到的解,而且不会影响解的最优性。

还是以任务调度问题为例。假设存在一个最优解 S,它和贪心算法得到的解 G 不一样。我们可以找到 S 中第一个和 G 不同的任务 T1,然后在 G 中找到对应的任务 T2。由于贪心算法每次都选择结束时间最早的任务,所以 T2 的结束时间肯定比 T1 早。我们把 T1 从 S 中去掉,把 T2 加入到 S 中,这样得到的新解 S' 依然是一个可行解,而且安排的任务数量不变。通过不断地进行这样的交换,最终可以把 S 转化为 G,这就证明了贪心算法得到的解也是最优解。

四、应用场景

贪心思想在很多实际问题中都有应用。

1. 背包问题(部分背包问题)

假如你有一个背包,它能承受一定的重量,现在有一堆物品,每个物品都有自己的重量和价值。你要在不超过背包承重的情况下,尽可能多装价值高的物品。在部分背包问题中,我们可以使用贪心算法,每次选择单位重量价值最高的物品,把它尽可能多地装进背包,直到背包装满或者没有物品可装了。

示例代码(Python 技术栈)

# 定义物品列表,每个物品是一个元组 (重量, 价值)
items = [(2, 3), (3, 4), (4, 5), (5, 6)]
# 背包的最大承重
capacity = 8

def fractional_knapsack(items, capacity):
    # 计算每个物品的单位重量价值
    ratios = [(value / weight, weight, value) for weight, value in items]
    # 按单位重量价值从高到低排序
    ratios.sort(reverse=True)
    total_value = 0
    for ratio, weight, value in ratios:
        if capacity == 0:
            break
        if weight <= capacity:
            # 如果物品可以全部装入背包
            total_value += value
            capacity -= weight
        else:
            # 如果物品不能全部装入背包,装入部分
            fraction = capacity / weight
            total_value += fraction * value
            capacity = 0
    return total_value

# 计算最大价值
max_value = fractional_knapsack(items, capacity)
print("背包能装的最大价值:", max_value)

2. 图的最小生成树(Prim 算法和 Kruskal 算法)

在一个连通图中,我们要找到一棵包含所有顶点,并且边的权值之和最小的树,这就是最小生成树问题。Prim 算法和 Kruskal 算法都是基于贪心思想的。Prim 算法每次选择和当前生成树连接的边中权值最小的边,Kruskal 算法每次选择图中权值最小的边,只要这条边不会形成环。

五、技术优缺点

优点

  • 简单高效:贪心算法的思路很简单,实现起来也不复杂,而且时间复杂度通常比较低。就像找零问题和部分背包问题,很容易就能实现,并且运行效率很高。
  • 适用于很多问题:在很多实际问题中,贪心思想都能发挥作用,比如任务调度、最小生成树等问题。

缺点

  • 不一定能得到全局最优解:贪心算法只考虑当前的局部最优选择,有些问题并不满足贪心选择性质和最优子结构性质,使用贪心算法可能得不到全局最优解。比如 0 - 1 背包问题(每个物品只能选或者不选),贪心算法就不能得到最优解。
  • 缺乏灵活性:贪心算法一旦确定了贪心策略,就不能根据后续的情况进行调整。如果问题的情况比较复杂,贪心算法可能就不适用了。

六、注意事项

  • 判断问题是否适合贪心算法:在使用贪心算法之前,一定要判断问题是否满足贪心选择性质和最优子结构性质。如果不满足,使用贪心算法可能得不到正确的结果。
  • 验证算法的正确性:即使问题看起来很适合用贪心算法,也需要通过证明或者测试来验证算法的正确性。可以使用数学归纳法或者交换论证法来证明,也可以通过一些测试用例来验证。
  • 考虑边界情况:在实现贪心算法时,要考虑一些边界情况,比如背包问题中背包容量为 0 的情况,任务调度问题中没有任务的情况等。

总结

贪心思想是一种很实用的算法思想,它通过每一步的局部最优选择,希望达到全局的最优结果。要实现从局部最优到全局最优的转化,需要满足贪心选择性质和最优子结构性质,并且可以使用数学归纳法和交换论证法来证明。贪心算法有简单高效、适用于很多问题的优点,但也有不一定能得到全局最优解、缺乏灵活性的缺点。在使用贪心算法时,要注意判断问题是否适合,验证算法的正确性,以及考虑边界情况。