代码随想录算法训练营Day12 栈与队列

#代码随想录算法训练营

代码随想录算法训练营Day12 栈与队列| 239. 滑动窗口最大值 347.前 K 个高频元素 总结

239. 滑动窗口最大值

给定一个数组 nums,有一个大小为 k 的滑动窗口从数组的最左侧移动到数组的最右侧。你只可以看到在滑动窗口内的 k 个数字。滑动窗口每次只向右移动一位。
返回滑动窗口中的最大值。

总体思路

暴力方法求出区间内的最大值是O(n*k)的时间复杂度。
如果使用大顶堆(优先级队列)来存放窗口里k个数字,就可以知道最大的最大值是多少,但由于窗口是移动的,大顶堆每次只能弹出最大值,无法移除其他数值,就会造成大顶堆维护的不是滑动窗口里面的数值。
此时我们需要一个队列,在队列中放入窗口的元素,然后随着窗口的移动队列也一进一出,由队列告诉我们最大的值。

clas MyQueue
public:
	void pop(int value){
	}
	void push(int value){
	}
	int front(){
		return que.front();
	}

每次窗口移动的时候,调用que.pop(滑动窗口中移除元素的数值),que.push(滑动窗口中添加元素的数值),然后que.front()就返回我们要的最大数值。
我们需要自己实现这个队列。
排序后的队列怎样把窗口要移除的元素(不一定是最大值)弹出呢?
其实队列没有必要维护窗口里的所有元素,只需要维护有可能成为窗口里最大值的元素就可以了,同时保证队列里的元素数值是由大到小的。
那么这个维护元素单调递减的队列就叫做单调队列,即单调递减或单调递增的队列。C++中没有直接支持单调队列,需要我们自己来实现一个单调队列
不要以为实现的单调队列就是 对窗口里面的数进行排序,如果排序的话,那和优先级队列又有什么区别了呢。
来看一下单调队列如何维护队列里的元素。

对于窗口的元素{2,3,5,1,4},单调队列里只维护{5,4}就够了,保持单调队列里单调递减,此时队列出口元素就是窗口里的最大值。
设计单调队列时,pop和bush要保持如下规则:

  1. pop(value):如果窗口移除元素value等于单调队列的出口元素,那么队列弹出元素,否则不用任何操作
  2. push(value):如果push的元素value大于入口元素的数值,那么就将队列入口的元素弹出,指导push元素的数值小于队列入口元素的数值为止。
    保持如上规则,每次窗口移动的时候,只要问que.front()就可以返回当前窗口的最大值。
    使用deque最为合适,常用的queue在没有指定容器的情况下,deque就是默认底层容器。
class MyQueue{//单调队列(从小到大)
	public:
		deque<int> que;//使用deque来实现单调队列
		//每次弹出的时候,比较要弹出的数值是否等于队列出口元素的数值,如果相等则弹出
		//同时pop之前判断队列当前是否为空
		void pop(int value){
			if(!que.empty()&&value==que.front()){
				que.pop_front();
			}
		}
		//如果pop的数值大于入口元素的数值,那么就将队列后端的数值弹出,直到push的数值小于等于队列入口元素的数为止
		//这样就保持了队列里的数值是单调从大到小的了
		void push(int value){
			if(!que.empty()&&value>que.front()){
				que.pop_back();
			}
			que.push_back(value);
		}
	int front(){
		return que.front();
	}
}

代码实现

class Solution {
public:
    class MyQueue{
    public:
        deque<int> que;
        void pop(int value){
            if(!que.empty()&&value==que.front()){
                que.pop_front();
            }
        }
        void push(int value){
            while(!que.empty()&&value>que.front()){
                que.pop_back();
            }
            que.push_back(value);
        }
        int front(){
            return que.front();
        }
    };  
    vector<int> maxSlidingWindow(vector<int>& nums, int k) {
        MyQueue que;
        vector<int> result;
        for(int i=0;i<k;i++){//先将k的元素放进队列
            que.push(nums[i]);      
        }
        result.push_back(que.front());//result记录前k的元素的最大值
        for(int i=k;i<nums.size();i++){
            que.pop(nums[i-k]);//滑动窗口移除最前面的元素
            que.push(nums[i]);//滑动窗口加入最后面的元素
            result.push_back(que.front());//记录对应的最大值
        }
        return result;
    }
};

347.前 K 个高频元素

给定一个非空的整数数组,返回其中出现频率前 k 高的元素。
示例 1:

  • 输入: nums = [1,1,1,2,2,3], k = 2
  • 输出: [1,2]

总体思路

这道题目主要涉及到如下三块内容:

  1. 要统计元素出现频率
  2. 对频率排序
  3. 找出前K个高频元素
    首先统计元素出现的频率,这一类的问题可以使用map来进行统计。
    然后是对频率进行排序,这里我们可以使用一种 容器适配器就是优先级队列
    什么是优先级队列呢?
    其实就是一个披着队列外衣的堆,因为优先级队列对外接口只是从队头取元素,从队尾添加元素,再无其他取元素的方式,看起来就是一个队列。
    而且优先级队列内部元素是自动依照元素的权值排列。那么它是如何有序排列的呢?
    缺省情况下priority_queue利用max-heap(大顶堆)完成对元素的排序,这个大顶堆是以vector为表现形式的complete binary tree(完全二叉树)。
    什么是堆呢?
    堆是一棵完全二叉树,树中每个结点的值都不小于(或不大于)其左右孩子的值。 如果父亲结点是大于等于左右孩子就是大顶堆,小于等于左右孩子就是小顶堆。
    所以大家经常说的大顶堆(堆头是最大元素),小顶堆(堆头是最小元素),如果懒得自己实现的话,就直接用priority_queue(优先级队列)就可以了,底层实现都是一样的,从小到大排就是小顶堆,从大到小排就是大顶堆。
    因为最后需要求高频元素,而如果使用大顶堆,每次更新元素都会将大的元素弹出,就无法保存最大的元素了,因此本题需要使用小顶堆,每次将最小的元素弹出,最后小顶堆里积累的才是前K个最大元素。

代码实现

class Solution {
public:
    //小顶堆
    class mycomparison{
    public:
        bool operator()(const pair<int, int>&lhs, const pair<int,int>&rhs){
            return lhs.second>rhs.second;
        }
    };
    vector<int> topKFrequent(vector<int>& nums, int k) {
        unordered_map<int,int> map;//map<nums[i],对应出现的次数>
        for(int i=0;i<nums.size();i++){
            map[nums[i]]++;
        }
        //对频率排序
        //定义一个小顶堆,大小为k
        priority_queue<pair<int,int>,vector<pair<int, int>>,mycomparison>pri_que;
        //用固定大小为k的小顶堆,扫描所有频率的数值
        for(unordered_map<int, int>::iterator it=map.begin();it!=map.end();it++){
            pri_que.push(*it);
            if(pri_que.size()>k){//如果堆的大小大于k,则队列的弹出,保证堆的大小一直小于k
                pri_que.pop();
            }
        }
        //找出前k个高频元素,因为小顶堆先弹出的是最小的,所以倒叙来输出到数组
        vector<int> result(k);
        for(int i=k-1;i>=0;i--){
            result[i]=pri_que.top().first;
            pri_que.pop();
        }
        return result;
    }
};

拓展

大家对这个比较运算在建堆时是如何应用的,为什么左大于右就会建立小顶堆,反而建立大顶堆比较困惑。
确实 例如我们在写快排的cmp函数的时候,return left>right 就是从大到小,return left<right 就是从小到大。
优先级队列的定义正好反过来了,可能和优先级队列的源码实现有关(我没有仔细研究),我估计是底层实现上优先队列队首指向后面,队尾指向最前面的缘故!

总结

  1. 栈是容器适配器,底层容器使用不同的容器,导致栈在内存中不是连续分布。
  2. 缺省情况下,默认底层容器是deque,那么deque的在内存中的数据分布是怎样的呢?答案是不连续的,下文也会提到deque
栈在系统中的应用

编译器在词法处理中处理括号、花括号的逻辑就是栈。
递归的实现是栈:每一次递归调用都会把函数的局部变量、参数值和返回地址等压入调用栈中,然后递归返回的时候从栈顶弹出上一次递归调用的各项参数,所以这就是递归为什么可以返回上一层位置的原因。

括号匹配问题

括号匹配是使用栈解决的经典问题。
建议要写代码之前要分析好有哪几种不匹配的情况,如果不动手之前分析好,写出的代码也会有很多问题。
先来分析一下 这里有三种不匹配的情况,

  1. 第一种情况,字符串里左方向的括号多余了 ,所以不匹配。
  2. 第二种情况,括号没有多余,但是 括号的类型没有匹配上。
  3. 第三种情况,字符串里右方向的括号多余了,所以不匹配。
    这里还有一些技巧,在匹配左括号的时候,右括号先入栈,就只需要比较当前元素和栈顶相不相等就可以了,比左括号先入栈代码实现要简单的多了!
字符串去重问题

将字符串放到一个栈中,相同的话就弹出,这样栈中就只剩下相邻不相同的元素了。

逆波兰表达式问题

每一个子表达式得出一个结果,然后那这个结果再进行运算,那么这岂不就是一个相邻字符串消除的过程,和栈与队列:匹配问题都是栈的强项中的对对碰游戏是不是就非常像了。

队列

滑动窗口的最大值问题

主要思想是队列没有必要维护窗口里的所有元素,只需要维护有可能成为窗口里最大值的元素就可以了,同时保证队列里的元素数值是由大到小的。
那么这个维护元素单调递减的队列就叫做单调队列,即单调递减或单调递增的队列。C++中没有直接支持单调队列,需要我们自己来一个单调队列
而且不要以为实现的单调队列就是 对窗口里面的数进行排序,如果排序的话,那和优先级队列又有什么区别了呢。
首先要明确的是,题解中单调队列里的pop和push接口,仅适用于本题。
单调队列不是一成不变的,而是不同场景不同写法,总之要保证队列里单调递减或递增的原则,所以叫做单调队列。
不要以为本地中的单调队列实现就是固定的写法。
我们用deque作为单调队列的底层数据结构,C++中deque是stack和queue默认的底层实现容器(这个我们之前已经讲过),deque是可以两边扩展的,而且deque里元素并不是严格的连续分布的。

求前k个高频元素

通过求前 K 个高频元素,引出另一种队列就是优先级队列
什么是优先级队列呢?
其实就是一个披着队列外衣的堆,因为优先级队列对外接口只是从队头取元素,从队尾添加元素,再无其他取元素的方式,看起来就是一个队列。
堆是一棵完全二叉树,树中每个结点的值都不小于(或不大于)其左右孩子的值。 如果父亲结点是大于等于左右孩子就是大顶堆,小于等于左右孩子就是小顶堆。
所以大家经常说的大顶堆(堆头是最大元素),小顶堆(堆头是最小元素),如果懒得自己实现的话,就直接用priority_queue(优先级队列)就可以了,底层实现都是一样的,从小到大排就是小顶堆,从大到小排就是大顶堆。
本题就要使用优先级队列来对部分频率进行排序。 注意这里是对部分数据进行排序而不需要对所有数据排序!
所以排序的过程的时间复杂度是 O ( log ⁡ k ) O(\log k) O(logk),整个算法的时间复杂度是 O ( n log ⁡ k ) O(n\log k) O(nlogk)

重点

在栈与队列系列中,我们强调栈与队列的基础,也是很多同学容易忽视的点。
使用抽象程度越高的语言,越容易忽视其底层实现,而C++相对来说是比较接近底层的语言。

补充:C++deque容器

deque是double-ended queue,又称双端队列容器。
deque 容器和 vecotr 容器有很多相似之处,比如:

  • deque 容器也擅长在序列尾部添加或删除元素(时间复杂度为O(1)),而不擅长在序列中间添加或删除元素。
  • deque 容器也可以根据需要修改自身的容量和大小。
    和 vector 不同的是,deque 还擅长在序列头部添加或删除元素,所耗费的时间复杂度也为常数阶O(1)。并且更重要的一点是,deque 容器中存储元素并不能保证所有元素都存储到连续的内存空间中。
    当需要向序列两端频繁的添加或删除元素时,应首选 deque 容器。
  1. 创建一个具有 n 个元素的 deque 容器,并为每个元素都指定初始值,例如:
std::deque<int> d(10, 5)

如此就创建了一个包含 10 个元素(值都为 5)的 deque 容器。
4) 在已有 deque 容器的情况下,可以通过拷贝该容器创建一个新的 deque 容器,例如:

1.  std::deque<int> d1(5);
2.  std::deque<int> d2(d1);

注意,采用此方式,必须保证新旧容器存储的元素类型一致。
5) 通过拷贝其他类型容器中指定区域内的元素(也可以是普通数组),可以创建一个新容器,例如:

//拷贝普通数组,创建deque容器
int a[] = { 1,2,3,4,5 };
std::deque<int>d(a, a + 5);
//适用于所有类型的容器
std::array<int, 5>arr{ 11,12,13,14,15 };
std::deque<int>d(arr.begin()+2, arr.end());//拷贝arr容器中的{13,14,15}

表 1 deque 容器的成员函数

函数成员函数功能
begin()返回指向容器中第一个元素的迭代器。
end()返回指向容器最后一个元素所在位置后一个位置的迭代器,通常和 begin() 结合使用。
rbegin()返回指向最后一个元素的迭代器。
rend()返回指向第一个元素所在位置前一个位置的迭代器。
cbegin()和 begin() 功能相同,只不过在其基础上,增加了 const 属性,不能用于修改元素。
cend()和 end() 功能相同,只不过在其基础上,增加了 const 属性,不能用于修改元素。
crbegin()和 rbegin() 功能相同,只不过在其基础上,增加了 const 属性,不能用于修改元素。
crend()和 rend() 功能相同,只不过在其基础上,增加了 const 属性,不能用于修改元素。
size()返回实际元素个数。
max_size()返回容器所能容纳元素个数的最大值。这通常是一个很大的值,一般是 232-1,我们很少会用到这个函数。
resize()改变实际元素的个数。
empty()判断容器中是否有元素,若无元素,则返回 true;反之,返回 false。
shrink _to_fit()将内存减少到等于当前元素实际所使用的大小。
at()使用经过边界检查的索引访问元素。
front()返回第一个元素的引用。
back()返回最后一个元素的引用。
assign()用新元素替换原有内容。
push_back()在序列的尾部添加一个元素。
push_front()在序列的头部添加一个元素。
pop_back()移除容器尾部的元素。
pop_front()移除容器头部的元素。
insert()在指定的位置插入一个或多个元素。
erase()移除一个元素或一段元素。
clear()移出所有的元素,容器大小变为 0。
swap()交换两个容器的所有元素。
emplace()在指定的位置直接生成一个元素。
emplace_front()在容器头部生成一个元素。和 push_front() 的区别是,该函数直接在容器头部构造元素,省去了复制移动元素的过程。
emplace_back()在容器尾部生成一个元素。和 push_back() 的区别是,该函数直接在容器尾部构造元素,省去了复制移动元素的过程。

和 array、vector 相同,C++ 11 标准库新增的 begin() 和 end() 这 2 个全局函数也适用于 deque 容器。这 2 个函数的操作对象既可以是容器,也可以是普通数组。当操作对象是容器时,它和容器包含的 begin() 和 end() 成员函数的功能完全相同;如果操作对象是普通数组,则 begin() 函数返回的是指向数组第一个元素的指针,同样 end() 返回指向数组中最后一个元素之后一个位置的指针(注意不是最后一个元素)。

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
代码随想录算法训练营是一个优质的学习和讨论平台,提供了丰富的算法训练内容和讨论交流机会。在训练营中,学员们可以通过观看视频讲解来学习算法知识,并根据讲解内容进行刷题练习。此外,训练营还提供了刷题建议,例如先看视频、了解自己所使用的编程语言、使用日志等方法来提高刷题效果和语言掌握程度。 训练营中的讨论内容非常丰富,涵盖了各种算法知识点和解题方法。例如,在第14天的训练营中,讲解了二叉树的理论基础、递归遍历、迭代遍历和统一遍历的内容。此外,在讨论中还分享了相关的博客文章和配图,帮助学员更好地理解和掌握二叉树的遍历方法。 训练营还提供了每日的讨论知识点,例如在第15天的讨论中,介绍了层序遍历的方法和使用队列来模拟一层一层遍历的效果。在第16天的讨论中,重点讨论了如何进行调试(debug)的方法,认为掌握调试技巧可以帮助学员更好地解决问题和写出正确的算法代码。 总之,代码随想录算法训练营是一个提供优质学习和讨论环境的平台,可以帮助学员系统地学习算法知识,并提供了丰富的讨论内容和刷题建议来提高算法编程能力。<span class="em">1</span><span class="em">2</span><span class="em">3</span> #### 引用[.reference_title] - *1* *2* *3* [代码随想录算法训练营每日精华](https://blog.csdn.net/weixin_38556197/article/details/128462133)[target="_blank" data-report-click={"spm":"1018.2226.3001.9630","extra":{"utm_source":"vip_chatgpt_common_search_pc_result","utm_medium":"distribute.pc_search_result.none-task-cask-2~all~insert_cask~default-1-null.142^v93^chatsearchT3_2"}}] [.reference_item style="max-width: 100%"] [ .reference_list ]

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值