算法之 贪心算法

1.贪心算法

「贪心算法 greedy algorithm」是一种常见的解决优化问题的算法,其基本思想是在问题的每个决策阶段,都选择当前看起来最优的选择,即贪心地做出局部最优的决策,以期望获得全局最优解。贪心算法简洁且高 效,在许多实际问题中都有着广泛的应用。
贪心算法和动态规划都常用于解决优化问题。它们之间存在一些相似之处,比如都依赖最优子结构性质,但 工作原理是不同的。
‧ 动态规划会根据之前阶段的所有决策来考虑当前决策,并使用过去子问题的解来构建当前子问题的解。
‧ 贪心算法不会重新考虑过去的决策,而是一路向前地进行贪心选择,不断缩小问题范围,直至问题被解 决。
我们先通过例题“零钱兑换”了解贪心算法的工作原理。
Q:给定 𝑛 种硬币,第 𝑖 种硬币的面值为 𝑐𝑜𝑖𝑛𝑠[𝑖 − 1] ,目标金额为 𝑎𝑚𝑡 ,每种硬币可以重复选取,问能够凑出目标金额的最少硬币个数。如果无法凑出目标金额则返回 −1
本题的贪心策略如下图所示。给定目标金额, 我们贪心地选择不大于且最接近它的硬币 ,不断循环该步骤,直至凑出目标金额为止。

 

  /* 零钱兑换:贪心 */
    int coinChangeGreedy(int[] coins, int amt) {
        // 假设 coins 列表有序
        int i = coins.length - 1;
        int count = 0;
        // 循环进行贪心选择,直到无剩余金额
        while (amt > 0) {
            // 找到小于且最接近剩余金额的硬币
            while (i > 0 && coins[i] > amt) {
                i--;
            }
            // 选择 coins[i]
            amt -= coins[i];
            count++;
        }
        // 若未找到可行方案,则返回 -1
        return amt == 0 ? count : -1;
    }

 

1 贪心优点与局限性

贪心算法不仅操作直接、实现简单,而且通常效率也很高。在以上代码中,记硬币最小面值为 min
(𝑐𝑜𝑖𝑛𝑠) , 则贪心选择最多循环 𝑎𝑚𝑡/ min(𝑐𝑜𝑖𝑛𝑠) 次,时间复杂度为 𝑂(𝑎𝑚𝑡/ min (𝑐𝑜𝑖𝑛𝑠)) 。这比动态规划解法的 时间复杂度 𝑂(𝑛 × 𝑎𝑚𝑡) 提升了一个数量级。
然而, 对于某些硬币面值组合,贪心算法并不能找到最优解 。下图给出了两个示例。
正例 𝑐𝑜𝑖𝑛𝑠 = [1, 5, 10, 20, 50, 100] :在该硬币组合下,给定任意 𝑎𝑚𝑡 ,贪心算法都可以找出最优
解。
反例 𝑐𝑜𝑖𝑛𝑠 = [1, 20, 50] :假设 𝑎𝑚𝑡 = 60 ,贪心算法只能找到 50 + 1 × 10 的兑换组合,共计
11 枚硬币,但动态规划可以找到最优解 20 + 20 + 20 ,仅需 3 枚硬币。
反例 𝑐𝑜𝑖𝑛𝑠 = [1, 49, 50] :假设 𝑎𝑚𝑡 = 98 ,贪心算法只能找到 50 + 1 × 48 的兑换组合,共计
49 枚硬币,但动态规划可以找到最优解49 + 49 ,仅需 2 枚硬币。

也就是说,对于零钱兑换问题,贪心算法无法保证找到全局最优解,并且有可能找到非常差的解。它更适合用动态规划解决。
一般情况下,贪心算法适用于以下两类问题。
1. 可以保证找到最优解 :贪心算法在这种情况下往往是最优选择,因为它往往比回溯、动态规划更高效。
2. 可以找到近似最优解 :贪心算法在这种情况下也是可用的。对于很多复杂问题来说,寻找全局最优解是非常困难的,能以较高效率找到次优解也是非常不错的。

1.2 贪心算法特性

那么问题来了,什么样的问题适合用贪心算法求解呢?或者说,贪心算法在什么情况下可以保证找到最优解? 相较于动态规划,贪心算法的使用条件更加苛刻,其主要关注问题的两个性质。
贪心选择性质 :只有当局部最优选择始终可以导致全局最优解时,贪心算法才能保证得到最优解。
最优子结构 原问题的最优解包含子问题的最优解。 最优子结构已经在动态规划章节中介绍过,不再赘述。值得注意的是,一些问题的最优子结构并不明显,但 仍然可使用贪心算法解决。
我们主要探究贪心选择性质的判断方法。虽然它的描述看上去比较简单, 但实际上对于许多问题,证明贪心 选择性质不是一件易事
例如零钱兑换问题,我们虽然能够容易地举出反例,对贪心选择性质进行证伪,但证实的难度较大。如果问: 满足什么条件的硬币组合可以使用贪心算法求解 ?我们往往只能凭借直觉或举例子来给出一个模棱两可的答案,而难以给出严谨的数学证明。

1.3 贪心解题步骤

贪心问题的解决流程大体可分为以下三步。
1. 问题分析 :梳理与理解问题特性,包括状态定义、优化目标和约束条件等。这一步在回溯和动态规划中都有涉及。
2. 确定贪心策略 :确定如何在每一步中做出贪心选择。这个策略能够在每一步减小问题的规模,并最终能解决整个问题。
3. 正确性证明 :通常需要证明问题具有贪心选择性质和最优子结构。这个步骤可能需要使用到数学证明, 例如归纳法或反证法等。 确定贪心策略是求解问题的核心步骤,但实施起来可能并不容易,主要包含以下原因。
不同问题的贪心策略的差异较大 。对于许多问题来说,贪心策略都比较浅显,我们通过一些大概的思考与尝试就能得出。而对于一些复杂问题,贪心策略可能非常隐蔽,这种情况就非常考验个人的解题经验与算法能力了。
某些贪心策略具有较强的迷惑性 。当我们满怀信心设计好贪心策略,写出解题代码并提交运行,很可能发现部分测试样例无法通过。这是因为设计的贪心策略只是“部分正确”的,上文介绍的零钱兑换就是个典型案例。
为了保证正确性,我们应该对贪心策略进行严谨的数学证明, 通常需要用到反证法或数学归纳法
然而,正确性证明也很可能不是一件易事。如若没有头绪,我们通常会选择面向测试用例进行 Debug ,一步步修改与验证贪心策略。

1.4 贪心典型例题

贪心算法常常应用在满足贪心选择性质和最优子结构的优化问题中,以下列举了一些典型的贪心算法问题。
硬币找零问题 :在某些硬币组合下,贪心算法总是可以得到最优解。
区间调度问题 :假设你有一些任务,每个任务在一段时间内进行,你的目标是完成尽可能多的任务。如果每次都选择结束时间最早的任务,那么贪心算法就可以得到最优解。
分数背包问题 :给定一组物品和一个载重量,你的目标是选择一组物品,使得总重量不超过载重量,且总价值最大。如果每次都选择性价比最高(价值 / 重量)的物品,那么贪心算法在一些情况下可以得到最优解。
股票买卖问题 :给定一组股票的历史价格,你可以进行多次买卖,但如果你已经持有股票,那么在卖出之前不能再买,目标是获取最大利润。
霍夫曼编码 :霍夫曼编码是一种用于无损数据压缩的贪心算法。通过构建霍夫曼树,每次选择出现频率最小的两个节点合并,最后得到的霍夫曼树的带权路径长度(即编码长度)最小。
Dijkstra 算法 :它是一种解决给定源顶点到其余各顶点的最短路径问题的贪心算法。

2 分数背包问题 

给定 𝑛 个物品,第 𝑖 个物品的重量为 𝑤𝑔𝑡[𝑖 − 1]、价值为 𝑣𝑎𝑙[𝑖 − 1] ,和一个容量为 𝑐𝑎𝑝
的 背包。每个物品只能选择一次, 但可以选择物品的一部分,价值根据选择的重量比例计算
,问 在不超过背包容量下背包中物品的最大价值。

 

分数背包和 0‑1 背包整体上非常相似,状态包含当前物品 𝑖 和容量 𝑐 ,目标是求不超过背包容量下的最大价值。
不同点在于,本题允许只选择物品的一部分。如下图所示, 我们可以对物品任意地进行切分,并按照重量 比例来计算物品价值
1. 对于物品 𝑖 ,它在单位重量下的价值为𝑣𝑎𝑙[𝑖 − 1]/𝑤𝑔𝑡[𝑖 − 1],简称为单位价值。
2. 假设放入一部分物品𝑖 ,重量为 𝑤 ,则背包增加的价值为 𝑤 × 𝑣𝑎𝑙[𝑖 − 1]/𝑤𝑔𝑡[𝑖 − 1]。

1. 贪心策略确定

最大化背包内物品总价值, 本质上是要最大化单位重量下的物品价值 。由此便可推出下图所示的贪心策略。
1. 将物品按照单位价值从高到低进行排序。
2. 遍历所有物品, 每轮贪心地选择单位价值最高的物品
3. 若剩余背包容量不足,则使用当前物品的一部分填满背包即可。

 

2. 代码实现

我们建立了一个物品类 Item ,以便将物品按照单位价值进行排序。循环进行贪心选择,当背包已满时跳出并返回解。
public class Test2 {
    public static void main(String[] args) {

    }

    /* 分数背包:贪心 */
    double fractionalKnapsack(int[] wgt, int[] val, int cap) {
        // 创建物品列表,包含两个属性:重量、价值
        Item[] items = new Item[wgt.length];
        for (int i = 0; i < wgt.length; i++) {
            items[i] = new Item(wgt[i], val[i]);
        }
        // 按照单位价值 item.v / item.w 从高到低进行排序
        Arrays.sort(items, Comparator.comparingDouble(item -> -((double) item.v / item.w)));
        // 循环贪心选择
        double res = 0;
        for (Item item : items) {
            if (item.w <= cap) {
                // 若剩余容量充足,则将当前物品整个装进背包
                res += item.v;
                cap -= item.w;
            } else {
                // 若剩余容量不足,则将当前物品的一部分装进背包
                res += (double) item.v / item.w * cap;
                // 已无剩余容量,因此跳出循环
                break;
            }
        }
        return res;
    }


}

/* 物品 */
class Item {
    int w; // 物品重量
    int v; // 物品价值
    public Item(int w, int v) {
        this.w = w;
        this.v = v;
    }
}
最差情况下,需要遍历整个物品列表, 因此时间复杂度为 𝑂(𝑛) ,其中 𝑛 为物品数量。
由于初始化了一个 Item 对象列表, 因此空间复杂度为 𝑂(𝑛)

3. 正确性证明

采用反证法。假设物品 𝑥 是单位价值最高的物品,使用某算法求得最大价值为 res ,但该解中不包含物品 𝑥 。
现在从背包中拿出单位重量的任意物品,并替换为单位重量的物品 𝑥 。由于物品 𝑥 的单位价值最高,因此替换后的总价值一定大于 res 这与 res 是最优解矛盾,说明最优解中必须包含物品 𝑥
对于该解中的其他物品,我们也可以构建出上述矛盾。总而言之, 单位价值更大的物品总是更优选择 ,这说明贪心策略是有效的。
如图所示,如果将物品重量和物品单位价值分别看作一个 2D 图表的横轴和纵轴,则分数背包问题可 被转化为“求在有限横轴区间下的最大围成面积”。这个类比可以帮助我们从几何角度理解贪心策略的有效 性。

3 最大容量问题  

Q:输入一个数组 ℎ𝑡 ,数组中的每个元素代表一个垂直隔板的高度。数组中的任意两个隔板,以及它们之间的空间可以组成一个容器。
容器的容量等于高度和宽度的乘积(即面积),其中高度由较短的隔板决定,宽度是两个隔板的数组索引之差。 请在数组中选择两个隔板,使得组成的容器的容量最大,返回最大容量。

如图所示:

容器由任意两个隔板围成, 因此本题的状态为两个隔板的索引,记为 [𝑖, 𝑗]
根据题意,容量等于高度乘以宽度,其中高度由短板决定,宽度是两隔板的索引之差。设容量为 𝑐𝑎𝑝[𝑖, 𝑗] ,则可得计算公式:
                                𝑐𝑎𝑝[𝑖, 𝑗] = min(ℎ𝑡[𝑖], ℎ𝑡[𝑗]) × (𝑗 − 𝑖)
其中(ℎ𝑡[𝑖)表示索引为i处的木板高度。

1. 贪心策略确定

这道题还有更高效率的解法。如图所示,现选取一个状态 [𝑖, 𝑗] ,其满足索引 𝑖 < 𝑗 且高度 ℎ𝑡[𝑖] < ℎ𝑡[𝑗] ,即𝑖 为短板、 𝑗 为长板。

如下图所示, 若此时将长板 𝑗 向短板 𝑖 靠近,则容量一定变小
这是因为在移动长板 𝑗 后,宽度 𝑗 − 𝑖 肯定变小;而高度由短板决定,因此高度只可能不变(𝑖 仍为短板)或 变小(移动后的𝑗 成为短板)。
反向思考, 我们只有向内收缩短板 𝑖 ,才有可能使容量变大 。因为虽然宽度一定变小, 但高度可能会变大(移动后的短板𝑖 可能会变长)。例如在下图中,移动短板后面积变大。

由此便可推出本题的贪心策略:初始化两指针分裂容器两端,每轮向内收缩短板对应的指针,直至两指针相遇。下图展示了贪心策略的执行过程。
1. 初始状态下,指针 𝑖 和 𝑗 分列与数组两端。
2. 计算当前状态的容量 𝑐𝑎𝑝[𝑖, 𝑗] ,并更新最大容量。
3. 比较板 𝑖 和 板 𝑗 的高度,并将短板向内移动一格。
4. 循环执行第2. 和 3. 步,直至 𝑖 𝑗 相遇时结束。

 

2. 代码实现

代码循环最多 𝑛 轮, 因此时间复杂度为 𝑂(𝑛)
变量 𝑖 𝑗 𝑟𝑒𝑠 使用常数大小额外空间, 因此空间复杂度为 𝑂(1)
    /* 最大容量:贪心 */
    int maxCapacity(int[] ht) {
        // 初始化 i, j 分列数组两端
        int i = 0, j = ht.length - 1;
        // 初始最大容量为 0
        int res = 0;
        // 循环贪心选择,直至两板相遇
        while (i < j) {
            // 更新最大容量
            int cap = Math.min(ht[i], ht[j]) * (j - i);
            res = Math.max(res, cap);
            // 向内移动短板
            if (ht[i] < ht[j]) {
                i++;
            } else {
                j--;
            }
        }
        return res;
    }

3. 正确性证明

之所以贪心比穷举更快,是因为每轮的贪心选择都会“跳过”一些状态。 比如在状态 𝑐𝑎𝑝[𝑖, 𝑗]
下, 𝑖 为短板、 𝑗 为长板。若贪心地将短板 𝑖 向内移动一格,会导致下图所示的状
态被“跳过”。 这意味着之后无法验证这些状态的容量大小
                𝑐𝑎𝑝[𝑖, 𝑖 + 1], 𝑐𝑎𝑝[𝑖, 𝑖 + 2], … , 𝑐𝑎𝑝[𝑖, 𝑗 − 2], 𝑐𝑎𝑝[𝑖, 𝑗 − 1]
观察发现, 这些被跳过的状态实际上就是将长板 𝑗 向内移动的所有状态 。而在第二步中,我们已经证明内移长板一定会导致容量变小。也就是说,被跳过的状态都不可能是最优解,跳过它们不会导致错过最优解 。以上的分析说明,移动短板的操作是“安全”的 ,贪心策略是有效的。

 

4.总结

贪心算法是一种优化问题的求解方法,其核心思想是在每一步选择中都采取当前状态下的最优策略,而不考虑全局最优解。贪心算法的基本思想是通过一系列局部最优选择,最终达到全局最优。

以下是贪心算法的主要特点和总结:

  1. 贪心选择性质: 贪心算法的关键在于贪心选择性质,即每一步都选择当前状态下的最优解,而不考虑对后续步骤的影响。这种局部最优的选择最终能够达到全局最优。

  2. 不回退: 贪心算法通常做出一次选择后就不再改变,没有回退的过程。因此,一旦做出的选择不符合最优解的性质,整体策略就可能失败。

  3. 适用场景: 贪心算法适用于一些具有贪心选择性质和最优子结构的问题,例如最小生成树、单源最短路径、任务调度等。在这些问题中,通过局部最优选择就能够得到全局最优解。

  4. 不一定总是最优解: 贪心算法得到的结果不一定是全局最优解,因为它不考虑所有可能的情况。在某些情况下,贪心算法可能会产生次优解或不可行解。

  5. 与动态规划的区别: 贪心算法与动态规划类似,都属于求解优化问题的范畴,但它们的区别在于贪心算法对每个子问题都做出了选择,而不考虑子问题的相互关系。动态规划则通常使用一个表格来存储子问题的解,避免重复计算,因此在某些情况下能够得到更优的结果。

总的来说,贪心算法是一种简单而高效的算法,适用于一些特定类型的问题。然而,在使用贪心算法时需要仔细分析问题的性质,确保问题具有贪心选择性质,以确保贪心算法能够得到满意的结果。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值