算法锦囊6:一文搞定贪心

527 阅读8分钟

这是我参与更文挑战的第12天,活动详情查看: 更文挑战

最近想把自己刷算法题的经验心得整理一下,一方面为了复习巩固,另一方面也希望我的分享能够帮助到更多在学习算法的朋友。

专栏名称叫《算法锦囊》,在讲解算法时会注重整体性,但不会面面俱到,适合有一定算法经验的人阅读。

这一次我们重点来看贪心,这一部分的所有题目和源码都上传到了github的该目录下,题解主要用Python语言实现。

概述

贪心算法是一种在每一步选择中都采取在当前状态下最好或最优(即最有利)的选择,从而希望导致结果是全局最好或最优的算法。

哪些问题适用贪心法?简单地说,问题能够分解成子问题来解决,子问题的最优解能递推到最终问题的最优解。一旦一个问题可以通过贪心法来解决,那么贪心法一般是解决这个问题的最好办法。由于贪心法的高效性以及其所求得的答案比较接近最优结果,贪心法也可以用作辅助算法或者直接解决一些要求结果不特别精确的问题。

贪心算法与动态规划的不同在于它对每个子问题的解决方案都做出选择,不能回退。动态规划则会保存以前的运算结果,并根据以前的结果对当前进行选择,有回退功能。

贪心法可以解决一些最优化问题,然而对于工程和生活中的问题,贪心法一般不能得到我们所要求的答案。

拿我们的人生为例,贪心法往往追求的是局部利益最大化,但是对于我们的人生,这是一段非常长的要走的路,追逐短期利益也许会让我们错失了很多重要的东西。而动态规划则仿佛是我们提前知道了人生的答案,每一步都精打细算,而且可以回退。

对比贪心和动态规划,贪心更像我们的真实人生,而动态规划更像理想型人生。很少有人能过动态规划的人生,毕竟几乎没人能提前知道人生的递推公式,大多数人都是过着贪心一般的人生,看似最优,却无法回退,结果也许会让人后悔,但是这份未知何尝不也是人生的精彩呢?

860. 柠檬水找零

该题出自leetcode 860. 柠檬水找零

在柠檬水摊上,每一杯柠檬水的售价为 5 美元。

顾客排队购买你的产品,(按账单 bills 支付的顺序)一次购买一杯。

每位顾客只买一杯柠檬水,然后向你付 5 美元、10 美元或 20 美元。你必须给每个顾客正确找零,也就是说净交易是每位顾客向你支付 5 美元。

注意,一开始你手头没有任何零钱。

如果你能给每位顾客正确找零,返回 true ,否则返回 false 。


示例 1:

输入:[5,5,5,10,20]
输出:true
解释:
前 3 位顾客那里,我们按顺序收取 35 美元的钞票。
第 4 位顾客那里,我们收取一张 10 美元的钞票,并返还 5 美元。
第 5 位顾客那里,我们找还一张 10 美元的钞票和一张 5 美元的钞票。
由于所有客户都得到了正确的找零,所以我们输出 true

这是典型的贪心题目,按照我们的日常思维,找零时会尽量先出大面额,再出小面额,这是子问题的最优解,同时也是最终问题的最优解。

python解法如下:

class Solution:
    def lemonadeChange(self, bills):
        five = ten = 0
        for bill in bills:
            if bill == 5:
                five += 1
            elif bill == 10:
                if not five: return False
                five -= 1
                ten += 1
            else:
                if ten and five:
                    ten -= 1
                    five -= 1
                elif five >= 3:
                    five -= 3
                else:
                    return False
        return True

322. 零钱兑换

该题出自leetcode 322. 零钱兑换

给定不同面额的硬币 coins 和一个总金额 amount。编写一个函数来计算可以凑成总金额所需的最少的硬币个数。如果没有任何一种硬币组合能组成总金额,返回 -1。

你可以认为每种硬币的数量是无限的。

示例 1:

输入:coins = [1, 2, 5], amount = 11
输出:3 
解释:11 = 5 + 5 + 1

这道题如果用第一题的思路,先用大面额去找零,比如11=5+5+1,也是一种比较优的解法。

但是要注意,题目并没有说明零钱的面额就是我们常规理解的1,2,5这些,可能为别的情况,比如零钱范围是[1,5,7],按照我们前面的贪心算法,则会算出来11=7+1+1+1+1,而更优的解法是11=5+5+1

所以,这道题其实是我举的贪心法的反例,正解是动态规划。

python解法:

class Solution:
    def coinChange(self, coins: List[int], amount: int) -> int:
        MAX = float('inf')
        dp = [0] + [MAX] * amount
        for i in range(1, amount+1):
            for c in coins:
                if i-c>=0:
                    dp[i] = min(dp[i-c]+1, dp[i])
        return -1 if dp[amount] == MAX else dp[amount]

122. 买卖股票的最佳时机 II

接下来看这一题,122. 买卖股票的最佳时机 II

给定一个数组 prices ,其中 prices[i] 是一支给定股票第 i 天的价格。

设计一个算法来计算你所能获取的最大利润。你可以尽可能地完成更多的交易(多次买卖一支股票)。

注意:你不能同时参与多笔交易(你必须在再次购买前出售掉之前的股票)。

示例 1:

输入: prices = [7,1,5,3,6,4]
输出: 7
解释: 在第 2 天(股票价格 = 1)的时候买入,在第 3 天(股票价格 = 5)的时候卖出, 这笔交易所能获得利润 = 5-1 = 4 。
     随后,在第 4 天(股票价格 = 3)的时候买入,在第 5 天(股票价格 = 6)的时候卖出, 这笔交易所能获得利润 = 6-3 = 3 

题目虽长,但读完后,我们很快就能发现,这道题的本质是求一个数组的所有连续递增数对差的和,这也是一种贪心的思路,只要第二天比第一天大,就计入利润。

class Solution:
    def maxProfit(self, prices: List[int]) -> int:
        res = 0
        for i in range(1, len(prices)):
            if prices[i] > prices[i-1]:
                res += prices[i] - prices[i-1]
        return res

455. 分发饼干

接下来看这一题,455. 分发饼干

假设你是一位很棒的家长,想要给你的孩子们一些小饼干。但是,每个孩子最多只能给一块饼干。

对每个孩子 i,都有一个胃口值 g[i],这是能让孩子们满足胃口的饼干的最小尺寸;并且每块饼干 j,都有一个尺寸 s[j] 。如果 s[j] >= g[i],我们可以将这个饼干 j 分配给孩子 i ,这个孩子会得到满足。你的目标是尽可能满足越多数量的孩子,并输出这个最大数值。

 
示例 1:

输入: g = [1,2,3], s = [1,1]
输出: 1
解释: 
你有三个孩子和两块小饼干,3个孩子的胃口值分别是:1,2,3。
虽然你有两块小饼干,由于他们的尺寸都是1,你只能让胃口值是1的孩子满足。
所以你应该输出1

将孩子的胃口数组和饼干数组先按有小到大排序,优先用小的饼干一个个地去试。

class Solution:
    def findContentChildren(self, g: List[int], s: List[int]) -> int:
        g.sort()
        s.sort()
        n, m = len(g), len(s)
        i = j = count = 0

        while i < n and j < m:
            while j < m and g[i] > s[j]:
                j += 1
            if j < m:
                count += 1
            i += 1
            j += 1
        
        return count

55. 跳跃游戏

最后,我们用这道题来进行巩固。55. 跳跃游戏

给定一个非负整数数组 nums ,你最初位于数组的 第一个下标 。

数组中的每个元素代表你在该位置可以跳跃的最大长度。

判断你是否能够到达最后一个下标。

示例 1:

输入:nums = [2,3,1,1,4]
输出:true
解释:可以先跳 1 步,从下标 0 到达下标 1, 然后再从下标 1 跳 3 步到达最后一个下标。
示例 2:

输入:nums = [3,2,1,0,4]
输出:false
解释:无论怎样,总会到达下标为 3 的位置。但该下标的最大跳跃长度是 0 , 所以永远不可能到达最后一个下标。

这道题我提供两个思路,一是从前往后,记录每一步所能到到的最大右边界。

class Solution:
    def canJump(self, nums: List[int]) -> bool:
        n, rightmost = len(nums), 0
        for i in range(n):
            if i <= rightmost:
                rightmost = max(rightmost, i + nums[i])
                if rightmost >= n - 1:
                    return True
        return False

第二个是从后往前,记录所能到达终点的最左边界。

class Solution:
    def canJump(self, nums: List[int]) -> bool:
        if not nums: return False
        end_reachable = len(nums) - 1
        for i in range(len(nums)-1, -1 , -1):
            if nums[i] + i >= end_reachable:
                end_reachable = i
        return end_reachable == 0

我们还可以看下这道题的升级版 45. 跳跃游戏 II,求出最小的跳跃步数。

这也需要用到贪心,不过思路比较难想。我们用贪心进行正向查找,每次找到可到达的最远位置,在具体的实现中,我们维护当前能够到达的最大下标位置,记为边界。我们从左到右遍历数组,到达边界时,更新边界并将跳跃次数增加 1。以下是python题解:

class Solution:
    def jump(self, nums: List[int]) -> int:
        size = len(nums)
        max_position, end, step = 0, 0, 0
        for i in range(size-1):
            max_position = max(max_position, i+nums[i])
            if i == end:
                end = max_position
                step += 1
        return step