在我们完成基础的几个数据结构,顺序表,链表后,STL容器里面也有栈,队列,还有一个新的容器deque,与栈,队列容器的实现有关。那么我们开始吧
目录
栈的实现
这里我们看到一个词叫做“适配器容器”,那适配器容器是什么呢?按照我的理解,适配器容器就是一个类里面可以放置多种容器作为底层实现,就是适配器容器是一个大框架,底层实现就是跟vector,list,map等等的容器
我们来看看cpp文档
适配器容器本质就是泛型编程思想,通过一个模版变量Container,来套入多种容器,在通过容器,调用容器的成员函数,适配则是可以满足多种容器。但是对于栈这个容器适配器来说,所适配的底层容器要能够满足“先入后出”的特性。
如果还听的云里雾里,我们在之前数据结构的学习中,也知道栈实际上不是自己重新实现的一个容器,本质上是可以通过数组,链表,那么栈的底层就是vector list之类的,所以栈可以成为适配器容器也不奇怪了
template<class T,class Container = deque<T>> // 可以通过模版参数Container控制底层容器,泛型思想
class my_stack { // 直接可以对应多个容器类型的栈,叫做容器适配器
public:
void push(const T& val) { // 通过容器来调用底层的push_back
_con.push_back(val);
}
void pop() {
_con.pop_back();
}
const T& top() {
return _con.back(); // 栈顶就是最上面的数据
}
bool empty() {
return _con.empty();
}
size_t size() {
return _con.size();
}
void swap(Container& con){
_con.swap(con);
}
private:
Container _con; // 定义容器来提供成员函数
};
那么我们测试一下my_stack,也可以用list来实现,这里我们就暂时vector来实现
void test_stack_Container() { // 测试容器适配器
my_stack<int> s1;
my_stack<int, vector<int>> s2;
cout << "before push ifEmpty: " << s2.empty() << endl;
s2.push(1);
s2.push(2);
cout << "top: " << s2.top() << endl;
cout << "size: " << s2.size() << endl;
cout << "after push ifEmpty: " << s2.empty() << endl;
s2.pop();
s2.pop();
cout << "after pop ifEmpty: " << s2.empty() << endl;
}
队列的实现
队列的实现跟栈的实现类似,不过需要注意的是,队列是先进先出,也就是尾插,头删,但是我们在cpp文档中可以知道,vector不支持头删,所以这时候就体现出了,适配器的适配性
template<class T,class Container = deque<T>> // 可以通过模版参数Container控制底层容器,泛型思想
class my_queue { // 直接可以对应多个容器类型的栈,叫做容器适配器
public:
void push(const T& val) { // 通过容器来调用底层的push_back
_con.push_back(val);
}
void pop() {
_con.pop_front();
}
const T& top() {
return _con.front();
}
bool empty() {
return _con.empty();
}
size_t size() {
return _con.size();
}
void swap(Container& con){
_con.swap(con);
}
private:
Container _con; // 定义容器来提供成员函数
};
这里也回答了我们的一个疑惑,为什么我们在之前vector和list的实现是,我们需要准备一个front和back来返回头值或者尾值,这时候就是给容器适配器使用的
浅谈deque
deque是一种双端队列,可以在队列的两端进行插入和删除操作。deque的名称来自于“double-ended queue”的缩写。
并且deque具有链表和顺序标的功能,能够进行下标访问
这个是deque的简易示意图,通过放置指针的中控数组,然后中控数组中的每一个下标对应一个放置着数据的buffer数组,这个数组可以恒定长短也可以变长。如图这里对应着定长buffer,在满数据时,头插会新开一个buffer,进行尾插。满数据后进行尾插,则会变为新buffer的头插。这时候有的人会想为什么不直接扩容,而是选择在开辟一个数组呢,这样不会看起来麻烦吗?实际上扩容需要创造一个更大的数组tmp然后将原有的数据拷贝构造,当数据量多的时候这样子,效率过低!
deque迭代器简图
具体实现我们在未来再解决
deque与vector和list的区别
- 内部实现:vector和deque都是通过数组实现的,而list则是通过双向链表实现的。
- 内存分配:vector和deque都是在内存中连续分配空间,而list则是动态分配空间。
- 随机访问效率:vector支持随机访问,时间复杂度为O(1),而deque也支持随机访问,但是由于其内部实现的原因,访问效率比vector略低。list不支持随机访问,只能通过遍历来访问元素,时间复杂度为O(n)。
- 插入和删除效率:vector在尾部插入和删除元素的效率较高,但在其他位置插入和删除元素的效率较低;deque在两端插入和删除元素的效率较高,但在其他位置插入和删除元素的效率较低;list在任意位置插入和删除元素的效率都很高。
- 空间占用:vector和deque在内存中连续分配空间,因此它们的空间占用比list更紧凑。
那么我们通过随机数大样本,插入deque,list,vector然后再对他排序,通过clock()函数在排序前后计时,计算时间差,来表示这三个组件的效率。另外为了更加深刻的对比,deque和vector,我们将deque拷贝在vector中排序,再转回deque来探讨它的效率
效率的代码实现:
void test_efficiency()
{
srand(time(0));
const int N = 1000000;
deque<int> dq1;
deque<int> dq2;
vector<int> v1;
list<int> lt;
for (int i = 0; i < N; ++i)
{
auto e = rand() + i; // 插入随机数
dq1.push_back(e);
dq2.push_back(e);
v1.push_back(e);
lt.push_back(e);
}
// deque的效率
int begin1 = clock();
sort(dq1.begin(), dq1.end());
int end1 = clock();
printf("deque sort:%d\n", end1 - begin1);
// deque转vector通过vector的排序再转为deque
vector<int> v(dq2.begin(), dq2.end());
int begin2 = clock();
sort(v.begin(), v.end());
dq2.assign(v.begin(), v.end());
int end2 = clock();
printf("deque copy vector sort, copy back deque:%d\n", end2 - begin2);
// vector的效率
int begin3 = clock();
sort(v1.begin(), v1.end());
int end3 = clock();
printf("vector sort:%d\n", end3 - begin3);
// list的效率
int begin4 = clock();
lt.sort();
int end4 = clock();
printf("list sort:%d\n", end4 - begin4);
}
如图:vector > deque copy vector >>deque sort (这里不加入list,因为它的sort是经过优化的)
所以我们可以看出vector的效率远高于deque,并且就算算上deque转为vector在转回deque的空间拷贝效率,这时的效率也远高于deque,所以deque的效率也不太好。所以deque在能用vector或者list的场景中,我们一般不会使用deque
deque的应用场景
deque(双端队列)是一种具有队列和栈的性质的数据结构,可以在队列两端进行插入和删除操作。deque的引用场景包括但不限于以下几种:
-
实现滑动窗口:deque可以在O(1)时间内在队列两端进行插入和删除操作,因此可以很方便地实现滑动窗口。
-
实现BFS算法:BFS算法需要使用队列来存储待访问的节点,而deque可以在队列两端进行插入和删除操作,因此可以很方便地实现BFS算法。
-
实现LRU缓存淘汰算法:LRU缓存淘汰算法需要在缓存满时删除最近最少使用的元素,而deque可以在队列两端进行插入和删除操作,并且支持O(1)时间复杂度的查找操作,因此可以很方便地实现LRU缓存淘汰算法。
-
实现单调队列:单调队列是一种特殊的队列,它的元素按照一定的单调性排列。deque可以在队列两端进行插入和删除操作,并且支持O(1)时间复杂度的查找操作,因此可以很方便地实现单调队列。
优先级队列priority_queue
priority_queue是C++ STL中的一个容器,它是一个优先队列,可以用来实现堆。它的特点是每次取出的元素都是当前队列中优先级最高的元素。
在priority_queue中,元素的优先级是通过元素类型的比较函数来确定的。默认情况下,priority_queue使用std::less作为比较函数,也就是说,元素类型必须支持小于操作符(operator<)。
priority_queue提供了以下几个常用的操作:
- push(x):将元素x插入到队列中。
- pop():弹出队列中优先级最高的元素。
- top():返回队列中优先级最高的元素。
- size():返回队列中元素的个数。
- empty():判断队列是否为空。
优先级队列的本质是堆
通过下面的代码和我们在数据结构中对堆的学习,我们也可以知道优先级队列和堆的关系,所以优先级队列的优先级实现,首先是分为大小堆,接着就是通过堆元素的删除,来删除堆顶的元素,这样就实现了优先级
void test_priority() {
// 优先级队列,默认为大堆
priority_queue<int> q_big;
q_big.push(3);
q_big.push(5);
q_big.push(1);
q_big.push(4);
while (!q_big.empty()) {
cout << q_big.top() << " ";
q_big.pop();
}
cout << endl;
// 这个为小堆
priority_queue<int, vector<int>, greater<int>> q_small;
q_small.push(3);
q_small.push(5);
q_small.push(1);
q_small.push(4);
while (!q_small.empty()) {
cout << q_small.top() << " ";
q_small.pop();
}
cout << endl;
}
控制台输出:
优先级队列的实现
// 这两个类实现了小根堆,大根堆两个不同的构造函数
template<class T>
class Less {
bool operator()(T x, T y) {
return x < y;
}
};
template<class T>
class Greater {
bool operator()(T x, T y) {
return x > y;
}
};
// priority_queue实质上就是一个大堆
template<class T, class Container = vector<T>, class Compare = Less<T>>
class my_priority_queue {
public:
// 容器适配器的拷贝与析构会调用容器的拷贝与析构
void adjust_up(int child) {
Compare com;
int parent = (child - 1) / 2;
while (child > 0) { // 当child恰好到下标为0退出循环
// if ( _con[parent] < _con[child]) 写死了
if ( com(_con[parent], _con[child]) ){
std::swap(_con[child], _con[parent]);
child = parent;
parent = (child - 1) / 2;
}
else {
break;
}
}
}
void adjust_down(int parent) {
size_t child = 2 * parent + 1;
while (child < _con.size()) {
if (child + 1 < _con.size() // 保证右孩子不越界
&& com(_con[child], _con[child + 1]) ) {
// 找到节点值小的来比较,大根堆需要把最大的换上去
++child;
}
// if (_con[parent] > _con[child])
if ( com(_con[parent], _con[child]) ) {
std::swap(_con[parent], _con[child]);
parent = child;
child = 2 * parent + 1;
}
else {
break;
}
}
}
void push(const T& val) {
_con.push_back(val);
// 因为是大堆,插入后需要按照堆的排列
adjust_up(_con.size() - 1);
}
void pop() {
std::swap(_con[0], _con[_con.size() - 1]);
_con.pop_back();
adjust_down(0);
}
const T& top() { return _con[0]; }
bool empty() { return _con.empty(); }
size_t size() { return _con.size(); }
private:
Container _con;
};
仿函数
仿函数(Functor)是一种重载了函数调用运算符 operator() 的类或结构体,它可以像函数一样被调用。通常用于泛型编程中,可以作为函数对象传递给算法或容器等函数,以实现更加灵活的操作。在优先级队列实现代码中,我们看到仿函数的使用,和仿函数的创造。
在这里通过仿函数,我们只用改变模版参数 Less 为 Greater 从大堆转为小堆,类似与函数重载,传入的参数不同进入不同的部分,所以这也体现出“仿函数”。再通过这两句代码的区别,第一行只能对于大堆的向上调整来实现(因为仅仅对应<)如果改为仿函数的话就可以进入不同的部分,这样子就更加合理。比如生活当中,我们通过大众点评,查找最好的餐厅排名后,可能也会去查差的餐厅排名,总不能让程序员随着相反需求的变化,来改代码吧!
// if ( _con[parent] < _con[child]) 写死了
if ( com(_con[parent], _con[child]) ){