LeetCode-贪心算法

6 篇文章 0 订阅
6 篇文章 0 订阅

一. 贪心算法

​ 贪心算法的实质就是,直接解决问题太难,于是寻找一个看起来比较tricky的解法,核心思想是先寻找 局部最优解,进而整合起来的结果仍然是 整体最优解。因为寻找局部最优解的过程,可以忽略很多情况条件(因为它考虑的是局部,而非整体),因此才显得比较“贪心”。但要注意的是,每一个地方都是局部最优解,最终的全局并不一定是整体的最优解,所以贪心算法只有在特定的题目才可以使用。

​ 那么什么题目才可以使用贪心算法?自然是具体问题具体分析,关键在于前面的最优解是否会对后续的过程产生影响。直接证明比较难,一般是通过反证法来证明。贪心算法有点像直觉上的不成形算法,关键在于这个局部最优解最后是否能演变成整体最优解,一般可以通过反证法来判断是否能得到整体最优解。接下来,我们通过几道LeetCode的题目来进行操作。

二. LeetCode 455. 分配饼干

455.Assign Cookies (Easy)

Leetcode / 力扣

Input: grid[1,3], size[1,2,4]
Output: 2

题目描述:每个孩子都有一个满足度 grid,每个饼干都有一个大小 size,只有饼干的大小大于等于一个孩子的满足度,该孩子才会获得满足。求解最多可以获得满足的孩子数量。

分析:一个饼干只能分配给一个小孩子。这道题,直觉上的思路:优先分配给满足度最小的孩子,并且给一个小孩子分配饼干的时候,应该分配尽量小但又能满足那个小孩子的饼干,留着更大的饼干用于给满足度比较大的孩子。这个思想应该很好理解,其实这就已经是 贪心算法,为什么?因为优先给当前满足度最小的孩子分配尽量小的饼干,这就是一个 局部最优解 的情况。那么,虽然直觉上是认为这样就是正确答案,也就是这个局部最优解,最后会演化成 整体最优解,但如果要专业的证明,如何证明?答案就是开头所说的,反证法

证明:假设在某次选择中,我们的贪心策略选择:给当前满足度最小的孩子分配第m个饼干,并且第m个饼干是可以满足该孩子的最小饼干。假设这并不一定是全局最优解,也就是存在一种更优的策略,可以给该孩子分配第n个饼干,并且m < n。我们可以发现,经过这一轮的分配,贪心策略可以剩下一个饼干n,而假设的更优策略,留下的是饼干m。又由于m < n,所以在后续的分配中,贪心策略一定能满足更多的孩子。所以不存在比贪心策略更优的策略,即贪心策略是最优策略。

总结:贪心算法看起来确实就是一种直觉上的算法,根据具体问题分析,得出局部最优解,进而获得全局最优解。所以后续的题目直接忽略证明(主要是因为懒得证明),讲述贪心算法的思路即可。(这道题还用到了双指针)

class Solution {
    // 优先给满足度最小的分配尽量小的饼干,局部最优达到全局最优
    public int findContentChildren(int[] g, int[] s) {
        Arrays.sort(g);
        Arrays.sort(s);
        int gIndex = 0, sIndex = 0;
        while (gIndex < g.length && sIndex < s.length) 
            if (g[gIndex] <= s[sIndex++])
                gIndex++;
        return gIndex;
    }
}

三. LeetCode 435. 不重叠的区间个数

435.Non-overlapping Intervals (Medium)

Leetcode / 力扣

Input: [ [1,2], [1,2], [1,2] ]
Output: 2

Explanation: You need to remove two [1,2] to make the rest of intervals non-overlapping.
Input: [ [1,2], [2,3] ]
Output: 0

Explanation: You don't need to remove any of the intervals since they're already non-overlapping.

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

分析:给数组排序,然后从左到右逐个选择,如果重叠(冲突)就去掉。记录选择的区间个数,最后得到的就是结果。那么这个排序的逻辑是什么?答案是:根据右区间进行排序。试想存在两个区间,[1, 2], [1, 3],应该选择哪个?显然是[1, 2],因为它右区间更小,进而后面能容纳的范围也更大。那么左区间不需要考虑吗?虽然左区间比自己的右区间更小,但并不一定比其他的左区间更小,比如[0, 2], [1, 3]。这时候我们可能会钻牛角尖,这种情况肯定是选[0, 2],但如果前面还有一个区间怎么样?比如: [-2, 1], [0, 2], [1, 3]。实际上“如果前面还有一个区间”,这种假设是不可能存在的,因为我们是 从左到右 进行选择的。所以不存在我们先选取了[0, 2],再因为前面还有区间而导致出错的情况。我们只会先选择[-2, 1],然后读到[0, 2]的时候,发现冲突,继续向前。读到[1, 3],没有冲突,于是最后的答案就是2。所以这种排序得到的确实是最优解。

总结:贪心算法最关键的是寻找局部最优解,并且要确保这是否真的为局部最优解。在验证的过程中,我们会使用测试用例,但不能忘记自己算法的规则(比如上面的从左到右,依次选择右区间最小的元素,如果忽略了从左到右,那么可能会在一些测试用例上浪费时间)

class Solution {
    public int eraseOverlapIntervals(int[][] intervals) {
        if (intervals.length <= 1)
            return 0;
        // 排序, 取右区间最小的区间,可以确保后续能容纳更多区间
        Arrays.sort(intervals, new Comparator<int[]>() {
            @Override
            public int compare(int[] o1, int[] o2) {
                return o1[1] - o2[1];
            }
        });
        int res = 0;
        int prev = intervals[0][1];
        for (int i = 1; i < intervals.length; i++) {
            if (intervals[i][0] < prev) {
                // 这些不符合的,重叠的,都需要去掉
                res++;
                continue;
            }
            prev = intervals[i][1];		// 记录当前的最右区间
        }
        return res;
    }
}

四. LeetCode 452. 投飞镖刺破气球

452.Minimum Number of Arrows to Burst Balloons (Medium)

Leetcode / 力扣

Input:
[[10,16], [2,8], [1,6], [7,12]]

Output:
2

题目描述:气球在一个水平数轴上摆放,可以重叠,飞镖垂直投向坐标轴,使得路径上的气球都被刺破。求解最小的投飞镖次数使所有气球都被刺破。

分析:其实跟上一题很相似,就是选择一定数量的数字,使得这些数字一起覆盖上面的区间。同理,进行排序,从左到右进行覆盖(也就是题目中的投飞镖刺气球)。同样是根据右区间排序,然后从左到右选择右区间的位置进行投飞镖。比如区间[1, 6],到底是在1~6哪个位置投最合适?答案是6,也就是右区间,因为更容易同时刺中后面的气球。如果你又要问“如果前面还有一个[3,4]的区间怎么办,建议回看上一题。。)所以这一题的思路跟上一题是一模一样的,只是处理的逻辑稍微有一点区别。

总结: 跟上一题一样。

class Solution {
    public int findMinArrowShots(int[][] points) {
        if (points.length == 0)
            return 0;
        Arrays.sort(points, new Comparator<int[]>() {
            @Override
            public int compare(int[] o1, int[] o2) {
                return o1[1] - o2[1];
            }
        });
        int res = 1;
        int current = points[0][1];
        for (int i = 1; i < points.length; i++) {
            if (current >= points[i][0] && current <= points[i][1])	// 同时刺破的气球
                continue;
            res++;
            current = points[i][1];
        }
        return res;
    }
}

五. LeetCode 406. 根据身高和序号重组队列

406.Queue Reconstruction by Height(Medium)

Leetcode / 力扣

Input:
[[7,0], [4,4], [7,1], [5,0], [6,1], [5,2]]

Output:
[[5,0], [7,0], [5,2], [6,1], [4,4], [7,1]]

题目描述:一个学生用两个分量 (h, k) 描述,h 表示身高,k 表示排在前面的有 k 个学生的身高比他高或者和他一样高。

分析

​ 首先,毫无疑问重组的队列是一个一个身高insert进去的,不可能一下全部丢进去,然后再重排。如果重排,那就是暴力法,把所有情况都列出来,时间复杂度肯定很高,不推荐。那么关键就是这个依次添加的每一个学生,如何不受后面的学生影响,或者说,后面的学生如何insert到恰当的位置?这就是每一步都寻求的最优解,也就是局部最优,所以也属于贪心算法的范畴。

​ 思考了之后,最不容易受影响的学生,就是最高的学生,因为比它们矮的学生,无论怎么排,都不会影响第二个数字(排在前面比它们高或者一样高的学生数量)。所以排序的第一个规则,h越大,就排在越前。那么如果h相同的情况应该怎么处理?答案是,先考虑k较小的情况,只要把该h,放在队列里的第k个位置,那么前面的学生就不会受到影响。拿这里举例:[7, 0], [6, 1], [7, 1]。[7, 0]先入队,放在队列的第0个位置,此时队列为{7}。然后[7, 1]进队,放在队列的第1个位置,此时队列为{7,7}。然后关键的[6, 1]来了,应该放在哪个位置?就是放在队列的第1个位置,这样队列为{7,6,7}。为什么是第0个位置?看起来很巧妙,很难思考,其实也是局部最优的原因。因为前面的放法都是局部最优,所以后面这样加的位置也是正确解。比如前面放置[7, 0]和[7, 1]的时候,不会怀疑这个放法,因为它前面也是局部最优(初始化当然是最优,只加一个的时候当然也是最优)。所以,关键是:身高h降序,个数k升序。

总结:贪心算法的核心是寻找局部最优,并非找到合适的排序就已经结束,以哪种顺序insert,这些都是要考虑的方面。

class Solution {
    public int[][] reconstructQueue(int[][] people) {
        // 排序, h降序, k升序, 使得后续的insert不会改变先前的相对位置
        Arrays.sort(people, new Comparator<int[]>() {
            @Override
            public int compare(int[] o1, int[] o2) {
                int tmp = o2[0] - o1[0];
                if (tmp != 0)
                    return tmp;
                return o1[1] - o2[1];
            }
        });
        // 按顺序insert, 每一个都insert到当前people[i][1]的位置
        List<int[]> list = new ArrayList<>();
        for (int[] p: people)
            list.add(p[1], p);
        return list.toArray(new int[list.size()][]);
    }
}

六. LeetCode 121. 买卖股票最大的利益

121.Best Time to Buy and Sell Stock (Easy)

Leetcode / 力扣

题目描述:一次股票交易包含买入和卖出,只进行一次交易,求最大收益。

分析:假设我们要在第i天出售,此时的最大利润是什么?是在前面(i - 1)天寻找一个最小值,然后用prices[i]减去第i天之前的最小值。这就是每一天的最优解(每一天出售时候的最大利益),全部取max,就是答案。

总结:一开始觉得这题跟贪心算法没联系,其实这题再翻译一下就是,在n天内,可以买卖股票,只进行一次交易,那么最大的利益是多少?里面隐含了一个条件,n天之内。实际上,我们只需要求出每一天出售时候的最大利益,最后就能通过每一天出售的最大利益,求得n天之内买卖的最大利益,也就是局部最优到全局最优。

class Solution {
    public int maxProfit(int[] prices) {
        if (prices.length <= 1)
            return 0;
        int min = prices[0];    // 记录当前的min
        int res = 0;
        for (int i = 1; i < prices.length; i++) {
            int tmp = prices[i] - min;  // 今天卖, 与min的差价
            if (tmp <= 0) {
                min = prices[i];
                continue;
            }
            res = res >= tmp ? res : tmp;
        }
        return res;
    }
}

七. LeetCode 122.买卖股票的最大利益Ⅱ

122.Best Time to Buy and Sell Stock II (Easy)

Leetcode / 力扣

题目描述:可以进行多次交易,多次交易之间不能交叉进行,可以进行多次交易。

分析:因为可以多次交易,所以上一题的方法无效。这道题很容易让人想太多,比如到底应该什么时候买,什么时候卖(仿佛就回到了现实的纠结当中)。其实有一个最关键的点:对于 [a, b, c, d],如果有 a <= b <= c <= d ,那么最大收益为 d - a。正确的做法应该是,第一天的时候买进a,第四天的时候以d的价格卖出去,所以中间的b和c都是干扰项,这么想就会把问题复杂化。而实际上,d - a = (d - c) + (c - b) + (b - a) ,因此当访问到一个 prices[i] 且 prices[i] - prices[i-1] > 0,那么就把 prices[i] - prices[i-1] 添加到收益中。如果是c <= a <= b <= d应该怎么办?同样的: d - a = (d - b) + ( b - a) 。一天只能买,或者卖,其实这就是干扰项,你第二天可以以b的价格卖掉,此时利润为(b - a),就当做是,你又以b的价格拿回来了,随时可以出售(当然这个现实做不到)。当后面没有比b更好的情况,你的利润就是(b - a),如果有更好的,那么就是继续上面的 d - a = (d - c) + (c - b) + (b - a) 。所以核心的关键是,只要prices[i] > prices[i - 1],这一部分的利润就可以赚取。这就是这道题的局部最优。

总结:局部最优解并不容易想,但只要想出来,并且能达到整体最优,那么贪心算法很把问题迅速解决。

class Solution {
    public int maxProfit(int[] prices) {
        int profit = 0;
        for (int i = 1; i < prices.length; i++)
            if (prices[i] > prices[i - 1])
                profit += prices[i] - prices[i - 1];
        return profit;
    }
}

八. LeetCode 605. 种植花朵

605.Can Place Flowers (Easy)

Leetcode / 力扣

Input: flowerbed = [1,0,0,0,1], n = 1
Output: True

题目描述:flowerbed 数组中 1 表示已经种下了花朵。花朵之间至少需要一个单位的间隔,求解是否能种下 n 朵花。

分析:这道题很显然,只要从左到右种,那么就一定是最优(只要当前的格子符合要求,就可以种)。应该很直观吧,不会有人觉得在某些特定的情况,左边不能种,应该种在稍右的地方吧(不会吧不会吧)。这个用反证法也很好证明,过程估计就跟第一题的证明一样,种在最左,右边能遗留的空间会更多。

总结:即使不知道这是贪心算法,也知道是这么回事,但确实这也是局部最优到整体最优的过程。

class Solution {
    public boolean canPlaceFlowers(int[] flowerbed, int n) {
        for (int i = 0; i < flowerbed.length; i++) {
            if (n == 0)
                break;
            if (flowerbed[i] == 1)
                continue;
            int left = i - 1, right = i + 1;
            boolean flag1 = (left < 0) || flowerbed[left] == 0;
            boolean flag2 = (right == flowerbed.length) || flowerbed[right] == 0;
            if (flag1 && flag2) {
                flowerbed[i] = 1;
                n--;
            }  
        }
        return n == 0;
    }
}

九. 总结

​ 只要是找局部最优解,进而演变成整体最优解,那么就是贪心算法。但是贪心算法的适用性其实并不广,所以并不能频繁考虑。可是贪心算法比较像直观上思考的算法,所以只要直观上觉得可行,就该考虑这是不是局部最优,是不是属于贪心算法,进而再进行解题。值得注意的是,局部最优解虽然暂时不用考虑整体,但有些情况下也并不容易找到,如果当前的局部解并不是最优解,最后的结果自然也是错的。总而言之,这是一个在特定情况下很好用的方法。

  • 0
    点赞
  • 4
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值