首页 > 编程语言 >代码随想录算法训练营Day12 栈与队列

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

时间:2023-02-13 16:57:23浏览次数:51  
标签:容器 deque 队列 训练营 元素 随想录 que 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()){
				rue.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:
	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.pop_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());//记录对应的最大值
		}
    }
};

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) {
		unorderd_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(unorderd_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(n\log k)$。

重点

在栈与队列系列中,我们强调栈与队列的基础,也是很多同学容易忽视的点。
使用抽象程度越高的语言,越容易忽视其底层实现,而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() 返回指向数组中最后一个元素之后一个位置的指针(注意不是最后一个元素)。

标签:容器,deque,队列,训练营,元素,随想录,que,Day12,单调
From: https://www.cnblogs.com/bailichangchuan/p/17116918.html

相关文章