2.贪心算法.题目

题目

9.柠檬水找零

(题目链接)
在柠檬水摊上,每一杯柠檬水的售价为 5 美元。顾客排队购买你的产品,(按账单 bills 支付的顺序)一次购买一杯。

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

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

给你一个整数数组 bills ,其中 bills[i] 是第 i 位顾客付的账。如果你能给每位顾客正确找零,返回 true ,否则返回 false 。


10.根据身高重建序列

(题目链接)
假设有打乱顺序的一群人站成一个队列,数组 people 表示队列中一些人的属性(不一定按顺序)。每个 people[i] = [hi, ki] 表示第 i 个人的身高为 hi ,前面 正好 有 ki 个身高大于或等于 hi 的人。
贪心:每一步for遍历均满足在数值hi情况,前面又ki个高于或等于本身数值的人。

    static bool cmp(const std::vector<int>& a, const std::vector<int>& b){
        // 先按身高从高往矮排,当身高相同时,序号按递增排
        if(a[0]== b[0]) return a[1]<b[1];
        return a[0]>b[0];
    }
    vector<vector<int>> reconstructQueue(vector<vector<int>>& people) {
        std::sort(people.begin(), people.end(), cmp);
        std::vector<std::vector<int>> que;
        for(int i=0; i<people.size(); i++){
            int pos = people[i][1];
            // 每一步que中数值均是大于或等于bill值的数
            que.insert(que.begin()+pos, people[i]);
        }
        return que;
    }

10.1探究std::vector.insert功能

使用vector(C++中的动态数组)来进行insert操作是费时的。对于普通数组,一旦定义了大小就不能改变,例如int a[10];,这个数组a至多只能放10个元素,改不了的;对于动态数组,就是可以不用关心初始时候的大小,可以随意往里放数据,那么耗时的原因就在于动态数组的底层实现。
在这里插入图片描述
当insert数据的时候,如果已经大于capicity,capicity会成倍扩容,但对外暴漏的size其实仅仅是+1,事实上重新申请一个二倍于原数组大小的数组,然后把数据都拷贝过去,并释放原数组内存,底层数组的内存起始地址已经变了。因此,使用vector来做insert的操作,此时大家可会发现,虽然表面上复杂度是O(n2),但是其底层都不知道额外做了多少次全量拷贝了,所以算上vector的底层拷贝,整体时间复杂度可以认为是O(n2 + t × n)级别的,t是底层拷贝的次数。


11.用最少数量的箭引爆气球

(题目链接)
有一些球形气球贴在一堵用 XY 平面表示的墙面上。墙面上的气球记录在整数数组 points ,其中points[i] = [xstart, xend] 表示水平直径在 xstartxend之间的气球。你不知道气球的确切 y 坐标。一支弓箭可以沿着 x 轴从不同点 完全垂直 地射出。在坐标 x 处射出一支箭,若有一个气球的直径的开始和结束坐标为 xstartxend, 且满足 xstart ≤ x ≤ xend,则该气球会被 引爆 。
贪心:有点类似每一支箭击穿尽量多的气球;

    static bool cmp(const std::vector<int>& a, const std::vector<int>& b){
        return a[0]<b[0];
    }
    int findMinArrowShots(vector<vector<int>>& points) {
        if(points.size()==0) return 0;
        std::sort(points.begin(), points.end(), cmp);
        int res = 1;
        for(int i=1; i<points.size(); i++){
            if(points[i-1][1]< points[i][0]) res++;
            else{
                points[i][1] = min(points[i][1], points[i-1][1]);
            }
        }
        return res;
    }

12.无重叠区间

(题目链接)
给定一个区间的集合 intervals ,其中 intervals[i] = [starti, endi] 。返回 需要移除区间的最小数量,使剩余区间互不重叠 。
这题与上述11题十分相似,通过节点总数-相互无折叠的节点数=要删除的节点数。而有无折叠是通过按照右侧排序后,当前节点的起始位置是否大于或等于上一节点的结束位置来判断的。

    static bool cmp(const std::vector<int>& a, const std::vector<int>& b){
        return a[0]<b[0];
    }
    int eraseOverlapIntervals(vector<vector<int>>& intervals) {
        if(intervals.size()==0) return 0;
        std::sort(intervals.begin(), intervals.end(), cmp);
        int res = 1;
        int end = intervals[0][1];
        for(int i=1; i<intervals.size(); i++){
            if(end<=intervals[i][0]){
                end = intervals[i][1];
                res += 1;//记录相互无重叠的节点数量
            }
        }
        return intervals.size() - res;
    }

13.划分字母区间

(题目链接)
字符串 S 由小写字母组成。我们要把这个字符串划分为尽可能多的片段,同一字母最多出现在一个片段中。返回一个表示每个字符串片段的长度的列表。例如输入:S = "ababcbacadefegdehijhklij",输出:[9,7,8] 解释: 划分结果为 "ababcbaca", "defegde", "hijhklij"。 每个字母最多出现在一个片段中。 像"ababcbacadefegde", "hijhklij"的划分是错误的,因为划分的片段数较少。
题目要求同一字母最多出现在一个片段中,那么如何把同一个字母的都圈在同一个区间里呢?在遍历的过程中相当于是要找每一个字母的边界,如果找到之前遍历过的所有字母的最远边界,说明这个边界就是分割点了。此时前面出现过所有字母,最远也就到这个边界了。
解题思路分为以下两步:
1.统计每一个字符最后出现的位置-使用一个hasn表来记录。
2.从头遍历字符,并更新字符的最远出现下标,如果找到字符最远出现位置下标和当前下标相等了,则找到了分割点
在这里插入图片描述

    vector<int> partitionLabels(string s) {
        int hash[27] = {0};
        for(int i=0; i<s.size(); i++){
            hash[s[i]-'a'] = i;
        }
        std::vector<int> res;
        int left = 0;
        int right = 0;
        for(int i=0; i<s.size(); i++){
            right = max(right, hash[s[i]-'a']);
            if(i==right){
                res.push_back(right-left+1);
                left = i+1;
            }
        }
        return res;
    }

其实这题没有感受到贪心,找不出局部最优推出全局最优的过程。就是用最远出现距离模拟了圈字符的行为,但其解法很奇妙。
这题与之前的11.使用最少弓箭,12.无重叠区间使用的是相同的思路,统计字符串种所有的字符的起始,结束位置,记录这些区间,将区间按左边界从小到大排序,找到边界区间划分成组,互不重叠,找到边界答案。


14.合并区间

(题目链接)
以数组 intervals 表示若干个区间的集合,其中单个区间为 intervals[i] = [starti, endi] 。请你合并所有重叠的区间,并返回 一个不重叠的区间数组,该数组需恰好覆盖输入中的所有区间 。例如:输入:intervals = [[1,3],[2,6],[8,10],[15,18]],输出:[[1,6],[8,10],[15,18]],解释:区间 [1,3] 和 [2,6] 重叠, 将它们合并为 [1,6].
思路
1.先排序,按左边界排序,让相邻区间尽可能在一起
2.排序后,发现重叠,即可将区间覆盖进行合并,进入一次的判断

    static bool cmp(const std::vector<int>& a, std::vector<int>& b){
        return a[0]<b[0];
    }
    vector<vector<int>> merge(vector<vector<int>>& intervals) {
        std::vector<std::vector<int>> res;
        if(intervals.size()==0) return res;
        std::sort(intervals.begin(), intervals.end(), cmp);
        res.push_back(intervals[0]);
        for(int i=1; i<intervals.size(); i++){
            if(res.back()[1]>=intervals[i][0]) res.back()[1] = max(res.back()[1], intervals[i][1]);
            else res.push_back(intervals[i]);
        }
        return res;
    }

15.单调递增的数字

(题目链接)
当且仅当每个相邻位数上的数字 x 和 y 满足 x <= y 时,我们称这个整数是单调递增的。给定一个整数 n ,返回 小于或等于 n 的最大数字,且数字呈单调递增
思路一是暴力解法:对于给定N进行一次遍历递减,得到一个新的数值,然后通过一个check函数判断该数值每位上相邻数字是否满足单调递减。但时间复杂度:O(n × m) m为n的数字长度;空间复杂度:O(1)——求解超时
思路二是贪心解法:对于给定数值从后向前的两两数值进行判断,一旦出现strNum[i - 1] > strNum[i]的情况(非单调递增),首先想让strNum[i - 1]--,然后题目要求最大数,因此在strNum[i]位可以赋值为9。

	// 暴力解法
    bool checknum(int n){
        int max = 10;
        while(n){
            int t = n%10;
            if(max>=t) max = t;
            else return false;
            n = n/10;
        }
        return true;
    }
    int monotoneIncreasingDigits(int n) {
        for(int i=n; i>0; i--){
            if(checknum(i)) return i;
        }
        return 0;
    }
	// 贪心解法
    int monotoneIncreasingDigits(int n) {
        std::string num = to_string(n);
        int flag = num.size();
        for(int i=num.size()-1; i>0; i--){
            if(num[i-1]>num[i]){
                flag = i;
                num[i-1]--;
            }
        }
        for(int i=flag; i<num.size(); i++){
            num[i] = '9';
        }
        return stoi(num);
    }

16.监控二叉树

(题目链接)
给定一个二叉树,我们在树的节点上安装摄像头。节点上的每个摄影头都可以监视其父对象、自身及其直接子对象。计算监控树的所有节点所需的最小摄像头数量
在这里插入图片描述
从示例中发现,一个摄像头节点可以覆盖上中下三层,根节点,叶子节点一般不会设置摄像头,因为这样会浪费一层覆盖。因此贪心的做法是从叶子节点从下往上遍历,局部最优——让叶子节点的父节点安摄像头,所使用的摄像头最少,直到根节点;整体最优——全部摄像头数量使用最少。本题难点:
1.二叉树的遍历——使用后序遍历-左右中的遍历方法**(递归)**
2.如何隔两个节点放置一个摄像头——需要使用状态转移公式
每个节点可能有以下三种状态:0.无覆盖 1.有摄像头 2.有覆盖
特殊情况-对于空节点的状态只能是有覆盖,这样就可以在叶子节点的父节点放摄像头;

主要单层递归逻辑是处理以下4种情况:

  1. 左右节点都有覆盖,那么此时中间节点应该就是无覆盖状态;
  2. 左右节点至少有一个无覆盖——共有5种可能,只有一个子节点没有覆盖,父节点就应该放置摄像头;
  3. 左右节点至少有一个摄像头,此时父节点应该就是有覆盖状态;
  4. 头节点无覆盖——最后检查根节点有无覆盖
    int res;
    int backtracking(TreeNode* root){
        // 终止条件
        if(root==nullptr) return 2;
        int left = backtracking(root->left);
        int right = backtracking(root->right);
        // 单层递归逻辑:根据left,right 判断父节点的状态,是否需要添加摄像头
        if(left == 2 && right == 2 ) return 0;
        if(left == 0 || right == 0){
            res++;
            return 1;
        }
        if(left == 1 || right == 1) return 2;
        return -1; // 逻辑不会走到这
    }
    int minCameraCover(TreeNode* root) {
        res = 0;
        if(backtracking(root)==0) res++; // 判断根节点是否有覆盖
        return res;
    }

时间复杂度: O(n),需要遍历二叉树上的每个节点;空间复杂度: O(n)
本题的难点首先是要想到贪心的思路,然后就是遍历和状态推导。在二叉树上进行状态推导,其实难度就上了一个台阶了,需要对二叉树的操作非常娴熟。这道题目是名副其实的hard。


总结

1.贪心很简单,就是常识?
跟着一起刷题的录友们就会发现,贪心思路往往很巧妙,并不简单。

2.贪心有没有固定的套路?
贪心无套路,也没有框架之类的,需要多看多练培养感觉才能想到贪心的思路。

3.究竟什么题目是贪心呢?
如果找出局部最优并可以推出全局最优,就是贪心,如果局部最优都没找出来,就不是贪心,可能是单纯的模拟。(并不是权威解读,一家之辞哈)。但我们也不用过于强调什么题目是贪心,什么不是贪心,那就太学术了,毕竟学会解题就行了。

4.如何知道局部最优推出全局最优,有数学证明么?
在做贪心题的过程中,如果再来一个数据证明,其实没有必要,手动模拟一下,如果找不出反例,就试试贪心。面试中,代码写出来跑过测试用例即可,或者自己能自圆其说理由就行了
就像是 要用一下 1 + 1 = 2,没有必要再证明一下 1 + 1 究竟为什么等于 2。(例子极端了点,但是这个道理)

贪心难题(区间问题):
1.跳跃游戏
2.跳跃游戏2
3.用最少数量的箭引爆求
4.无重叠区间
5.划分字母区间
6.合并区间

  • 20
    点赞
  • 12
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值