【STL学习】(8)stack|queue|deque

前言

本文讲解了:

  1. stack和queue的使用和模拟
  2. 了解容器适配器和deque

一、stack的介绍和使用

1. stack的介绍

stack的文档介绍

  1. stack是一种容器适配器,专门用在具有后进先出操作的上下文环境中,其删除只能从容器的一端进行元素的插入与提取操作。
  2. stack是作为容器适配器被实现的,容器适配器即是对特定类封装作为其底层的容器,并提供一组特定的成员函数来访问其元素,将特定类作为其底层的,元素特定容器的尾部(即栈顶)被压入和弹出。
  3. stack的底层容器可以是任何标准的容器类模板或者一些其他特定的容器类,这些容器类应该支持以下操作:
    • empty:判空操作
    • back:获取尾部元素操作
    • push_back:尾部插入元素操作
    • pop_back:尾部删除元素操作
  4. 标准容器vector、deque、list均符合这些需求,默认情况下,如果没有为stack指定特定的底层容器,默认情况下使用deque。
    在这里插入图片描述

2. stack的使用

2.1 常用接口的使用

函数接口说明
stack()构造空的栈
empty()检测stack是否为空,为空返回真,反之返回假
size()返回stack中元素的个数
top()返回栈顶元素的引用
push(const value_type& val)将元素val压入stack中
pop()将stack中尾部的元素弹出,注意pop没有返回值

代码示例:

void test_stack_queue()
{
	stack<int> st;
	//push入栈
	st.push(1);
	st.push(2);
	st.push(3);
	st.push(4);
	//pop出栈,但注意stack不能随便的出数据,需要判断栈是否为空
	while (!st.empty())
	{
		//栈不为空,才可以出数据
		cout << st.top() << " ";
		st.pop();
	}
	cout << endl;
}

2.2 stack的经典题目

最小栈

思路: 定义两个栈,一个正常栈,一个最小栈存正常栈中的最小元素

方案1:

  • push:正常栈push时,正常栈中的最小元素push最小栈
  • pop:正常栈pop时,最小栈也pop
  • getMin:最小栈top
  • top:正常栈top

方案2:对方案1的优化

  • push:最小栈没有必要在正常栈push都push正常栈的最小元素,只有当正常栈push的元素小于或等于最小元素时,最小栈才push最小元素
  • pop:只有当正常栈pop的元素等于最小栈top的元素的时候,最小栈才pop

图示:在这里插入图片描述

  • 方案2的代码实现:
class MinStack {
private:
    stack<int> _st;//正常栈:保存栈中的元素
    stack<int> _minst;//最小栈:保存栈的最小值
public:
    //构造函数我们不实现,自定义类型的成员变量会去调用它自己的默认构造
    MinStack() {
    }
    
    //正常栈push的元素小于或等于最小元素,最小栈才push
    void push(int val) {
        _st.push(val);
        if(_minst.empty() || val <= _minst.top())
        {
            _minst.push(val);
        }
    }
    
    //正常栈pop的元素等于最小栈top的元素,最小栈pop
    void pop() {
        if(_st.top() == _minst.top())
        {
            _minst.pop();
        }
        _st.pop();
    }
    
    //获取正常栈的top元素
    int top() {
       return _st.top();
    }
    
    //获取最小栈的top元素
    int getMin() {
        return _minst.top();
    }
};

栈的压入、弹出序列

思路: 用入栈序列模拟出栈序列,如果到最后栈为空,则出栈序列合理,反之则不合理

逻辑步骤:

  1. 入栈
  2. 每一次入栈之后,栈顶元素与出栈序列做匹配
    • 匹配:循环,把所有匹配的元素全出栈,直到不匹配(注意:当栈为空时,不能做匹配操作)
    • 不匹配:如果不匹配,则继续入栈,直到没有数据就不入栈了
  3. 最后判断栈是否为空
class Solution {
public:
    /**
     * 代码中的类名、方法名、参数名已经指定,请勿修改,直接返回方法规定的值即可
     *
     * 
     * @param pushV int整型vector 
     * @param popV int整型vector 
     * @return bool布尔型
     */
    bool IsPopOrder(vector<int>& pushV, vector<int>& popV) {
        stack<int> st;
        //定义两个下标分别指向进栈、出栈序列
        int pushi = 0;
        int popi = 0;
        //当没有数据就不进栈了
        while (pushi < pushV.size()) {
            //进栈
            st.push(pushV[pushi++]);
            //判断栈顶元素是否与出栈序列匹配,将匹配的所有元素出栈,注意栈为空时不能出栈
            while(!st.empty() && st.top() == popV[popi])
            {
                st.pop();
                //迭代:继续与下一个做匹配
                popi++;
            }
        }
        //最后如果栈为空,则说明出栈序列合理
        return st.empty();
    }
};

逆波兰表达式

中缀表达式和后缀表达式:

  • 中缀表达式:操作符在操作数的中间,像我们平时写表达式都是中缀表达式。因为操作符的优先级和计算机是从前往后读数据的,所以中缀表达式非常不利于我们的计算机运算,一般都需要将其转换为后缀表达式
  • 后缀表达式:又叫做逆波兰表达式,中缀表达式转后缀表达式操作数顺序不变,操作符按优先级重排


操作符的优先级是如何确定的?

  • 相邻操作符比较优先级,例如:2+1-3*4
  • 操作符的优先级是根据从左到右相邻的操作符来确定的,不是全局确定。所以下一个操作符就能确定上一个操作符的优先级


中缀表达式转后缀表达式:

  • 操作数:操作数输出——输出指的是将其放在一个容器中
  • 操作符:
    • 栈为空或当前操作符比栈顶的优先级高,继续入栈
    • 栈不为空且当前操作符比栈顶的优先级低或相等(优先级相等,先算前面的),则输出栈顶操作符
  • 表达式结束后,依次输出栈里的操作符
  • tip:如果是遇到(),我们可以使用递归解决,遇到‘(’开始递归,遇到‘)’结束递归


后缀表达式的计算:后缀表达式的计算需要借助一个栈,具体步骤如下

  • 操作数:入栈
  • 操作符:取栈顶元素两个进行运算,运算结果继续入栈

代码示例:

class Solution {
public:
    int evalRPN(vector<string>& tokens) {
        stack<int> s;
        for(auto& str : tokens)
        {
            if(str == "+" || str == "-" || str == "*" || str == "/")
            {
                //C++stack的缺陷:在pop的时候没有返回值,所以我们必须先将栈顶元素保存
                int right = s.top();
                s.pop();

                int left = s.top();
                s.pop();

                //注意switch中的表达式必须是整形
                switch(str[0])
                {
                    case '+':
                        s.push(left + right);
                        break;
                    case '-':
                        s.push(left - right);
                        break;
                    case '*':
                        s.push(left * right);
                        break;
                    case '/':
                        s.push(left / right);
                        break;
                }
            }
            else
            {
                //stoi将字符串转换为integral
                s.push(stoi(str));
            }
        }
        return s.top();
    }
};

二、queue的介绍和使用

1. queue的介绍

queue的文档介绍

  1. 队列是一种容器适配器,专门用于在FIFO上下文(先进先出)中操作,其中从容器一端插入元素,另一端提取元素。
  2. 队列作为容器适配器实现,容器适配器即将特定容器类封装作为其底层容器类,queue提供一组特定的成员函数来访问其元素。元素从队尾入队列,从队头出队列。
  3. 底层容器可以是标准容器类模板之一,也可以是其他专门设计的容器类。该底层容器应至少支持以下操作:
    • empty:检测队列是否为空
    • size:返回队列中有效元素的个数
    • front:返回队头元素的引用
    • back:返回队尾元素的引用
    • push_back:在队列尾部入队列
    • pop_front:在队列头部出队列
  4. 标准容器类deque和list满足了这些要求。默认情况下,如果没有为queue实例化指定容器类,则使用标准容器deque。
    在这里插入图片描述

2. queue的使用

2.1 常用接口

函数接口说明
queue()构造空的队列
empty()检测队列是否为空,是返回true,否则返回false
size()返回队列中有效元素的个数
front()返回队头元素的引用
back()返回队尾元素的引用
push(const value_type& val)在队尾将元素val入队列
pop()将队头元素出队列,注意pop没有返回值

代码示例:

void test_stack_queue()
{
	stack<int> st;
	//push入栈
	st.push(1);
	st.push(2);
	st.push(3);
	st.push(4);
	//pop出栈,但注意stack不能随便的出数据,需要判断栈是否为空
	while (!st.empty())
	{
		//栈不为空,才可以出数据
		cout << st.top() << " ";
		st.pop();
	}
	cout << endl;

	queue<int> q;
	//push入队列
	q.push(1);
	q.push(2);
	q.push(3);
	q.push(4);
	//pop出队列,但注意queue和stack一眼不能随便的出数据,需要判断队列是否为空
	while (!q.empty())
	{
		//队列不为空,才可以出数据
		cout << q.front() << " ";
		q.pop();
	}
	cout << endl;
}

tip:stack和queue都不支持遍历,所以他们没有提供迭代器!

2.2 queue的经典题目

二叉树的层序遍历

层序遍历:自上而下,自左至右逐层访问树的节点的过程就是层序遍历

层序遍历的思路:队列实现——利用队列先进先出的性质,出上一层,带入下一层。

问题:如何知道结点是哪一层的?

  • 方案1:双队列——一个队列存结点,一个队列存结点的层级
  • 方案2:定义一个levelSize变量来控制队列一层一层的出

方案2代码示例:

/**
 * Definition for a binary tree node.
 * struct TreeNode {
 *     int val;
 *     TreeNode *left;
 *     TreeNode *right;
 *     TreeNode() : val(0), left(nullptr), right(nullptr) {}
 *     TreeNode(int x) : val(x), left(nullptr), right(nullptr) {}
 *     TreeNode(int x, TreeNode *left, TreeNode *right) : val(x), left(left), right(right) {}
 * };
 */
class Solution {
public:
    vector<vector<int>> levelOrder(TreeNode* root) {
    	//保存每一层的结点值
        vector<vector<int>> vv;
        queue<TreeNode*> q;
        //levelSize变量的作用:控制队列一层一层的出结点
        int levelSize = 0;
        //树不为空——初始化队列
        if(root)
        {
            q.push(root);
            levelSize = 1;
        }
        //出上一层,带入下一层
        while(!q.empty())
        {
            vector<int> v;
            //levelSize控制变量一层一层的出
            while(levelSize--)
            {
                //根节点出队列,根的左右孩子入队列
                TreeNode* front = q.front();
                q.pop();
                v.push_back(front->val);
                if(front->left)
                {
                    q.push(front->left);
                }
                if(front->right)
                {
                    q.push(front->right);
                }
            }
            //得到下一层的结点个数
            levelSize = q.size();
            vv.push_back(v);
        }
        return vv;
    }
};

三、stack和queue的模拟实现

  1. 队列和栈都是一个容器适配器
  2. 适配器就是用现有的东西转换,转换出我要的东西
  3. 适配器的本质:复用
  4. 例如:电源适配器,本质上是做电压、电流的转换,目的是安全、与设备适配,但在转换的时候能源的本质没有变

1. stack的模拟实现

  1. stack是一种容器适配器,只能从容器固定的一端进行插入与删除。进行数据插入和删除操作的一端称为栈顶,另一端称为栈底。栈中的数据元素遵守后进先出LIFO(Last In First Out)的原则。
  2. 容器适配器就是使用已有的容器封装进行适配转换,将已有的容器作为其底层容器
  3. stack的底层容器可以是任何标准的容器类模板或者一些其他特定的容器类,只要这些容器类支持以下操作:
    • empty:检测容器是否为空
    • size:获取容器中有效元素的个数
    • back:获取容器的尾部元素
    • push_back:在容器尾部插入元素
    • pop_back:在容器尾部删除元素
  4. 标准容器vector、deque、list均符合这些需求,默认情况下,如果没有为stack指定特定的底层容器,默认情况下使用deque
  5. 由于deque我们还没有学习,所以在模拟实现的时候我们使用list和vector来做stack的底层容器,不使用deque
  6. 问题:我们如何指定stack的底层容器?
    • 模板:我们可以定义一个模板参数Container来让我们自己选择stack的底层容器

代码示例:

namespace wjs
{
	//Stack是一个容器适配器
	//容器适配器:就是对已有的容器进行适配转换
	/*
		stack的类模板定义两个模板参数:
			第一个模板参数T:栈存什么数据
			第二个模板参数Container:栈使用什么容器来进行适配转换

		栈的底层可以使用链表、数组实现,但使用数组实现更好一些,
		所以我们可以让vector<T>做第二个模板参数的缺省值
	*/
	template<class T, class Container = vector<T>>
	class Stack
	{
	public:
		void push(const T& val)
		{
			_con.push_back(val);
		}

		void pop()
		{
			_con.pop_back();
		}

		T& top()
		{
			/*
				在这里我们不能使用下标,因为我们不知道栈在使用的时候选择了什么容器来进行转换

				但list和vector都提供了back来访问容器中的最后一个元素
			*/
			return _con.back();
		}

		size_t size()
		{
			return _con.size();
		}

		bool empty()
		{
			return _con.empty();
		}
	private:
		//因为成员变量是自定义类型,所以我们可以使用默认生成的构造、拷贝、析构,因为它会自动去调用
		Container _con;
	};

	void test_stack()
	{
		//默认就是数组栈
		Stack<int> st1;
		st1.push(1);
		st1.push(2);
		st1.push(3);
		st1.push(4);
		while (!st1.empty())
		{
			cout << st1.top() << " ";
			st1.pop();
		}
		cout << endl;

		//链式栈
		Stack<int, list<int>> st2;
		st2.push(1);
		st2.push(2);
		st2.push(3);
		st2.push(4);
		while (!st2.empty())
		{
			cout << st2.top() << " ";
			st2.pop();
		}
		cout << endl;
	}
}

2. queue的模拟实现

  1. queue是一种容器适配器,从容器队尾插入,队尾删除。是一种先进先出FIFO(first in first out)的线性表,允许插入的一端称为队尾,允许删除的一端称为队头。
  2. queue底层容器可以是标准容器类模板之一,也可以是其他专门设计的容器类。该底层容器应至少支持以下操作:
    • empty:检测容器是否为空
    • size:返回容器中有效元素的个数
    • front:返回容器的队头元素
    • back:返回容器的队尾元素
    • push_back:在容器队尾插入元素
    • pop_front:在容器队头删除元素
  3. 标准容器类deque和list满足了这些要求。默认情况下,如果没有为queue实例化指定容器类,则使用标准容器deque。
  4. 问题:为什么queue底层容器不使用vector?
    • 因为vector头删的效率太低,没有提供头删pop_front接口,即使vector可以使用erase头删,但是库中都杜绝了erase的使用,只能使用pop_front。

代码示例:

namespace wjs
{
	//Queue也是一个容器适配器
	template<class T, class Container = list<T>>
	class Queue
	{
	public:
		void push(const T& val)
		{
			_con.push_back(val);
		}

		void pop()
		{
			_con.pop_front();
			//虽然list和vector都提供了erase方法,可以使用该方法进行头删,
			//但是库中的pop内部仍然使用pop_front来进行头删,因为vector头删效率太低了,
			//所以queue都不使用vector来进行转换
			//_con.erase(_con.begin());
		}

		T& front()
		{
			return _con.front();
		}

		T& back()
		{
			return _con.back();
		}

		size_t size()
		{
			return _con.size();
		}

		bool empty()
		{
			return _con.empty();
		}
	private:
		//因为成员变量是自定义类型,所以我们可以使用默认生成的构造、拷贝、析构,因为它会自动去调用
		Container _con;
	};

	void test_queue()
	{
		queue<int> q;
		q.push(1);
		q.push(2);
		q.push(3);
		q.push(4);
		while (!q.empty())
		{
			cout << q.front() << " ";
			q.pop();
		}
		cout << endl;
	}
}

四、容器适配器和deque

1. 什么是适配器

适配器是一种设计模式(设计模式是一套被反复使用的、多数人知晓的、经过分类编目的、代码设计经验的总结),该种模式是将一个类的接口转换成客户希望的另外一个接口。

2. STL标准库中stack和queue的底层结构

虽然stack和queue中也可以存放元素,但在STL中并没有将其划分在容器的行列,而是将其称为容器适配器,这是因为stack和队列只是对其他容器的接口进行了包装,STL中stack和queue默认使用deque,如下图:

在这里插入图片描述

3. deque的简单介绍(了解)

3.1 deque的接口

deque的文档介绍


我们看deque接口,它既有头插头删操作还有尾插尾删,而且在数据访问的时候还可以是有下标+[]访问。

在这里插入图片描述


从接口来看,它似乎vector+list的合体,是一个六边形战士,list和vector能做的,deque基本都能做。

问题:deque是一个六边形战士,为什么我们还要学习vector和list,直接重点学习deque不就可以了吗?

  1. deque虽然可以随机访问,但是相较于vector的随机访问效率会慢很多
  2. deque的中间插入删除是挪动数据,相较于list的插入删除效率很差

3.2 deque的原理介绍

3.2.1 deque的原理介绍

deque(双端队列):是一种双开口的"连续"空间的数据结构,双开口的含义是:可以在头尾两端进行插入和删除操作,且时间复杂度为O(1),与vector比较,头插效率高,不需要搬移元素;与list比较,空间利用率比较高。

在这里插入图片描述


在正式了解deque原理之前,我们先来回顾vector和list结构的优缺点

  • vector的优缺点:
    • 优点:1、支持随机访问;2、CPU高速缓存命中高
    • 缺点:1、扩容问题(原来空间不够了,重新开一个新空间,将原来的内容拷贝到新空间中);2、头部和中间插入删除效率低
  • list的优缺点:
    • 优点:1、按需申请空间;2、任意位置插入删除效率高O(1)
    • 缺点:1、不支持随机访问;2、CPU高速缓存命中低

在这里插入图片描述


deque并不是真正连续的空间,而是由一段段连续的小空间拼接而成的,实际deque类似于一个动态的二维数组,其底层结构如下图所示:

在这里插入图片描述

  • buffer的空间大小是固定的——方便计算下标指向的元素
  • deque空间不够时,不需要挪动deque中的元素,只需要继续开buffer即可——注:即使map中控数组满载了,再找一块更大的空间来作为map即可,那些buffer中的数据不需要动
  • map中控优先从中间开始插入buffer的地址——方便头插元素时保存开辟在前面的buffer
  • deque头插是从第一个buffer的尾部开始插,尾插是从最后一个buffer的尾部开始插
  • 如何找到下标i指向的元素:
    • 先看在不在第一个buffer数组,在就找位置访问
    • 不在第一个buffer:①i -= 第一个buffer数组size;②第几个buffer = i / buffer;③在这个buffer第几个 = i % buffersize


双端队列底层是一段假象的连续空间,实际是分段连续的,为了维护其“整体连续”以及随机访问的假象,落在了deque的迭代器身上,因此deque的迭代器设计就比较复杂,如下图所示:

在这里插入图片描述

那deque是如何借助其迭代器维护其假想连续的结构呢?

在这里插入图片描述

3.2.2 deque的优缺点

与vector相比:

  • 优点:
    • deque头部插入删除,不需要挪动元素,效率特别高O(1)
    • deque扩容时,不需要挪动buffer中的元素,效率高
  • 缺点:
    • []不够极致,需要计算在哪个buffer,在哪个buffer的第几个


与list相比:

  • 优点:
    • 可以支持下标随机访问
    • CPU高速缓存效率不错
  • 缺点:
    • 头尾插入删除都可以,但是中间删除效率很差——中间插入删除要么修改buffer的大小要么挪动元素,修改buffer的大小会导致[]效率很差,所以库中采取挪动数据


综上:deque不适合频繁的使用[]或使用迭代器遍历(偶尔使用还是可以的),和中间插入删除,因为效率低下。但是deque适合高频的头插头删和尾插尾删!

deque虽然看似一个六边形全能高手,但实际上并不是如此,比频繁访问容器元素不如vector,比中间位置插入删除不如list,所以在实际中,需要线性结构时,大多情况下优先考虑vector和list,deque的实际应用并不多,目前能看到的一个应用就是,STL用其作为stack和queue的底层数据结构。

3.3 为什么选择deque作为stack和queue的底层默认容器

stack是一种后进先出的特殊线性数据结构,因此只要具有push_back()和pop_back()操作的线性结构,都可以作为stack的底层容器,比如vector和list都可以;queue是先进先出的特殊线性数据结构,只要具有push_back和pop_front操作的线性结构,都可以作为queue的底层容器,比如list。但是STL中对stack和queue默认选择deque作为其底层容器,主要是因为:

  1. stack和queue不需要遍历(因此stack和queue没有迭代器),只需要在固定的一端或者两端进行操作。
  2. 在stack中元素增长时,deque比vector的效率高(扩容时不需要搬移大量数据);queue中的元素增长时,deque不仅效率高,而且内存使用率高。


结合了deque的优点,而完美的避开了其缺陷。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值