栈与队列
代码随想录刷题笔记
理论基础
队列是先进先出,栈是先进后出。
如图所示:
那么我这里在列出四个关于栈的问题,大家可以思考一下。以下是以C++为例,相信使用其他编程语言的同学也对应思考一下,自己使用的编程语言里栈和队列是什么样的。
- C++中stack 是容器么?
- 我们使用的stack是属于哪个版本的STL?
- 我们使用的STL中stack是如何实现的?
- stack 提供迭代器来遍历stack空间么?
相信这四个问题并不那么好回答, 因为一些同学使用数据结构会停留在非常表面上的应用,稍稍往深一问,就会有好像懂,好像也不懂的感觉。
有的同学可能仅仅知道有栈和队列这么个数据结构,却不知道底层实现,也不清楚所使用栈和队列和STL是什么关系。
所以这里我在给大家扫一遍基础知识,
首先大家要知道 栈和队列是STL(C++标准库)里面的两个数据结构。
C++标准库是有多个版本的,要知道我们使用的STL是哪个版本,才能知道对应的栈和队列的实现原理。
那么来介绍一下,三个最为普遍的STL版本:
- HP STL 其他版本的C++ STL,一般是以HP STL为蓝本实现出来的,HP STL是C++ STL的第一个实现版本,而且开放源代码。
- P.J.Plauger STL 由P.J.Plauger参照HP STL实现出来的,被Visual C++编译器所采用,不是开源的。
- SGI STL 由Silicon Graphics Computer Systems公司参照HP STL实现,被Linux的C++编译器GCC所采用,SGI STL是开源软件,源码可读性甚高。
接下来介绍的栈和队列也是SGI STL里面的数据结构, 知道了使用版本,才知道对应的底层实现。
来说一说栈,栈先进后出,如图所示:
栈提供push 和 pop 等等接口,所有元素必须符合先进后出规则,所以栈不提供走访功能,也不提供迭代器(iterator)。 不像是set 或者map 提供迭代器iterator来遍历所有元素。
栈是以底层容器完成其所有的工作,对外提供统一的接口,底层容器是可插拔的(也就是说我们可以控制使用哪种容器来实现栈的功能)。
所以STL中栈往往不被归类为容器,而被归类为container adapter(容器适配器)。
那么问题来了,STL 中栈是用什么容器实现的?
从下图中可以看出,栈的内部结构,栈的底层实现可以是vector,deque,list 都是可以的, 主要就是数组和链表的底层实现。
我们常用的SGI STL,如果没有指定底层实现的话,默认是以deque为缺省情况下栈的低层结构。
deque是一个双向队列,只要封住一段,只开通另一端就可以实现栈的逻辑了。
SGI STL中 队列底层实现缺省情况下一样使用deque实现的。
我们也可以指定vector为栈的底层实现,初始化语句如下:
std::stack<int, std::vector<int> > third; // 使用vector为底层容器的栈
刚刚讲过栈的特性,对应的队列的情况是一样的。
队列中先进先出的数据结构,同样不允许有遍历行为,不提供迭代器, SGI STL中队列一样是以deque为缺省情况下的底部结构。
也可以指定list 为起底层实现,初始化queue的语句如下:
std::queue<int, std::list<int>> third; // 定义以list为底层容器的队列
所以STL 队列也不被归类为容器,而被归类为container adapter( 容器适配器)。
我这里讲的都是C++ 语言中情况, 使用其他语言的同学也要思考栈与队列的底层实现问题, 不要对数据结构的使用浅尝辄止,而要深挖起内部原理,才能夯实基础。
用栈实现队列
232. 用栈实现队列
请你仅使用两个栈实现先入先出队列。队列应当支持一般队列支持的所有操作(push、pop、peek、empty):
实现 MyQueue 类:
void push(int x) 将元素 x 推到队列的末尾
int pop() 从队列的开头移除并返回元素
int peek() 返回队列开头的元素
boolean empty() 如果队列为空,返回 true ;否则,返回 false
说明:
你 只能 使用标准的栈操作 —— 也就是只有 push to top, peek/pop from top, size, 和 is empty 操作是合法的。
你所使用的语言也许不支持栈。你可以使用 list 或者 deque(双端队列)来模拟一个栈,只要是标准的栈操作即可。
示例 1:
输入:
["MyQueue", "push", "push", "peek", "pop", "empty"]
[[], [1], [2], [], [], []]
输出:
[null, null, null, 1, 1, false]
解释:
MyQueue myQueue = new MyQueue();
myQueue.push(1); // queue is: [1]
myQueue.push(2); // queue is: [1, 2] (leftmost is front of the queue)
myQueue.peek(); // return 1
myQueue.pop(); // return 1, queue is [2]
myQueue.empty(); // return false
解题思路:
这是一道模拟题,不涉及到具体算法,考察的就是对栈和队列的掌握程度。
使用栈来模式队列的行为,如果仅仅用一个栈,是一定不行的,所以需要两个栈:一个输入栈,一个输出栈,这里要注意输入栈和输出栈的关系。
-
在push数据的时候,很简单,只要数据放进输入栈就好
-
但在pop的时候,操作就复杂一些,
首先需要判断输出栈是否为空。
输出栈如果为空,就把输入栈的所有数据全部导入进来(注意是全部导入),再从出栈弹出数据,
如果输出栈不为空,则直接从出栈弹出数据就可以了。
-
实现peek,其实和pop操作大致相同,可以直接复用pop操作,但同时将首元素弹出来了,所以需要将首元素再放进去。为什么这个地方是复用pop而不是将pop的代码复制粘贴过来呢?原因如果pop代码修改,那么不需要操作peek
-
empty如何实现呢?如果进栈和出栈都为空的话,说明模拟的队列为空了。
参考代码:
class MyStack {
public:
queue<int> que;
MyStack() {
}
void push(int x) {
que.push(x);
}
int pop() {
int size = que.size();
//首先size--,使最后一个元素保留在队列
size--;
while(size--){// 将队列头部的元素(除了最后一个元素外) 重新添加到队列尾部
que.push(que.front());
que.pop();
}
int result = que.front(); // 此时弹出的元素顺序就是栈的顺序了
que.pop();
return result;
}
int top() {
return que.back();
}
bool empty() {
return que.empty();
}
};
/**
* Your MyStack object will be instantiated and called as such:
* MyStack* obj = new MyStack();
* obj->push(x);
* int param_2 = obj->pop();
* int param_3 = obj->top();
* bool param_4 = obj->empty();
*/
用队列实现栈
225. 用队列实现栈
请你仅使用两个队列实现一个后入先出(LIFO)的栈,并支持普通栈的全部四种操作(push、top、pop 和 empty)。
实现 MyStack 类:
void push(int x) 将元素 x 压入栈顶。
int pop() 移除并返回栈顶元素。
int top() 返回栈顶元素。
boolean empty() 如果栈是空的,返回 true ;否则,返回 false 。
注意:
你只能使用队列的基本操作 —— 也就是 push to back、peek/pop from front、size 和 is empty 这些操作。
你所使用的语言也许不支持队列。 你可以使用 list (列表)或者 deque(双端队列)来模拟一个队列 , 只要是标准的队列操作即可。
示例:
输入:
["MyStack", "push", "push", "top", "pop", "empty"]
[[], [1], [2], [], [], []]
输出:
[null, null, null, 2, 2, false]
解释:
MyStack myStack = new MyStack();
myStack.push(1);
myStack.push(2);
myStack.top(); // 返回 2
myStack.pop(); // 返回 2
myStack.empty(); // 返回 False
题解1:使用两个队列模拟栈
刚刚做过栈与队列:我用栈来实现队列怎么样? (opens new window)的同学可能依然想着用两个队列来实现栈。
队列是先进先出的规则,把一个队列中的数据导入另一个队列中,数据的顺序并没有变,并没有变成先进后出的顺序。
所以用栈实现队列, 和用队列实现栈的思路还是不一样的,这取决于这两个数据结构的性质。
但是依然还是要用两个队列来模拟栈,只不过没有输入和输出的关系,而是另一个队列完全用来备份的(暂存除最后一个元素外其余元素)!
class MyStack {
public:
queue<int> que1;
queue<int> que2; // 辅助队列,用来备份
/** Initialize your data structure here. */
MyStack() {
}
/** Push element x onto stack. */
void push(int x) {
que1.push(x);
}
/** Removes the element on top of the stack and returns that element. */
int pop() {
int size = que1.size();
size--;
while (size--) { // 将que1 导入que2,但要留下最后一个元素
que2.push(que1.front());
que1.pop();
}
int result = que1.front(); // 留下的最后一个元素就是要返回的值
que1.pop();
que1 = que2; // 再将que2赋值给que1
while (!que2.empty()) { // 清空que2
que2.pop();
}
return result;
}
/** Get the top element. */
int top() {
return que1.back();
}
/** Returns whether the stack is empty. */
bool empty() {
return que1.empty();
}
};
题解2:使用一个队列模拟栈
其实,这道题目还可以优化,只需要用一个队列就可以实现。
一个队列在模拟栈弹出元素的时候只要将队列头部的元素(除了最后一个元素(size-1个元素)外) 重新添加到队列尾部,此时再弹出元素就是栈的顺序了。
代码细节:
-
**push:**和栈的操作一样,只需要将数据放到队列中即可。
-
pop:
(1)需要获取队列size(为了模拟栈后进先出,所以用于将除最后一个元素外其他size-1个元素弹出后重新放入队列)
(2)使用while循环(while(size–)),将前size-1个元素全弹出加回到队列中(que.push(que.front())),接着,将队列中弹出的元素删除(que.pop())
(3)剩下的最后一个元素就是pop的元素。(res = que.front())
-
top:
栈里获取top元素,对应于队列里其实是队尾元素,所以直接返回队列中队尾元素即可。(return que.back())
-
empty:
判断空,只需要判断队列是否为空,对应于栈也是否为空
参考代码:
class MyStack {
public:
queue<int> que;
MyStack() {
}
void push(int x) {
que.push(x);
}
int pop() {
int size = que.size();
//首先size--,使最后一个元素保留在队列
size--;
while(size--){// 将队列头部的元素(除了最后一个元素外) 重新添加到队列尾部
que.push(que.front());
que.pop();
}
int result = que.front(); // 此时弹出的元素顺序就是栈的顺序了
que.pop();
return result;
}
int top() {
return que.back();
}
bool empty() {
return que.empty();
}
};
/**
* Your MyStack object will be instantiated and called as such:
* MyStack* obj = new MyStack();
* obj->push(x);
* int param_2 = obj->pop();
* int param_3 = obj->top();
* bool param_4 = obj->empty();
*/
栈经典题目
20. 有效的括号
给定一个只包括 ‘(’,‘)’,‘{’,‘}’,‘[’,‘]’ 的字符串 s ,判断字符串是否有效。
有效字符串需满足:
左括号必须用相同类型的右括号闭合。
左括号必须以正确的顺序闭合。
每个右括号都有一个对应的相同类型的左括号。
示例 1:
输入:s = "()"
输出:true
示例 2:
输入:s = "()[]{}"
输出:true
示例 3:
输入:s = "(]"
输出:false
解题思路:
这道题是用栈解决问题的经典题目。由于栈结构的特殊性,非常适合做对称匹配类的题目。
首先,需要弄清楚,字符串中的括号不匹配有几种基本情况,其实,归根到底一共有三种不匹配场景:
-
字符串里左方向的括号多余
([{}]()
-
括号没有多余,但括号的类型没有匹配上
([{}})
-
字符串里右方向的括号多余
([{}])))
处理好以上三种情况,就解决了不匹配的问题
- 已经遍历完字符串,但是栈不为空,说明有相应的左括号没有右括号匹配
- 遍历字符串过程中,发现栈里没有要匹配的字符,说明有括号的类型没有匹配
- 遍历字符串过程中,栈已经为空,没有匹配的字符,说明右括号没有找到对应的左括号
那么什么是完全匹配情况呢?就是字符串遍历完,栈也为空,则说明全都匹配了。
还有一个技巧,就是在遍历字符串时,如果遇到左括号,那么让对应的右括号入栈,这样遍历到右括号时,将栈顶元素弹出,只需要比较当前元素和栈顶相不相同即可,比右括号入栈代码实现简单。
另外可以先求出字符串长度,由于匹配的括号,所以字符串长度一定为偶数,这样就可以达到剪枝操作。
代码细节:
(1)定义一个栈 st
(2)剪枝:if(s.size % 2 != 0) return false;
(3)遍历字符串,
处理遇到左括号场景:将对应右括号push进栈中
处理遇到右括号场景:
先判断栈是否为空(st.empty()),
-
若为空,return false;(情况3)
-
若不为空,将栈顶元素弹出,与对应当前右括号匹配,
如果不匹配:return false(情况2)
如果匹配:将这个栈顶元素pop删除,继续遍历
(3)循环遍历结束后,需要判断栈是否为空
- 若不为空,return false (情况1)
- 若为空,则匹配成功,return true
这种情况可以合并为一个—return st.empty(); 即可
参考代码:
class Solution {
public:
bool isValid(string s) {
if(s.size() % 2 != 0){ // 如果s的长度为奇数,一定不符合要求
return false;
}
stack<char> st;
for(int i = 0; i < s.size(); i++){ //遍历字符串
//遇到左括号,push入栈对应右括号
if(s[i] == '(') st.push(')');
else if(s[i] == '{') st.push('}');
else if(s[i] == '[') st.push(']');
//其余情况均为遇到右括号情况
// 第三种情况:遍历字符串匹配的过程中,栈已经为空了,没有匹配的字符了,说明右括号没有找到对应的左括号 return false
// 第二种情况:遍历字符串匹配的过程中,发现栈里没有我们要匹配的字符。所以return false
else if(st.empty() || st.top() != s[i]) return false;
// 只剩下st.top() 与 s[i]相等情况,所以栈弹出元素,继续向后遍历
else st.pop();
}
// 第一种情况:此时我们已经遍历完了字符串,但是栈不为空,说明有相应的左括号没有右括号来匹配,所以return false,否则就return true
return st.empty();
}
};
1047. 删除字符串中的所有相邻重复项
给出由小写字母组成的字符串 S,重复项删除操作会选择两个相邻且相同的字母,并删除它们。
在 S 上反复执行重复项删除操作,直到无法继续删除。
在完成所有重复项删除操作后返回最终的字符串。答案保证唯一。
示例:
输入:"abbaca"
输出:"ca"
解释:
例如,在 "abbaca" 中,我们可以删除 "bb" 由于两字母相邻且相同,这是此时唯一可以执行删除操作的重复项。之后我们得到字符串 "aaca",其中又只有 "aa" 可以执行重复项删除操作,所以最后的字符串为 "ca"。
解题思路:
本题要删除相邻相同元素,相对于20. 有效的括号 (opens new window)来说其实也是匹配问题,20. 有效的括号 是匹配左右括号,本题是匹配相邻元素,最后都是做消除的操作。
所以本题也是使用栈来解决的经典题目。
那么栈里应该放什么元素呢?
分析题目,在遍历过程中,其实我们就是寻找当前遍历的这个元素,在前一位是不是遍历过一样数值的元素。那么记录前面遍历过的元素,就是栈的意义。
也就是当遍历当前的元素时,去栈里寻找我们是不是遍历过相同数值的相邻元素(栈顶元素)。
如果找到,则做对应的消除操作(弹出栈顶元素)。
如果没找到,将遍历的元素push到栈中。
如此操作,只需要遍历一次,就可以模拟出题目要求的反复执行重复项删除操作(因为动态消除栈顶后,又可以和栈顶比较)。
最终结果,从栈中弹出的元素是倒序的,所以需要对字符串进行反转一下,得到最终结果。
其实,在这里,我们还可以拿字符串直接作为栈,这样省去了栈还要转为字符串的操作。!这样更方便!
代码细节:
使用字符串result模拟栈。
遍历s中的每个字母c,
如果栈空(result.empty())或者栈顶元素(字符串尾部)与当前遍历元素不相等(s != result.back()),那么将当前元素c加入到栈(字符串)(result.push_back©)中。
如果栈顶元素与当前遍历元素相等(else情况),那么将栈顶(字符串尾部)弹出(result.pop_back();)
所以遇到这种相邻元素匹配问题使,可以选择栈来实现。
参考代码:
(使用栈解决)
class Solution {
public:
string removeDuplicates(string s) { //使用栈解决
stack<char> st;
for(char c : s){
if(st.empty() || st.top() != c){ //栈空或者栈顶元素与当前遍历元素不相等
st.push(c);
} else {
st.pop(); //栈顶元素与当前遍历元素相等
}
}
string result = ""; //将栈中元素转化为字符串
while(!st.empty()){// 将栈中元素放到result字符串汇总
result += st.top();
st.pop();
}
reverse(result.begin(), result.end());// 此时字符串需要反转一下
return result;
}
};
(使用字符串模拟栈操作)
class Solution {
public:
string removeDuplicates(string s) {//使用字符串模拟栈
string result;
for(char c : s){
if(result.empty() || result.back() != c){ //注意栈顶元素,对应于字符串尾部
result.push_back(c); //在字符串尾部追加c
}
else {
result.pop_back();
}
}
return result;
}
};
150. 逆波兰表达式求值
根据 逆波兰表示法,求表达式的值。
有效的算符包括 +、-、*、/ 。每个运算对象可以是整数,也可以是另一个逆波兰表达式。
注意 两个整数之间的除法只保留整数部分。
可以保证给定的逆波兰表达式总是有效的。换句话说,表达式总会得出有效数值且不存在除数为 0 的情况。
示例 1:
输入:tokens = ["2","1","+","3","*"]
输出:9
解释:该算式转化为常见的中缀算术表达式为:((2 + 1) * 3) = 9
示例 2:
输入:tokens = ["4","13","5","/","+"]
输出:6
解释:该算式转化为常见的中缀算术表达式为:(4 + (13 / 5)) = 6
示例 3:
输入:tokens = ["10","6","9","3","+","-11","*","/","*","17","+","5","+"]
输出:22
解释:该算式转化为常见的中缀算术表达式为:
((10 * (6 / ((9 + 3) * -11))) + 17) + 5
= ((10 * (6 / (12 * -11))) + 17) + 5
= ((10 * (6 / -132)) + 17) + 5
= ((10 * 0) + 17) + 5
= (0 + 17) + 5
= 17 + 5
= 22
提示:
1 <= tokens.length <= 104
tokens[i] 是一个算符(“+”、“-”、“*” 或 “/”),或是在范围 [-200, 200] 内的一个整数
逆波兰表达式:
逆波兰表达式是一种后缀表达式,所谓后缀就是指算符写在后面。
-
平常使用的算式则是一种中缀表达式,如 ( 1 + 2 ) * ( 3 + 4 ) 。
-
该算式的逆波兰表达式写法为 ( ( 1 2 + ) ( 3 4 + ) * ) 。
逆波兰表达式主要有以下两个优点:
- 去掉括号后表达式无歧义,上式即便写成 1 2 + 3 4 + * 也可以依据次序计算出正确结果。
- 适合用栈操作运算:遇到数字则入栈;遇到算符则取出栈顶两个数字进行计算,并将结果压入栈中
解题思路:
依据题意,逆波兰表达式需要使用栈来实现:遇到数字则入栈;遇到算符则取出栈顶两个数字进行计算,并将结果压入栈中。整个后缀表达式的最终结果就是计算后的栈中元素。
代码细节:
定义一个栈,
遍历字符串s,
-
如果(if)遇到算符,那么就去栈中寻找栈顶两个数字(num1 = st.top(); st.pop(); nums2 = st.top(); st.pop())进行对应算符计算。
-
如果(else)遇到数字,那么将其(转为int型)push到栈中
最终 return st.top()即可。
这里有个小点点~如何将字符串转化为数字呢?使用stoi()函数。
C ++ STL stoi()函数 (C++ STL stoi() function)
stoi() stands for string to integer, it is a standard library function in C++ STL, it is used to convert a given string in various formats (like binary, octal, hex or a simple number in string formatted) into an integer.
stoi()表示将字符串转换为整数 ,它是C ++ STL中的标准库函数,用于将各种格式(例如二进制,八进制,十六进制或字符串格式的简单数字)的给定字符串转换为整数。
Syntax:
句法:
int stoi (const string& str, [size_t* idx], [int base]);
Parameters:
参数:
-
const string& str is an input string.
const string&str是输入字符串。
-
size_t* idx is an optional parameter (pointer to the object whose value is set by the function), it’s default value is 0 or we can assign it to nullptr.
size_t * idx是一个可选参数(指向由函数设置值的对象的指针),其默认值为0或我们可以将其分配给nullptr 。
-
int base is also an optional parameter, its default is 10. It specifies the radix to determine the value type of input string (2 for binary, 8 for octal, 10 for decimal and 16 for hexadecimal).
int base也是一个可选参数,默认值为10。它指定基数来确定输入字符串的值类型(二进制为2,八进制为8,十进制为10,十六进制为16)。
Return value: It returns converted integer value.
**返回值:**返回转换后的整数值。
参考代码:
class Solution {
public:
int evalRPN(vector<string>& tokens) {
stack<int> st;
for(int i = 0; i < tokens.size(); i++){
//遇到算符,将栈顶两个元素弹出后做计算,并将结果压入栈中
if(tokens[i] == "+" || tokens[i] == "-" || tokens[i] == "*" || tokens[i] == "/"){
//取出栈顶两元素
int num1 = st.top(); st.pop();
int num2 = st.top(); st.pop();
//最对应计算同时将结果压入栈中
//减和除注意顺序 是num2在前!
if(tokens[i] == "+") st.push(num1 + num2);
if(tokens[i] == "-") st.push(num2 - num1);
if(tokens[i] == "*") st.push(num2 * num1);
if(tokens[i] == "/") st.push(num2 / num1);
}
//其余情况,遇到数字,将该字符转为数字后压入栈中
else {
st.push(stoi(tokens[i]));
}
}
int result = st.top();
st.pop();
return result;
}
};
队列经典题目
239. 滑动窗口最大值
给你一个整数数组 nums,有一个大小为 k 的滑动窗口从数组的最左侧移动到数组的最右侧。你只可以看到在滑动窗口内的 k 个数字。滑动窗口每次只向右移动一位。
返回 滑动窗口中的最大值 。
示例 1:
输入:nums = [1,3,-1,-3,5,3,6,7], k = 3
输出:[3,3,5,5,6,7]
解释:
滑动窗口的位置 最大值
--------------- -----
[1 3 -1] -3 5 3 6 7 3
1 [3 -1 -3] 5 3 6 7 3
1 3 [-1 -3 5] 3 6 7 5
1 3 -1 [-3 5 3] 6 7 5
1 3 -1 -3 [5 3 6] 7 6
1 3 -1 -3 5 [3 6 7] 7
示例 2:
输入:nums = [1], k = 1
输出:[1]
解题思路:
这是使用单调队列的经典题目。
难点是如何求一个区间里的最大值呢?
首先想到暴力方法,遍历一遍的过程中每次从窗口中在找到最大的数值,这样很明显是O(n × k)的算法。
所以寻找一个更优的方式:
那就是使用队列,放进去窗口里的元素,然后随着窗口的移动,队列也一进一出,每次移动之后,队列告诉我们里面的最大值是什么。
每次窗口移动的时候,调用que.pop(滑动窗口中移除元素的数值),que.push(滑动窗口添加元素的数值),然后que.front()就返回我们要的最大值。
其中需要自己实现单调队列,队列里的元素一定是要排序的,而且要最大值放在出队口。
维护元素单调递减的队列就叫做单调队列,即单调递减或单调递增的队列。C++中没有直接支持单调队列,需要我们自己来一个单调队列
那么如何维护单调队列呢?队列没有必要维护窗口里的所有元素,只需要维护有可能成为窗口里最大值的元素就可以了,同时保证队列里的元素数值是由大到小的。
单调队列模拟过程:
初始化(只需要push):
push规则:
将滑动窗口内的元素加入,
如果当前元素比队列中的元素大,将该元素之前的元素(滑动窗口过程中也需要弹出之前元素,所以没必要维护)全部弹出(维护出口处为最大值)。
如果当前元素比队列中的元素小,将该元素加入到队列中。
滑动窗口向后移动(要经历一遍pop前一个元素,push后一个元素):
pop规则:
如果pop的元素是当前队列最大值(出口处元素)那么将其pop出队列
如果pop的元素不是当前队列最大值(出口处元素)那么不需要管
push规则:
如果当前元素比队列中的元素小,将该元素(对获取最大值没有影响)加入到队列中。
如果当前元素比队列中的前面元素大,将该元素之前的元素(使用while循环)全部弹出。
如果当前
设计单调队列的时候,pop,和push操作要保持如下规则:
- pop(value):如果窗口移除的元素value等于单调队列的出口元素,那么队列弹出元素,否则不用任何操作
- push(value):如果push的元素value大于入口元素的数值,那么就将队列入口的元素弹出,直到push元素的数值小于等于队列入口元素的数值为止
保持如上规则,每次窗口移动的时候,只要问que.front()就可以返回当前窗口的最大值。
代码细节:
- 那么我们用什么数据结构来实现这个单调队列呢?
使用deque最为合适,在理论基础中,我们就提到了常用的queue在没有指定容器的情况下,deque就是默认底层容器。(deque que)
基于刚刚说过的单调队列pop和push的规则,代码不难实现,如下:
pop(int value):
首先判断队列是否为空,且pop的元素(value)是否等于滑动窗口内最大值(que.front())。
如果队列不为空,且相等(!que.empty() && value == que.front()),需要将出口处元素pop出队列(que.pop_front())
其他情况,不需要做pop,因为加入最大值的时候,就把前面的小值pop出了
push(int value):
首先判断队列是否为空,且push的元素(value)是否比队列里的前面元素(que.back())都大。
如果队列不为空,且value大于出口处元素(while( !que.empty() && value > que.back() )),那么将队列中的前面元素弹出(que.pop_back()),直到找到可能前面有元素大于当前value,将value加入当前队列(que.push_back(value))
getMaxValue:
直接返回队列出口元素即可(return que.front())
参考代码:
class Solution {
private:
class MyQueue { //自定义单调队列,实现出口处永远保持最大值
public:
deque<int> que;// 使用deque来实现单调队列
// 每次弹出的时候,比较当前要弹出的数值是否等于队列出口元素的数值,如果相等则弹出。
// 同时pop之前判断队列当前是否为空。
void pop(int value){
if(!que.empty() && value == que.front()){
que.pop_front();
}
}
// 如果push的数值大于入口元素的数值,那么就将队列后端的数值弹出,直到push的数值小于等于队列入口元素的数值为止。
// 这样就保持了队列里的数值是单调从大到小的了。
void push(int value){
while(!que.empty() && value > que.back()){
que.pop_back();
}
que.push_back(value);
}
// 查询当前队列里的最大值 直接返回队列前端也就是front就可以了。
int getMaxValue(){
return que.front();
}
};
public:
vector<int> maxSlidingWindow(vector<int>& nums, int k) {
// 初始化que对象
MyQueue que;
// result存放滑动窗口划过的最大值
vector<int> result;
for(int i = 0; i < k; i++){ //初始化只需要push,将前k各元素放进队列
que.push(nums[i]);
}
// result 记录前k的元素的最大值
result.push_back(que.getMaxValue());
for(int i = k; i < nums.size(); i++){ //模拟滑动窗口移动过程
que.pop(nums[i - k]); //滑动窗口移除最前面元素
que.push(nums[i]); //滑动窗口加入最后面元素
result.push_back(que.getMaxValue()); //result记录当前最大值
}
return result;
}
};
*347. 前 K 个高频元素
给你一个整数数组 nums 和一个整数 k ,请你返回其中出现频率前 k 高的元素。你可以按 任意顺序 返回答案。
示例 1:
输入: nums = [1,1,1,2,2,3], k = 2
输出: [1,2]
示例 2:
输入: nums = [1], k = 1
输出: [1]
解题思路:
两个难点:
1.如何求数组中每个元素出现的频率
2.如何对频率进行排序并求出前k个高频元素
解决:
-
使用map存放对应元素以及出现的次数
-
按照value进行排序(使用快排要将map转换为vector的结构,然后对整个数组进行排序,:O(nlogn)),将排序后的前k个元素的key输出。
但其实不需要对所有元素进行排序,只需要求前k个高频元素也就是维护k个有序集合即可。
也就是如何维护k个有序集合呢?这就是大顶堆、小顶堆的经典应用。—在大的数据集中求前k个高频或低频的元素
那么,是使用大顶堆还是小顶堆呢?
一般思路,求前k个高频元素,使用大顶堆。
但是!根据大顶堆的操作:定义一个大小为k的大顶堆,在每次移动更新大顶堆的时候,每次弹出都把最大的元素弹出去了,那么怎么保留下来前K个高频元素呢?(所以如果是只维护k个元素,不能使用大顶堆,但是如果全部元素排序,则使用大顶堆排序)
所以!我们要**用小顶堆,因为要统计最大前k个元素,**只有小顶堆每次将最小的元素弹出,最后小顶堆里积累的才是前k个最大元素。
使用堆时间复杂度:O(logk)—数组集合很大,k较小,时间复杂度明显优于所有元素全都排序的时间复杂度。
其实,在c++中,大顶堆和小顶堆有现成的数据结构:priority_queue(优先级队列),底层实现都是一样的,从小到大排就是小顶堆,从大到小排就是大顶堆。
代码细节:
- 定义map数据结构(map<nums[i],对应出现的次数>),用于记录元素出现次数
- 遍历数组中每个元素i,使用map统计每个元素(key)所对应的出现次数(value)(map[nums[i]]++)
- 定义优先级队列(priority_queue):要求:小顶堆+对map中的value进行排序。所以需要子定义一个compare函数
- 遍历map中每个元素( * it),将每个元素加入到优先级队列中(pri_que.push(*it);)因为,只维持前k个元素,所以如果队列的元素个数大于k时(que.size() > k),执行pop操作(que.pop()),这样每次pop出最小元素,留下的就是较大的元素。
- 遍历完成后,优先级队列中的元素,就是前k个高频元素。但是由于是小顶堆,所以它的根节点(pop的第一个元素)就是堆中最小的元素,而题意要求返回频率从大到小的前k个元素。所以此时需要新定义一个数组,for循环从后(k-1)向前(for(int i = k - 1; i >= 0; i–),存入优先队列pop出的元素(注意pop出的是map对象,而我们存的是对应元素,所以要取first)(result[i] = que.top().first())
参考代码:
// 时间复杂度:O(nlogk)
// 空间复杂度:O(n)
class Solution {
public:
//自定义优先队列排序规则:小顶堆的实现
class mycomparison{
public:
//左右map型字数传入,按照value进行排序,构建小顶堆
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) {
//使用map统计元素出现频率
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的小顶堆,使用迭代器遍历map中所有元素
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--){
// 取出top(map类型)的first也就是键
result[i] = pri_que.top().first;
pri_que.pop();
}
return result;
}
};
总结
栈与队列的理论基础
首先我们在栈与队列:来看看栈和队列不为人知的一面 (opens new window)中讲解了栈和队列的理论基础。
里面提到了灵魂四问:
- C++中stack,queue 是容器么?
- 我们使用的stack,queue是属于那个版本的STL?
- 我们使用的STL中stack,queue是如何实现的?
- stack,queue 提供迭代器来遍历空间么?
相信不仅仅是C++中有这些问题,那么大家使用其他编程语言,也可以考虑一下这四个问题,栈和队列是如何实现的。
栈与队列是我们熟悉的不能再熟悉的数据结构,但它们的底层实现,很多同学都比较模糊,这其实就是基础所在。
可以出一道面试题:栈里面的元素在内存中是连续分布的么?
这个问题有两个陷阱:
- 陷阱1:栈是容器适配器,底层容器使用不同的容器,导致栈内数据在内存中是不是连续分布。
- 陷阱2:缺省情况下,默认底层容器是deque,那么deque的在内存中的数据分布是什么样的呢? 答案是:不连续的,下文也会提到deque。
所以这就是考察候选者基础知识扎不扎实的好问题。
大家还是要多多重视起来!
了解了栈与队列基础之后,那么可以用栈与队列:栈实现队列 (opens new window)和 栈与队列:队列实现栈 (opens new window)来练习一下栈与队列的基本操作。
值得一提的是,用栈与队列:用队列实现栈还有点别扭 (opens new window)中,其实只用一个队列就够了。
一个队列在模拟栈弹出元素的时候只要将队列头部的元素(除了最后一个元素外) 重新添加到队列尾部,此时在去弹出元素就是栈的顺序了。
#栈经典题目
#栈在系统中的应用
如果还记得编译原理的话,编译器在 词法分析的过程中处理括号、花括号等这个符号的逻辑,就是使用了栈这种数据结构。
再举个例子,linux系统中,cd这个进入目录的命令我们应该再熟悉不过了。
cd a/b/c/../../
1
这个命令最后进入a目录,系统是如何知道进入了a目录呢 ,这就是栈的应用。这在leetcode上也是一道题目,编号:71. 简化路径,大家有空可以做一下。
递归的实现是栈:每一次递归调用都会把函数的局部变量、参数值和返回地址等压入调用栈中,然后递归返回的时候,从栈顶弹出上一次递归的各项参数,所以这就是递归为什么可以返回上一层位置的原因。
所以栈在计算机领域中应用是非常广泛的。
有的同学经常会想学的这些数据结构有什么用,也开发不了什么软件,大多数同学说的软件应该都是可视化的软件例如APP、网站之类的,那都是非常上层的应用了,底层很多功能的实现都是基础的数据结构和算法。
所以数据结构与算法的应用往往隐藏在我们看不到的地方!
#括号匹配问题
在栈与队列:系统中处处都是栈的应用 (opens new window)中我们讲解了括号匹配问题。
括号匹配是使用栈解决的经典问题。
建议要写代码之前要分析好有哪几种不匹配的情况,如果不动手之前分析好,写出的代码也会有很多问题。
先来分析一下 这里有三种不匹配的情况,
- 第一种情况,字符串里左方向的括号多余了 ,所以不匹配。
- 第二种情况,括号没有多余,但是 括号的类型没有匹配上。
- 第三种情况,字符串里右方向的括号多余了,所以不匹配。
这里还有一些技巧,在匹配左括号的时候,右括号先入栈,就只需要比较当前元素和栈顶相不相等就可以了,比左括号先入栈代码实现要简单的多了!
#字符串去重问题
在栈与队列:匹配问题都是栈的强项 (opens new window)中讲解了字符串去重问题。 1047. 删除字符串中的所有相邻重复项
思路就是可以把字符串顺序放到一个栈中,然后如果相同的话 栈就弹出,这样最后栈里剩下的元素都是相邻不相同的元素了。
#逆波兰表达式问题
在栈与队列:有没有想过计算机是如何处理表达式的? (opens new window)中讲解了求逆波兰表达式。
本题中每一个子表达式要得出一个结果,然后拿这个结果再进行运算,那么这岂不就是一个相邻字符串消除的过程,和栈与队列:匹配问题都是栈的强项 (opens new window)中的对对碰游戏是不是就非常像了。
#队列的经典题目
#滑动窗口最大值问题
在栈与队列:滑动窗口里求最大值引出一个重要数据结构 (opens new window)中讲解了一种数据结构:单调队列。
这道题目还是比较绕的,如果第一次遇到这种题目,需要反复琢磨琢磨
主要思想是队列没有必要维护窗口里的所有元素,只需要维护有可能成为窗口里最大值的元素就可以了,同时保证队列里的元素数值是由大到小的。
那么这个维护元素单调递减的队列就叫做单调队列,即单调递减或单调递增的队列。C++中没有直接支持单调队列,需要我们自己来一个单调队列
而且不要以为实现的单调队列就是 对窗口里面的数进行排序,如果排序的话,那和优先级队列又有什么区别了呢。
设计单调队列的时候,pop,和push操作要保持如下规则:
- pop(value):如果窗口移除的元素value等于单调队列的出口元素,那么队列弹出元素,否则不用任何操作
- push(value):如果push的元素value大于入口元素的数值,那么就将队列出口的元素弹出,直到push元素的数值小于等于队列入口元素的数值为止
保持如上规则,每次窗口移动的时候,只要问que.front()就可以返回当前窗口的最大值。
一些同学还会对单调队列都有一些困惑,首先要明确的是,题解中单调队列里的pop和push接口,仅适用于本题。
单调队列不是一成不变的,而是不同场景不同写法,总之要保证队列里单调递减或递增的原则,所以叫做单调队列。
不要以为本地中的单调队列实现就是固定的写法。
我们用deque作为单调队列的底层数据结构,C++中deque是stack和queue默认的底层实现容器(这个我们之前已经讲过),deque是可以两边扩展的,而且deque里元素并不是严格的连续分布的。
#求前 K 个高频元素
在栈与队列:求前 K 个高频元素和队列有啥关系? (opens new window)中讲解了求前 K 个高频元素。
通过求前 K 个高频元素,引出另一种队列就是优先级队列。
什么是优先级队列呢?
其实就是一个披着队列外衣的堆,因为优先级队列对外接口只是从队头取元素,从队尾添加元素,再无其他取元素的方式,看起来就是一个队列。
而且优先级队列内部元素是自动依照元素的权值排列。那么它是如何有序排列的呢?
缺省情况下priority_queue利用max-heap(大顶堆)完成对元素的排序,这个大顶堆是以vector为表现形式的complete binary tree(完全二叉树)。
什么是堆呢?
堆是一棵完全二叉树,树中每个结点的值都不小于(或不大于)其左右孩子的值。 如果父亲结点是大于等于左右孩子就是大顶堆,小于等于左右孩子就是小顶堆。
所以大家经常说的大顶堆(堆头是最大元素),小顶堆(堆头是最小元素),如果懒得自己实现的话,就直接用priority_queue(优先级队列)就可以了,底层实现都是一样的,从小到大排就是小顶堆,从大到小排就是大顶堆。
本题就要使用优先级队列来对部分频率进行排序。 注意这里是对部分数据进行排序而不需要对所有数据排序!
所以排序的过程的时间复杂度是 O ( log k ) O(\log k) O(logk),整个算法的时间复杂度是 O ( n log k ) O(n\log k) O(nlogk)。
#总结
在栈与队列系列中,我们强调栈与队列的基础,也是很多同学容易忽视的点。
使用抽象程度越高的语言,越容易忽视其底层实现,而C++相对来说是比较接近底层的语言。
我们用栈实现队列,用队列实现栈来掌握的栈与队列的基本操作。
接着,通过括号匹配问题、字符串去重问题、逆波兰表达式问题来系统讲解了栈在系统中的应用,以及使用技巧。
通过求滑动窗口最大值,以及前K个高频元素介绍了两种队列:单调队列和优先级队列,这是特殊场景解决问题的利器,是一定要掌握的。
好了,栈与队列我们就总结到这里了,接下来Carl就要带大家开启新的篇章了,大家加油!
stack和queue默认的底层实现容器(这个我们之前已经讲过),deque是可以两边扩展的,而且deque里元素并不是严格的连续分布的。
#求前 K 个高频元素
在栈与队列:求前 K 个高频元素和队列有啥关系? (opens new window)中讲解了求前 K 个高频元素。
通过求前 K 个高频元素,引出另一种队列就是优先级队列。
什么是优先级队列呢?
其实就是一个披着队列外衣的堆,因为优先级队列对外接口只是从队头取元素,从队尾添加元素,再无其他取元素的方式,看起来就是一个队列。
而且优先级队列内部元素是自动依照元素的权值排列。那么它是如何有序排列的呢?
缺省情况下priority_queue利用max-heap(大顶堆)完成对元素的排序,这个大顶堆是以vector为表现形式的complete binary tree(完全二叉树)。
什么是堆呢?
堆是一棵完全二叉树,树中每个结点的值都不小于(或不大于)其左右孩子的值。 如果父亲结点是大于等于左右孩子就是大顶堆,小于等于左右孩子就是小顶堆。
所以大家经常说的大顶堆(堆头是最大元素),小顶堆(堆头是最小元素),如果懒得自己实现的话,就直接用priority_queue(优先级队列)就可以了,底层实现都是一样的,从小到大排就是小顶堆,从大到小排就是大顶堆。
本题就要使用优先级队列来对部分频率进行排序。 注意这里是对部分数据进行排序而不需要对所有数据排序!
所以排序的过程的时间复杂度是 O ( log k ) O(\log k) O(logk),整个算法的时间复杂度是 O ( n log k ) O(n\log k) O(nlogk)。
#总结
在栈与队列系列中,我们强调栈与队列的基础,也是很多同学容易忽视的点。
使用抽象程度越高的语言,越容易忽视其底层实现,而C++相对来说是比较接近底层的语言。
我们用栈实现队列,用队列实现栈来掌握的栈与队列的基本操作。
接着,通过括号匹配问题、字符串去重问题、逆波兰表达式问题来系统讲解了栈在系统中的应用,以及使用技巧。
通过求滑动窗口最大值,以及前K个高频元素介绍了两种队列:单调队列和优先级队列,这是特殊场景解决问题的利器,是一定要掌握的。
好了,栈与队列我们就总结到这里了,接下来Carl就要带大家开启新的篇章了,大家加油!