来谈谈贪心算法

>>强大,10k+点赞的 SpringBoot 后台管理系统竟然出了详细教程!

前言

之前讲了动态规划,在翻阅资料的时候看到了不少谈论贪心算法的,这两种算法也很有相似之处,正好最近又做到了有关贪心的题,所以今天写篇文章来谈一谈。

贪心算法(英语:greedy algorithm),又称贪婪算法,是一种在每一步选择中都采取在当前状态下最好或最优(即最有利)的选择,从而希望导致结果是最好或最优的算法。
贪心算法在有最优子结构的问题中尤为有效。最优子结构的意思是局部最优解能决定全局最优解。简单地说,问题能够分解成子问题来解决,子问题的最优解能递推到最终问题的最优解。
贪心算法与动态规划的不同在于它对每个子问题的解决方案都做出选择,不能回退。动态规划则会保存以前的运算结果,并根据以前的结果对当前进行选择,有回退功能。
贪心法可以解决一些最优化问题,如:求图中的最小生成树、求哈夫曼编码……对于其他问题,贪心法一般不能得到我们所要求的答案。一旦一个问题可以通过贪心法来解决,那么贪心法一般是解决这个问题的最好办法。由于贪心法的高效性以及其所求得的答案比较接近最优结果,贪心法也可以用作辅助算法或者直接解决一些要求结果不特别精确的问题。
——摘自维基百科

动态规划和贪心算法很像,在各种对它们的描述中都有将问题分解为子问题的说法,其实还有分治法也是这种模式。但是动态规划实质上是穷举法,只是会省去重复计算,而贪心算法,正如它的名字,贪心,每次都选择局部的最优解,并不考虑这个局部最优选择对全局的影响。
可以说贪心算法是动态规划的一种特例,也正由于贪心算法只考虑子问题的最优解,可以说,贪心算法实际上能解决的问题有限,它是一个目光短浅的算法,只考虑当下,只有当这种基于局部最优的选择最终能导致整体最优解的情形才能用贪心算法来解决。

还是举个栗子

一起来看一下一道leetcode上的题:

假设你是一位很棒的家长,想要给你的孩子们一些小饼干。但是,每个孩子最多只能给一块饼干。对每个孩子 i ,都有一个胃口值 gi ,这是能让孩子们满足胃口的饼干的最小尺寸;并且每块饼干 j ,都有一个尺寸 sj 。如果 sj >= gi ,我们可以将这个饼干 j 分配给孩子 i ,这个孩子会得到满足。你的目标是尽可能满足越多数量的孩子,并输出这个最大数值。
注意:
你可以假设胃口值为正。
一个小朋友最多只能拥有一块饼干。
来源:力扣(LeetCode)
链接:https://leetcode-cn.com/problems/assign-cookies
著作权归领扣网络所有。商业转载请联系官方授权,非商业转载请注明出处。

是的,这是一位很棒(抠门)的家长,要尽可能用少的饼干满足多的孩子。比如现在有三个孩子胃口是[1,2,3],那么哪怕家长手上有一百块尺寸为1的小饼干,也只能满足一个孩子,因为他每个孩子最多只给一个饼干。
让我们来想一想如何“贪心”呢?
要想最节省饼干,我们可以把饼干尺寸孩子胃口这两个数据先做一下升序排序,然后每次都用最小的饼干去试试能否满足胃口最小的孩子,这样我们需要维护两个索引。

代码实现:

class Solution:
    def findContentChildren(self, g: List[int], s: List[int]) -> int:
        count = 0
        g.sort()
        s.sort()
        gi, si = 00
        while gi < len(g) and si < len(s):
            if s[si] >= g[gi]:
                count += 1
                gi += 1
                si += 1
            elif s[si] < g[gi]:
                si += 1
        return count

当饼干尺寸刚好大于等于孩子胃口,计数+1,两个索引值+1,否则,饼干尺寸列表索引+1,看看更大的那块饼干能否满足当前孩子。
题外话:经常看到有的Python代码中,将某个列表长度值保存到某个变量中,像size = len(alist)这样,事实上len()函数花费的是O(1)常数时间。Python的设计中一切皆对象,列表当然也是对象,当你创建一个列表后,len()实质上只是去提取了这个列表实例的长度属性值而已,并没有遍历列表之类的操作。

实践

再来看个题目:

在一条环路上有 N 个加油站,其中第 i 个加油站有汽油 gas[i] 升。
你有一辆油箱容量无限的的汽车,从第 i 个加油站开往第 i+1 个加油站需要消耗汽油 cost[i] 升。你从其中的一个加油站出发,开始时油箱为空。
如果你可以绕环路行驶一周,则返回出发时加油站的编号,否则返回 -1。
说明:
如果题目有解,该答案即为唯一答案。
输入数组均为非空数组,且长度相同。
输入数组中的元素均为非负数。

首先我们可以想到,有一种情况,是一定不可能跑完全程的,那就是加油站的油量总和小于路上消耗的总油量时。也就是说,如果sum(gas) < sum(cost),那么就要返回-1
第二点,如果我们选择一个加油站i为起始点,如果这个加油站所能够获得的油量小于前往下一个加油站所花费的油量,也就是gas[i] < cost[i]的话,说明这个加油站不能做为起点。

代码实现:

class Solution:
    def canCompleteCircuit(self, gas: List[int], cost: List[int]) -> int:
        total, curr = 00
        start = 0
        for i in range(len(gas)):
            total += gas[i] - cost[i]
            curr += gas[i] - cost[i]
            if curr < 0:
                start = i + 1
                curr = 0

        return start if total >= 0 else -1

这里用total保存最终的油量,curr表示当前油箱油量,start表示起点,初值都设为0,遍历整个列表,如果在加油站i,gas[i] - cost[i] < 0,那么就选择第i+1个加油站做为起点,最后如果total小于0,返回-1,否则就返回start

  • 时间复杂度:O(n)

  • 空间复杂度:O(1)

看看缺陷

以下例子来自知乎用户@阮行止:

先来看看生活中经常遇到的事吧——假设您是个土豪,身上带了足够的1、5、10、20、50、100元面值的钞票。现在您的目标是凑出某个金额w,需要用到尽量少的钞票。  依据生活经验,我们显然可以采取这样的策略:能用100的就尽量用100的,否则尽量用50的……依次类推。在这种策略下,666=6×100+1×50+1×10+1×5+1×1,共使用了10张钞票。  这种策略称为“贪心”:假设我们面对的局面是“需要凑出w”,贪心策略会尽快让w变得更小。能让w少100就尽量让它少100,这样我们接下来面对的局面就是凑出w-100。长期的生活经验表明,贪心策略是正确的。  但是,如果我们换一组钞票的面值,贪心策略就也许不成立了。如果一个奇葩国家的钞票面额分别是1、5、11,那么我们在凑出15的时候,贪心策略会出错:  15=1×11+4×1    (贪心策略使用了5张钞票)  15=3×5               (正确的策略,只用3张钞票)
作者:阮行止
链接:https://www.zhihu.com/question/23995189/answer/613096905
来源:知乎
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。

可以看到,在第一种情况下,使用贪心策略,很快就能得出答案,但是当条件稍微改变,就无法得出正确答案了。贪心算法在这个问题中,每次都选择面额最大的钞票,快速减少了最终要凑出的W的量,但是在例子的特殊情况里,第一次选择最大的面额11的钞票,会导致后面只能选择4张1元钞票,最终得到的解是不正确的。
可以说,动态规划是在暴力枚举的基础上,避免了重复计算,但是每一个子问题都被考虑到了,而贪心算法则每次都短视的选择当前最优解而不去考虑剩下的情况。
最后留个思考,试试把这个特殊面额钞票的问题用动态规划解决一下。

扫码关注微信公众号:

来谈谈贪心算法


原文始发于微信公众号(公子政的宅日常):来谈谈贪心算法