C++--stack和queue的使用及其模拟实现
stack 和 queue 的使用及其模拟实现
- 1. 设计模式
- 1.1 什么是设计模式
- 1.2 STL标准库中stack和queue的底层结构
- 2. stack
- 2.1 stack 的介绍
- 2.2 stack 的定义方式
- 2.3 stack 的常用接口
- 2.4 stack 经典OJ题
- 2.4.1 最小栈
- 2.4.2 栈的压入、弹出序列
- 2.4.3 逆波兰表达式求值
- 2.5 stack 的模拟实现
- 3. queue
- 3.1 queue 的介绍
- 3.2 queue 的定义方式
- 3.3 queue 的常用接口
- 3.4 queue 的经典OJ题
- 3.4.1 用队列实现栈
- 3.5 queue 的模拟实现
- 4. priority_queue
- 4.1 priority_queue的介绍
- 4.2 priority_queue 的定义方式
- 4.3 priority_queue 的常用接口
- 4.4 priority_queue 的模拟实现
- 5. deque
- 5.1 deque 的原理介绍
- 5.2 deque 的底层结构
- 5.3 deque 的迭代器设计
- 5.4 deque 的优缺点
1. 设计模式
1.1 什么是设计模式
设计模式是一套被反复使用的、多数人知晓的、经过分类编目的、代码设计经验的总结。Java 语言非常关注设计模式,而 C++ 并没有太关注,但是一些常见的设计模式还是要学习。
迭代器模式
其实在前面学习 string
、vector
和 list
时就已经接触过设计模式了。迭代器就是一种设计模式。迭代器模式是封装后提供统一的接口 iterator
,在不暴露底层实现细节的情况下,使得上层能够以相同的方式来访问不同的容器。
适配器模式
适配器模式则是将一个类的接口转换成客户希望的另外一个接口,即根据已有的东西转换出想要的东西。
1.2 STL标准库中stack和queue的底层结构
虽然 stack
和 queue
中也可以存放元素,但在 STL 中并没有将其划分在容器的行列,而是将其称为容器适配器,这是因为 stack
和 queue
只是对其他容器的接口进行了包装,STL中 stack
和 queue
默认 使用 deque
。
有关 deque
具体的介绍在最后一小节。
2. stack
2.1 stack 的介绍
stack是一种容器适配器,专门用在具有后进先出操作的环境中,其只能从容器的一端进行元素的插入与提取操作。
和之前学的容器不同,为了不破坏栈 LIFO
的特性,stack
不提供迭代器,所以 stack
不是迭代器模式,而是一种容器适配器:
如图,stack
使用 dqueue
容器作为默认的适配容器,关于 dqueue
的内容,放在文章最后面讲。
2.2 stack 的定义方式
**方式一:**使用默认的适配器定义栈。
stack<int> st1;
方式二: 使用特定的适配器定义栈。
stack<int, vector<int>> st2;
stack<int, list<int>> st3;
注意: 如果没有为stack指定特定的底层容器,默认情况下使用deque。
2.3 stack 的常用接口
stack当中常用的成员函数如下:
函数说明 | 接口说明 |
---|---|
stack() | 构造空的栈 |
empty() | 检测 stack 是否为空 |
size() | 返回 stack 中元素的个数 |
top() | 返回栈顶元素的引用 |
push() | 栈顶入栈 |
pop() | 栈顶出栈 |
2.4 stack 经典OJ题
2.4.1 最小栈
题目链接:155. 最小栈 - 力扣(LeetCode)
题目描述:
设计一个支持
push
,pop
,top
操作,并能在常数时间内检索到最小元素的栈。实现
MinStack
类:
MinStack()
初始化堆栈对象。void push(int val)
将元素val推入堆栈。void pop()
删除堆栈顶部的元素。int top()
获取堆栈顶部的元素。int getMin()
获取堆栈中的最小元素。
class MinStack
{
public://可以为空,初始化列表会走自定义类型的默认构造MinStack() { }//如果插入的元素小于等于最小元素就插入_minSTvoid push(int val) { _st.push(val);if(_minST.empty() || val <= _minST.top()) _minST.push(val);}void pop() { //如果_min栈顶的元素等于出栈的元素,_min顶的元素要移除int Top = _st.top();_st.pop();if(Top == _minST.top()) _minST.pop(); }int top() {return _st.top();}int getMin() {return _minST.top();}private:stack<int> _st; //保存栈中的元素stack<int> _minST; //保存栈的最小值
};
思路总结:
- 使用一个专门的栈
minST
来记录最小值,当minST
中为空或者是插入的元素小于或等于minST
栈顶元素时才插入进minST
,同样pop
数据时也只有当pop
的值和minST
栈顶元素相同时才pop
。
-
但是如果当插入的最小元素出现大量重复的时候,一直需要向 minST 中插入相同的元素,这样十分浪费空间资源。
**解决办法:**再创建一个结构体
ValCout
,其中包含_val
和_count
,并改变原来的 minST 的结构,将其类型更改为stack<ValCount>
。此时的minST
就不是原来单纯存储数据的栈了,现在不仅可以存储元素,还可以显示这个元素存在个数,这样就可以节省重复数据占用的空间。
2.4.2 栈的压入、弹出序列
题目链接:栈的压入、弹出序列_牛客题霸_牛客网
题目描述:
输入两个整数序列,第一个序列表示栈的压入顺序,请判断第二个序列是否可能为该栈的弹出顺序。假设压入栈的所有数字均不相等。例如序列1,2,3,4,5是某栈的压入顺序,序列4,5,3,2,1是该压栈序列对应的一个弹出序列,但4,3,5,1,2就不可能是该压栈序列的弹出序列。
0<=pushV.length == popV.length <=1000
-1000<=pushV[i]<=1000
pushV 的所有数字均不相同
class Solution
{
public:bool IsPopOrder(vector<int> pushV,vector<int> popV) {stack<int> st;int popi = 0;int pushi = 0;while(pushi < pushV.size()) //不断插入数据{st.push(pushV[pushi++]);//这里写成循环,因为可能连续出栈while(!st.empty() && st.top() == popV[popi]) { popi++;st.pop();}}return st.empty();}
};
思路总结:
这道题只需要模拟出栈顺序即可,将 pushV
中的元素入栈,入栈时和 popV
中的元素比较,若相同,则说明当前元素在此处出栈。当循环结束后,如果 pushV
中入栈的元素全部被 pop
或者 popV
走到了结尾,则说明出栈顺序是正确的,反之错误。需要注意的是,由于出栈可能是多个元素连续出栈,所以需要写成循环。
2.4.3 逆波兰表达式求值
题目链接:150. 逆波兰表达式求值 - 力扣(LeetCode)
题目描述:
给你一个字符串数组
tokens
,表示一个根据 逆波兰表示法 表示的算术表达式。请你计算该表达式。返回一个表示表达式值的整数。
输入:tokens = ["2","1","+","3","*"] 输出:9 解释:该算式转化为常见的中缀算术表达式为:((2 + 1) * 3) = 9输入:tokens = ["4","13","5","/","+"] 输出:6 解释:该算式转化为常见的中缀算术表达式为:(4 + (13 / 5)) = 6
class Solution
{
public:int evalRPN(vector<string>& tokens) {stack<int> st;//使用引用传参,避免不必要的拷贝,提升效率for(auto& str : tokens) {//运算符取两个栈顶元素运算后入栈if(str == "+" || str == "-" || str == "*" || str == "/") { int right = st.top(); //取出右操作数st.pop();int left = st.top(); //取出左操作数st.pop();switch(str[0]) {case '+':st.push(left + right);break;case '-':st.push(left - right);break;case '*':st.push(left * right);break;case '/':st.push(left / right);break;default:break;}} else //数字直接入栈{ //将string类型元素转化为int类型st.push(stoi(str));}} return st.top();}
};
2.5 stack 的模拟实现
完整代码:
namespace tcq //防止命名冲突
{template<class T, class Container = std::deque<T>>class stack{public://元素入栈void push(const T& x){_con.push_back(x);}//元素出栈void pop(){_con.pop_back();}//获取栈顶元素T& top(){return _con.back();}const T& top() const{return _con.back();}//获取栈中有效元素个数size_t size() const{return _con.size();}//判断栈是否为空bool empty() const{return _con.empty();}//交换两个栈中的数据void swap(stack<T, Container>& st){_con.swap(st._con);}private:Container _con; //控制stack底层结构};
}
3. queue
3.1 queue 的介绍
队列是一种容器适配器,专门用在具有先进先出操作的上下文环境中,其只能从容器的一端插入元素,另一端提取元素。
和 stack
一样,queue
也是一种容器适配器,也不提供迭代器:
可以看到,queue
也是使用 deque
作为默认适配容器。
3.2 queue 的定义方式
方式一: 使用默认的适配器定义队列。
queue<int> q1;
方式二: 使用特定的适配器定义队列。
queue<int, vector<int>> q2;
queue<int, list<int>> q3;
注意: 如果没有为queue指定特定的底层容器,默认情况下使用deque。
3.3 queue 的常用接口
函数声明 | 接口说明 |
---|---|
queue() | 构造空的队列 |
empty() | 检测队列是否为空 |
size() | 返回队列中有效元素的个数 |
front() | 返回队头元素的引用 |
back() | 返回队尾元素的引用 |
push() | 在队尾将元素 val 入队列 |
pop() | 将队头元素出队列 |
3.4 queue 的经典OJ题
3.4.1 用队列实现栈
题目链接:225. 用队列实现栈 - 力扣(LeetCode)
题目描述:
请你仅使用两个队列实现一个后入先出(LIFO)的栈,并支持普通栈的全部四种操作(
push
、top
、pop
和empty
)。实现
MyStack
类:
void push(int x)
将元素 x 压入栈顶。int pop()
移除并返回栈顶元素。int top()
返回栈顶元素。boolean empty()
如果栈是空的,返回true
;否则,返回false
。
class MyStack {
public://自动走初始化列表,调用其默认构造MyStack() { }//向非空队列中pushvoid push(int x) { if(!_q1.empty()) _q1.push(x);else _q2.push(x);}//相当于pop非空队列队尾的元素int pop() { int Top = 0; //用于记录空队列队尾的元素if(_q1.empty()) {//注意这里是元素个数不为1,而不是队头元素不等于队尾元素while(_q2.size() != 1) { int Top = _q2.front();_q1.push(Top);_q2.pop();}Top = _q2.back(); //其实此时q2中就只有一个元素了_q2.pop();} else if(_q2.empty()) {while(_q1.size() != 1) { int Top = _q1.front();_q2.push(Top);_q1.pop();}Top = _q1.back();_q1.pop();}return Top;}//非空队列的队尾元素为栈顶元素int top() { int Top = 0;if(!_q1.empty()) Top = _q1.back();else if(!_q2.empty()) Top = _q2.back();return Top;}bool empty() {return _q1.empty() && _q2.empty();}private:queue<int> _q1;queue<int> _q2;
};
思路总结:
- 入数据:定义两个队列 q1 和 q2,最开始入栈时随便将数据插入哪个队列,第二次及以后插入时把数据插入到非空队列中。
- 出数据:出栈就比较复杂了,由于队列是先入先出而栈是后入先出的,所以队尾的数据就是栈顶的数据。出栈时需要将队尾前面的元素全部挪到另一个为空的队列中,然后出掉队列中剩余的那个数据,即队尾数据,这样才完成了出栈。删除后此队列就为空,而另一个队列就不为空了,那么下次再出栈时就可以将另一个不为空队列中除队尾外所有数据挪动到此空队列中,再出掉那个队列中的数据,以此类推。
- 注意:将
q1
中的数据挪动q2
中的数据时数据的前后顺序不会改变,即入q1
的数据顺序为 1 2 3 4 5,那么将数据取出来插入到q2
后,q2
中数据的顺序仍是 1 2 3 4 5,这是因为队列的入队顺序和出队顺序是一致的。 - 总结:不进行出栈操作时,始终有一个队列是空的,入栈直接插入到非空队列中,出栈则需要先将非空队列中除队尾以外的数据全部挪动的空队列中,再出掉非空队列中剩余的那个数据。
3.5 queue 的模拟实现
namespace tcq //防止命名冲突
{template<class T, class Container = std::deque<T>>class queue{public://队尾入队列void push(const T& x){_con.push_back(x);}//队头出队列void pop(){_con.pop_front();}//获取队头元素T& front(){return _con.front();}const T& front() const{return _con.front();}//获取队尾元素T& back(){return _con.back();}const T& back() const{return _con.back();}//获取队列中有效元素个数size_t size() const{return _con.size();}//判断队列是否为空bool empty() const{return _con.empty();}//交换两个队列中的数据void swap(queue<T, Container>& q){_con.swap(q._con);}private:Container _con; //控制queue底层结构};
}
4. priority_queue
4.1 priority_queue的介绍
优先级队列默认使用 vector
作为其底层存储数据的容器,在 vector
上又使用了堆算法将 vector 中的元素构造成堆的结构,因此 priority_queue
就是堆,所有需要用到堆的位置,都可以考虑使用 priority_queue
。
注意: 默认情况下priority_queue
是大堆。
4.2 priority_queue 的定义方式
方式一: 使用vector作为底层容器,内部构造大堆结构。
priority_queue<int, vector<int>, less<int>> q1;
方式二: 使用vector作为底层容器,内部构造小堆结构。
priority_queue<int, vector<int>, greater<int>> q2;
方式三: 不指定底层容器和内部需要构造的堆结构。
priority_queue<int> q;
注意: 此时默认使用vector作为底层容器,内部默认构造大堆结构。
4.3 priority_queue 的常用接口
成员函数 | 功能 |
---|---|
push | 插入元素到队尾(并排序) |
pop | 弹出队头元素(堆顶元素) |
top | 访问队头元素(堆顶元素) |
size | 获取队列中有效元素个数 |
empty | 判断队列是否为空 |
swap | 交换两个队列的内容 |
4.4 priority_queue 的模拟实现
priority_queue
的底层实际上就是堆结构,实现 priority_queue
之前,需要先认识两个重要的堆算法。(下面这两种算法均以大堆为例)
一下这两种算法已经在初阶数据结构进行了详细介绍。
向上调整算法:
//堆的向上调整(大堆)
void AdjustUp(vector<int>& v, int child)
{int parent = (child - 1) / 2; //通过child计算parent的下标while (child > 0)//调整到根结点的位置截止{if (v[parent] < v[child])//孩子结点的值大于父结点的值{//将父结点与孩子结点交换swap(v[child], v[parent]);//继续向上进行调整child = parent;parent = (child - 1) / 2;}else//已成堆{break;}}
}
向下调整算法:
//堆的向下调整(大堆)
void AdjustDown(vector<int>& v, int n, int parent)
{//child记录左右孩子中值较大的孩子的下标int child = 2 * parent + 1;//先默认其左孩子的值较大while (child < n){if (child + 1 < n&&v[child] < v[child + 1])//右孩子存在并且右孩子比左孩子还大{child++;//较大的孩子改为右孩子}if (v[parent] < v[child])//左右孩子中较大孩子的值比父结点还大{//将父结点与较小的子结点交换swap(v[child], v[parent]);//继续向下进行调整parent = child;child = 2 * parent + 1;}else//已成堆{break;}}
}
完整代码:
namespace tcq //防止命名冲突
{//仿函数:比较方式(使内部结构为大堆),由大到小排序template<class T>struct less{bool operator()(const T& x, const T& y){return x < y;}};//仿函数:比较方式(使内部结构为小堆),由小到大排序template<class T>struct greater{bool operator()(const T& x, const T& y){return x > y;}};//优先级队列的模拟实现template<class T, class Container = vector<T>, class Compare = less<T>>class priority_queue{public://堆的向上调整void AdjustUp(int child){int parent = (child - 1) / 2; //通过child计算parent的下标while (child > 0)//调整到根结点的位置截止{//使用仿函数进行大小判断if (_comp(_con[parent], _con[child]))//通过所给比较方式确定是否需要交换结点位置{//将父结点与孩子结点交换swap(_con[child], _con[parent]);//继续向上进行调整child = parent;parent = (child - 1) / 2;}else//已成堆{break;}}}//堆的向下调整void AdjustDown(int n, int parent){int child = 2 * parent + 1;while (child < n){if (child + 1 < n&&_comp(_con[child], _con[child + 1])){child++;}//使用仿函数进行大小判断if (_comp(_con[parent], _con[child]))//通过所给比较方式确定是否需要交换结点位置{//将父结点与孩子结点交换swap(_con[child], _con[parent]);//继续向下进行调整parent = child;child = 2 * parent + 1;}else//已成堆{break;}}}//构造函数priority_queue(InputIterator first, InputIterator last):_con(first, last){// 从最后一个非叶子节点开始建堆for (int i = (_con.size()-1-1)/2; i >= 0; i--){AdjustDown(i);}}//插入元素到队尾(并排序)void push(const T& x){_con.push_back(x);AdjustUp(_con.size() - 1); //将最后一个元素进行一次向上调整}//弹出队头元素(堆顶元素)void pop(){swap(_con[0], _con[_con.size() - 1]);_con.pop_back();AdjustDown(_con.size(), 0); //将第0个元素进行一次向下调整}//访问队头元素(堆顶元素)T& top(){return _con[0];}const T& top() const{return _con[0];}//获取队列中有效元素个数size_t size() const{return _con.size();}//判断队列是否为空bool empty() const{return _con.empty();}private:Container _con; //底层容器Compare _comp; //比较方式};
}
5. deque
5.1 deque 的原理介绍
deque (双端队列):是一种双开口的 “连续” 空间的数据结构,双开口的含义是 deque 可以在头尾两端进行插入和删除操作,且时间复杂度为O(1)。
deque
与 vector
比较,在头部和中间任意位置的插入删除操作效率更高,不需要移动元素。与 list
比较,deque
的空间利用率比较高,“随机访问” 效率较高。
5.2 deque 的底层结构
deque 并不是真正连续的空间,而是由一段段连续的小空间拼接而成的,实际 deque 类似于一个动态的二维数组,其结构示意图如下:
由上图可以知道 deque 的底层结构:
deque
具有多个buffer
数组,每个buffer
数组可以存储n
个数据 (n
一般为10),还具有一个用于管理buffer
数组的中控指针数组,数组中的每个元素都指向一个buffer
数组。- 中控指针数组的使用:让数组最中间的元素指向第一个
buffer
,当第一个buffer
数组满开辟第二个buffer
数组时,让指针数组的后一个位置或者前一个位置指向新开辟的buffer
数组,因为头插导致新buffer
数组开辟就让前一个位置指向新buffer
数组,尾插导致就让后一个位置指向新buffer
数组。- deque 的扩容:当中控指针数组满后扩容,让中控指针数组的容量变为原来的二倍,然后将原中控数组里面的数据 (即各个
buffer
数组的地址) 拷贝到新的中控指针数组中。
5.3 deque 的迭代器设计
为了维护 deque “整体连续” 以及随机访问的假象,deque 的迭代器设计非常复杂。
deque的迭代器拥有四个成员,分别是first,last,node,以及cur,其各个成员的作用如下:
- first 指向当前的缓冲区的起始地址
- last指向当前缓冲区的末尾地址
- node指向map中的当前缓冲区,便于跳跃到下一个缓冲区。
- cur指向缓冲区的当前元素(即访问的当前元素)
5.4 deque 的优缺点
根据 deque 的设计结构,可以知道其优点:
- 具有 vector 的优点:支持随机访问、缓存命中率较高、尾部插入删除数据效率高。
- 具有 list 的优点:空间浪费少、头部插入插入数据效率高。
但同时 deque 也存在一些缺点:
- deque 的随机访问效率较低:需要先通过中控数组找到对应的
buffer
数组,再找到具体的位置 (假设偏移量为 i,需先 i/10 得到位于第几个buffer数组,再 i%10 得到 buffer 数组中的具体位置),即 deque 随机访问时一次跳过一个buffer数组,需要跳多次才能准确定位,其效率虽比 list 高了不少,但比 vector 低了不少。- **deque 在中部插入删除数据的效率是比较低的:**需要挪动数据,但不一定后续 buffe 数组中的数据全部挪动,可以控制只挪一部分,即中间插入删除数据的效率高于 vector,但是低于 list。
基于 deque
的优缺点可以发现,虽然 deque
综合了 vector
和 list
的优缺点,看似很完美,但是它单方面的性能是不如 vector
或者 list
的,也就是说,deque 有点东西,但不多,因此 deque 在实际应用中使用的非常少。
不过不可否认的是 deque 确实很适合作为 stack
和 list
的默认适配容器,毕竟它对于 stack
和 list
的通用的,这也是 STL 中选择 deque
作为 stack
和 queue
默认适配容器的原因。
deque
特别适合需要大量进行头插和尾部数据的插入删除、偶尔随机访问、偶尔中部插入删除的场景,不太适合需要大量进行随机访问与中部数据插入删除的场景,特别是排序。