DAY39:贪心算法(七)根据身高重建队列(注意思路)+最少箭引爆气球(重叠区间)

文章目录

    • 406.根据身高重建队列(注意思路)
      • 思路
        • 两个维度
        • 降序排序注意点
      • 完整版
        • vector容器插入相关复习
        • 为什么能直接根据ki数值插入ki位置的下标
      • 时间复杂度
      • vector-insert操作存在的问题
      • 链表优化版
      • 时间复杂度
      • list和vector的插入与访问操作区别
    • 452.最少弓箭引爆气球(重叠区间)
      • 思路
        • 情况分析
      • 完整版
        • 时间复杂度
        • 弓箭初值设置的原因
      • 总结

406.根据身高重建队列(注意思路)

  • 如果某元素前面有k个满足条件的元素那么这个元素的下标就是k,而不是k-1。本题排序结束之后,如果想要>=hi的元素个数=ki,那么需要插入的位置下标就是ki

  • 本题的两个维度,和 135.分发糖果 类似,当遇到两个维度的问题的时候,一定不要两个维度同时考虑,需要先考虑一个再考虑另一个!

假设有打乱顺序的一群人站成一个队列,数组 people 表示队列中一些人的属性(不一定按顺序)。每个 people[i] = [hi, ki] 表示第 i 个人的身高为 hi ,前面 正好ki 个身高大于或等于 hi 的人。

请你重新构造并返回输入数组 people 所表示的队列。返回的队列应该格式化为数组 queue ,其中 queue[j] = [hj, kj] 是队列中第 j 个人的属性(queue[0] 是排在队列前面的人)。

示例 1:

输入:people = [[7,0],[4,4],[7,1],[5,0],[6,1],[5,2]]
输出:[[5,0],[7,0],[5,2],[6,1],[4,4],[7,1]]
解释:
编号为 0 的人身高为 5 ,没有身高更高或者相同的人排在他前面。
编号为 1 的人身高为 7 ,没有身高更高或者相同的人排在他前面。
编号为 2 的人身高为 5 ,有 2 个身高更高或者相同的人排在他前面,即编号为 01 的人。
编号为 3 的人身高为 6 ,有 1 个身高更高或者相同的人排在他前面,即编号为 1 的人。
编号为 4 的人身高为 4 ,有 4 个身高更高或者相同的人排在他前面,即编号为 0123 的人。
编号为 5 的人身高为 7 ,有 1 个身高更高或者相同的人排在他前面,即编号为 1 的人。
因此 [[5,0],[7,0],[5,2],[6,1],[4,4],[7,1]] 是重新构造后的队列。

示例 2:

输入:people = [[6,0],[5,0],[4,0],[3,2],[2,2],[1,4]]
输出:[[4,0],[5,0],[2,2],[3,2],[1,4],[6,0]]

提示:

  • 1 <= people.length <= 2000
  • 0 <= hi <= 10^6
  • 0 <= ki < people.length

题目数据确保队列可以被重建。

思路

本题首先要理解题意,是针对每个人属性第二栏的有多少个人比此人高,来对队列重新排列,使得队列前面h>=此人身高hi的人数=ki,如下图所示:

DAY39:贪心算法(七)根据身高重建队列(注意思路)+最少箭引爆气球(重叠区间)_第1张图片

两个维度

本题有h和k两个维度,看到这种题目一定要想如何确定一个维度,然后再按照另一个维度重新排列

本题的两个维度体现在同时有两个需要考虑的限制条件。朴素的想法应该是直接降序排序,然后降序排序的同时满足hi≥当前hi的数字个数=ki,但是不能同时满足

所以,我们需要先把hi降序排序,ki放在降序之后考虑。因为如果按照k来从小到大排序,排完之后,会发现k的排列并不符合条件,身高也不符合条件,两个维度哪一个都没确定下来。而当身高h定死的时候,k也就定死了(也就是降序排列之后,对于每个人,前面有多少个比他高的人就定死了),此时再去调整K,就会方便很多。

移动策略如下图所示:

DAY39:贪心算法(七)根据身高重建队列(注意思路)+最少箭引爆气球(重叠区间)_第2张图片
hi先降序排完,再根据ki的情况去insert。降序排序结束之后,再从头开始遍历,对于每个组合{h,k},判断该数字前面>=h的数字个数,并且把该向量放到个数=k的下标位置

例如{6,1}这个例子,从头遍历需要找到1个大于6的数字,找到的数字是第一个数字nums[0]=7>6,所以放在nums[1]的位置。遍历第一遍的策略情况如图粉色线条所示。

降序排序注意点

注意,在降序排序的过程中,同样hi的组合,应该是ki较小的放在前面。也就是说{5,0}{5,2}这个组合,应该是{5,0}放在前面。我们假设{5,2}放在前面,那么先遍历{5,2},再遍历{5,0},遍历到{5,0}的时候把{5,0}又放在了{5,2}的前面,这个时候{5,2}前面就又多了一个5!就会导致结果错误(因为大于/等于都算)。

完整版

  • 降序排序需要满足两条,一条是降序,一条是相同的时候k从小到大排!注意cmp的写法,是比较了一维数组的两个元素
  • 降序排列结束之后,如果想要>=hi的元素个数=ki,那么需要插入的位置下标就是ki
class Solution {
public:
    //注意cmp接收的是两个一维数组,而不是二维数组
    static bool cmp(vector<int>& P1,vector<int>& P2){
        if(P1[0]>P2[0])  return true;//整体降序
        if(P1[0]==P2[0]){
            if(P1[1]<P2[1]) 
                return true;//p1[0]相同的时候按照p1[1]升序
        }
        return false;
    }
    vector<vector<int>> reconstructQueue(vector<vector<int>>& people) {
        //先对所有的hi降序排序,因为本题的people中的变量是{a,b},所以需要自定义sort cmp
        sort(people.begin(),people.end(),cmp);
        //定义新的二维数组作为输出
        vector<vector<int>>result;
        //开始遍历排序后的people
        for(int i=0;i<people.size();i++){
            //因为此时已经排序完毕,所以[6,1]直接插入到下标为1的地方,[5,0]直接插入下标为0的地方
            int position=people[i][1];//people[i][1]就代表着第i个集合people的第二个元素!
            //元素放到对应的二维结果数组里
            result.insert(result.begin()+position,people[i]);
        }
        return result;

    }
};

vector容器插入相关复习

(1条消息) vector容器语法相关_大磕学家ZYX的博客-CSDN博客

为什么能直接根据ki数值插入ki位置的下标

这也是本题的一个思维问题,当我们降序排序结束之后,降序排序就是为了把大于这个元素的因素,全都放在这个元素的前面。因此,以[7,1]为例,当遍历到[7,1]的时候,[7,1]前面的元素一定是>=[7,1]的!

因此此时如果想要前面只有ki个>=[7,1]的元素直接把[7,1]移动到ki的位置就行了

前面有k个满足条件的元素,下标又是从0开始,因此当前下标就是k而不是k-1

DAY39:贪心算法(七)根据身高重建队列(注意思路)+最少箭引爆气球(重叠区间)_第3张图片

时间复杂度

在C++中,std::vector::insert的时间复杂度是O(n),其中n是从插入点到vector末尾的元素数量。这是因为插入新元素时,所有在插入点后的元素都需要移动以创建空间。因此,对于在vector的开头插入元素,需要移动所有的元素,这是最糟糕的情况,对应于O(n)的时间复杂度。对于在vector的末尾插入元素,不需要移动任何元素,这是最好的情况,对应于O(1)的时间复杂度。

代码中的循环体内使用了std::vector::insert,因此,循环的每一次迭代都可能需要移动元素。在最糟糕的情况下,这个代码的时间复杂度是O(n^2),其中n是people中的元素数量。

此外,还需要考虑到代码中的排序操作。std::sort函数的时间复杂度通常为O(n log n),其中n是要排序的元素数量。

  • 时间复杂度:O(nlog n + n^2)
  • 空间复杂度:O(n)

vector-insert操作存在的问题

使用vector是非常费时的,C++中vector(可以理解是一个动态数组,底层是普通数组实现的)如果插入元素大于预先普通数组大小,vector底部会有一个扩容的操作,即申请两倍于原先普通数组的大小,然后把数据拷贝到另一个更大的数组上

所以使用vector(动态数组)来insert,是费时的,插入再拷贝的话,单纯一个插入的操作就是O(n2)了**,甚至**可能拷贝好几次,就不止O(n2)了

因此我们这道题,在结果数组的数据结构选择上,可以选择把vector换成List。list底层是链表实现,链表不存在双倍扩容的问题

链表优化版

  • list不支持随机访问迭代器,因此result.begin()+position这种操作是不被允许的。
class Solution {
public:
    static bool cmp(vector<int>& P1,vector<int>& P2){
        if(P1[0]>P2[0])  return true;
        if(P1[0]==P2[0]){
            if(P1[1]<P2[1]) 
                return true;
        }
        return false;
    }
    vector<vector<int>> reconstructQueue(vector<vector<int>>& people) {
        sort(people.begin(),people.end(),cmp);
        //结果数组类型修改为list>
        list<vector<int>>result;
        
        //遍历排序后的people
        for(int i=0;i<people.size();i++){
            int position=people[i][1];
            //找到position位置之后,定义迭代器再插入
            list<vector<int>>::iterator it = result.begin();
            //注意这里insert的写法,先寻找插入位置
            while(position--){
                it++;
            }
            //while结束之后找到插入位置
            result.insert(it,people[i]);
        }
        //把结果转换为vector>,相当于构造新的二维vector
        return vector<vector<int>>(result.begin(),result.end());

    }
};

时间复杂度

区别讲解:代码随想录 (programmercarl.com)

链表的做法,时间复杂度也是O(nlog n + n^2)

首先,std::list插入操作的时间复杂度是O(1),但这只是指插入操作本身,即在已知要插入的位置的情况下的插入。然而,你需要找到要插入的位置,而在std::list中找到一个位置的时间复杂度是O(n)

在代码中有一个while循环,用于找到每个元素应插入的位置。这个查找操作的时间复杂度是O(n)。因此,每次插入的总时间复杂度(查找+插入)是O(n)。由于你在循环中对每个元素都进行了这样的操作,因此,总的时间复杂度仍然是O(n^2)

因此,链表做法的时间复杂度还是O(n log n + n^2)。其中,O(n log n)对应于排序操作,O(n^2)对应于插入操作

std::vector的情况类似。std::vector插入操作的时间复杂度是O(n),但这已经包含了查找位置和插入两个步骤(对于std::vector,查找位置的时间复杂度是O(1),但插入的时间复杂度是O(n))。所以,std::vector的做法的时间复杂度也是O(n log n + n^2)

vector的主要问题在Insert上,我们使用vector来做insert的操作,insert每一次插入都会动态扩容虽然表面上复杂度是O(n2),但是其底层都不知道额外做了多少次全量拷贝了,所以算上vector的底层拷贝,整体时间复杂度可以认为是O(n2 + t × n)级别的,t是底层拷贝的次数

list和vector的插入与访问操作区别

博客整理:list和vector对比

std::liststd::vector是C++中的两种常见数据结构,它们在不同的使用场景下各有优势。

  • std::vector的内部实现是动态数组,它在连续的内存块中存储数据。这使得std::vector访问元素时具有非常高的效率,因为可以直接通过索引来访问元素,时间复杂度为O(1)。然而,std::vector在插入和删除元素时可能需要移动大量的元素,特别是在非尾部进行插入或删除操作时,时间复杂度为O(n)
  • std::list的内部实现是双向链表,它在非连续的内存块中存储数据。这使得std::list插入和删除元素时具有非常高的效率,因为你只需要修改相关节点的指针,无需移动其他元素时间复杂度为O(1)。然而,std::list在访问元素时可能需要遍历整个链表,时间复杂度为O(n)

如果主要的操作是插入元素insert操作,那么使用std::list会比使用std::vector更高效。

虽然插入操作的理论时间复杂度没有改变,但在实践中,由于std::list不需要移动元素,所以实际运行时间会更短。这就是为什么使用std::list后代码运行时间减少的原因。

452.最少弓箭引爆气球(重叠区间)

  • 重叠区间题目需要注意元素初值的问题,包括计数变量的初值,以及有时候需要考虑数组i=0时候的初值(因为重叠判断大都是i=1开始的)

有一些球形气球贴在一堵用 XY 平面表示的墙面上。墙面上的气球记录在整数数组 points ,其中points[i] = [xstart, xend] 表示水平直径在 xstartxend之间的气球。你不知道气球的确切 y 坐标。

一支弓箭可以沿着 x 轴从不同点 完全垂直 地射出。在坐标 x 处射出一支箭,若有一个气球的直径的开始和结束坐标为 xstart,xend, 且满足 xstart ≤ x ≤ xend,则该气球会被 引爆 。可以射出的弓箭的数量 没有限制 。 弓箭一旦被射出之后,可以无限地前进。

给你一个数组 points ,返回引爆所有气球所必须射出的 最小 弓箭数 。

示例 1:

输入:points = [[10,16],[2,8],[1,6],[7,12]]
输出:2
解释:气球可以用2支箭来爆破:
-在x = 6处射出箭,击破气球[2,8][1,6]-在x = 11处发射箭,击破气球[10,16][7,12]

示例 2:

输入:points = [[1,2],[3,4],[5,6],[7,8]]
输出:4
解释:每个气球需要射出一支箭,总共需要4支箭。

示例 3:

输入:points = [[1,2],[2,3],[3,4],[4,5]]
输出:2
解释:气球可以用2支箭来爆破:

-在x = 2处发射箭,击破气球[1,2][2,3]-在x = 4处射出箭,击破气球[3,4][4,5]

提示:

  • 1 <= points.length <= 10^5
  • points[i].length == 2
  • -2^31 <= xstart < xend <= 2^31 - 1

思路

本题是一道经典的重叠区间类型题目,重点就是用一只弓箭尽量射重叠最多的气球。题意示意图如下。

DAY39:贪心算法(七)根据身高重建队列(注意思路)+最少箭引爆气球(重叠区间)_第4张图片
本题关键在于代码的模拟。首先是记录重叠的气球,再引爆气球。

首先,想要得到重叠情况的统计,需要先对气球左边界进行排序。按照左边界对气球排序之后,才能得到大概类似上图,气球相邻的情况,方便处理气球。

情况分析

  • 气球不重叠:第i个气球左边界>第i-1个气球的右边界,说明这两个气球一定不重合,此时一定要添加一个弓箭
//注意数组points第一个量是第几个气球,第二个量是左边界or右边界
//示例:points = [[10,16],[2,8],[1,6],[7,12]]
if(i>0&&points[i][0]>points[i-1][1]){
    //不重叠一定要添加弓箭
    arrow++;
}
  • 气球重叠:判断右边界就可以判断相邻气球是不是重叠了,i左边界<=i-1右边界
  • 但是气球重叠的情况存在一个问题,如果气球一直重叠,我们需要判断共有几个重叠的气球可以用一根箭。此时我们使用的方法是更新最小右边界,示意图如下所示。
  • 更新的右边界是最新气球的右边界,是取最新气球右边界上一个重叠气球右边界的最小值,注意是取最大值,因为points[i-1][1]可能大于points[i][1]
if(points[i][0]<=points[i-1][1]){
    //此时,第i个气球和第i-1重合了,还需要判断是不是和下一个也重合
    //方法:更新最小右边界,也就是把第i个气球的右边界,取为第i-1个气球的右边界和第i个的右边界的最大值
    points[i][1]=min(points[i-1][1],points[i][1]);  
}

DAY39:贪心算法(七)根据身高重建队列(注意思路)+最少箭引爆气球(重叠区间)_第5张图片

完整版

  • arrow初值设置为1,是因为这样遍历下去,到了最后一个就会缺失弓箭增加的逻辑,此时直接初值设置为1即可
  • 也可以考虑,当气球不为0的时候,至少需要一只箭,所以初值是1
class Solution {
public:
    static bool cmp(vector<int>&a,vector<int>&b){
        if(a[0]<b[0]) 
            return true;
        return false;
    }
    int findMinArrowShots(vector<vector<int>>& points) {
        int arrow=1;
        if(points.size()==0) return 0;
        //先对气球左边界进行升序排序
        sort(points.begin(),points.end(),cmp);
        //排序完了进行遍历,先是不重叠
        for(int i=1;i<points.size();i++){
            if(points[i][0]>points[i-1][1]){
                arrow++;
            }
            //其他情况就都是重叠
            else{
                //重叠,更新最小右边界
                points[i][1]=min(points[i-1][1],points[i][1]);
            }
        }
	 return arrow;
    }
};

时间复杂度

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

弓箭初值设置的原因

依然以上面的图为例,我们按照更新最小右边界的逻辑,遇到重叠的就继续遍历,遇到不重叠才++,这种逻辑在遇到最后一个元素的时候,就缺失了弓箭++的操作。

DAY39:贪心算法(七)根据身高重建队列(注意思路)+最少箭引爆气球(重叠区间)_第6张图片
但是,在只有最后一个元素是这样,其他元素不受影响的情况下,我们可以直接通过调整初值来实现逻辑,也就是直接把初值设置为1即可!这样就不需要在代码里单独加上处理最后一个数字的逻辑了。

总结

这道题目贪心的思路很简单也很直接,就是重复的一起射了,但是真正模拟引爆气球是有难度的。

我们需要注意的一点是,气球并不需要真的引爆,只需要累积弓箭数目+1就行了

另外,气球的左右数值也不是不能改变的,我们可以通过更新最小右边界的形式,相当于"修改"这个气球,使得当前气球继续判断和下一个气球是否重合的逻辑。

你可能感兴趣的:(刷题记录,贪心算法,算法,leetcode,c++,数据结构)