前言
这周开始实习了,白天的时间都要花在实习上,所以自己学习的时间就变少了很多。目前我还没有想到很好的平衡实习与自己学习的办法,原本计划的在四月份之前看完STL的主要容器和容器适配器的任务也不知道能不能完成,3月29号是周五,还有一个周末,目前今晚不管睡多晚,我都一定把deque看完。看明天后天两天能不能把stack/queue给看了,这个周末就隔离一切干扰,把AVLTree和RBTree给搞懂,可以的话自己写一写map和set,四月份再制定新计划了~
——2024年3月27日
deque的使用
swap();//std::swap的特化版本
size();//返回容器的大小
empty();//检查容器是否为空
insert(iterator pos, const T& x);//在pos处插入x
erase(iterator pos);//删除pos处的元素
更多使用参考:Standard library header <deque> - cppreference.com
源码解析
迭代器解析
我们先来看deque的迭代器设计,与vector的设计不同,deque因为要提供两端进出的能力,所以不像vector那样采用原生的raw指针实现迭代,deque的迭代器要复制一些。
vector因为其只在容器末尾添加元素的特性,所以原生的raw指针的性能会比较高。而deque则不行,STL里deque的设计是一种逻辑上的连续,而不是像vector那样物理意义上的连续。deque的内存管理是通过分段空间模拟“连续”空间来实现的。所以在上图中我们可以看到,SGI中deque定义了三个T*类型的指针cur/first/last以及一个指向指针的指针T** node;
(上图中第一个圈中是的函数是一种Kludge的,用以处理一些编译器可能出现的常量表达式处理报错的方案,我觉得很有趣,就圈出来了)
迭代器的初始化,可以看到first和last都是用node来初始化的,所以deque的迭代器实现种map_pointer这个指向指针的指针是控制的中心。
我们来看一下deque这种通过指针和双重指针的非连续容器的设计思路。可以看到,当对迭代器执行++/--操作时,我们需要判断其所在的node是否到达了末尾/开头,如果达到了,就要切换缓冲区,即进行node+1(node - 1)操作,然后再重新设置cur节点。对于多个node来说,每个node都有其first和last,而我们的迭代器相当于操纵cur这个T*类型的指针在这些node之间“跳跃”。就像一个一个连起来的空岛,构成了deque容器。通过迭代器的这种设计,在用户看来deque是一段连续的空间。
我们来看一下这种多个缓冲区(后文buffer)相连的怎么实现随机读取,其中最基础的函数即上面这个。我们通过传参获取迭代器跳跃“距离”,再将该距离与(cur - first)相加即可得到目前移动的相对距离。如果相对距离小于一个buffer的大小,直接操作cur指针移动即可。若超出了buffer的大小,就要根据正负进行判断它具体能跳跃几个“buffer”(可以跳过中间一整个完整的buffer,这里有点像取模操作,即一个很大的数对一个较小的数取模,也差不多是跳跃多个“小数的值”最后得到模值)。
后面的+ - += -= [] 这些操作符都是调用的上面这个函数,仅仅是调用方式有些许差异。所以deque的随机访问就是基于上面这个操作符重载实现的。
主类解析
主类的基本类型大多与其他相同,除了这个 difference_type
类型,该类型是deque的距离移动的表示。下面是迭代器的定义,即上文所讲内容。
deque在具体实现的时候,start和finish是逻辑上的头和尾,具体到每一个buffer上又另有first和last,从而实现了从两端加减元素的功能。
在随机访问的时候,deque调用的是迭代器的 operator []
,而迭代器的 operator[]
调用是 self& operator+=(difference_type n)
。同理,deque的 ++/-- 也是调用的迭代器的 ++ / --。
构造函数的重载比较多,但总体来说是调用的create_map_and_nodes()
这个函数,所以我们搞懂create_map_and_nodes()
即可。
在deque 的构造中,我们要根据在deque内部设置的默认buffer_size()大小来决定配置几个node , 即用元素的数量/ 每个节点的元素数量 得到节点数量,再将map_pointer指向map的中间区域,而不是最边上得两个节点。随后通过for ()循环为每个node 单独分配内存,最后设置整个deque的start和finish指针,即第一个nstart和nfinish。
析构函数通过map_pointer对每一个node进行挥手空间,并释放map自身即可。
swap
我没有想到swap居然是直接调用std::swap对两个元素的首尾迭代器各swap一下就完了(😂)
push
push又分两种情况讨论,即还有备用空间,那直接调用construct
构建并移动cur指针即可。如果没有备用空间或者只剩下一个元素的备用空间,那么就调用push_back_aux(t);
我们先来看第一个红框:
push_back_aux
是通过重新分配一个新的map_pointer
空间来实现的,而不是我们认为的在原有空间上添加node来实现的,这是因为在原有空间上添加node可能导致map_pointer
失效,进而导致迭代器失效。这是因为在构造函数的时候,就已经定义好了map_pointer
的范围,所以当范围扩大时,就需要重新为map_pointer
分配,这样做的好处是,我们不需要重新为pointer
分配内存,于是deque
就不会出现迭代器失效的情况(两端插入删除时)。而vector
由于是使用的原生raw指针而不是像deque
这种一个类里面封装了两重指针,所以会有较多的迭代器失效的场景。
第二个红框:在重新分配map_pointer
后,我们就可以直接添加node分配一个新的buffer了,然后我们再construct
元素,设置finish/start
迭代即可。
在插入元素时,deque会像vector那样移动元素。不同于vector那种需要移动pos后面的所有元素(最坏情况下每次插入到0处,所有元素都需要移动,时间复杂度O(n)),deque由于其内部基于多个buffer相连的实现,所以最坏情况下是O(n/2),好吧其实也是O(n),不太适合中间插入,但至少比vector的最坏情况快一倍。其中copy操作大概就是遍历移动,也是调用的std的全局函数。
结束语
deque实现起来有点复杂,我估计今晚是完不成了,而且后面也不一定会去实现。我觉得deque就是属于那种理解起来不复杂,但是实现起来细节特别多的类型,很容易让人掉头发。所以看情况吧,我后面可能看看stack/queue,然后直接基于stl::deque去模拟stack和queue了~