【C++ 秘籍】解锁 stack、queue 和 priority_queue 及容器适配器的神奇世界
勇敢滴勇 2024-08-22 16:35:02 阅读 89
文章目录
一.什么是容器适配器二.stack栈2.1 stack的介绍2.2 stack的使用2.3 stack的模拟实现
三.queue队列3.1 queue介绍3.2 queue的使用3.3queue的模拟实现
四.deque的简单介绍1.STL标准库中stack和queue的底层结构2.deque的原理介绍3.deque底层结构工作原理扩容机制
4.deque的缺陷`deque` 的优点:缺点分析
5.为什么选择deque作为stack和queue的底层默认容器6.`deque` 迭代器的设计迭代器的示意图结论
一.什么是容器适配器
容器适配器是一种在编程中常用的设计模式,它允许我们将一个类的接口转换成另一个客户希望的接口。具体到容器适配器,它们用于转换已有的序列式容器(如vector、deque和list)以满足特定场景的需求。通过封装和重新组合这些容器中的成员函数,容器适配器可以提供不同的接口,使得原本的序列容器在新的使用情境下更加适用和高效。举例来说,栈(stack)就是一种典型的容器适配器。它通过封装一个底层序列容器(例如deque),并提供了栈操作所需的接口(如push、pop和top),使得这个底层序列容器能够像栈一样工作。这种模式使得我们可以在不改变已有容器的基础上,适配它们以满足不同的算法或应用需求。总结来说,容器适配器是一种灵活的设计模式,能够有效地重用现有的代码和容器功能,同时为不同的编程场景提供定制化的解决方案。
二.stack栈
2.1 stack的介绍
官方文档介绍点这里:stack的文档介绍
stack是一种容器适配器,专门用在具有后进先出操作的上下文环境中,其删除只能从容器的一端进行元素的插入与提取操作。stack是作为容器适配器被实现的,容器适配器即是对特定类封装作为其底层的容器,并提供一组特定的成员函数来访问其元素,将特定类作为其底层的,元素特定容器的尾部(即栈顶)被压入和弹出。stack的底层容器可以是任何标准的容器类模板或者一些其他特定的容器类,这些容器类应该支持以下操作:
empty:判空操作
back:获取尾部元素操作
push_back:尾部插入元素操作
pop_back:尾部删除元素操作标准容器vector、deque、list均符合这些需求,默认情况下,如果没有为stack指定特定的底层容器,默认情况下使用deque。
压栈:栈的插入操作叫做 进栈 / 压栈 / 入栈 ,入数据在栈顶。
出栈:栈的删除操作叫做出栈。出数据也在栈顶。
2.2 stack的使用
需要包含头文件#include <stack
<code>#include <iostream>
#include <stack>
using namespace std;
int main() { -- -->
stack<int> myStack;
// 检测 stack 是否为空
if (myStack.empty()) {
cout << "Stack is empty" << endl;
}
// 向 stack 中压入元素
myStack.push(10);
myStack.push(20);
myStack.push(30);
// 返回 stack 中元素的个数
cout << "Size of stack: " << myStack.size() << endl;
// 返回栈顶元素的引用
cout << "Top element: " << myStack.top() << endl;
// 弹出 stack 中尾部的元素
myStack.pop();
cout << "Top element after pop: " << myStack.top() << endl;
return 0;
}
2.3 stack的模拟实现
namespace mystack
{
template<class T,class Container=deque<T>>
class stack
{
public:
// 将元素 x 压入栈中
void push(const T& x)
{
_con.push_back(x);
}
// 弹出栈顶元素
void pop()
{
_con.pop_back();
}
// 返回栈顶元素的引用
const T& top() const
{
return _con.back();
}
// 返回栈中元素的个数
size_t size()
{
return _con.size();
}
// 检测栈是否为空
bool empty()
{
return _con.empty();
}
private:
// 用于存储栈元素的容器
Container _con;
};
}
这种写法采用了模板编程和容器适配器的思想。
模板编程:通过使用模板参数,类stack能够适用于不同的数据类型和底层容器。容器适配器:stack并不是从零开始实现数据存储,而是利用现有的标准容器(如deque)来实现其功能。这种方法减少了重复代码,提高了代码的重用性和可维护性。
为什么这样写?
灵活性:通过模板参数,用户可以自定义stack的底层容器类型,而不仅限于deque。这使得stack更为灵活和通用。减少代码重复:利用现有的标准容器如deque、vector、list等,避免了重新实现基本的容器操作(如插入、删除等)。提高效率:使用标准库中的容器通常可以提供高效和经过优化的实现。
为什么第二个模板参数是class Container = deque ?
默认参数:class Container = deque意味着Container默认使用deque作为底层容器。如果用户没有指定第二个模板参数,stack将使用deque作为其底层容器。适应性:deque是一个双端队列,支持在两端进行快速的插入和删除操作,非常适合用作stack的底层容器。
模板参数也支持缺省值吗?
是的,模板参数支持缺省值。缺省值为模板提供了默认的行为,如果用户在实例化模板时没有提供某个模板参数,编译器将使用缺省值。在你的代码中,class Container = deque就是一个模板参数缺省值的例子。用户可以选择性地提供自己的容器类型,比如vector或list,也可以直接使用默认的deque。
为什么没有默认成员函数以及赋值运算符重载函数?
类模板stack的默认构造函数、析构函数、拷贝构造函数和赋值运算符重载函数都是由编译器自动生成的。因为类模板stack中的成员变量_con是一个容器对象(如std::deque),而这些容器类本身已经提供了适当的构造、析构、拷贝和赋值操作,所以默认生成的函数能够正常工作。
详解const T& top() const
在
const T& top() const
中,两个const
限定符分别应用于不同的情况。
第一个
const
限定符
const T& top()
中的const
表示返回的引用是一个常量引用。这意味着调用top()
函数时,返回的引用不能用于修改栈中元素的值。这是为了保护栈的内部数据,确保它不会被意外修改。例如:
const T& top();
当你通过
top()
返回一个元素时,不能通过这个引用去修改该元素。
第二个
const
限定符
const T& top() const
中的第二个const
修饰符用于声明top()
函数本身是一个常量成员函数。这意味着该函数不会修改调用它的对象的状态。换句话说,你可以在一个常量对象上调用top()
函数,并且它不会改变对象的状态。这样可以确保对对象进行只读访问。例如:
const T& top() const;
这个修饰符是对对象的“只读”承诺,保证了函数调用不会修改对象的任何成员变量。
综合作用
这两个
const
修饰符的结合确保了top()
函数既不会修改对象的状态,也不会允许通过返回的引用修改对象的内部数据。这种设计提供了对栈对象的安全的只读访问,并保护了栈的内部数据免受意外修改。
对比
size
函数
例如,在
size()
函数中,仅需一个const
修饰符:
size_t size() const;
这个修饰符仅表示
size()
函数不会修改对象的内部状态,因为它只是返回栈的大小,不涉及任何对内部数据的修改。这里不需要返回类型的const
修饰符,因为size()
返回的是一个值类型,而不是引用。
测试一下
void test_stack() {
stack<int> s; // 默认使用 std::vector 作为底层容器
s.push(1);
s.push(2);
s.push(3);
s.push(4);
while (!s.empty()) {
cout << s.top() << " "; // 输出栈顶元素
s.pop(); // 弹出栈顶元素
}
cout << endl;
}
void test_stack1() {
stack<int, list<int>> s; //也可指定使用 std::list 作为底层容器
s.push(1);
s.push(2);
s.push(3);
s.push(4);
while (!s.empty()) {
cout << s.top() << " "; // 输出栈顶元素
s.pop(); // 弹出栈顶元素
}
cout << endl;
}
测试结果如下:
三.queue队列
3.1 queue介绍
.
队列是一种容器适配器,专门用于在FIFO上下文(**先进先出)**中操作,其中从容器一端插入元素,另一端提取元素。队列作为容器适配器实现,容器适配器即将特定容器类封装作为其底层容器类,queue提供一组特定的成员函数来访问其元素。元素从队尾入队列,从队头出队列。底层容器可以是标准容器类模板之一,也可以是其他专门设计的容器类。该底层容器应至少支持以下操作:
empty:检测队列是否为空
size:返回队列中有效元素的个数
front:返回队头元素的引用
back:返回队尾元素的引用
push_back:在队列尾部入队列
pop_front:在队列头部出队列
标准容器类deque和list满足了这些要求。默认情况下,如果没有为queue实例化指定容器类,则使用标准容器deque。
3.2 queue的使用
需要包含头文件#include
<code>#include <iostream>
#include <queue>
using namespace std;
void test_queue() { -- -->
// 构造空的队列
queue<int> q;
// 检查队列是否为空
if (q.empty()) {
cout << "The queue is currently empty." << endl;
}
// 在队尾入队元素
q.push(10);
q.push(20);
q.push(30);
q.push(40);
// 输出队列大小
cout << "The queue size is: " << q.size() << endl;
// 返回队头和队尾元素的引用
cout << "Front element is: " << q.front() << endl;
cout << "Back element is: " << q.back() << endl;
// 逐个出队并输出队头元素
while (!q.empty()) {
cout << "Popping front element: " << q.front() << endl;
q.pop();
}
// 再次检查队列是否为空
if (q.empty()) {
cout << "The queue is now empty." << endl;
}
}
int main() {
test_queue();
return 0;
}
3.3queue的模拟实现
queue 不能用 vector 去适配,因为 vector 压根就没有 pop_front 这个接口。
namespace myqueue
{
//queue模拟实现
template<class T,class Container=deque<T>>
class queue
{
public:
void push(const T& x)
{
_con.push_back(x);
}
void pop()
{
_con.pop_front();
}
const T& front()
{
return _con.front();
}
const T& back()
{
return _con.back();
}
size_t size()
{
return _con.size();
}
bool empty()
{
return _con.empty();
}
private:
Container _con;
};
四.deque的简单介绍
1.STL标准库中stack和queue的底层结构
虽然stack和queue中也可以存放元素,但在STL中并没有将其划分在容器的行列,而是将其称为容器适配
器,这是因为stack和队列只是对其他容器的接口进行了包装,STL中stack和queue默认使用deque,比如:
2.deque的原理介绍
deque(双端队列):是一种双开口的"连续"空间的数据结构,双开口的含义是:可以在头尾两端进行插入和
删除操作,且时间复杂度为O(1),与vector比较,头插效率高,不需要搬移元素;与list比较,空间利用率比较高。
<code>deque在功能上兼具了 vector
和 list
的一些优点。它支持在两端进行高效的插入和删除操作,同时也支持随机访问,这使得它在某些情况下非常有用。然而,要全面理解 deque
,需要了解它的底层实现和它的优缺点。
3.deque底层结构
deque并不是真正连续的空间,而是由一段段连续的小空间拼接而成的,实际deque类似于一个动态的二维数组,其底层结构如下图所示:
<code>deque 是由一段段连续的小块空间组成的,这些小块空间通过一个中控指针数组连接起来。指针数组用于管理这些小块空间,使得在两端进行插入和删除操作时更加高效。
工作原理
小块空间分配:
deque
最初分配一块小空间来存储元素。当这块空间用完时,它不会像 vector
那样进行整体扩容,而是分配一个新的小块空间。这种方法避免了频繁的内存拷贝,提高了效率。 中控指针数组:
为了管理这些小块空间,deque
维护了一个中控指针数组,指向这些小块空间。中控数组从中间位置开始存储指针,这样可以方便地在两端进行操作。 插入操作:
头插: 如果需要在头部插入元素,deque
不需要像 vector
那样移动数据,只需在头部前方分配新的小块空间即可。尾插: 如果需要在尾部插入元素,且最后一个小块空间没有满,可以直接插入。如果满了,则分配新的小块空间。
扩容机制
虽然 deque
的小块空间不会扩容,但中控指针数组可能会满。当中控数组满了时,需要扩容。但这种扩容的代价相对较低,因为只需拷贝指针,并且每个指针只指向一个小块空间。
4.deque的缺陷
<code>deque 的优点:
扩容代价低:
与 vector
相比,deque
的扩容代价更低,因为 deque
只需要为中控指针数组分配新的空间,而不是移动所有的元素。 高效的头尾插入和删除操作:
deque
在头部和尾部插入和删除元素时不需要移动大量数据,这使得这些操作的效率较高。相比之下,vector
在头部插入或删除元素时需要移动大量数据,效率较低。 支持随机访问且效率比 list
好:
与 list
不同,deque
支持高效的随机访问。虽然 deque
的随机访问效率不如 vector
,但比 list
好得多,因为 list
需要遍历节点才能访问特定位置的元素,而 deque
可以通过计算索引直接访问。
缺点分析
虽然 deque
结合了 vector
和 list
的优点,但它也存在一些缺点:
中间插入和删除效率低:
deque
的结构在进行中间插入和删除操作时效率较低。因为在这种操作下,需要移动大量数据。虽然每个小数组(buffer数组)可以设计为不同大小来减少移动的数据量,但这会影响随机访问的效率。因此,标准的 deque
实现中每个小数组大小是固定的,这导致中间插入和删除时需要移动大量数据,效率较低。 空间管理复杂:
deque
需要管理多个小块空间以及一个中控指针数组,这使得它的实现和维护比单纯的 vector
或 list
更复杂。这种复杂性可能带来一些额外的开销。 没有极致优化:
尽管 deque
结合了 vector
和 list
的优点,但这些优点在 deque
上并没有达到极致。对于需要频繁随机访问的场景,vector
更合适,因为它的随机访问效率最高。而对于需要频繁在头部或中间进行插入和删除的场景,list
更合适,因为它不需要移动大量数据。deque
在这些方面虽然能兼顾,但都不如 vector
和 list
那么高效。 内存使用:
由于 deque
需要维护多个小块空间和一个指针数组,相对于 vector
和 list
,它的内存使用效率可能更低。这些额外的指针和小块空间可能导致内存碎片和更高的内存开销。
5.为什么选择deque作为stack和queue的底层默认容器
stack是一种后进先出的特殊线性数据结构,因此只要具有push_back()和pop_back()操作的线性结构,都可以作为stack的底层容器,比如vector和list都可以;queue是先进先出的特殊线性数据结构,只要具有push_back和pop_front操作的线性结构,都可以作为queue的底层容器,比如list。但是STL中对stack和queue默认选择deque作为其底层容器,
主要是因为:
stack和queue不需要遍历(因此stack和queue没有迭代器),只需要在固定的一端或者两端进行操作。在stack中元素增长时,deque比vector的效率高(扩容时不需要搬移大量数据);queue中的元素增长时,deque不仅效率高,而且内存使用率高。
结合了deque的优点,而完美的避开了其缺陷。
6.deque
迭代器的设计
deque
(双端队列)是一个设计精巧的数据结构,它利用分段连续的内存块来模拟一个整体上连续的容器。为了维护这种“假想连续”的结构,deque
需要一个复杂的迭代器机制。以下是 deque
如何借助其迭代器来实现这一点的详细解释:
内部结构<code>deque 的底层由多个小的内存块(通常称为缓冲区)组成,这些缓冲区通过一个指针数组(中控数组)进行管理。每个缓冲区都是一个连续的内存块。deque
的这种结构允许它在两端高效地进行插入和删除操作,同时避免了像 vector
那样的重新分配和搬移操作。迭代器的组成deque
的迭代器设计相对复杂,因为它必须处理以下几种情况:
这样,一个 deque
迭代器通常由以下几个部分组成:
缓冲区指针:deque
迭代器需要持有指向当前缓冲区的指针。缓冲区偏移量:deque
迭代器需要记录在当前缓冲区中的位置(偏移量)。指向中控数组的指针:deque
迭代器还需要持有指向中控数组的指针,用于在缓冲区间移动。buffer_ptr
:指向当前缓冲区的指针。buffer_offset
:在当前缓冲区中的偏移量。map_ptr
:指向中控数组的指针(存储缓冲区指针的数组)。
迭代器操作由于 deque
的缓冲区不是全局连续的,所以迭代器需要执行额外的计算来确保操作的正确性。这包括:
前进与后退:
前进:当迭代器向前移动时,它可能会跨越缓冲区的边界。如果当前缓冲区的偏移量达到缓冲区的末尾,迭代器将需要移动到下一个缓冲区。后退:类似地,当迭代器向后移动时,它也可能需要跨越缓冲区的边界,回到前一个缓冲区。 随机访问:
随机访问操作(例如 operator+
和 operator-
)需要考虑跨越多个缓冲区。计算新的迭代器位置时,必须计算缓冲区之间的偏移,并更新指向相应缓冲区的指针。 迭代器的比较:
由于缓冲区是分段的,迭代器比较时需要比较缓冲区指针和偏移量,确保比较的正确性。
迭代器的示意图
假设我们有一个 <code>deque,其底层可能如下所示:
中控数组
+---------+---------+---------+---------+
| Buffer1 | Buffer2 | Buffer3 | Buffer4 |
+---------+---------+---------+---------+
其中每个 Buffer
都是一个内存块。一个 deque
迭代器可能包括:
指向 Buffer1 的指针 和 在 Buffer1 中的偏移量。指向 Buffer2 的指针 和 在 Buffer2 中的偏移量。指向中控数组的指针。
当迭代器在 Buffer1
中向前移动时,它将继续保持在 Buffer1
中,直到达到末尾,然后移动到 Buffer2
中的起始位置。类似地,当向后移动时,它可能从 Buffer2
移动到 Buffer1
中的起始位置。
结论
deque
通过复杂的迭代器机制来维护其“假想连续”结构。迭代器需要跟踪多个缓冲区和中控数组的指针,同时处理跨缓冲区的移动和随机访问。尽管这种设计增加了迭代器的复杂性,但它使得 deque
能够在两端高效地插入和删除元素,同时保持相对较高的随机访问效率。
📜 [ 声明 ] 由于作者水平有限,本文有错误和不准确之处在所难免,本人也很想知道这些错误,恳望读者批评指正!
声明
本文内容仅代表作者观点,或转载于其他网站,本站不以此文作为商业用途
如有涉及侵权,请联系本站进行删除
转载本站原创文章,请注明来源及作者。