【C++栈和队列:数据结构中的经典组合,高效处理先进先出与后进先出问题的最佳方案】

[本节目标]

  • 1. stack的介绍和使用

  • 2. queue的介绍和使用

  • 3. priority_queue的介绍和使用

  • 4. 容器适配器

1. stack的介绍和使用

1.1 stack的介绍

1. stack是一种容器适配器,专门用在具有后进先出操作的上下文环境中,其删除只能从容器的一端进行元素的插入与提取操作。

2. stack是作为容器适配器被实现的,容器适配器即是对特定类封装作为其底层的容器,并提供一组特定的成员函数来访问其元素,将特定类作为其底层的,元素特定容器的尾部(即栈顶)被压入和弹出。

3. stack的底层容器可以是任何标准的容器类模板或者一些其他特定的容器类,这些容器类应该支持以下 操作:

  • empty:判空操作
  • back:获取尾部元素操作
  • push_back:尾部插入元素操作
  • pop_back:尾部删除元素操作

4. 标准容器vector、deque、list均符合这些需求,默认情况下,如果没有为stack指定特定的底层容器, 默认情况下使用deque。

1.2 stack的使用

函数说明 接口说明
stack() 构造空的栈
empty() 检测stack是否为空
size() 返回stack中元素的个数
top() 返回栈顶元素的引用
push() 将元素val压入stack中
pop() 将stack中尾部的元素弹出

1.3 stack题目的练习

​​​​​​155. 最小栈 - 力扣(LeetCode)

我们来看看这个题目的解题思路,我们首先来看一种错误的解题方法,看看它为什么错误。

错误思路:将数组中的值依次入栈,此时定义一个变量min去记录最小值,当入栈的值比min小,更新min的值。

我们来看看这个思路为什么错误,如果栈中途没有元素出栈,那么这个思路完全正确,因为入栈的过程相当于把这个栈遍历了一遍,此时就可以找到最小值,可是当我们有元素出栈呢?我们看看上面的图片,此时已经找到最小值了3,可是随后3出栈了,但是此时的min该如何变化呢?此时找min就需要再遍历一次栈,所以这个思路是行不通的。

正确思路:建立两个栈,一个普通栈正常入数据,另外一个最小栈入栈最小值,当普通栈为空的时候,此时该值就直接入最小栈,当普通栈入栈的数据大于上次最小值入栈的值时,此时再次将上次的最小值入最小栈,当普通栈入栈的数据小于上次最小值入栈的值时,此时将这个小值入最小栈,如果要取出最小值的时候,只需要取最小栈的栈顶值即可。

我们能不能再进一步优化呢?我们上面的最小栈没必要存储那么多的元素5,当普通栈栈顶元素 > 最小栈栈顶元素的时候,此时就不需要入栈,但是普通栈栈顶元素 = 最小栈栈顶元素的时候,我们需要入最小栈。

解题代码:

class MinStack {
public:
    MinStack() {
        //这里可以不用写
        //自定义类型会去调用自己的默认构造
    }
    
    void push(int val) {
        st.push(val);
        if(minst.empty() || st.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();
    }
    stack<int> st;
    stack<int> minst;
};

此时面试官又会提一个问题,如果入普通栈的数据全部都是3呢?按照我们上面的逻辑,此时最小栈也要全部存3,那么我们上面的优化就相当于没有优化,此时我可以通过计数器来解决,当普通栈有多个相同值时,此时最小栈入栈还可以存储一个计数器,每次入最小栈的时候++count;栈每次pop的时候只需要--count,当减到0的时候就可以删除3。

栈的压入、弹出序列_牛客题霸_牛客网 (nowcoder.com)

解题思路:题目要我们判断两个序列是否符合入栈出栈的次序,我们就可以用一个栈来模拟。对于入栈序列,我们让它入栈,只要栈为空,序列肯定要依次入栈。那什么时候出来呢?自然是遇到一个元素等于当前的出栈序列的元素,那我们就放弃入栈,让它先出来。

💡强调一下:我们这里不是拿的入栈序列和出栈序列依次比较,而是构建一个辅助栈,让的入栈序列再次入栈,然后再比较的!!!因为我们是想判断出栈顺序是否匹配,且中途有元素出栈,所以需要入栈序列入栈来模拟出栈过程。

具体做法:

  • step 1:准备一个辅助栈st,两个下标curpush和curpop分别访问两个序列。
  • step 2:辅助栈st为空或者栈顶不等于出栈数组当前元素,就持续将入栈数组加入栈中。
  • step 3:辅助栈st不为空并且栈顶等于出栈数组当前元素,就出栈。
  • step 4:当入栈数组访问完,出栈数组无法依次弹出,就是不匹配的,否则两个序列都访问完就是匹配的。
bool IsPopOrder(vector<int>& pushV, vector<int>& popV) {
    stack<int> st;
    int curpush = 0;
    int curpop = 0;
    while(curpush < pushV.size())
    {
        //栈为空或者栈顶元素不等于出栈数组当前元素,直接入栈
        st.push(pushV[curpush++]);
        //这里需要写成while,因为可能会有多次匹配
        //当栈为空时,就不需要再出栈了
        while(!st.empty() && st.top() == popV[curpop])
        {
            st.pop();
            ++curpop;
        }
    }  
    //根据出栈序列的下标即可判断是否匹配
    return curpop == popV.size();
}

150. 逆波兰表达式求值 - 力扣(LeetCode)

💡逆波兰式是一种数学表达式的表示方法,也称为后缀表达式。与传统的中缀表达式不同,逆波兰式将操作符置于操作数的后面,从而无需使用括号来指定运算顺序。

💡在逆波兰式中,每个运算符都紧跟在其相关的操作数之后。例如,中缀表达式 " (2 + 1) * 3" 在逆波兰式中表示为 "2 1 + 3 * "。计算逆波兰式时,从左到右扫描表达式,遇到操作数就将其压入栈中,遇到操作符就从栈中弹出相应数量的操作数进行运算,并将结果压回栈中。最终,栈中的唯一元素即为整个表达式的结果。

💡逆波兰式的优势在于不需要考虑运算符优先级和括号的使用,使得计算机能够更容易地处理和解析数学表达式。这种表示方法在计算器和编程语言中得到广泛应用。

解题思路:

使用一个栈存储操作数,从左到右遍历逆波兰表达式,进行如下操作:

  • step 1:如果遇到操作数,则将操作数入栈;
  • step 2:如果遇到运算符,则将两个操作数出栈,其中先出栈的是右操作数,后出栈的是左操作数,使用运算符对两个操作数进行运算,将运算得到的新操作数入栈。
  • step 3:整个逆波兰表达式遍历完毕之后,栈内只有一个元素,该元素即为逆波兰表达式的值。

代码实现:

class Solution {
public:
    int evalRPN(vector<string>& tokens) {
        stack<int> st;
        int right = 0;
        int left = 0;
        for(auto& e : tokens)
        {
            if(e == "+" || e == "-" || e == "*" || e == "/")
            {
                right = st.top();
                st.pop();
                left = st.top();
                st.pop();
                switch(e[0])//返回的char类型
                {
                    case '+':
                        st.push(left + right);
                        break;
                    case '-':
                        st.push(left - right);
                        break;
                    case '*':
                        st.push(left * right);
                        break;
                    case '/':
                        //题目明确不发生为除数为0清空
                        st.push(left / right);
                        break;
                }
            }
            else
            {
                //转为数字
                st.push(stoi(e));
            }
        } 
    return st.top();
    }
};

这道题目只要清除栈的规则很容易做出来,因为题目已经把后缀表达式给我们了,导致题目非常容易,如果这道题给我们的是中缀表达式呢?我们就需要先转为后缀表达式,然后再按照上述步骤进行计算,转话的步骤也需要栈来完成。

  • step 1:如果遇到操作数,则将操作数输出;
  • step 2:如果遇到运算符,栈为空,操作符入栈;当前操作符比栈顶的操作符优先级高,操作符也入栈。
  • step 3:如果遇到运算符,操作符比栈顶的操作符优先级低或者相等,代表栈定的操作符可以运算了,出栈顶操作符

可是带括号呢???

  • step 1:如果遇到操作数,则将操作数输出;
  • step 2:如果遇到运算符,栈为空,操作符入栈;当前操作符比栈顶的操作符优先级高,操作符也入栈。
  • step 3:如果遇到运算符,操作符比栈顶的操作符优先级低或者相等,代表栈定的操作符可以运算了,出栈顶操作符
  • step4:如果遇到运算符是括号,找到左括号和右括号的位置,然后让这段区间重复上面的step1- step3过程,也就是递归,如果当前元素是左括号,则递归处理括号内的子表达式,将子表达式的后缀形式输出到后缀表达式。如果当前元素是右括号,则返回上一级递归。

这里就不实现了,仅仅为本题的扩展。

1.4 stack的模拟实现

我们先来看一下我们传统的栈的写法。

namespace yu
{
	template<class T>
	class stack
	{
	public:
		push(const T& x)
		{
			//...
		}
	private:
		T* _a;
		size_t _top;
		size_t _capacity;
	};
}

但是从栈的接口中可以看出,栈实际是一种特殊的vector,因此使用vector完全可以模拟实现stack。

namespace yu
{
	//新增一个模板参数
	template<class T, class Contanier>
	class stack
	{
	public:
        //元素入栈
		void push(const T& x)
		{
			_con.push_back(x);
		}
        //元素出栈
		void pop() 
		{ 
			_con.pop_back(); 
		}
        //普通栈 返回栈顶元素
		T& top() 
		{ 
			return _con.back(); 
		}
        //const栈 返回栈顶元素
		const T& top()const 
		{ 
			return _con.back(); 
		}
        //返回栈内元素的个数
		size_t size()const 
		{ 
			return _con.size();
		}
        //判断栈是否为空
		bool empty()const 
		{ 
			return _con.empty();
		}
	private:
		Contaner _con;
	};
}

我们上面stack的模拟实现新增了一个模板,它是一个容器,容器这个概念对于我们来说并不陌生,我们之前就已经学过vector和list容器了,那是不是意味着这里需要传容器呢?比如vector<int>,然后里面_con的类型就是vector<int>,里面的尾插,尾删等其他函数就会调用vector的尾插,尾删等其他函数,这样就轻松的实现了我们的stack,我们来测试一下。

int main()
{
	yu::stack<int, vector<int>> st;
	st.push(1);
	st.push(2);
	st.push(3);
	st.push(4);
	st.push(5);

	while (!st.empty())
	{
		cout << st.top() << " "; 
		st.pop();
	}
	cout << endl;
	return 0;
}

运行结果:

我们发现使用将vector容器传入就可以直接模实现拟栈,并且也能符合栈的先进后出的特点。如果我们传入的容器是list呢?

int main()
{
	yu::stack<int, list<int>> st;
	st.push(1);
	st.push(2);
	st.push(3);
	st.push(4);
	st.push(5);

	while (!st.empty())
	{
		cout << st.top() << " "; 
		st.pop();
	}
	cout << endl;
	return 0;
}

 运行结果:

我们发现此时也能实现栈,并且也能符合出栈的先进后出的特点。并且我们上面的stack还不用写构造函数,因为_con是自定义类型,如果我们没显示的写构造函数,自定义类型会去调用它自己的构造函数去初始化,比如传入的容器时vector,那么就会去调用vector的构造函数。不过需要注意一下,如果我们传入的容器没有尾插、尾删等函数,那么模拟实现的栈就会报错啦!!!但是我们发现库里面的stack的模拟实现容器给是缺省值,但缺省值不是vector<int>,也不是我们的list<int>,而是deque<int>。

deque(双端队列):是一种双开口的"连续"空间的数据结构,双开口的含义是:可以在头尾两端进行插入和删除操作,且时间复杂度为O(1),与vector比较,头插效率高,不需要搬移元素;与list比较,空间利用率比较高。deque它包含了vector和list的功能,同时还综合了vector和list的优点,我们后面的会介绍,这里我们先来使用它。

#include "stack.h"
#include <deque>
#include <stack>

int main()
{
	//stack<int, deque<int>> st;
	//这里也可以不用传入deque<int>
	//因为库里面实现是deque<int>作为缺省值
	//但是我们自己实现的需要带上
    //如果我们也不想带呢?用下面的语句
    //template<class T, class Container = deque<T>>
	yu::stack<int, deque<int>> st;
	st.push(1);
	st.push(2);
	st.push(3);
	st.push(4);
	st.push(5);

	while (!st.empty())
	{
		cout << st.top() << " "; 
		st.pop();
	}
	cout << endl;
	return 0;
}

 运行结果:

2. queue的介绍和使用

2.1 queue的介绍

1. 队列是一种容器适配器,专门用于在FIFO上下文(先进先出)中操作,其中从容器一端插入元素,另一端 提取元素。

2. 队列作为容器适配器实现,容器适配器即将特定容器类封装作为其底层容器类,queue提供一组特定的 成员函数来访问其元素。元素从队尾入队列,从队头出队列。

3. 底层容器可以是标准容器类模板之一,也可以是其他专门设计的容器类。该底层容器应至少支持以下操 作:

  • empty:检测队列是否为空
  • size:返回队列中有效元素的个数
  • front:返回队头元素的引用
  • back:返回队尾元素的引用
  • push_back:在队列尾部入队列
  • pop_front:在队列头部出队列

4. 标准容器类deque和list满足了这些要求。默认情况下,如果没有为queue实例化指定容器类,则使用标准容器deque。

2.2 queue的使用

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

2.3 queue题目的练习

225. 用队列实现栈 - 力扣(LeetCode)

首先我们做这道题目之前,要理解队列和栈的特性,栈的特性是先入后出,而队列的特点是先入先出,这道题目用队列实现栈,很明显队列出的是第一个数据,而栈出的是最后一个数据,所以本题我们就借用两个队列来实现。

具体思路:

1.判断两个队列那个队列不为空,不为空的队列入数据。

2.出队列时,判断哪个一个队列为空,一个队列不为空,将不为空的队列的前n-1个数据入到为空的那个队列上,此时不为空的队列剩下的哪一个数据就是栈顶元素。

代码实现:

class MyStack {
public:
    MyStack() {

    }
    
    void push(int x) {
      if(!q1.empty())
      {
        q1.push(x);
      }  
      else
      {
        q2.push(x);
      }
    }
    
    int pop() {
        if(!q1.empty() && q2.empty())
        {
            int i = q1.size()-1;
            while(i--)
            {
                q2.push(q1.front());
                q1.pop();
            }
            int ret = q1.front();
            q1.pop();
            return ret;
        }
        else
        {
            int i = q2.size()-1;
            while(i--)
            {
                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();
    }

    queue<int> q1;
    queue<int> q2;
};

102. 二叉树的层序遍历 - 力扣(LeetCode)

这个题目初看很简单,但是题目的要求逐层遍历所有结点,即在输出的时候我们必须要知道那个数字是那一个层的。

2.4 queue的模拟实现

有了之前的stack,我们这里实现queue就非常简单.


namespace yu
{
	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()
		{
			return _con.front();
		}
		//取队尾数据
		const T& back()
		{
			return _con.back();
		}
		//返回队列内元素的个数
		size_t size()const
		{
			return _con.size();
		}
		//判断队列是否为空
		bool empty()const
		{
			return _con.empty();
		}
	private:
		Contaner _con;
	};
}

然后我们来测试一下

int main()
{
	yu::queue<int, deque<int>> q;
	q.push(1);
	q.push(2);
	q.push(3);
	q.push(4);
	q.push(5);

	while (!q.empty())
	{
		cout << q.front() << " ";
		q.pop();
	}
	cout << endl;
	return 0;
}

运行结果:

同样的我们这里给上list容器都能是实现队列的先进进出的特点,但是vector却不行,因为vector没有pop_front接口,所以会报错,但是我们这里可以使用erase接口,但是由于队列是出队头数据,而vector的erase头删效率比较低,需要挪动数据,故一般很少传入vector容器去适配。

3. priority_queue的介绍和使用

3.1 priority_queue的介绍

1. 优先队列是一种容器适配器,根据严格的弱排序标准,它的第一个元素总是它所包含的元素中最大的。

2. 此上下文类似于堆,在堆中可以随时插入元素,并且只能检索最大堆元素(优先队列中位于顶部的元素)。

3. 优先队列被实现为容器适配器,容器适配器即将特定容器类封装作为其底层容器类,queue提供一组特定的成员函数来访问其元素。元素从特定容器的“尾部”弹出,其称为优先队列的顶部。

4. 底层容器可以是任何标准容器类模板,也可以是其他特定设计的容器类。容器应该可以通过随机访问迭代器访问,并支持以下操作:

  • empty():检测容器是否为空
  • size():返回容器中有效元素个数
  • front():返回容器中第一个元素的引用
  • push_back():在容器尾部插入元素
  • pop_back():删除容器尾部元素

5. 标准容器类vector和deque满足这些需求。默认情况下,如果没有为特定的priority_queue类实例化指 定容器类,则使用vector。

6. 需要支持随机访问迭代器,以便始终在内部保持堆结构。容器适配器通过在需要时自动调用算法函数 make_heap、push_heap和pop_heap来自动完成此操作。

3.2 priority_queue的使用

优先级队列默认使用vector作为其底层存储数据的容器,在vector上又使用了堆算法将vector中元素构造成 堆的结构,因此priority_queue就是堆,所有需要用到堆的位置,都可以考虑使用priority_queue。注意: 默认情况下priority_queue是大堆。

默认情况下,priority_queue是大堆。

#include <iostream>
#include <vector>
#include <queue>
#include <functional> // greater算法的头文件

using namespace std;

void TestPriorityQueue()
{
	// 默认情况下,创建的是大堆,其底层按照小于号比较
	vector<int> v{ 3,2,7,6,0,4,1,9,8,5 };
	priority_queue<int> q1;
	for (auto& e : v)
		q1.push(e);
	while (!q1.empty())
	{
		cout << q1.top() << " ";//建大堆,每次取堆顶数据,降序
		q1.pop();
	}
	cout << endl;
	// 如果要创建小堆,将第三个模板参数换成greater比较方式
	priority_queue<int, vector<int>, greater<int>> q2(v.begin(), v.end());
	while (!q2.empty())
	{
		cout << q2.top() << " ";//建小堆,每次取堆顶数据,升序
		q2.pop();
	}
}

int main()
{
	TestPriorityQueue();
	return 0;
}

运行结果:

3.3 priority_queue题目的练习

215. 数组中的第K个最大元素 - 力扣(LeetCode)

思路一:利用算法库的sort进行降序排序,然后再通过下标[]直接返回元素。

class Solution {
public:
    int findKthLargest(vector<int>& nums, int k) {
        sort(nums.begin(),nums.end(),greater<int>());
        return nums[k-1];
    }
};

这里要提一点,之前的priority_queue我们想建小堆传入的参数是priority_queue<int, vector<int>, greater<int>> q2;而这里sort排序却是sort(nums.begin(),nums.end(),greater<int>());sort这里还多一个(),为什么呢?

我们上面的程序虽然能运行,但是不符合题目要求,我们的一个排序时间复杂度就是O(N*logN)。

思路二:通过建大堆 + pop掉前k个数据,此时堆顶的数据就是第K个大的数据

class Solution {
public:
    int findKthLargest(vector<int>& nums, int k) {
        priority_queue<int> pq(nums.begin(),nums.end());//大堆
        while(--k)//循环k-1次
        {
            pq.pop();//把前k-1最大的数据pop掉
        }
        return pq.top();
    }
};
  1. 构建堆: 使用 priority_queue 构建堆的时间复杂度为 O(n),其中 n 是数组 nums 的长度。

  2. 循环 k-1 次的 pop 操作: 这一部分的时间复杂度为 O(k * log(n)),因为每次 pop 操作都涉及到对堆的调整,而每次调整的时间复杂度是堆的高度,即 log(n)。

总的时间复杂度为 O(n + k * log(n))。

需要注意的是,当 k 远小于 n 时,时间复杂度为 O(n),即构建堆的时间。当 k 较大时,可能需要进行多次的 pop 操作,对应的时间复杂度为 O(k * log(n)),此时就接近O(n * log(n))。

思路三:如果N和K非常大,利用TopK解决问题,前k个数据建小堆,当有数据大于堆顶,然后pop堆顶数据,大于的那个数据入堆,最后走完堆顶的数就是第K个大的数。

class Solution {
public:
    int findKthLargest(vector<int>& nums, int k) {
        //左闭右开,前k个数据建小堆,包括第k个数据
        priority_queue<int, vector<int>, greater<int>> pq(nums.begin(),nums.begin() + k);
        
        for(int i = k;i < nums.size(); ++i)
        {
            if(nums[i] > pq.top())
            {
                pq.pop();
                pq.push(nums[i]);
            }
        }
        return pq.top();
    }
};
  1. 构建小顶堆: 使用小顶堆的构建时间复杂度为 O(k),其中 k 是输入参数。

  2. 遍历数组: 从第 k 个元素开始,遍历数组,并在小顶堆中维护前 k 个最大的元素。这一部分的时间复杂度为 O((n - k) * log(k)),其中 n 是数组 nums 的长度。

总的时间复杂度为 O(k + (n - k) * log(k))。此时就能完美接近O(n)。当k远小于n,此时时间复杂度就是O(n),当k很大,n - k此时也就可以忽略不计,此时时间复杂度就是O(logk)。但是呢?如果k是n的一半,此时效率也就不太行。

3.4 priority_queue的模拟实现

通过对priority_queue的底层结构就是堆,因此此处只需对对进行通用的封装即可。

这里容器给的缺省值是vector<int>,并没有给我们的deque<int>,说明deque<int>也是有缺点的,至于缺点是啥,我们后面会提到。这里priority默认实现的是大堆,因此我们也先来实现一下大堆。

我们这里的堆插入逻辑是尾插,然后再向上调整,注意这里不能头擦向下调整,因为头插后父子之间的关系就不对了,并且此时不满足向下调整的前提:左右子树是堆。

堆的删除逻辑是

  1. 将堆顶元素与堆中最后一个元素进行交换。
  2. 删除堆中最后一个元素。
  3. 将堆顶元素向下调整到满足堆特性为止。
  4. 向下调整的结束条件是child等于叶子结点。

namespace yu
{
	template<class T,class Container = vector<T>>
	class priority_queue
	{
	public:
		void adjust_up(int child)
		{
			int parent = (child - 1) / 2;
			while (child > 0)
			{
				if (_con[parent] < _con[child])
				{
					swap(_con[parent], _con[child]);
					child = parent;
					parent = (child - 1) / 2;
				}
				else
				{
					break;
				}
			}
		}
		void adjust_down(int parent)
		{
			int child = parent * 2 + 1;
			while (child < _con.size())
			{
				if (child + 1 < _con.size() && _con[child] < _con[child + 1])
				{
					child = child + 1;
				}
				if (_con[parent] < _con[child])
				{
					swap(_con[parent], _con[child]);
					parent = child;
					child = parent * 2 + 1;
				}
				else
				{
					break;
				}
			}
		}
		void push(const T& x)
		{
			_con.push_back(x);

			adjust_up(_con.size() - 1);
		}
		void pop()
		{
			swap(_con[0], _con[_con.size() - 1]);
			_con.pop_back();
			adjust_down(0);
		}
		const T& top()
		{
			return _con[0];
		}
		//返回堆内元素的个数
		size_t size()const
		{
			return _con.size();
		}
		//判断堆是否为空
		bool empty()const
		{
			return _con.empty();
		}
	private:
		Container _con;
	};
}

我们来测试一下:

int main()
{
	yu::priority_queue<int> q;
	q.push(1);
	q.push(2);
	q.push(3);
	q.push(4);
	q.push(5);

	while (!q.empty())
	{
		cout << q.top() << " ";
		q.pop();
	}
	cout << endl;
	return 0;
}

运行结果:

如果是小堆呢,我们就要修改上面的代码。

namespace yu
{
	template<class T,class Container = vector<T>>
	class priority_queue
	{
	public:
		void adjust_up(int child)
		{
			int parent = (child - 1) / 2;
			while (child > 0)
			{
				if (_con[parent] > _con[child])
				{
					swap(_con[parent], _con[child]);
					child = parent;
					parent = (child - 1) / 2;
				}
				else
				{
					break;
				}
			}
		}
		void adjust_down(int parent)
		{
			int child = parent * 2 + 1;
			while (child < _con.size())
			{
				if (child + 1 < _con.size() && _con[child] > _con[child + 1])
				{
					child = child + 1;
				}
				if (_con[parent] > _con[child])
				{
					swap(_con[parent], _con[child]);
					parent = child;
					child = parent * 2 + 1;
				}
				else
				{
					break;
				}
			}
		}
		void push(const T& x)
		{
			_con.push_back(x);

			adjust_up(_con.size() - 1);
		}
		void pop()
		{
			swap(_con[0], _con[_con.size() - 1]);
			_con.pop_back();
			adjust_down(0);
		}
		const T& top()
		{
			return _con[0];
		}
		//返回堆内元素的个数
		size_t size()const
		{
			return _con.size();
		}
		//判断堆是否为空
		bool empty()const
		{
			return _con.empty();
		}
	private:
		Container _con;
	};
}

运行结果:

注意:我们这里不能使用list去适配,因为list不支持[ ]去访问元素,而我们堆需要大量的[ ]去访问元素!我们上面通过修改堆里面的代码去实现大小堆的切换,但是这样的做法很不好,有的使用者如果不清楚堆的逻辑,就不知道怎么去修改,我们应该提供一个方法传递大于就是大堆,小于就是小堆,能完成大小堆的切换,而不是通过去修改堆的代码,c语言一般通过函数指针 + 回调函数去解决,但是比较复杂,C++就通过仿函数/函数对象解决。

//仿函数/函数对象
namespace yu
{
	class less
	{
	public:
		bool operator()(int x, int y)
		{
			return x < y;
		}
	};
}
int main()
{
	yu::less lessFunc;
	cout << lessFunc.operator()(1, 2) << endl;//运算符重载
	cout << lessFunc(2, 1) << endl;//有点像函数调用 - 仿函数
	//lessFunc像函数名 - 实际上是一个对象 - 函数对象
	return 0;
}

运行结果:

然后我们再来看一下函数指针的缺陷

bool lessfunc(int x, int y)
{
	return x < y;
}
bool greaterfunc(int x, int y)
{
	return x > y;
}
// A这个类要回调lessfunc
//构成函数的函数指针是一个对象,不能在类模板的参数传递
//函数指针只能通过函数传递,光有类型还不够,我们要找到指针指向的函数
class A
{
public:
	A(bool(*pf)(int, int))
		:_pf(pf)
	{}

	void func(int x, int y)
	{
		cout << _pf(x, y) << endl;;
	}
private:
	bool(*_pf)(int, int);//类型为bool(*)(int,int),变量名为pf
};


int main()
{
	yu::less lessFunc;
	cout << lessFunc.operator()(1, 2) << endl;//运算符重载
	cout << lessFunc(2, 1) << endl;//有点像函数调用 - 仿函数
	//lessFunc像函数名 - 实际上是一个对象 - 函数对象

	//函数指针
	A aa1(lessfunc);
	aa1.func(1, 2);
	A aa2(greaterfunc);
	aa2.func(1, 2);
	return 0;
}

函数指针的类型比较复杂,并且函数指针只能通过函数参数传递,比较复杂,我们来看一下仿函数实现回调呢?

//仿函数/函数对象
namespace yu
{
	class less
	{
	public:
		bool operator()(int x, int y)
		{
			return x < y;
		}
	};
}
//仿函数/函数对象
namespace yu
{
	class greater
	{
	public:
		bool operator()(int x, int y)
		{
			return x > y;
		}
	};
}

template<class T, class Compare>
class A
{
public:
	void func(const T& x, const T& y)
	{
		Compare com;
		cout << com(x, y) << endl;;
	}
};

int main()
{
	A<int, yu::less> aa1;
	aa1.func(1, 2);
	A<int, yu::greater> aa2;
	aa2.func(1, 2);
	return 0;
}

这样通过一个仿函数就也实现了类似函数指针的功能,并且实现更简单。所以我们现在也要实现一个仿函数,能让我们的堆结构实现大小堆的切换。

namespace yu
{
	template<class T>
	class less
	{
	public:
		bool operator()(const T& x, const T& y)
		{
			return x < y;
		}
	};
	template<class T>
	class greater
	{
	public:
		bool operator()(const T& x, const T& y)
		{
			return x > y;
		}
	};
	template<class T,class Container = vector<T>,class Compare = yu::less<T>>
	class priority_queue
	{
	public:
		void adjust_up(int child)
		{
			int parent = (child - 1) / 2;
			while (child > 0)
			{
				//这里的less是默认建大堆 - 使用小于
				//if (_con[parent] < _con[child])
				//com是less类的对象,可以调用内部成员函数
				if (com(_con[parent],_con[child]))
				{
					swap(_con[parent], _con[child]);
					child = parent;
					parent = (child - 1) / 2;
				}
				else
				{
					break;
				}
			}
		}
		void adjust_down(int parent)
		{
			int child = parent * 2 + 1;
			while (child < _con.size())
			{
				//if (child + 1 < _con.size() && _con[child] < _con[child + 1])
				if (child + 1 < _con.size() && com(_con[child], _con[child + 1]))
				{
					child = child + 1;
				}
				//if (_con[parent] < _con[child])
				if (com(_con[parent], _con[child]))
				{
					swap(_con[parent], _con[child]);
					parent = child;
					child = parent * 2 + 1;
				}
				else
				{
					break;
				}
			}
		}
		void push(const T& x)
		{
			_con.push_back(x);

			adjust_up(_con.size() - 1);
		}
		void pop()
		{
			swap(_con[0], _con[_con.size() - 1]);
			_con.pop_back();
			adjust_down(0);
		}
		const T& top()
		{
			return _con[0];
		}
		//返回堆内元素的个数
		size_t size()const
		{
			return _con.size();
		}
		//判断堆是否为空
		bool empty()const
		{
			return _con.empty();
		}
	private:
		Container _con;
		Compare _com;//实现比较
	};
}

我们来测试一下:

int main()
{
	yu::priority_queue<int,vector<int>, yu::less<int>> q1;
	q1.push(1);
	q1.push(2);
	q1.push(3);
	q1.push(4);
	q1.push(5);

	while (!q1.empty())
	{
		cout << q1.top() << " ";
		q1.pop();
	}
	cout << endl;

	yu::priority_queue<int, vector<int>, yu::greater<int>> q;
	q.push(1);
	q.push(2);
	q.push(3);
	q.push(4);
	q.push(5);

	while (!q.empty())
	{
		cout << q.top() << " ";
		q.pop();
	}
	cout << endl;

	return 0;
}

运行结果:

我们再来看一下这张图

priority_queue是只能传递仿函数,如果也传入函数指针类型的话,它就找不到那个要回调的函数,而sort里面的不仅可以传入仿函数对象,还可以传入函数指针,不仅能推导出函数指针的类型,同时还拿到了指向要回调函数的指针。

4. 容器适配器

4.1 什么是适配器

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

💡适配器的本质就是封装复用!!!

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

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

为什么stack和queue都使用我们的deque去做适配器,而priority_queue却选择我们的verctor去做适配器,这里我们就需要了解一下deque容器。

4.3 deque的简单介绍(了解)

我们首先来看一下vector和list的优缺点

而deque的功能既包括了vector和list,同时还具有vector和list的优点,两者兼得!!!

4.3.1 deque的原理介绍

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

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

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

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

那deque如何尾插和头插呢?如果cur不等于last,就可以直接插入数据,如果cur等于last,就表示buff已经满了,此时就需要新开一个buff,让finish指向新开的buff,通过node找到这个新开的位置,直接插入即可。如果是头插呢?也是新开一个buff,然后修改start中的node指针指向中控位置,再让cur指向插入的数据,first指向新开buff的开始,last指向新开buff的结束。

那如何判断第一个buff是不是从头开始的呢?如果是从头开始,那么cur一定是和first是相等的。我们再来看一下迭代器如何遍历。cur里面存入的是数据,在一个buff中,我们只需要让cur如果不等于last,就可以遍历到这一个buff里面的全部数据,当这个buff遍历完,让node++,找到下一个buff继续遍历数据。我们开一下源码里面的迭代器++。

4.3.2 deque的缺陷

与vector比较,deque的优势是:头部插入和删除时,不需要搬移元素,效率特别高,而且在扩容时,也不需要搬移大量的元素,因此其效率是必vector高的。

与list比较,其底层是连续空间,空间利用率比较高,不需要存储额外字段。

但是,deque有一个致命缺陷:不适合遍历,因为在遍历时,deque的迭代器要频繁的去检测其是否移动到 某段小空间的边界,导致效率低下,而序列式场景中,可能需要经常遍历,因此在实际中,需要线性结构 时,大多数情况下优先考虑vector和list,deque的应用并不多,而目前能看到的一个应用就是,STL用其作 为stack和queue的底层数据结构。

结论:

  • 1.deque下标随机访问,效率不错,但是和vector仍有差距。
  • 2.deque中间插入删除,效率较差。
  • 使用场景:
  • 大量头插头删尾插尾删:deque
  • 大量下标访问元素:vector
  • 大量中间插入:list

4.4 为什么选择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的优点,而完美的避开了其缺陷。

​​​​​​​

  • 42
    点赞
  • 17
    收藏
    觉得还不错? 一键收藏
  • 1
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值