在 C C C 语言中已经对栈和队列有了一定的了解,同时 C C C ++中我们对 STL
模板中的 string
list
vector
等容器进行了详细的探讨,从而获得了对这些容器操作的清晰理解。基于这些知识,现在转向学习 C C C ++ 里的 stack
(栈) 和 queue
(队列)就显得相对简单了,值得注意的是 STL
里的 stack
和 queue
通过适配的方法实现,更是一种封装的体现。
1、容器适配器
1.1什么是适配器
适配器是一种设计模式(设计模式是一套被反复使用的、多数人知晓的、经过分类编目的、代码设计经验的总结),该种模式是将一个类的接口转换成客户希望的另外一个接口
1.2 STL标准库中 stack
和 queue
的底层结构
虽然 stack
和 queue
中也可以存放元素,但在 STL
中并没有将其划分在容器的行列,而是将其称为容器适配器,这是因为 stack
和 queue
只是对其他容器的接口进行了包装, STL
中 stack
和 queue
默认使用 deque
,比如:
1.3 deque
的简单介绍
deque
(双端队列):是一种双开口的"连续"空间的数据结构,双开口的含义是:可以在头尾两端进行插入和删除操作,且时间复杂度为 O O O ( 1 1 1 ),与 vector
比较,头插效率高,不需要搬移元素;与 list
比较,空间利用率比较高。
deque
并不是真正连续的空间,而是由一段段连续的小空间拼接而成的,实际 deque
类似于一个
动态的二维数组,其底层结构如下图所示:
那么 deque
是如何借助其迭代器维护其假想连续的结构呢?
双端队列底层是一段假象的连续空间,实际是分段连续的,为了维护其“整体连续”以及随机访问的假象,落在了
deque
的迭代器的operator
++和
operator--
重载运算符上。
**
deque
应具备的结构:**
● 能够指出分段连续空间(缓冲区)的位置
● 能判断自己是否已经处于其所在缓冲区的边缘,如果前进或后退时就必须跳跃至下一个或上一个缓冲区。
为了能够正确跳跃,deque
必须要有掌控中心(map
)。
迭代器 start
内的 cur
指针指向缓冲区的第一个元素,迭代器 finish
内的 cur
指向缓冲区的最后元素(的下一个位置)。注意:最后一个缓冲区尚有备用空间,稍后如果有新元素要插入到尾端,可直接拿此备用空间来使用。
template <class T, class Ref, class Ptr, size_t BufSiz>
struct _daque_iterator
{
//...
//保持与容器的联结
T* cur; //此迭代器指向缓冲区中的现行
T* first; //此迭代器指向缓冲区的头
T* last; //此迭代器指向缓冲区的尾(含备用空间)
map_pointer node;//指向控制中心(指针数组)
};
deque
的底层框架
deque
除了维护一个指向 map
的指针外,也维护 start
和 finish
两个迭代器,分别指向第一缓冲区的第一个元素和最后缓冲区的最后一个元素(的下一个位置)。此外,它当然也必须记住目前 map
的大小,因为,一旦 map
所提供的节点不足,就必须重新配置更大的一块 map
。
iterator start;
iterator finish;
map_pointer map;
size_type map_size;
deque
的缺陷
与 vector
比较, deque
的优势是:头部插入和删除时,不需要搬移元素,效率特别高,而且在扩容时,也不需要搬移大量的元素,因此其效率是比 vector
高的。与 list
比较,其底层是连续空间,空间利用率比较高,不需要存储额外字段。但是, deque
有一个致命缺陷:不适合遍历,因为在遍历时, deque
的迭代器要频繁的去检测其是否移动到某段小空间的边界,导致效率低下,而序列式场景中,可能需要经常遍历,因此在实际中,需要线性结构时,大多数情况下优先考虑 vector
和 list
, deque
的应用并不多,而目前能看到的一个应用就是, STL
用其作为 stack
和 queue
的底层数据结构。
为什么选择 deque
作为 stack
和 queue
的底层默认容器?
stack
是一种后进先出的特殊线性数据结构,因此只要具有push_back
()
和pop_back
()
操作的线性结构,都可以作为stack
的底层容器,比如vector
和list
都可以;queue
是先进先出的特殊线性数据结构,只要具有push_back
和pop_front
操作的线性结构,都可以作为queue
的底层容器,比如list
。但是STL
中对stack
和queue
默认选择deque
作为其底层容器。主要是因为:
**
stack
和queue
不需要遍历 (因此stack
和queue
没有迭代器),只需要在固定的一端或者两端进行操作。 在stack
中元素增长时,deque
比vector
的效率高(扩容时不需要搬移大量数据);
queue
中的元素增长时,deque
不仅效率高,而且内存使用率高。 结合了deque
的优点,而完美的避开了其缺陷。
2 、stack
与 queue
的介绍使用
2.1stack
与queue
介绍
stack介绍
stack
是一种容器适配器,专门用在具有**LIFO(后进先出)**操作的上下文环境中,其删除只能从容器的一端进行元素的插入与提取操作。类似与向箱子里放入取出物品,只能一端进行操作
stack
是作为容器适配器( 一种设计模式 )被实现的,容器适配器即是对特定类封装作为其底层的容器,并提供一组特定的成员函数来访问其元素,将特定类作为其底层的,元素特定容器的尾部(即栈顶)被压入和弹出。
stack
的底层容器可以是任何标准的容器类模板或者一些其他特定的容器类,这些容器类应该支持以下
操作:
empty
:判空操作
back
:获取尾部元素操作
push_back
:尾部插入元素操作
pop_back
:尾部删除元素操作
标准容器 vector
、 deque
、 list
均符合这些需求,默认情况下,如果没有为 stack
指定特定的底层容器,默认情况下使用 deque
。
queue介绍
queue
是一种容器适配器,专门用于在**FIFO(先进先出)**中操作,其中从容器一端插入元素,另一端
提取元素。类似于排队打饭,只能从尾端进入,从头离开。
queue
作为容器适配器实现,容器适配器即将特定容器类封装作为其底层容器类, queue
提供一组特定的
成员函数来访问其元素。元素从队尾入队列,从队头出队列。
底层容器可以是标准容器类模板之一,也可以是其他专门设计的容器类。该底层容器应至少支持以下操
作:
empty
:检测队列是否为空
size
:返回队列中有效元素的个数
front
:返回队头元素的引用
back
:返回队尾元素的引用
push_back
:在队列尾部入队列
pop_front
:在队列头部出队列
标准容器类 deque
和 list
满足了这些要求。默认情况下,如果没有为 queue
实例化指定容器类,则使用标准容器 deque
。
2.2stack
与queue
模拟
Stack
模拟实现
#define _CRT_SECURE_NO_WARNINGS
#pragma once
#include<list>
#include<vector>
#include<iostream>
#include<deque>
namespace xc
{
// container 适配转换 stack
template<class T,class Container=deque<T>>
class stack
{
public:
void push(const T&x)
{
_con.push_back(x);
}
void pop()
{
_con.pop_back();
}
const T& top()const
{
return _con.back();
}
size_t size()const
{
return _con.size();
}
bool empty()const
{
return _con.empty();
}
private:
Container _con;
};
}
Queue
模拟实现
#define _CRT_SECURE_NO_WARNINGS
#pragma once
#include<list>
#include<vector>
#include<iostream>
#include<deque>
namespace xc
{
// container 适配转换 queue
template<class T, class Container = deque<T>>
class queue
{
public:
void push(const T& x)
{
_con.push_back(x);
}
void pop()
{
_con.pop_front();
}
const T& front()const
{
return _con.front();
}
const T& back()const
{
return _con.back();
}
size_t size()const
{
return _con.size();
}
bool empty()const
{
return _con.empty();
}
private:
Container _con;
};
}
2.3stack
与queue
使用
接下来我们在解题中体会 stack
与 queue
的使用方法
有效的括号
思路分析:
遍历字符串 s s s 左括号入栈,右括号与出栈的左括号匹配,如果不匹配直接 false
Tip
:仅有右括号或者左括号,数量不匹配问题也应该注意结合判空返回真假
class Solution {
public:
bool isValid(string s) {
stack<char>st;
for(int i=0;i<s.size();i++)
{
//左括号入栈
if(s[i]=='('||s[i]=='{'||s[i]=='[')
{
st.push(s[i]);
}
else
{
if(st.empty())//栈里没有左括号 直接到右括号 匹配失败
return 0;
char tmp=st.top();
st.pop();//取出右括号 进行匹配
if(tmp=='('&&s[i]!=')'
||tmp=='['&&s[i]!=']'
||tmp=='{'&&s[i]!='}')
return 0;
}
}
if(!st.empty())//遍历完字符串 如果栈里还有左括号 也是属于匹配错误
return 0;
return 1;
}
};
队列实现栈
思路分析:
因为队列是先进先出,而需要实现的栈是后进先出的。所以我们需要借助另外一个队列导数据才能实现后进先出的栈
如何出栈得到 4 4 4 呢?将非空队列 q q q 1 1 1 前 size
- 1 1 1 个数据挪到空队列 q q q 2 2 2 ,再 pop
q q q 1 1 1 最后一个元素即栈顶元素
即非空队列入数据
class MyStack {
public:
queue<int>q1;
queue<int>q2;
MyStack() {
}
void push(int x) {
//非空队列入数据
if(!q1.empty())
{
q1.push(x);
}
else
q2.push(x);
}
int pop() {
//先判断哪个队列是空的然后while循环导size-1数据,最后pop
if(!q1.empty()) //假设法判断出空队列 q1为非空 交换q2非空
{
swap(q1,q2);
}
while(q2.size()>1) //将前面数据导入空队列 此时 q1 空队列
{
q1.push(q2.front());
q2.pop();
}
int ret=q2.front();
q2.pop();
return ret;;
}
int top() {
if(!q1.empty())
return q1.back();
else return q2.back();
}
bool empty() {
return q1.empty()&&q2.empty();
}
};
还有一种思路 只需要一个队列实现栈:简单来说就是把队列当成一个环用,每次都把除了队列末端的元素都出队然后依次加到原本末端元素的后端,这样原本最后的元素就被推到了队列最前端,实现了 Last
In
First
Out
。
class MyStack {
public:
queue<int>q;
MyStack() {
}
void push(int x) {
int n=q.size();
q.push(x);
while(n>=1)
{
q.push(q.front());
q.pop();
n--;
}
}
int pop() {
int ret=q.front();
q.pop();
return ret;
}
int top() {
return q.front();
}
bool empty() {
return q.empty();
}
};
栈实现队列
思路分析:
出数据应该是 1 1 1 2 2 2 3 3 3 4 4 4 ,发现只要将数据导入另一个栈中 pop
顺序刚好是 1 1 1 2 2 2 3 3 3 4 4 4
这样我们就可以利用一个 push
栈 和一个 pop
栈实现队列的先进先出,当 pop
为空时就需要导入数据
class MyQueue {
public:
stack<int> pushst;
stack<int> popst;
MyQueue() {
}
void push(int x) {
pushst.push(x);
}
int pop() {
int ret =peek();
popst.pop();
return ret;
}
int peek() {
if(popst.empty()) //判断出的栈是否为空
{
while(!pushst.empty())//导数据
{
popst.push(pushst.top());
pushst.pop();
}
}
return popst.top();
}
bool empty() {
return popst.empty()&&pushst.empty();
}
};
二叉树的层序遍历
思路一:
因为题目要求返回一个二维vector 所以我们需要记录节点层数
class Solution {
public:
vector<vector<int>> levelOrder(TreeNode* root) {
vector<vector<int>> ret;
if (root == nullptr)
return ret; // 空树 返回空数组
queue<TreeNode*> nodeQueue; // 存储节点的队列
queue<int> levelQueue; // 存储节点层数的队列
nodeQueue.push(root);
levelQueue.push(0);
while (!nodeQueue.empty()) {
auto node = nodeQueue.front();
nodeQueue.pop();
auto level = levelQueue.front();
levelQueue.pop();
// 如果 ret 的大小还没有达到当前层数,需要扩展 ret
if (ret.size() == level) {
ret.push_back(vector<int>());
}
ret[level].push_back(node->val); // 将当前节点的值加入对应层
// 把左右子节点加入队列并记录层数
if (node->left) {
nodeQueue.push(node->left);
levelQueue.push(level + 1);
}
if (node->right) {
nodeQueue.push(node->right);
levelQueue.push(level + 1);
}
}
return ret;
}
};
思路二:
class Solution {
public:
vector<vector<int>> levelOrder(TreeNode* root) {
vector<vector<int>>ret;
if(root==nullptr)
return ret;
queue<TreeNode*>q;
q.push(root);
while(!q.empty())
{
int CurrentLevelSize=q.size();
ret.push_back(vector<int>()); // 添加一个新的向量
while(CurrentLevelSize>0) {
//依次将当前层数据插入vector里
auto node = q.front();
q.pop();
ret[ret.size()-1].push_back(node->val); // 将当前节点的值加入当前层
if (node->left) q.push(node->left);
if (node->right) q.push(node->right);
CurrentLevelSize--; //更新值 否则循环错误
}
}
return ret;
}
};
设计循环队列
思路分析:
题意有限的资源重复利用保证先进先出,也就是要求 head
和 tail
在队列里回绕重复使用队列的空间
上述方法可以解决掉判空和判满的冲突问题
在循环队列中,当队列为空,可知 head
= tail
;而当所有队列空间全占满时,也有 tail
= head
。为了区别这两种情况,假设队列使用的数组有 capacity
个存储空间,则此时规定循环队列最多只能有 capacity
− 1 1 1 个队列元素,当循环队列中只剩下一个空存储单元时,则表示队列已满。根据以上可知,队列判空的条件是 head
= tail
,而队列判满的条件是 head
=( tail
+ 1 1 1 ) mod
capacity
对于一个固定大小的数组,只要知道队尾 tail
与队首 head
,即可计算出队列当前的长度:
( tail
− head
+ capacity
) mod
capacity
class MyCircularQueue {
private:
int head;
int tail;
vector<int>con;
public:
MyCircularQueue(int k) {
con.reserve(k+1);
head=tail=0;
}
bool enQueue(int value) {
if(isFull())
return 0;
con[tail]=value;
tail=(tail+1)%con.capacity(); //解决回绕问题 假设 k为4 tail==4时候 再插入 tail应为0
return 1;
}
bool deQueue() {
if(isEmpty())
return 0;;
head=(head+1)%con.capacity();
return 1;
}
int Front() {
if(isEmpty())
return -1;
return con[head];
}
int Rear() {
//tail-1可能为负值 可以 tail-1+capacity %capacity
if(isEmpty())
return -1;
return con[(tail-1+con.capacity())%con.capacity()];
}
bool isEmpty() {
return head==tail;
}
bool isFull() {
//解决tail+1溢出问题 现需要循环回绕
return (tail+1)%con.capacity()==head;
}
};
总结:循环队列的实现
数组 + size
变量:代码清晰、操作简便,但数组大小固定。
仅使用 head
和 tail
的数组:保留一个空位,减少变量开销,适合容量固定的情况。
链表实现:动态大小,适用于需要弹性容量的场景,但性能相对较低。
双端队列实现:可以直接使用 STL
deque
实现循环队列,方便但缺乏灵活性。
最小栈
思路
要做出这道题目,首先要理解栈结构先进后出的性质。
对于栈来说,如果一个元素 a a a 在入栈时,栈里有其它的元素 b b b , c c c , d d d ,那么无论这个栈在之后经历了什么操作,只要 a a a 在栈中, b b b , c c c , d d d 就一定在栈中,因为在 a a a 被弹出之前, b b b , c c c , d d d 不会被弹出。
因此,在操作过程中的任意一个时刻,只要栈顶的元素是 a a a ,那么我们就可以确定栈里面现在的元素一定是 a a a , b b b , c c c , d d d 。
那么,我们可以在每个元素 a a a 入栈时把当前栈的最小值 m m m 存储起来。在这之后无论何时,如果栈顶元素是 a a a ,我们就可以直接返回存储的最小值 m m m 。
class MinStack {
public:
stack<int>st;
stack<int>minst;
MinStack() {
//minst.push(INT_MAX);
}
void push(int val) {
st.push(val);
if(minst.empty()|| val<=minst.top()) //第一次插入 minst可能空栈 无法调用 top
minst.push(val);
}
void pop() {
if(st.top()==minst.top())
minst.pop();
st.pop();
}
int top() {
return st.top();
}
int getMin() {
return minst.top();
}
};
栈的压入、弹出序列
可以用来判断出栈序列的正确性
bool IsPopOrder(vector<int>& pushV, vector<int>& popV) {
stack<int>st;
int n=pushV.size();
int j=0;
//并列遍历 而不是嵌套
for(int i=0;i<n;i++)//依次遍历 push和pop数组 这里 i 控制的是 popV
{
while(j<n&&(st.empty()||st.top()!=popV[i])) //j 控制的是 push V
{
st.push(pushV[j]);
j++;
}
if(st.top()==popV[i]) //遇到了popV数组元素就需要出栈
st.pop();
else return 0;
}
return 1;
}
class Solution {
public:
int evalRPN(vector<string>& tokens) {
stack<int>st;
int n=tokens.size();
for(int i=0;i<n;i++)
{
string&str=tokens[i];
if(!(str=="+"||str=="-"||str=="*"||str=="/")) //数字入栈
st.push(stoi(str)); // stoi 将 string转为int
else //操作符
{
int left=st.top(); st.pop(); //后入的元素为左操作数
int right=st.top(); st.pop();
if(str=="+")
st.push(right+left);
if(str=="-")
st.push(right-left);
if(str=="*")
st.push(right*left);
if(str=="/")
st.push(right/left);
}
}
return st.top();
}
};
总结:
多是观察栈的特性 后进先出,借用辅助栈来解决一些问题
队列涉及到 BFS
广度优先——层序遍历,还有注意的就是循环队列的一些特性方法