代码随想录第三十五天 | 贪心:判断空间少,以及贪心模拟策略选择原因(860);两个维度分开确定(406,类似于135,及 vector insert为何费时);判断气球出现一起射的重叠思路(452)

本文围绕LeetCode算法题展开,涉及柠檬水找零、根据身高重建队列、用最少数量的箭引爆气球等题目。通过贪心算法解决问题,如在找零问题中优先消耗10美元,在队列重建中先按身高排序再按k插入。还分析了vector插入元素费时的原因,对比了vector和list的效率。

1、判断空间少,以及贪心模拟策略选择原因

1.1 leetcode 860:柠檬水找零

第一遍代码
针对5,10,20块钱给出不同的找零方案,5,10块钱自然一个不找,一个找5块,对于20块,先把10块钱用完

class Solution {
public:
//针对5,10,20块钱给出不同的方案
    bool lemonadeChange(vector<int>& bills) {
        vector<int> mon(3, 0);
        for(int i = 0; i < bills.size(); i++) {
            if(bills[i] == 5) {
                mon[0]++;
            }
            else if(bills[i] == 10) {
                mon[1]++;
                mon[0]--;
                if(mon[0] < 0) {
                    return false;
                }
            }
            else {//20块钱不需要统计,不可能找零
                if(mon[1] > 0) {//先把10块钱用完
                    if(mon[0] < 1) {
                        return false;//5块钱太少直接无了,10块再多都没用
                    }
                    else {
                        mon[1]--;
                        mon[0]--;
                    }
                }
                else {
                    if(mon[0] < 3) {//5块钱不足
                        return false;
                    }
                    else {
                        mon[0] -= 3;
                    }
                }
            }
        }
        return true;
    }
};

思路
可供我们做判断的空间非常少,意味着可以直接对每一种情况判断
只需要维护三种金额的数量,5,10和20

有如下三种情况
情况一:账单是5直接收下
情况二:账单是10消耗一个5增加一个10
情况三:账单是20优先消耗一个10和一个5,如果不够再消耗三个5

此时大家就发现 情况一,情况二,都是固定策略,都不用我们来做分析了,而唯一不确定的其实是情况三
而情况三逻辑也不复杂甚至感觉纯模拟就可以了,其实情况三这里是有贪心的(从这里开始,做法相同,但是其实没有想清楚是为什么优先消耗10

账单是20的情况,为什么要优先消耗一个10和一个5呢?
因为美元10只能给账单20找零,而美元5可以给账单10和账单20找零,美元5更万能
所以局部最优:遇到账单20优先消耗美元10,完成本次找零
全局最优:完成全部账单的找零
局部最优可以推出全局最优,并找不出反例,那么就试试贪心算法

C++代码如下:
直接用3个变量记录三种纸币的剩余张数

class Solution {
public:
    bool lemonadeChange(vector<int>& bills) {
        int five = 0, ten = 0, twenty = 0;
        for (int bill : bills) {
            // 情况一
            if (bill == 5) five++;
            // 情况二
            if (bill == 10) {
                if (five <= 0) return false;
                ten++;
                five--;
            }
            // 情况三
            if (bill == 20) {
                // 优先消耗10美元,因为5美元的找零用处更大,能多留着就多留着
                if (five > 0 && ten > 0) {
                    five--;
                    ten--;
                    twenty++; // 其实这行代码可以删了,因为记录20已经没有意义了,不会用20来找零
                } else if (five >= 3) {
                    five -= 3;
                    twenty++; // 同理,这行代码也可以删了
                } else return false;
            }
        }
        return true;
    }
};

时间复杂度: O(n)
空间复杂度: O(1)

2、两个维度分开确定(类似于135,及 vector insert为何费时)

2.1 leetcode 406:根据身高重建队列

第一遍代码,报错:Error: AddressSanitizer: SEGV on unknown address (pc 0x0000002c27b4 bp 0x000000000000 sp 0x7ffcc00b88f0 T0)
应该是越界数组引用超越了左右边界,但是不知道为什么,这个时候要重点检查取数组中元素位置的字母有没有写错,以及是否有关键位置的元素赋值有问题,最后ac了

思路是:经代码随想录提示,按照之前分发糖果的思路对两个参数分开讨论先按从高到低排,对于身高相等的,按排在前面的人数递增排序
不符合 前面个数的条件往前插,从第一个元素往后循环,因为是从高到低的,所以后面小的元素前插不影响前面的元素

从高到低排,对于身高相等的,按排在前面的人数递增排序(想一想极端情况 第一次的时候对于两个同是身高最大的应该怎么排)完成
第一个不用动了,因为所有大的都在前面了,后面的元素 只可能 往前插 / 保持位置往前插的时候不影响前面的元素(因为更小

class Solution {
public:
//经代码随想录提示,按照之前分发糖果的思路对两个参数分开讨论,先按从高到低排,对于身高相等的,按排在前面的人数递增排序
//不符合前面个数的条件的往前插,从第一个元素往后循环,因为是从高到低的,所以后面小的元素前插不影响前面的元素
    vector<vector<int>> reconstructQueue(vector<vector<int>>& people) {
        int start = 0;
        vector<int> tmp(2, 0);
        while(start < people.size() - 1) {
            for(int i = people.size() - 1; i > start; i--) {
                if(people[i][0] > people[i-1][0]) {
                    tmp = people[i-1];
                    people[i-1] = people[i];
                    people[i] = tmp;
                }
                if(people[i][0] == people[i-1][0]) {
                    if(people[i][1] < people[i-1][1]) {
                        tmp = people[i-1];
                        people[i-1] = people[i];
                        people[i] = tmp;
                    }
                }
            }
            start++;
        }
        //先按从高到低排,对于身高相等的,按排在前面的人数递增排序(想一想极端情况 第一个对于两个同是身高最大的应该怎么排)完成
        //第一个不用动了,后面的元素只可能往前插,往前插的时候并不影响前面的元素(因为更小)
        for(int i = 1; i < people.size(); i++) {
            int pos = people[i][1];//第二个元素就是前插的最终位置下标
目标位置肯定是取前面有几个数的(第1个位置),而不是取身高(第0个位置)
            tmp = people[i];
            for(int ii = i; ii > pos; ii--) {
                people[ii] = people[ii-1];
            } 
            people[pos] = tmp;
        }
        return people;
    }
};

思路
其实第一遍思路跟代码随想录思路一致实现不同
本题有两个维度,h和k,看到这种题目一定要想如何确定一个维度,然后再按照另一个维度重新排列,思路跟 leetcode 135:分发糖果 一致
遇到两个维度权衡的时候,一定要先确定一个维度,再确定另一个维度,如果两个维度一起考虑一定会顾此失彼

先确定k还是先确定h呢,也就是究竟先按h排序呢,还是先按照k排序呢?
如果按照k来从小到大排序,排完之后,会发现k的排列并不符合条件身高也不符合条件两个维度哪一个都没确定下来

那么按照身高h来排序呢,身高一定是从大到小排身高相同的话则k小的站前面),让高个子在前面
此时我们可以确定一个维度了,就是身高前面的节点一定都比本节点高插入的时候并不影响待插入节点前面节点(因为 本来前面节点就大插一个小的不会有影响,只有 在前面的大于等于的才算

那么只需要 按照 k 为下标重新插入队列 就可以了,为什么呢?
以图中{5,2} 为例:
插入的时候并不影响待插入节点前面节点
按照身高排序之后,优先按身高高的people的k来插入后序插入节点不会影响前面已经插入的节点,最终按照k的规则完成了队列

所以在按照身高从大到小排序后:
局部最优优先按身高 高的people的k来插入。插入操作过后的people满足队列属性
全局最优最后都做完插入操作,整个队列满足题目队列属性
局部最优可推出全局最优,找不出反例,那就试试贪心
手动模拟一下感觉可以局部最优推出整体最优,而且想不到反例,那么就试一试贪心,至于严格的数学证明,就不在讨论范围内了

回归本题,整个插入过程如下
排序完的people: [[7,0], [7,1], [6,1], [5,0], [5,2],[4,4]]
插入的过程:
插入[7,0]:[[7,0]]
插入[7,1]:[[7,0],[7,1]]
插入[6,1]:[[7,0],[6,1],[7,1]]
插入[5,0]:[[5,0],[7,0],[6,1],[7,1]]
插入[5,2]:[[5,0],[7,0],[5,2],[6,1],[7,1]]
插入[4,4]:[[5,0],[7,0],[5,2],[6,1],[4,4],[7,1]]
此时就按照题目的要求完成了重新排列

代码随想录C++实现代码如下:

// 版本一
class Solution {
public:
    static bool cmp(const vector<int>& a, const 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) {
        sort (people.begin(), people.end(), cmp);
        vector<vector<int>> que;
        for (int i = 0; i < people.size(); i++) {
            int position = people[i][1];
            que.insert(que.begin() + position, people[i]);
        }
        return que;
    }
};

代码随想录版本一 通过 sort 函数(自己定义排序规则)来完成排序,而 第一遍代码通过 自己实现一个 冒泡排序完成排序;同时 对于元素的插入,代码随想录版本一 通过 vector 的 insert 函数来完成,而 第一遍代码手动把元素都往后移

class Solution {
private:
    static bool cmp(const vector<int>& v1, const vector<int>& v2) {
        if (v1[0] == v2[0]) return v1[1] < v2[1];
        return v1[0] > v2[0];
    }
public:
    vector<vector<int>> reconstructQueue(vector<vector<int>>& people) {
        sort(people.begin(), people.end(), cmp);
        list<vector<int>> li;
        for (int i = 0; i < people.size(); i++) {
            if (people[i][1] < i) { // 其实不管是否>people[i][1]都是在那个位置插入
                int t = people[i][1];
                auto it = li.begin();
                while (t--)
                    it++; // 列表迭代器只支持每次++,不支持+n
                li.insert(it, people[i]);
            }
            else
                li.insert(li.end(), people[i]);
        }
        vector<vector<int>> res(li.begin(), li.end());
        return res;
    }
};

版本一的自己实现:
注意注释

class Solution {
public:
    static bool cmp(vector<int>& a, 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) {
        //注意自定义bool用法
        sort(people.begin(), people.end(), cmp);
        vector<vector<int>> re;//注意insert是对一个空白二维数组re插入
        for(int i = 0; i < people.size(); i++) {
            int pos = people[i][1];
            //不用考虑insert插到数组外面,pos肯定在已插入元素内,注意insert第一个参数要是一个指针
            re.insert(re.begin() + pos, people[i]);
            //re.begin()不是people.begin()
        }
        return re;
    }
};

但使用vector非常费时的,C++中vector(可以理解是一个动态数组底层普通数组实现的)如果插入元素大于预先普通数组大小,vector底部会有一个扩容的操作,即申请两倍于原先普通数组的大小,然后把数据拷贝到另一个更大的数组
所以使用vector(动态数组)来insert,是费时的,插入再拷贝的话,单纯一个插入的操作就是 O(n2) 了,甚至可能拷贝好几次,就不止 O(n2) 了
如果不断向 vector 的前面插入元素,并且每次插入 都需要重新分配内存,这种情况 就会频繁地触发 O(n) 的拷贝操作,加上每次插入元素的位置的 O(n) 的移动操作

快速插入不需要随机读取,想到链表链表使用list,但是list的 list.begin() 是一个迭代器,不是指针,所以其插入insert 使用迭代器
最后**别忘了改回 **vector<vector<int>>

改成链表之后,参照代码随想录自己实现的代码如下:
vector<vector<int>>(que.begin(), que.end()) 不写变量名直接返回一个该类型的变量

class Solution {
public:
    static bool cmp(vector<int>& a, 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) {
        sort(people.begin(), people.end(), cmp);
        list<vector<int>> que;
        for(int i = 0; i < people.size(); i++) {
            int pos = people[i][1];
            std::list<vector<int>>::iterator iter = que.begin();
            //前面一定要加std
            while(pos--) {
                iter++;
            }
            que.insert(iter, people[i]);
        }
        return vector<vector<int>>(que.begin(), que.end());
        //不写变量名就直接返回一个该类型的变量
        //vector<vector<int>> res = vector<vector<int>>(que.begin(), que.end());
    }
};

2.2 leetcode 406:总结

关于出现两个维度一起考虑的情况,我们已经做过两道题目了,另一道就是leetcode 135,其技巧都是确定一边然后贪心另一边,两边一起考虑,就会顾此失彼

最后我给出了两个版本的代码,可以明显看是使用C++中的list(底层链表实现)比vector(数组)效率高得多
对使用某一种语言容器的使用,特性的选择 都会不同程度上影响效率

2.3 为什么vector插入元素更费时

大家都知道对于普通数组,一旦定义了大小就不能改变,例如int a[10];,这个数组a至多只能放10个元素,改不了的
对于动态数组,就是可以不用关心初始时候的大小,可以随意往里放数据,那么耗时的原因就在于动态数组的底层实现

动态数组为什么可以不受初始大小的限制,可以随意 push_back 数据呢
首先vector的底层实现也是普通数组
vector的大小有两个维度一个是size一个是capicitysize就是我们 平时用来遍历vector时候用的,例如:

for (int i = 0; i < vec.size(); i++) {

}

capicity是vector底层数组(就是普通数组)的大小,capicity可不一定就是size
当insert数据的时候,如果已经大于capicitycapicity成倍扩容,但对外暴漏的size其实仅仅是+1

那么既然vector底层实现是普通数组,怎么扩容的?
就是重新申请一个二倍于原数组大小的数组,然后把数据都拷贝过去,并释放原数组内存。举一个例子,如图:
vector底层实现是普通数组,扩容的原理
原vector中的size和capicity相同都是3,初始化为1 2 3,此时要push_back一个元素4
那么底层其实就要申请一个大小为6的普通数组,并且把原元素拷贝过去释放原数组内存,注意图中底层数组的内存起始地址已经变
使用vector来做insert的操作,此时大家可会发现,虽然表面上复杂度是O(n2),但是其底层都不知道额外做了多少次全量拷贝了,所以算上vector的底层拷贝,整体时间复杂度可以认为是**O(n2 + t × n)**级别的,t是底层拷贝的次数

是不是可以直接确定好vector的大小,不让它在动态扩容了,可以定义好一个固定大小的vector,这样我们就可以控制vector,不让它底层动态扩容,这种方法需要自己模拟插入的操作,就像第一次代码一样

可能是就算避免的vector的底层扩容,但这个固定大小的数组每次向后移动元素赋值的次数方法一中移动赋值的次数要多很多
因为使用vector插入中一开始数组是很小的,插入操作,向后移动元素次数比较少,即使有偶尔的扩容操作。而像第一遍代码那样每次手动模拟每次都是按照最大数组规模向后移动元素
所以对于两种使用数组的方法,也不好确定谁优,但一定都没有使用方法二链表效率高

3、判断气球出现一起射的重叠思路

3.1 leetcode 452:用最少数量的箭引爆气球

第一遍代码
刚开始的思路就是 看数组有没有在某一区间相交,因为 更大区间的更容易满足,所以优先搞定小区间,所以让小区间排在前面,当写到记录已经覆盖的区间时发现没办法判定是不是可以用一只箭射到

因为不是只要与已知区间有交集就可以射到的,决定是否可以一只箭射到的是 最右端时间最左端时间,思路错误比如 [1,3],[2,5],[4,7] 彼此之间有交集,但是显然不可能用一只箭射到

static bool cmp(vector<int>& a, vector<int>& b) {//小区间排在前面代码
        if(a[1] - a[0] > b[1] - b[0]) {
            return false;
        }
        else {
            return true;
        }
    }
sort(points.begin(), points.end(), cmp);

也不知道对不对,按上节学到的自定义sort写的

思路
如何使用最少的弓箭呢?
直觉上来看,貌似对于每一个要射的气球,需要 尽可能多射气球(即 射所有重叠的气球)用的弓箭一定最少(那么有没有当前重叠了三个气球,我射两个,留下一个和后面的一起射这样弓箭用的更少的情况呢?尝试一下举反例,发现没有这种情况)
那么就试一试贪心吧!局部最优:当气球出现重叠,一起射,所用弓箭最少。全局最优:把所有气球射爆所用弓箭最少

算法确定下来了,那么如何模拟气球射爆的过程呢?是在数组中移除元素还是做标记呢?
如果真实的模拟射气球的过程,应该射一个,气球数组就remove一个元素,这样最直观,毕竟气球被射了
但仔细思考一下就发现:如果把气球排序之后,从前到后遍历气球,被射过的气球仅仅跳过就行了(常用思路),没有必要让气球数组remove气球,只要记录一下箭的数量就可以了
以上为思考过程,已经确定下来使用贪心了,那么开始解题

为了让每一次射的气球 尽可能多重叠,需要对数组进行排序
那么按照气球起始位置排序,还是按照气球终止位置排序呢?
其实都可以!只不过对应的遍历顺序不同,代码随想录就按照气球的起始位置排序了(而不是按照区间长度排序,因为不是有重叠就一定可以一起射跟其起始和结束位置都有关

既然 按照起始位置排序,那么就 从前向后遍历气球数组,怎么 判断气球是否重叠
如果气球重叠了,重叠气球中 右边边界的 最小值(所以需要其他气球的左边界小于等于 重叠气球中 右边边界的最小值 才有可能一起射,对于符合要求的重叠气球在循环中跳过去)之前的区间一定需要一个弓箭

以题目示例: [[10,16],[2,8],[1,6],[7,12]]为例,如图:(已经排序)
气球重叠了,重叠气球中 右边边界的 最小值 之前的区间一定需要一个弓箭
可以看出首先第一组重叠气球,一定是需要一个箭,气球3,的左边界大于了 第一组重叠气球的最小右边界,所以再需要一支箭来射气球3了

根据思路实现代码

class Solution {
public:
    static bool cmp(vector<int>& a, vector<int>& b) {
        if(a[0] == b[0]) return a[1] < b[1];//当开始节点一样时,选取那个结束更小的那个判断是否可以一枪搞定
        return a[0] < b[0];
    }
    int findMinArrowShots(vector<vector<int>>& points) {
        sort(points.begin(), points.end(), cmp);
        int count = 0;
        int leastEnd = 0;
        int i = 0; 
        while(i < points.size()) {
            count++;
            leastEnd = points[i][1];
            cout << leastEnd;
            while(i < points.size() && points[i][0] <= leastEnd) {
                i++;
            }
        }
        return count;
    }
};

报错:
Testcase
[[9,12],[1,10],[4,11],[8,12],[3,9],[6,9],[6,7]]
Answer
1
Expected Answer
2

单纯考虑第一次右边界大于等于左边界就可以用一只箭搞定也是不合理
随着每次重叠气球的加入最小右边界要同步更新,这是所有已经重叠气球之中的最小右边界。对于之前的思路理解出现偏差了,最后在箭增加的之前的那个最小右边界就是射击气球的时候

class Solution {
private:
    static bool cmp(const vector<int>& v1, const vector<int>& v2) {
        return v1[0] < v2[0];
    }
public:
    int findMinArrowShots(vector<vector<int>>& points) {
        sort(points.begin(), points.end(), cmp);
        int res = 1;
        int end = points[0][1]; 
        //可能出现最小负数,没办法确保碰到第一个区间的时候可以立马更新res,所以res从1开始
        for (int i = 1; i < points.size(); i++) {
            if (points[i][0] <= end) {
                if (points[i][1] < end)
                    end = points[i][1];
            }
            else {
                res++;
                end = points[i][1]; // 不管在不在范围内end都可能更新
            }
        }
        return res;
    }
};

更新代码实现,ac了
开始节点一样时,选取哪个结束更小的那个判断是否可以一枪搞定不需要了,因为会随时更新最小的右边界
i++; 不能在这里更新i,因为后面的leastEnd 最小右区间需要在满足要求的情况下更新,也就是先判断满足 i < points.size() && points[i][0] <= leastEnd 再更新

只需要考虑 右边界就行了

class Solution {
public:
    static bool cmp(vector<int>& a, vector<int>& b) {
        /*
        if(a[0] == b[0]) return a[1] < b[1];
        当开始节点一样时,选取那个结束更小的那个判断是否可以一枪搞定
        不需要了,因为会随时更新最小的右边界
        */
        return a[0] < b[0];
    }
    int findMinArrowShots(vector<vector<int>>& points) {
        sort(points.begin(), points.end(), cmp);
        int count = 0;
        int leastEnd = 0;
        int i = 0; 
        while(i < points.size()) {
            count++;
            leastEnd = points[i][1];
            while(i < points.size() && points[i][0] <= leastEnd) {
                //i++; 不能在这里更新i,因为后面的leastEnd 最小右区间需要在满足要求的情况下更新
                //也就是先判断满足 i < points.size() && points[i][0] <= leastEnd 再更新
                leastEnd = min(leastEnd, points[i][1]); // 找最小而不是直接替换
                i++;
            }
        }
        return count;
    }
};

另一种实现

class Solution {
public:
    static bool cmp(vector<int>& vec1, vector<int>& vec2) {
        if (vec1[0] == vec2[0]) return vec1[1] < vec2[1];
        return vec1[0] < vec2[0];
    }
    int findMinArrowShots(vector<vector<int>>& points) {
        sort(points.begin(), points.end(), cmp);
        long rightBorder = INT32_MIN - (long)1; 
        // 必须在式子右边加long,初始值为了解决输入中有左边界为INT32_MIN
        long leftBorder = INT32_MIN - (long)1;
        int count = 0;
        for (int i = 0; i < points.size(); i++) {
            if (points[i][0] > rightBorder) {
                count++;
                leftBorder = points[i][0];
                rightBorder = points[i][1];
            }
            else if (points[i][0] > leftBorder) {
                leftBorder = points[i][0];
            }
            else if (points[i][1] < rightBorder) {
                rightBorder = points[i][1];
            }           
        }
        return count;
    }
};

代码随想录实现代码,循环控制 使用对for的控制代替while,直接使用points[i][1]代替end

class Solution {
private:
    static bool cmp(const vector<int>& a, const vector<int>& b) {
        return a[0] < b[0];
    }
public:
    int findMinArrowShots(vector<vector<int>>& points) {
        if (points.size() == 0) return 0;
        sort(points.begin(), points.end(), cmp);

        int result = 1; // points 不为空至少需要一支箭
        for (int i = 1; i < points.size(); i++) {
            if (points[i][0] > points[i - 1][1]) {  // 气球i和气球i-1不挨着,注意这里不是>=
                result++; // 需要一支箭
            }
            else {  // 气球i和气球i-1挨着
                points[i][1] = min(points[i - 1][1], points[i][1]); // 更新重叠气球最小右边界
            }
        }
        return result;
    }
};

时间复杂度:O(nlog n),因为有一个快排
空间复杂度:O(1),有一个快排,最差情况(倒序)时,需要n次递归调用。因此确实需要O(n)的栈空间

注意事项
注意题目中说的是:满足 xstart ≤ x ≤ xend,则该气球会被引爆。那么说明两个气球挨在一起不重叠也可以一起射爆
所以代码中if (points[i][0] > points[i - 1][1])不能是>=

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值