
◆ 博主名称: 晓此方-CSDN博客
大家好,欢迎来到晓此方的博客。
⭐️C++系列个人专栏:
⭐️踏破千山志未空,拨开云雾见晴虹。 人生何必叹萧瑟,心在凌霄第一峰
目录
0.1概要&序論
这里是此方,久しぶりです!。list的学习告一段落, 本篇将正式进入stack&queue 的系统讲解,同时我们还会引入deque的思想等内容,由浅入深干货满满,千万不要错过。这里是「此方」。让我们现在开始吧!(前排预告,已经讲了两个STL的接口使用,stack&queue的重复接口我们不再提及了)
一,什么是适配器
适配器是一种设计模式 (设计模式是一套被反复使用的、多数人知晓的、经过分类编目的、代码设计经验的总结,迭代器就是一种设计模式 ),该种模式是将一个类的接口转换成客户希望的另外一个接口。
二,什么是stack&queue
和vector,string以及list不同,栈和队列 stack&queue不是容器,而是容器适配器 ,它们并不直接管理元素存储,而是基于某个已有容器进行接口封装,通过限制和重塑该容器的访问方式来提供特定的抽象语义(如栈的 LIFO、队列的 FIFO)。
栈和队列的特性以及接口使用方式我们就不多说了,直接上干货。
三,封装容器实现容器适配器
我们以stack为例,stack有两个模板参数,第一个存放类型,第二个就是其作为适配器的适配对象(容器)。 对传过来的continer容器进行适配转换 。所以栈的构造函数和析构函数是可以通过自动生成的构造和析构调用原生continer的构造函数和析构函数实现的 。不需要自己写。其他接口都可以封装continer接口来实现。
cpp
namespace work
{
template <class T, class Continer=vector <T> >
class mystack
{
可以在调用的时候可以自由控制这个栈是要链式栈还是数组栈 。其他接口都可以通过封装接口实现,以下举一个例子:我们在这个容器适配器里面封装了一个被适配容器的实例化对象,这个对象可以在这个容器里面调用这个被封装容器的接口。
栈和队列是容器适配器不是容器,所以没有迭代器,并且栈和队列如果实现了迭代器就不能保证先进先出和后进先出。
cpp
template <class T, class Continer=vector <T> >
class mystack{
public:
void push(const T& x){_con.push_back(x);}
private:
Continer _con;
};
实现过程中关于文件包含的一些问题:
1,为什么没有在stack.h文件中包含<vector>头文件还能使用vector作为模板参数的默认参数?
头文件stack.h不在头文件编译,而是在编译前的预处理中在.cpp文件中展开。展开后可以调用<vector>的内容。

2,编译的时候不是向上查找吗?为什么这里的stack.h头文件在vector头文件上面,还能找到vector的定义?
原因是stack头文件中的模板问题。模板在没有实例化的时候是不会编译的。我们只是把stack.h中的模板在.cpp中展开,然后在main函数中构造实例化它,在这个时候才会编译模板,自然从这个位置向上查找就能找到vector的定义。但是只要我们在头文件里面加一个函数而不是其他模板,这个函数调用了vector,就会报错找不到定义。
四,模板按需实例化
模板按需实例化的特性是:模板内部的各种接口只有再调用它们的时候才会被实例化出来。(比如构造一个对象的时候实例化构造函数,插入数据的时候实例化push)。
这就**导致了编译器在没有调用模板里面的某些接口的时候只会对其进行简单扫描。**对于一些细节的错误编译器无法查找出来,对于一些明显的语法错误(比如没有加;)要根据编译器的版本来做判断,较老的编译器如VS2013是查不出来的,较新的编译器如VS2022是会查出来的。
于是就会出现没有调用某个接口的时候不会报错,在调用某个接口的时候才会报错。以下举两个例子:
cpp
template <class T, class Continer=vector <T> >
class mystack{
public:
void push(const T& x){_con.push_front(x);}
private:
Continer _con;
};
int main()
{
mystack <int> mst;
return 0;
}
如上,我们自己写的stack内部组合了一个默认vector类型的对象_con,_con调用的是push_front,这个接口不存在于vector的库中,但是在函数中我们只是构造了一个对象mst,没有调用push接口,所以没有报错。
五,deque的底层逻辑
5.1deque的定义和与其他容器的关联
以上我们实现的stack采用的默认容器是vector,但是库里面的确是deque,这是为什么?首先我们要知道deque是什么。**deque是一个双端队列,**属于头文件<deque>。
如下,deque可以支持双向进出,所以称之为双端队列。

deque是vector和list的合体,但是相比两者也有优点和缺点。首先我们总结一下vector和list的优缺点,deque的优缺点我们在讲完底层后再说。
vector优点: 1、尾插尾删效率不错,支持高效下标随机访问。2、物理空间连续,所以高速缓存利用率高。**缺点:**1、空间需要扩容,扩容有一些代价(效率和空间浪费)。2、头部和中间插入删除效率低。
list优点: 1、按需申请释放空间,不需要扩容。2、任意位置插入删除。**缺点:**1、不支持下标随机访问。2,缓存利用率低下。
5.2deque的底层组件
deque的底层由buffer数组,中控数组,迭代器三大组件构成。如下图,中控数组是一个指针数组,buffer数组是一个存储实际数据的数组,迭代器由四个指针构成。

5.3deque是怎么插入和获取数据的
5.3.1插入第一个数据
首先,创建一个指针类型的中控数组,再创建第一个buffer数组,把这个buffer数组的指针存储在中控数组的中间位置。注意是中间位置不是最左边。
在buffer数组中从左到右插入第一个数据,此时迭代器:node指向中控数组的中间位置,也就是第一个buffer数组的指针存储位置。first指针指向buffer数组的开头,last指向buffer数组的结尾,cur指针指向第一个被插入数据的位置。

5.3.2尾部插入
1,buffer数组空间充足且中控数组空间充足:
插入第二给数据,第三个四个......:在buffer数组中从左到右插入数据。------直到,buffer数组满了。
2,buffer数组空间不足且中控数组空间充足:
cur==last。这个时候开辟一个新的buffer数组, 然后把数组的指针插入到中控数组原来第一个buffer数组指针的后面一个位置。修改first/last/cur指针的位置到新的buffer数组对应的位置,将node指针向后移动一格。
3,buffer数组空间不足且中控数组空间不足:
继续插入第n个数据,n+1个数据......:直到,中控数组的空间不足。停止插入。开辟一个更加大的中控数组(二倍扩容 ),将原来中控数组的指针数据拷贝给新数组 。修改迭代各个指针的位置。可见扩容的效率相比vector要高很多。
5.3.3头部插入
头插的逻辑在意料之外又在情理之中 ,不论第一个buffer数组有没有满,在头插第一个数据的时候都要新建一个buffer数组,修改迭代器位置然后在新建的数组中从后往前插入数据。

5.3.4获取数据
我们用一个指针ptr指向中控数组有效数据的开头,这个指针解引用就是这个中控数组这个位置存放的指针(便于解释,我们记为buf指针 )。(这里有点绕,理解一下:整型数组的某个位置指针解引用就是整型,指针数组的某个位置解引用就是指针)
我们获得这个buf指针 后就相当于知道了我们要找的这个数据在哪一个buffer数组中,也获得了调取这个buffer数组中数据的 权柄------指针算数+解引用获取。
如何完成上面这两步:首先假设要得到第N个数据,每一个buffer的大小是sz。 N/sz确定在中控数组的哪里,N%sz确定在这个buffer的哪里。得到以下公式:

5.4deque是如何借助其迭代器维护其假想连续的结构
5.4.1总体结构
deque的底层由两个迭代器构成,如下图:

start迭代器各个指针指向的位置安排(last指针同理):
- first指针指向最左边的buffer数组的开始位置。
- last指针指向最左边的buffer数组的结束位置。
- node指针指向最左边的buffer数组的指针在中控数组中的位置。
- cur指针指向当前数据的位置(头插到哪里就指向哪里)。
5.4.2如何实现迭代器++
先让cur++,指向buffer数组的下一个位置,如果buffer数组已经满了就指向下一个buffer数组的第一个位置,代码如下:
cpp
self& operator++() {
++cur;
if (cur == last) {
set_node(node + 1);
cur = first;
}
return *this;
}
void set_node(map_pointer new_node) {
node = new_node;
first = *new_node;
last = first + difference_type(buffer_size());
}
5.5总结deque对比vector&list
- deque头插尾插效率很高,更甚于vector和list。
- 下标随机访问也还不错,相比vector略逊一筹(因为要进行大量计算)。
- 中间插入删除效率很低,要挪动数据,是O(N)。
对于第三点:可以选择不大量在所有的buff中挪动数据,那么就是对当前的buff扩容缩容。 但是这样会导致每个buff不一致 ,operator[ ]的时候不能用简单除法获取第n个buff。**导致operator[]效率进一步下降。**所以库里面也是采用挪动数据来妥协:
cpp
self& operator+=(difference_type n) {
difference_type offset = n + (cur - first);
if (offset >= 0 && offset < difference_type(buffer_size()))
cur += n;
else {
difference_type node_offset =
offset > 0 ? offset / difference_type(buffer_size())
: -difference_type((-offset - 1) / buffer_size()) - 1;
set_node(node + node_offset);
cur = first + (offset - node_offset * difference_type(buffer_size()));
}
return *this;
}
5.5.1与vector对比
-
头部插入/删除效率高: 在头部插入或删除时,vector需要整体搬移元素,时间复杂度为 O(n),deque 采用分段连续空间结构 (buffer + map),头部插入/删除仅涉及块指针调整或少量分配,复杂度接近 O(1)。
-
扩容代价更低:vector扩容时必须重新申请更大的连续内存,并整体搬移元素。扩容通常只需增加 map 中的块指针,不需要整体搬移已有元素。
因此,在"频繁两端操作"场景下,deque在理论复杂度上优于vector。
5.5.2与list对比
-
**空间利用率更高:**list为双向链表,每个节点都需要存储前驱、后继指针,deque的块内是连续存储,不需要额外节点指针,空间利用率优于list。
-
缓存局部性更好:list完全离散存储,缓存命中率低。deque至少块内连续,缓存友好性明显优于list。
结合了其所有优势,同时规避了其最大缺陷 (不需要中间插入删除和大量访问),是stack和queue的理想默认底层容器。
六,优先级队列
6.1什么是优先级队列
先去复习以下堆再回来看优先级队列,传送门:
从零开始手搓堆:核心操作实现 + 堆排序 + TopK 算法+ 向上调整 vs 向下调整建堆的时间复杂度严密证明!-CSDN博客
https://blog.csdn.net/Z2314246476/article/details/155205808?spm=1001.2014.3001.5501 优先级队列就是堆,为什么叫优先级队列而不是叫堆?因为不是所有人在学C++前都学过数据结构。基于这种情况,根据堆与队列的特性,所以改名为优先级队列。 优先级队列没有单独的头文件。同样使用<queue>。 
三个模板参数分别是:T:这个容器适配器里面存的数据类型,Continer:这个容器适配器适配的是什么容器,Compare:比较器(建堆用的)。
为什么默认容器是vector?因为堆的底层是数组,**deque的operator[]效率是vector的一半(大约)。**而建堆和访问(*2+1找子等操作)会反复调用operator[],所以vextor最合适。
6.1.1仿函数
关于比较器这里,我要先补充一个知识点:**仿函数。仿函数实际上是一个类,一个重载了()的类。因为在使用的时候像在使用函数,所以称之为仿函数。**这里的operator()的()是函数的参数列表的那个括号。比如 void func()。
cpp
template <class T>
class greater{
public:
bool operator()(T x,T y){return x > y;}
};
template <class T>
class less{
public:
bool operator()(T x, T y){return x < y;}
};
如上,我们写了两个仿函数,**一个是greater比较大仿函数。一个是less比较小仿函数。**还不理解我们使用一下就明白了:
cpp
void AdjustUp(size_t child ){
int futher = (child - 1) / 2;
while (futher >= 0){
//if(_con[futher] > _con[child])
if(_cmp(_con[futher] , _con[child])){
std::swap(_con[futher], _con[child]);
child = futher;
futher = (child - 1) / 2;
}
else
break;
}
}
如上,这是一个向上调整算法,如果父亲结点大于子节点,那么就交换父子结点。这种逻辑是建小堆,反之则是建大堆。建小堆和建大堆取决于这个位置的大于小于。
想象一种场景:某宝的购物筛选系统要从升序排序改为降序排序,这个系统的底层逻辑是堆排序,难道还需要手动修改代码才能修正吗?所以这里的问题是如何自由修改这个位置的大小比较关系。
仿函数的出现改变了这一切,在整个类的模板参数中加一个compare参数。这个参数专门传递比较器,也就是我们自己写的仿函数(库里面有,这里是自主实现)。
cpp
template<class T, class Continer = vector<T>,class compare = greater<T>>
class priority_queue
{
在这里调用仿函数 :if(_cmp(_con[futher] , _con[child]))。这样, 如果我们调用的是less比较器,那么就比较小于;如果传递的是greater比较器,就比较大于。这样就实现了类外部根据需求修改操作。
默认less建大堆,传递greater建小堆。
顺带一提,仿函数是空类,空类的大小默认为1字节。
6.1.2仿函数的应用
我们可以在排序里面使用仿函数,如下,只需要和上面一样设置一个比较参数,然后用这个比较参数传递仿函数控制排序的升和降。
cpp
template<class Compare>
void BubbleSort(int* a, int n, Compare com){
for (int j = 0; j < n; j++){
int flag = 0;
for (int i = 1; i < n - j; i++){
// if (a[i] < a[i - 1])
if (com(a[i], a[i - 1])){
swap(a[i - 1], a[i]);
flag = 1;
}
}
}
}
int a[] = { 9,1,2,5,7,4,6,3 };
int main()
{
Less<int> LessFunc;
Greater<int> GreaterFunc;
int a[] = { 9,1,2,5,7,4,6,3 };
BubbleSort(a, 8, LessFunc);
BubbleSort(a, 8, GreaterFunc);
return 0;
}
**也可以用匿名对象。**less和greater仿函数不需要自己写,库里面有,主要在functio头文件里面,但是也有可能被间接包含。但是有些时候需要我们自己写。
cpp
BubbleSort(a, 8, Less<int>());
BubbleSort(a, 8, Greater<int>());
讲解一下什么时候需要我们自己写:内置的less和greater支持比较大小,但是比较的逻辑不是你想要的。比如我们传递指针类型,原本我们想要去比较指针解引用后的数据大小 ,但是内置比较函数会比较指针数值的大小(指针每一次运行都会发生变化,这就导致了不确定性)或者说我们想要比较类类型中的某一个成员变量,但是内置比较函数不会这么做。
优先级队列讲完了,没错。东西不多,接口可以查阅,堆的知识以前完整讲过。
好了,本期内容到此结束,我是此方,我们下期再见。バイバイ!
