贪心算法解题套路框架

举个简单的例子,就能直观的展现贪心算法了。

比方说现在有两种钞票,面额分为为 1 元和 100 元,每种钞票的数量无限,但现在你只能选择 10 张,请问你应该如何选择,才能使得总金额最大?

那你肯定会说,这还用问么?肯定是 10 张全拿 100 元的钞票,共计 1000 元,这就是最优策略,但凡犹豫一秒就是傻瓜。

你这么说,也对,也不对。

说你对,因为这确实是最优解法,没毛病。

说你不对,是因为这个解法暴露的是你只想捞钱的本质 (¬‿¬) ,跳过了算法的产生、优化过程,不符合计算机思维。

那计算机就要提问了, 一切算法的本质是穷举,现在你还没有穷举出所有可能的解法,凭什么说这就是最优解呢?

按照算法思维,这个问题的本质是做 10 次选择,每次选择有两种可能,分别是 1 元和 100 元,一共有 2(指数 10)种可能的选择。

所以你心里首先应该出现一棵高度为 10 的二叉树来穷举所有可行解,遍历这些可行解,然后可以得到最优解。

心里只要有这样一棵二叉树,就应该能写出代码:


// 定义:做 n 次选择,返回可以获得的最大金额
int findMax(int n) {
    if (n == 0) return 0;

    // 这次选择 1 元,然后递归求解剩下的 n - 1 次选择的最大值
    int result1 = 1 + findMax(n - 1);
    // 这次选择 100 元,然后递归求解剩下的 n - 1 次选择的最大值
    int result2 = 100 + findMax(n - 1);

    // 返回两种选择中的最大值
    return Math.max(result1, result2);
}

这个算法的复杂度是二叉树的节点数量,是指数级别,非常高。不过到这里你应该已经看出来了,findMax(n - 1) 的值肯定都一样,那么 100 + findMax(n - 1) 必然大于 1 + findMax(n - 1),因此可以进行优化:

// 优化一、没必要对两种选择进行比较了
int findMax(int n) {
    if (n == 0) return 0;
    int result = 100 + findMax(n - 1);
    return result;
}

// 优化二、递归改为迭代
int findMax(int n) {
    int result = 0;
    for (int i = 0; i < n; i++) {
        result += 100;
    }
    return result;
}

// 优化三、直接计算结果就行了
int findMax(int n) {
    return 100 * n;
}

这就是贪心算法,复杂度从 O(2 指数 n) 优化到了 O(1),堪称离谱。

你可能会认为这个例子太简单?

其实算法本来就很简单,就是穷举,有什么了不起的嘛?围绕穷举,衍生出各种优化方法,起了些花里胡哨的名字,不懂的人就容易被名字骗到,其实从原理上讲,没多大差别,不过是见招拆招罢了。

上面的例子虽然简单,但已经蕴含了贪心算法的精髓。接下来我们拓展延伸一下,在真实的算法问题中,如何发现贪心选择性质,如何用贪心算法解决问题。

贪心选择性质

首先来举例说明什么是贪心选择性质。

本文开头举例的这个最大金额的问题符合贪心选择性质,所以我们可以用贪心算法,把 O(2 指数 n) 的暴力穷举算法直接优化到了 O(1)。

作为对比,我们稍微改一改题目:

现在有两种钞票,面额分别为 1 元和 100 元,每种钞票的数量无限。现在给你一个目标金额 amount,请问你最少需要多少张钞票才能凑出这个金额?

这道题其实就是 动态规划解题套路框架 中讲解的凑零钱问题。

为了方便讲解,本文开头的最大金额问题我们称为「问题一」,这里的最少钞票数量问题我们称为「问题二」。

我们是如何解决问题二的?首先也是抽象出递归树,写出指数级别的暴力穷举算法,然后发现了重叠子问题,于是用备忘录消除重叠子问题,这就是标准的动态规划算法的求解过程,不能再优化了。

所以,问题二和问题一到底有什么区别?区别在于,前者没有贪心选择性质,而后者有。

贪心选择性质

贪心选择性质就是说能够通过局部最优解直接推导出全局最优解。

结合案例来说,对于问题一,局部最优解就是每次都选择 100 元,因为 100 > 1;对于问题二,局部最优解也是每次都选择 100 元,因为每张面额尽可能大,所需的钞票数量就能尽可能少。

但区别在于,问题一中每一次选择的局部最优解组合起来就是全局最优解,而问题二中不是。

比方说目标金额 amount = 3,虽然每次选择 100 元是局部最优解,但想凑出 3 元,只能选择 3 张 1 元,局部最优解不一定能构成全局最优解。

对于问题二的场景,不符合贪心选择性质,所以不能用贪心算法,只能穷举所有可行解,才能计算出最优解。

贪心选择性质 vs 最优子结构

动态规划解题套路框架 中提到,算法问题必须要有「最优子结构」性质,才能通过子问题的最优解推导出全局最优解,这是动态规划算法的基础。

那么就有读者搞不清楚了,这个「贪心选择性质」和「最优子结构」有什么区别?

最优子结构的意思是说,现在我已经把所有子问题的最优解都求出来了,然后我可以基于这些子问题的最优解推导出原问题的最优解。

贪心选择性质的意思是说,我只需要进行一些局部最优的选择策略,就能直接知道哪个子问题的解是最优的了,且这个局部最优解可以推导出原问题的最优解。此时此刻我就能知道,不需要等到所有子问题的解算出来才知道。

所以贪心算法的效率一般都比较高,因为它不需要遍历完整的解空间。

例题实战

先来看第一道例题,力扣第 55 题「跳跃游戏」

注意题目说 nums[i] 表示可以跳跃的最大长度,不是固定长度。假设 nums[i] = 3,意味着你可以在索引 i 往前跳 1 步、2 步或 3 步。

我们先思考暴力穷举解法吧,如何穷举所有可能的跳跃路径?你心里的那棵多叉树画出来了没有?

假设 N 为 nums 的长度,这道题相当于做 N 次选择,每次选择有 nums[i] 种选项,想要穷举所有的跳跃路径,就是一棵高度为 N 的多叉树,每个节点有 nums[i] 个子节点:

这个算法本质上还是穷举了所有可能的选择,可以走动态规划那一套流程进行优化,但是这里我们先不急,可以再仔细想想,这个问题有没有贪心选择性质?

题目的关键点:

1、在索引 i,可以跳跃 1 ~ nums[i] 步。

2、只要能跳到最后一个索引,就返回 true

这里面有个细节,比方说你现在站在 nums[i] = 3 的位置,你可以跳到 i+1, i+2, i+3 三个位置,此时你真的需要分别跳过去,然后递归求解子问题 dp(i+1), dp(i+2), dp(i+3),最后通过子问题的答案来决定 dp(i) 的结果吗?

其实不用的,i+1, i+2, i+3 三个候选项,它们谁能走得最远,你就选谁,准没错。

具体来说,i+1 能走到的最远距离是 i+1+nums[i+1],i+2 能走到的最远距离是 i+2+nums[i+2],i+3 能走到的最远距离是 i+3+nums[i+3],你看看谁最大,就选谁。

这就是贪心选择性质,通过局部最优解就能推导全局最优解,不需要等到递归计算出所有子问题的答案才能做选择。

所以这道题的最优解法如下:

func canJump(nums []int) bool {
    n := len(nums)
    farthest := 0
    for i := 0; i < n-1; i++ {
        // 不断计算能跳到的最远距离
        farthest = max(farthest, i+nums[i])
        // 可能碰到了 0,卡住跳不动了
        if farthest <= i {
            return false
        }
    }
    return farthest >= n-1
}

// Helper function to return the max of two integers
func max(a, b int) int {
    if a > b {
        return a
    }
    return b
}

只需要遍历所有元素,记录当前能到达的最远位置就行了,时间复杂度是 O(N),空间复杂度是 O(1),非常高效。

接下来看第二道例题,力扣第 45 题「跳跃游戏 II」

现在的问题是,保证你一定可以跳到最后一格,请问你最少要跳多少次,才能跳过去。

暴力穷举肯定也是可以做的,就类似上面的那道题,只不过修改一下 dp 数组的定义,返回值从 boolean 改成 int,表示最少需要跳跃的次数就行了。

题目问的就是 dp(nums, 0) 的结果,base case 就是当 p 超过最后一格时,不需要跳跃:

if (p >= nums.length - 1) {
    return 0;
}

根据前文 动态规划套路详解 的动规框架,就可以暴力穷举所有可能的跳法,通过备忘录 memo 消除重叠子问题,取其中的最小值最为最终答案:

func jump(nums []int) int {
    n := len(nums)
    // 备忘录都初始化为 n,相当于 INT_MAX
    // 因为从 0 跳到 n - 1 最多 n - 1 步
    memo := make([]int, n)
    for i := range memo {
        memo[i] = n
    }

    return dp(nums, 0, memo)
}

// 定义:从索引 p 跳到最后一格,至少需要 dp(nums, p) 步
func dp(nums []int, p int, memo []int) int {
    n := len(nums)
    // base case
    if p >= n - 1 {
        return 0
    }
    // 子问题已经计算过
    if memo[p] != n {
        return memo[p]
    }
    steps := nums[p]
    // 你可以选择跳 1 步,2 步...
    for i := 1; i <= steps; i++ {
        // 穷举每一个选择
        // 计算每一个子问题的结果
        subProblem := dp(nums, p+i, memo)
        // 取其中最小的作为最终结果
        memo[p] = min(memo[p], subProblem+1)
    }
    return memo[p]
}

这个解法已经通过备忘录消除了冗余计算,时间复杂度是 递归深度 x 每次递归需要的时间复杂度,即 O(N²),在 LeetCode 上是无法通过所有用例的,会超时。

所以进一步的优化就只能是贪心算法了,我们要仔细思考是否存在贪心选择性质,是否能够通过局部最优解推导全局最优解,避免全量穷举所有的可能解。

和上面的题目是一样的优化思路:我们真的需要递归地计算出每一个子问题的结果,然后求最值吗?其实不需要。

hexinkuangjia67

上图这种情况,我们站在索引 0 的位置,可以向前跳 1,2 或 3 步,你说应该选择跳多少呢?

你可以确定地说,应该跳 2 步调到索引 2,因为 nums[2] 的可跳跃区域涵盖了索引区间 [3..6],比其他的都大。题目求最少的跳跃次数,那么往索引 2 跳必然是最优的选择。

这就是贪心选择性质,我们不需要真的递归穷举出所有选择的具体结果来比较求最值,而只需要每次选择那个最有潜力的局部最优解,最终就能得到全局最优解。

绕过这个弯儿来就可以写代码了。代码可能有点不好理解,关键在于对 i, end, farthest, jumps 几个变量的更新:

每次跳跃可达的是一个索引区间,代码中 [i, end] 维护当前的跳跃次数 jumps 可以覆盖的索引区间,farthest 表示从 [i, end] 区间内起跳,可以跳到的最远索引。

可以结合代码注释和可视化来理解:

func jump(nums []int) int {
    if len(nums) <= 1 {
        return 0
    }
    n := len(nums)
    // jumps 步可以跳到索引区间 [i, end]
    end, jumps := 0, 0
    // 在 [i, end] 区间内,最远可以跳到的索引是 farthest
    farthest := 0
    for i := 0; i < n-1; i++ {
        // 计算从索引 i 可以跳到的最远索引
        if nums[i]+i > farthest {
            farthest = nums[i] + i
        }
        if i == end {
            // [i, end] 区间是 jumps 步可达的索引范围
            // 现在已经遍历完 [i, end],所以需要再跳一步
            jumps++
            end = farthest
            if farthest >= n-1 {
                // 如果已经可以到达终点,则可以直接返回
                return jumps
            }
        }
    }
    // 如果无法到达终点,则返回 -1
    return -1
}

这个解法的时间复杂度是 O(N),空间复杂度是 O(1),可以通过所有测试用例。

至此,两道例题都解决了。

贪心算法的解题步骤

贪心算法的关键在于问题是否具备贪心选择性质,所以只能具体问题具体分析,没办法抽象出一套固定的算法模板或者思维模式,判断一道题是否是贪心算法。

我的经验是,没必要刻意地识别一道题是否具备贪心选择性质。你只需时刻记住,算法的本质是穷举,遇到任何题目都要先想暴力穷举思路,穷举的过程中如果存在冗余计算,就用备忘录优化掉。

如果提交结果还是超时,那就说明不需要穷举所有的解空间就能求出最优解,这种情况下肯定需要用到贪心算法。你可以仔细观察题目,是否可以提前排除掉一些不合理的选择,是否可以直接通过局部最优解推导全局最优解。