每天一种算法(二) --贪心算法

595 阅读13分钟

贪心算法

什么是贪心算法?

贪心算法是一种比较简单的算法,它的核心理念就是保证每次操作都是局部最优的,从而使最后得到的结果是最优的。我们来看一个简单的例子,来深入了解贪心算法:

最少纸币支付问题

问题描述:

我们去商店买东西,手里有100、50、20、10、5、2、1这些面额的纸币,且数量足够多。我们选中一件788的商品,我们结账时如何用最少的纸币数量进行支付?

解题思路:

我们每次用当前可用的最大面额的纸币进行支付,然后需要支付的总金额减掉对应的纸币金额,重复之前步骤,直到需要支付的总金额变为0。具体步骤如下:

  1. 我们用788元除以当前可用最大面额100元,得到7,所以我们使用7张面额为100的纸币,此时还需支付88元。

  2. 我们用88元除以当前可用最大面额50元,得到1,所以我们使用1张50元,此时还需支付38元。

  3. 我们用38元除以当前可用最大面额20元,得到1,所以我们使用1张20元,此时还需支付18元。

  4. 我们用18元除以当前可用最大面额10元,得到1,所以我们使用1张10元,此时还需支付8元。

  5. 我们用8元除以当前可用最大面额5元,得到1,所以我们使用1张5元,此时还需支付3元。

  6. 我们用3元除以当前可用最大面额2元,得到1,所以我们使用1张2元,此时还需支付1元。

  7. 我们用1元除以当前可用最大面额1元,得到1,所以我们使用1张1元,此时还需支付0元。

至此,我们就得到了结论:一件788元的衣服,使用最少的纸币数量为7张100元、1张50元、1张20元、1张10元、1张5元、1张2元、1张1元,共13张。

上述问题就是最典型的贪心算法,我们不断在剩余的选择中选择局部最优的那一项,最后得到全局最优的结果。

js代码实现:

function payMoney(money) {
    const moneyArr = [100, 50, 20, 10, 5, 2, 1];
    let _money = money;
    let total = 0; //最终需要使用的纸币数量
    for(let i = 0; i < moneyArr.length; i++) {
        //当前纸币的面额
        const nowMoney = moneyArr[i];
        if(_money >= nowMoney) {
            // 当前总金额除以当前最大面额金额,向下取整,得到当前最大面额纸币的数量,加在总数上
            total += Math.floor(_money / nowMoney);
            _money = _money % nowMoney;
        }
    }
    return total;
}

分析时间复杂度:

上述代码的核心循环部分(即每次使用的纸币面额)共遍历了7次,是常数时间的时间复杂度。

所以时间复杂度是:O(n) = 1;

应用场景

贪心算法一般可应用的场景有两种:

  • 分配问题

  • 区间问题

我们在刚才解决的最少纸币支付问题就是典型的分配问题,我们接着再来看几道分配问题和区间问题。

分配问题-变形:饼干分配

题目描述(对应leecode 第455题)

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

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

输入输出示例:

示例 1:

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

示例 2:

输入: g = [1,2], s = [1,2,3]
输出: 2
解释: 
    你有两个孩子和三块小饼干,2个孩子的胃口值分别是1,2。
    你拥有的饼干数量和尺寸都足以让所有孩子满足。
    所以你应该输出2.

解题思路

要尽量满足越多数量的孩子,其实很简单,我们先满足胃口最小的孩子,再满足胃口第二小的孩子...,直到没有饼干或所有孩子被满足时,遍历结束。以示例1为例,我们来看具体步骤:

  1. 先将每个孩子按照胃口(入参数组g)从小到大的顺序排序[1,2,3],在把饼干按照尺寸(入参数组s)从小到大的顺序排序[1,1,2]。(当前g:[1,2,3], s:[1,1,2])

  2. 我们拿出当前最小的饼干(尺寸为1),问当前胃口最小的孩子(胃口为1),这块饼干能否满足你,发现可以满足,我们将饼干给到孩子(对应就是数组s和数组g推出头部元素),继续看下一块儿饼干和下一个孩子。(当前g:[2,3], s:[1,2])

  3. 我们拿出当前最小的饼干(尺寸为1),我们拿出当前最小的饼干(尺寸为1),问当前胃口最小的孩子(胃口为2),这块饼干能否满足你,发现不能满足,我们将饼干放下(对应就是数组g推出头部元素),继续找下一块饼干。(当前g:[2,3], s:[2])

  4. 我们拿出当前最小的饼干(尺寸为2),问当前胃口最小的孩子(胃口为2),这块饼干能否满足你,发现可以满足,我们将饼干给到孩子(对应就是数组s和数组g推出头部元素),继续看下一块儿饼干和下一个孩子。(当前g:[3], s:[])

  5. 我们发现没有饼干了(即s的长度为0),遍历结束,输出当前可以满足的孩子数量,即得到题解。

js代码实现

function findContentChildren(g, s) {
    // 先排序
    g.sort((a,b) => a - b);
    s.sort((a,b) => a - b);
    
    let result = 0;
    
    while(g.length && s.length) {
        if(s[0] >= g[0]) {
            result ++;
            g.shift();
            s.shift();
        } else {
            s.shift();
        }
    }
    return result;
}

分析时间复杂度

上述代码中,排序分别遍历了mlogm次 和nlogn次,

核心循环部分(即s和g推出首部元素)最多遍历了m次 和 n次。

由于前者在渐进意义下大于后者,因此总时间复杂度为 O(mlogm + nlogn)。

分配问题-再变形:糖果问题

题目描述(对应leecode 第135题)

老师想给孩子们分发糖果,有 N 个孩子站成了一条直线,老师会根据每个孩子的表现,预先给他们评分。

你需要按照以下要求,帮助老师给这些孩子分发糖果:

每个孩子至少分配到 1 个糖果。 评分更高的孩子必须比他两侧的邻位孩子获得更多的糖果。 那么这样下来,老师至少需要准备多少颗糖果呢?

输入输出示例

示例 1:

输入:[1,0,2]
输出:5
解释:你可以分别给这三个孩子分发 212 颗糖果。

示例 2:

输入:[1,2,2]
输出:4
解释:你可以分别给这三个孩子分发 121 颗糖果。
     第三个孩子只得到 1 颗糖果,这已满足上述两个条件。

解题思路

因为每个孩子至少得到一颗糖果,所以我们先给每个孩子发一颗糖果。然后又因为评分更高的孩子必须比他两侧的邻位的孩子获得更多的糖果,所以我们先从左向右遍历一次,将比其左侧评分高的孩子的糖果数量设为其左侧孩子糖果数量+1。经过本轮遍历,我们满足了:评分更高的孩子比他左侧的邻位的孩子的糖果数量更多。然后我们再从右向左遍历一次,将比其右侧评分高的孩子的糖果数量设为当前数量和应有数量(其右侧孩子糖果数量+1)中的最大值。,经过本轮遍历,我们满足了:评分更高的孩子比他两侧的邻位的孩子的糖果数量更多,即实现题解。以示例1为例,我们来看具体步骤:

  1. 输入为[1,0,2],即有3个孩子,对应评分分别为1,0,2。

  2. 我们设置一个初始值为[1,1,1]的数组result,存放需要给每个孩子的糖果数量(一开始给每个孩子发一颗糖果)。

  3. 我们从左往右(即对应数组的从前到后)遍历一次,给比左侧孩子评分更高的孩子更多的糖果。

1. 下标为0的孩子,左侧没有孩子,跳过。此时result为[1,1,1]。
2. 下标为1的孩子,他的评分(0)没有左侧孩子的评分(1)高,跳过。此时result为[1,1,1]。
3. 下标为2的孩子,他的评分(2)比左侧孩子的评分(1)高,将他的糖果数量置为左侧孩子糖果数量(1)+ 1。此时result为[1,1,2]。
4.此时下标2已到数组末尾,遍历结束,得到评分更高的孩子比他左侧的邻位的孩子的糖果数量更多的结果。
  1. 我们从右往左(即对应数组的从后到前)再遍历一次,给比右侧孩子评分更高的孩子更多的糖果。
1. 下标为2的孩子,右侧没有孩子,跳过。此时result为[1,1,2](第一次遍历的结果)。
2. 下标为1的孩子, 他的评分(0)没有右侧孩子的评分(2)高,跳过。此时result为[1,1,2]。
3. 下标为0的孩子,他的评分(1)比右侧孩子的评分(0)高,将他的糖果数量置为右侧孩子糖果数量(1)+ 1。此时result为[2,1,2]。
4.此时下标2已到数组头部,遍历结束,得到评分更高的孩子比他两侧的邻位的孩子的糖果数量更多的结果。

注意: 我们在第二次遍历时需要比较当前孩子的糖果数量与右侧孩子的糖果数量,如果右侧孩子的糖果数量本身就没有左侧孩子的糖果数量高,我们不用额外做处理,跳过即可。

  1. 第三次遍历(遍历结果数组),将每个孩子得到糖果的数量相加,得到最终结果。

js代码实现

var candy = function(ratings) {
    const total = ratings.length;
    // 初始化一个每一项都为1的长度为孩子数量的一位数组,用于存放每个孩子的糖果数量
    const arr = new Array(total).fill(1);
    for(let i = 1 ; i < total; i++) {
        if(ratings[i] > ratings[i-1]) {
            arr[i] = arr[i - 1] + 1;
        }
    }
    for(let j = total - 1; j >= 0; j--) {
        if(ratings[j] > ratings[j+1]) {
            // 这里取当前孩子糖果数量和右侧孩子糖果数量+1的最大值
            arr[j] = Math.max(arr[j], arr[j + 1] + 1);
        }
    }
    let result = 0;
    arr.forEach((item) => result+= item);
    return result;
};

分析时间复杂度

上述代码,核心循环部分执行了3n次。分别是:

  1. 从左到右遍历n次

  2. 从右到左遍历n次

  3. 遍历结果数组n次

是线性时间的时间复杂度,所以时间复杂度是:O(n) = n。

区间问题:无重叠区间

题目描述

给定一个区间的集合,找到需要移除区间的最小数量,使剩余区间互不重叠。

注意: 可以认为区间的终点总是大于它的起点。区间 [1,2] 和 [2,3] 的边界相互“接触”,但没有相互重叠。

输入输出示例

示例 1:

输入: [ [1,2], [2,3], [3,4], [1,3] ]
输出: 1
解释: 移除 [1,3] 后,剩下的区间没有重叠。


示例 2:

输入: [ [1,2], [1,2], [1,2] ]
输出: 2
解释: 你需要移除两个 [1,2] 来使剩下的区间没有重叠。

解题思路

这道题目的描述没有之前几道题目直观,但思路也是比较简单的。我们先将所有的区间按照终点从小到达排序,这样每一个前面区间的终点都不小于后面区间的终点。这时第一个区间的终点是所有区间终点最小的那一个,如果后一个区间的起点比当前区间的终点大或相等,那么他们肯定不是重叠的区间,我们把它插到第一个区间的后面,然后再比较第二个和下一个区间,遍历一次,我们就可以找到最多有多少个区间没有重叠,那区间总数减去不重叠区间的个数即为题解。我们以示例1为例,来看具体步骤:

  1. 我们将区间按照终点大小排序,得到排序后的结果: [[1,2],[2,3],[1,3],[3,4]];

  2. 我们把区间[1,2]放入结果数组(result: [[1,2]])

  3. 然后比较区间[1,2]和区间[2,3],发现区间[2,3]的起点2等于[1,2]的终点2,区间[1,2]和区间[2,3]不重叠。我们把区间[2,3]放入结果数组(result: [[1,2], [2,3]])

  4. 我们继续比较区间[2,3]和区间[1,3],发现区间[1,3]的起点1小于区间[2,3]的终点2,区间2和区间3重叠,跳过区间3

  5. 继续比较区间[2,3]和区间[3,4],发现区间[3,4]的起点3等于区间[2,3]的终点3,区间[2,3]和区间[3,4]不重叠。我们把区间[3,4]放入结果数组(result: [[1,2], [2,3], [3,4]])

  6. 所有区间遍历过一次,不重叠的区间最多有3个,那总数4-3,得到题解1,即最少只需要移除1个区间,即可使剩余区间不重叠。

js代码实现

function eraseOverlapIntervals(intervals) {
    const len = intervals.length;
    if(len <= 1) return 0;
    // 排序
    intervals.sort((a,b) => a[1] - b[1]);
    const result = [];
    // 把第一个区间放入结果数组
    result.push(intervals[0]);
    for(let i = 1; i < len; i++) {
        // 当前区间的起点大于等于结果数组区间的终点
        if(intervals[i][0] >= result[result.length - 1][1]) {
            result.push(intervals[i]);
        }
    }
    return len - result.length;
}

分析时间复杂度

我们需要 O(nlogn) 的时间对所有的区间按照右端点进行升序排序,并且需要 O(n) 的时间进行遍历。由于前者在渐进意义下大于后者,因此总时间复杂度为 O(nlogn)。

所以时间复杂度是:O(n) = nlogn;

总结

在这一节,我们详细介绍了贪心算法,并通过js代码实现了多种经典题目,还对每道题目中分析了解题思路、js代码实现、以及时间复杂度分析。我们会发现:贪心算法最重要的就是分析和制定贪心策略,大家一定要分析不同情况,能够在看到题目后很快地确认是否应该使用贪心算法,以及如何贪心。

接下来我们会继续介绍双指针、DFS、BFS等有趣的算法,让我们一起来领略编程的乐趣吧!

我是何以庆余年,如果文章对你起到了帮助,希望可以点个赞,谢谢!

如有问题,欢迎在留言区一起讨论。