c++STL——stack、queue、priority_queue的模拟实现
文章目录
- stack、queue、priority_queue的模拟实现
- 使用部分
- 模拟实现
- 容器适配器
- deque的介绍
- 原理
- 真实结构
- deque的迭代器
- deque的操作
- deque的优缺点
- stack的模拟实现
- 按需实例化
- queue的模拟实现
- priority_queue的模拟实现
- 为何引入仿函数
- 代码实现
stack、queue、priority_queue的模拟实现
使用部分
对于stack和queue的使用其实是非常简单的。当我们进行文档的查阅的时候,会发现提供的接口其实并不算多,也没有迭代器。这是因为栈只能在栈顶操作数据,队列只能在队尾队头操作数据。不能遍历数据。
而priority_queue名称是优先级队列,看着很陌生,实际上我们早已经接触过,其实就是我们以前实现的数据结构——堆。
对于它们三个的使用我们就不进行过多的介绍了,因为接口的使用是非常简单的,且以往我们已经使用和实现过。所以自行查阅文档使用即可:
queue的使用(包括priority_queue)
stack的使用
模拟实现
本篇文章的重点将放在三者的模拟实现上。这三者的实现和以往的模拟实现是有些不同的。
容器适配器
在以往学习list等容器的时候,我们会发现模板参数里面是有内存池的声名:
即allocator< T >,这是内存池的声名。
但是我们仔细翻看stack和queue的时候发现:
我们发现模板参数里面竟然声明了一个Container类型,且给有缺省值deque< T >。这里就需要说一下,模板参数也是可以给缺省值的。
其实priorty_queue也是一样的。也是使用这样的模板声名方式。
其实也就是说,其实stack和queue其实并不是像我们以前那样自行开辟数组空间或者实现链式队列一样。再整出一些顺序表来实现这些结构。
栈和队列的本质就是线性表,只不过是有限制的线性表。只能在表的固定位置进行访问和修改。而STL库中已经实现好了vector和list这样比较复杂的线性表,并且是有提供相关的接口进行操作的。
那也就是说,完全可以将已经实现好的线性表再进行一次封装,只提供固定位置的修改和访问操作的接口。那这样子其实就很方便了,就不用再费时费力去手撕一个栈或队列的代码出来。
我们看文档图中被方框圈起来的字样,Container Adaptor其实就是容器适配器的意思,其实就是通过对别的容器进行二次封装后实现的容器。
只不过说,对于默认的适配容器,这里选择了deque这个容器。其实很好理解,因为对于栈来说,使用vector最为方便,对于队列来说,使用list最为方便。(在c语言数据结构部分就已经说过)。但是反过来就不方便了。
基于此,STL库中实现了一种将vector和list的特性融合的容器,即为deque,我们可以理解为是二者的缝合怪。
deque的介绍
我们先来了解一下deque是什么。
原理
deque本质也是个容器,其内部结构比较复杂:
deque(双端队列):是一种双开口的"连续"空间的数据结构,双开口的含义是:可以在头尾两端进行插入和删除操作,且时间复杂度为O(1),与vector比较,头插效率高,不需要搬移元素;与list比较,空间利用率比较高:
真实结构
这样一看,那和vector也没什么太大的区别嘛。就是多了一端进行插入删除操作。
其实并不是这样子的。实际上,deque是一段由一段的连续空间(缓冲区)存储数据,然后这每一段空间的地址会放在一个叫中控数组的地方:
大致是这个样子。中控数组存储的是各个缓冲区的地址,各个缓冲区进行存储数据。
这样子是结合了vector和list的特点进行构造的数据结构。直到结构后,我们就得明白deque时如何访问存储的数据的,这样子才能明白其对应的操作是如何进行的。
deque的迭代器
实现了list后我们肯定就知道,对于deque这么复杂的结构,使用原生指针肯定是不可能实现迭代器的功能的。肯定是需要对其进行封装,我们来看看源码:
迭代器有四个成员变量,有三个是指向缓冲区数据类型的指针,一个是指向缓冲区的指针。
具体的指向是怎么样的呢?
对于first和last指针,分别指向的是缓冲区的开头数据位置和末尾数据位置的下一个。我们可以认为是以原生指针形式实现的、每一小段缓冲区的迭代器。而cur指针是用来遍历数据的,一旦达到了last的位置就需要遍历到下一段缓冲区了。而node指针是用来找到当前缓冲区所处中控数组的位置的。
缓冲区的指针只需要存储在这个中控器上,存储的位置一定是从中间开始存储一直扩散至两侧。因为需要头插和尾插,放在中间肯定是更方便的。当中控数组位置不够时扩容即可。对于中控数组的扩容代价是一点也不大的,只需要将地址复制到新中控器,将原中控器的指向置空再释放即可。也就是说,使用浅拷贝就可以。
deque的操作
实现好这样的迭代器之后,只需要通过迭代器来进行容器的一系列操作即可。这个结构我们只需做一些简单的了解即可。不用去模拟实现。因为这个结构还是有那么一些的缺陷的。
deque容器中会存放两个迭代器,一个是_start,指向第一个缓冲区,一个是_finish,指向最后一个缓冲区。可能还有有一些别的数据,如缓冲区个数等(即中控器中数据个数)。
尾插操作:就是在_finish这个迭代器的cur指针位置插入后,再将cur指针往后移动。如果当前cur指针和last指针是处于一个位置的时候,就需要移动至下一个缓冲区。也就是将node指针向后移动一个位置。再调整其余指针的位置后再执行上述操作。当然如果中控器位置不够需要重新扩容。
头插操作:过程与尾插相反,需要在_start这个迭代器的cur指针位置插入,只不过我们需要注意的是,对于区间的选择,一般都是倾向于左闭右开,所以尾插的时候cur指针指向的是最后一个数据的下一个位置,而头插的cur指针指向的是第一个数据。
且尾插是在缓冲区中是从前往后插入。头插不一样,是从后往前插入:
随机访问操作:deque是支持随机访问,也就是说它的迭代器是随机类型。可以支持运算符[]的重载。想要访问也是很简单。比如访问第25个数据(从第0个开始),每个缓冲区有10个数据。可以先25 / 10 = 2算出当前是第几个缓冲区,然后再25 % 10 = 5算出位于缓冲区的位置。然后通过中控器访问,指向中控器的map是一个二级指针,经过解引用得到内部数据,就是指向缓冲区的一级指针,再经过解引用就可以得到数据,所以此时deque dq[25] = map[25 / 10][25 % 10] 就可以访问到了,还是很简单的。
置于其他的使用其实很简单,只要前面的容器有认真学习和使用过都会用。因为底层都是将接口进行封装了,我们只需要关心怎么用的。
deque的优缺点
- 与vector比较,deque的优势是:头部插入和删除时,不需要搬移元素,效率特别高,而且在扩容时,也不需要搬移大量的元素,因此其效率是必vector高的。
- 与list比较,其底层是连续空间,空间利用率比较高,不需要存储额外字段。
- 但是,deque有一个致命缺陷:不适合遍历,因为在遍历时,deque的迭代器要频繁的去检测其是否移动到某段小空间的边界,导致效率低下,而序列式场景中,可能需要经常遍历,因此在实际中,需要线性结构时,大多数情况下优先考虑vector和list,deque的应用并不多,而目前能看到的一个应用就是,STL用其作为stack和queue的底层数据结构。
还要说的是是中间位置插入的效率,对于deque来说,除了头部尾部之外的插入其实都很麻烦,这是显而易见的。所以一旦涉及到大量的中间位置的插入,我们都是可以选择使用list,因为任意位置的插入是O(1),且不挪动数据。但是由于其尾插头插效率极高,甚至更甚vector一筹,所以对于栈和队列这种只在一侧或者两侧大量操作的容器,很明显用deque是效率更高。
stack的模拟实现
了解完deque后,我们来实现一下stack,我们直接上代码:
namespace MySpace {template<class T, class Container = deque<T>>class stack {public:stack() {}//一定要写 会走初始化列表 如果是自定义类型会调用其默认构造函数stack(const initializer_list<T>& x){typename initializer_list<T>::iterator it = x.begin();while (it != x.end()) {push(*it); ++it;}} 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.front();}size_t size() const{return _con.size();}bool empty() const {return _con.empty();}private: Container _con;};}
当然,用initializer_list这个构造函数可以不写,只写那个空的即可。我写只是为了创建对象的时候方便一些,便于大量测试。
那个空的构造函数是一定要写的。我们来回顾一下:
因为即使是空的构造函数,成员变量也要走初始化列表的操作,对于内置类型,有传参用传参的,反之用缺省值,如果再没有只能给随机值了。而自定义类型不一样了,会调用其默认构造函数,没有就报错。我们这里的stack是只有一个容器的,这个容器默认是STL 库里面的deque,肯定是有其默认构造函数的。如果这里是我们自己整的容器那我们也要提供默认构造函数。
而后对于入栈出栈以及判空,获取栈顶数据,获取数据个数都是调用的容器内已有的接口。这十分方便。
按需实例化
很多人会好奇,既然是调用的容器的接口,但是对于有些容器来说,有些接口它是没有的。就比如vector是没有pop_front接口的。如果我们在类模板里面调用了这个接口,但是显示实例化类对象的时候传了vector这个容器怎么办呢?
这个不用担心,编译器会报错的。模板最大的特点就是按需实例化,如果有些接口我们不进行调用,写在那里它并不会报错。如果我们调用了那个接口,如果与当前容器不符,编译器是能够与检查的出来的。
queue的模拟实现
queue的实现其实和stack也是很类似的,稍微修改一下就好:
namespace MySpace {template<class T, class Container = deque<T>>class queue {public:queue() {}queue(const initializer_list<T>& x) {typename initializer_list<T>::iterator it = x.begin(); while (it != x.end()) { push(*it); ++it; } }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;};}
这里就是刚刚说到的那种情况,我们调用了pop_front接口,当然deque和list<是有这样的接口的,所以队列的是新是不能用vector的,这其实也符合之前的判断。因为用vector不适合头删数据。
priority_queue的模拟实现
priority_queue的模拟实现其实早已经实现过,但是在这里我们得引入一些新的用法。我们先来看看文档中是怎么定义的:
我们发现,除了适配容器之外,模板参数中还多了一个叫Compare的参数,这个是什么呢?
这个其实是仿函数,其实也是一个类。
为何引入仿函数
还记得以前模拟实现堆这个数据结构的时候,我们可能需要通过手动调整向上调整算法和向下调整算法的的比较逻辑,从而达到是建大堆还是建小堆。
但这很明显是非常不合理的。总不能每一次都要手动调整吧。但是在学习c语言的时候是没有太多办法解决这个问题,除了函数指针。
函数指针用来控制比较逻辑其实我们早已见过,就是使用c库内自带的qsort函数时,需要我们自己传一个比较逻辑,这个比较逻辑按照库中的要求就是排成升序。但是我们实现和库要求相反的比较函数,就可以排升序。这些就不再多说,都是以前的知识。
但是c++中是不太喜欢使用函数指针这个东西的,因为函数指针还是比较复杂的,且代码的可读性一般。有没有什么办法能够达到这样的效果:
即我传入一个类似小于号的东西,就建立大堆,传入类似大于号的东西,就建立小堆。然后就使用这个传入的内容去执行比较逻辑,复用这个逻辑建立大堆或者小堆呢?
答案是可以的,标准库里面就是用仿函数实现的。至于为什么要规定小于号建大堆,大于号建小堆这是不用管的,标准库中就是这样玩的。
我们来看看仿函数是怎么写的:
template<class T>
class less {
public:bool operator()(const T& x1, const T& x2) {return x1 < x2;}
};template<class T>
class greater {
public:bool operator()(const T& x1, const T& x2) {return x1 > x2;}
};
当前我们只需要简单的了解一下仿函数怎么简单的实现一个并且会使用即可。
仿函数其实就是一个空的类,没有成员变量,内部有一个对()进行重载的函数,返回值是bool类型。注意这个空类的大小为1个字节,这是为了占位。这在类的大小部分说过。
这也是个类模板,其实很好理解,因为比较的内容可能不只是整形或者浮点型,各式各样的都需要比较。STL内的容器也是可以比较的,因为内部都有重载比较逻辑的函数。
如果是我们自己实现的类比较,我们可以在内部自行实现比较逻辑。但是如果我们现在是传入两个指针指向两个对象,那使用类内的比较逻辑可能就不是我们想要的。解决方法就是:要不然就是在内部重载一个专门针对指针的比较逻辑,要不然就是重载一个仿函数即可。
代码实现
我们来看看代码实现:
namespace MySpace {template<class T>class less {public:bool operator()(const T& x1, const T& x2) {return x1 < x2;}};template<class T>class greater {public:bool operator()(const T& x1, const T& x2) {return x1 > x2;}};template<class T, class Container = vector<T>, class Compare = less<T>> //默认less代表传小于号 建大堆 反之相反class priority_queue {public: priority_queue() {}template <class InputIterator>priority_queue(InputIterator first, InputIterator last) {InputIterator it = first;while (it != last) {push(*it);++it;}}template <class T>priority_queue(const initializer_list<T>& x) { typename initializer_list<T>::iterator it = x.begin(); while (it != x.end()) { push(*it);++it; } }void push(const T& x) {_con.push_back(x);Adjustup(size() - 1);}void pop() {std::swap(_con[0], _con[size() - 1]);_con.pop_back();AdjustDown(0);}bool empty() const {return _con.empty();}size_t size() const {return _con.size();}T& top() const {return _con[0];}void Adjustup(size_t child) {int Child = child, Parent = (child - 1) / 2;while (Child > 0) {if (_cmp(_con[Parent], _con[Child])) {std::swap(_con[Parent], _con[Child]);Child = Parent;Parent = (Parent - 1) / 2;}else break;}}void AdjustDown(size_t parent) {size_t Parent = parent, Child = 2 * Parent + 1; while (2 * Parent + 1 < size()) {if (Child + 1 < size() && _cmp(_con[Child], _con[Child + 1])) ++Child; if (_cmp(_con[Parent], _con[Child])) {std::swap(_con[Parent], _con[Child]);Parent = Child; Child = Child * 2 + 1;}else break;}}private:Container _con;Compare _cmp;};}
虽然以前实现过这个数据结构,但是把它们放在类里面还是需要进行调整的。比如向上向下调整算法的参数就是被修改的。
从这里我们也看得出来为什么是仿函数了,因为调用的形式就和函数一样,传入两个参数进行比较,表达式的返回值就是比较的结果。和函数是一样的方法,但是我们还能通过外界控制整个逻辑。
当然由于引入了仿函数的机制,所以向上向下调整算法的逻辑可能稍微需要修改一下,和以往我们在c语言数据结构中实现的那个版本还是有一些区别的。但是换汤不换药,我们可以将符号代入进去验证即可。(STL库中实现的是传类less建大堆,反之小堆,默认建大堆)。
总的来说,这个部分的模拟实现还是非常简单的,只需了解一下适配器模式和仿函数的概念即可轻松手撕这样的容器出来。