前言
栈的介绍和使用:
1. stack是一种容器适配器,专门用在具有后进先出操作的上下文环境中,其删除只能从容器的一端进行元素的插入与提取操作。
2. stack是作为容器适配器被实现的,容器适配器即是对特定类封装作为其底层的容器,并提供一组特定的成员函数来访问其元素,将特定类作为其底层的,元素特定容器的尾部(即栈顶)被压入和弹出。
3. stack的底层容器可以是任何标准的容器类模板或者一些其他特定的容器类,这些容器类应该支持以下操作:
empty:判空操作
back:获取尾部元素操作
push_back:尾部插入元素操作
pop_back:尾部删除元素操作
4. 标准容器vector、deque、list均符合这些需求,默认情况下,如果没有为stack指定特定的底层容器,默认情况下使用deque。
队列的介绍和使用:
1. 队列是一种容器适配器,专门用于在FIFO上下文(先进先出)中操作,其中从容器一端插入元素,另一端提取元素。
2. 队列作为容器适配器实现,容器适配器即将特定容器类封装作为其底层容器类,queue提供一组特定的成员函数来访问其元素。元素从队尾入队列,从队头出队列。
3. 底层容器可以是标准容器类模板之一,也可以是其他专门设计的容器类。该底层容器应至少支持以下操作:
empty:检测队列是否为空
size:返回队列中有效元素的个数
front:返回队头元素的引用
back:返回队尾元素的引用
push_back:在队列尾部入队列
pop_front:在队列头部出队列
4. 标准容器类deque和list满足了这些要求。默认情况下,如果没有为queue实例化指定容器类,则使用标准容器deque。
一、栈和队列的模拟实现
容器适配器:
适配器是一种设计模式(设计模式是一套被反复使用的、多数人知晓的、经过分类编目的、代码设计经验的总结), 该种模式是将一个类的接口转换成客户希望的另外一个接口。
前面我们说过stack和queue是一种容器适配器,同样也是作为容器适配器被实现的,所以我们可以直接使用一个公共的容器来当stack的适配器,适配器是用来转换的,将已有的东西进行转换,比如将vector当我们栈的容器实现栈的功能,我们先看一下库中如何描述栈的:
可以看到库中是用的deque来当stack的容器,并且stack的接口非常简单,所以我们现在就开始实现:
#include <vector> #include <iostream> using namespace std; namespace sxy { template<class T,class Container = vector<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(); } private: Container _con; }; void test3() { stack<int> st; st.push(1); st.push(2); st.push(3); st.push(4); while (!st.empty()) { cout << st.top() << " "; st.pop(); } } }
因为我们要使用vector做适配器所以包含了vector的头文件,然后我们所有的函数直接使用容器的公共接口即可,比如vector的push_back等等。在模板中我们用了vector的缺省容器,如果你不想用vector,你也可以使用list作为stack的容器,只需要:stack<int,list<int>> 我们的第一步是创建一个容器的对象_con.
void push(const T& x) { _con.push_back(x); } 对于push我们直接使用容器的尾插即可。 void pop() { _con.pop_back(); }
对于pop直接使用尾删即可,因为栈本来就是后进先出的。
T& top() { return _con.back(); } const T& top() const { return _con.back(); }
top是返回栈顶元素,容器中的back()接口返回的就是栈顶元素。
size_t size() const { return _con.size(); } bool empty() const { return _con.empty(); }
size()和empty与上述同理,都使用容器的公共接口就能解决。
队列的模拟实现
我们先看一下库中的实现,同样也是用deque作为queue的容器。
在这里我们要说明一下,由于vector的头删效率极低,所以vector是不能作为queue的容器的。这里可以用list作为queue的容器,也可以用deque作为queue的容器。第一步是创建一个容器的对象_con.
#include <deque> #include <iostream> using namespace std; namespace sxy { template<class T,class Container = 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(); } private: Container _con; }; void test2() { queue<int> qe; qe.push(1); qe.push(2); qe.push(3); qe.push(4); while (!qe.empty()) { cout << qe.front() << " "; qe.pop(); } } }
队列的实现与栈不同的是队尾插入队头出,所以对于pop接口和top接口而言是不一样的:
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(); }
这里提供了队头元素的返回和队尾元素的返回,因为库中也是实现的。
这样栈和队列的模拟实现就完成了,对于栈和队列我们只需要明白适配器的使用,只要学到了这个后面很多的实现都变简单了。
二、优先级队列
什么是优先级队列呢?
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来自动完成此操作。
下面我们来看看库中的优先级队列:
我们可以看到优先级队列的实现除了容器外还多了一个Compare,这个是什么呢?这个其实是一个仿函数,这里的仿函数是来解决优先级队列是小的优先级高还是大的优先级高,我们默认是大的优先级高也就是大堆。
#include <deque> using namespace std; #include <iostream> namespace sxy { 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 = deque<T>,class Compare = less<T>> class priority_queue { public: void adjust_up(int child) { Compare com; size_t parent = (child - 1) / 2; while (child > 0) { //if (_con[parent] < _con[child]) if (com(_con[parent],_con[child])) { swap(_con[parent], _con[child]); child = parent; parent = (child - 1) / 2; } else { break; } } } void adjust_down(int parent) { Compare com; size_t child = 2 * parent + 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; } //if (_con[parent] < _con[child]) if (com(_con[parent],_con[child])) { swap(_con[parent], _con[child]); parent = child; child = 2 * parent + 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); } T& top() { return _con.front(); } const T& top() const { return _con.front(); } size_t size() const { return _con.size(); } bool empty() const { return _con.empty(); } private: Container _con; }; void test1() { priority_queue<int> pq; pq.push(1); pq.push(16); pq.push(14); pq.push(19); pq.push(17); pq.push(12); while (!pq.empty()) { cout << pq.top() << " "; pq.pop(); } } }
第一步是创建一个容器的对象_con.
我们一个函数一个函数依次来分析:
void push(const T& x) { _con.push_back(x); adjust_up(_con.size() - 1); }
首先是push,由于优先级队列本质还是一个队列,所以遵循先进先出的原则。插入的时候在队尾插入即可,这个时候我们不能结束,因为要排优先级,所以我们要将最后插入的这个数依次向前调整。听到这里是不是很熟悉呢?没错!这其实就是我们之前讲过的堆,而这个adjust_up函数也和我们当时实现的向上调整一模一样,所以在这里我们就不在详细的介绍如何调整了,具体的可以去看我的那篇关于堆的文章。
void pop() { swap(_con[0], _con[_con.size() - 1]); _con.pop_back(); adjust_down(0); }
对于一个已经排好序的队列我们是不可以直接出数据的,为了保证数据的有序性我们先讲第一个元素和最后一个元素交换,这样我们就把要删除的第一个队顶元素移到了队尾,然后直接尾删就删掉了原先的队头元素,刚刚我们将最后一个元素换到了对头,所以我们要让对头这个数据依次向下调整来保持有序性。
T& top() { return _con.front(); } const T& top() const { return _con.front(); }
由于优先级队列只提供对头元素的返回所以我们只用容器的front接口即可。
想要看明白向上调整接口需要先介绍一下仿函数:
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; } };
我们可以看到仿函数的实现其实就是创建一个类然后里面封装一个()的运算符重载,比如我们的less小于,对于一个数是否小于另一个数我们可以直接写为:Compare com com(x,y),这时候肯定会有人说有什么用呢,用其他方式也能替代。如果这样想那就错了,仿函数作为STL的六大接口之一又怎么会没用呢?下面我们就能见识到仿函数的重要性:
void adjust_up(int child) { Compare com; size_t parent = (child - 1) / 2; while (child > 0) { //if (_con[parent] < _con[child]) if (com(_con[parent],_con[child])) { swap(_con[parent], _con[child]); child = parent; parent = (child - 1) / 2; } else { break; } } }
对于向上调整函数的实现我们先创建一个仿函数对象,然后计算孩子节点的父节点,当孩子节点大于0时进入循环,在这里我们是不用判断child>=0的,因为等于0这个孩子就变成所有节点的祖先了还哪有父节点呢?然后我们直接用仿函数判断如果父亲小于孩子(因为默认是大堆)节点就进行向上调整,向上调整就是将父节点和孩子节点交换,然后让孩子节点到以前父节点的位置上,在重新算父节点这样就能依次向上连续调整,一旦发现父节点大于孩子节点满足大堆的条件我们就break即可。在这里如果没有这个仿函数,我们该如何随意控制优先级队列是大堆还是小堆呢?所以仿函数真的很重要。
void adjust_down(int parent) { Compare com; size_t child = 2 * parent + 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; } //if (_con[parent] < _con[child]) if (com(_con[parent],_con[child])) { swap(_con[parent], _con[child]); parent = child; child = 2 * parent + 1; } else { break; } } }
向下调整函数是将父节点依次和左右孩子节点相比较,默认是大堆,如果父节点小于孩子节点就交换,由于向下调整的时候我们需要看孩子节点哪个更大,父节点与大的那个孩子节点交换,所以我们先默认孩子节点是左孩子,由于孩子节点是依次向下调整的,所以循环条件为孩子小于size()就进入循环,进入循环后我们首先判断右孩子是否存在,因为一个完全二叉树是有可能没有右孩子的。如果右孩子存在并且左孩子小于右孩子我们就让孩子节点++变成右孩子。当父节点小于孩子节点的时候我们就进行调整,先交换父节点和孩子节点,然后让父节点到刚刚孩子节点的位置上去,再重新计算孩子节点。当父节点不小于孩子节点时就不需要调整直接break即可。
下面我们验证一下我们写的优先级队列:
因为默认是大堆所以我们看到确实排序完成了,下面我们换个适配器验证一下小堆。
下面我们简单的介绍一下什么是deque:
deque 的原理介绍:
deque( 双端队列 ) :是一种双开口的 " 连续 " 空间的数据结构,双开口的含义是:可以在头尾两端进行插入和删除操作,且时间复杂度为O(1),与vector比较,头插效率高,不需要搬移元素;与list比较,空间利用率比较高。
deque并不是真正连续的空间,而是由一段段连续的小空间拼接而成的,实际deque类似于一个动态的二维数组,其底层结构如下图所示:
双端队列底层是一段假象的连续空间,实际是分段连续的,为了维护其 “ 整体连续 ” 以及随机访问的假象,落 在了 deque 的迭代器身上,因此deque的迭代器设计就比较复杂,如下图所示:
那deque是如何借助其迭代器维护其假想连续的结构呢?看下图:
我们可以看到deque的实现非常的复杂,虽然插入删除比vector方便了不少并且对于空间的扩容也优于vector,但却没有极致的优点,所以只能沦为容器适配器。
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作为其底层容器,主要是因为:
1. stack和queue不需要遍历(因此stack和queue没有迭代器),只需要在固定的一端或者两端进行操作。
2. 在stack中元素增长时,deque比vector的效率高(扩容时不需要搬移大量数据);queue中的元素增长时,deque不仅效率高,而且内存使用率高。
结合了deque的优点,而完美的避开了其缺陷。
总结
本节相对比较简单,因为很多都是我们之前学过的知识,本篇文章的重点在于了解并且会熟悉的使用适配器,我们会发现有了适配器是非常方便的,之前我们用C语言写栈和队列的时候随便就一两百行代码,而用c++实现栈和队列不到60行就能搞定。deque的出现本来是为了替代vector和list的,结果却成为了stack和queue的容器适配器,这是因为deque没有一个极致的优点,不像vector那样可以随意访问元素,也不能像list那样任意位置插入删除的效率高。下一篇要讲的内容是反向迭代器的实现与适配(干货满满哦~)。