【C++】:stack、queue和deque全面讲解
希望文章能对你有所帮助,有不足的地方请在评论区留言指正,一起交流学习!
目录
1.stack
1.1.stack的介绍
1.2.stack的使用
1.3.stack模拟实现
2.queue
2.1.queue的介绍
2.2.queue的使用
2.3.queue模拟实现
3.deque
3.1.deque介绍
3.2.deque的结构
3.3.deque的迭代器
3.4.deque作为默认底层容器的原因
本篇博客将介绍 stack、queue和deque的使用,以及stack和queue的模拟实现问题。
1.stack
1.1.stack的介绍
stack核心要点
工作方式:是 后进后出的(LIFO)的容器适配器,最后放入的元素最先取出。
底层容器: 默认使用deque<T>作为底层容器存储数据;也可以使用其他的容器,例如vector、list,但是这些容器要支持empty(判空)、size(取大小)、back(访问末尾元素)、push_back(末尾添加)、pop_back(末尾删除)这些操作。(使用前提)
操作特点:元素从栈顶(即对应容器的末尾)进行压入(push)和弹出(pop)。
综上:stack是STL(标准模板库中的)中容器适配器,他提供了一种先进后出的数据结构;没有自己的数据存储,而是去封装一个已有的底层序列容器来实现功能。
容器适配器简单的说类似于电源适配器,电源适配器可以将交流电转换成手机、平板、电脑需要的直流的电。而stack容器适配器将底层的数据结构转换成一个栈的形式的数据结构。
stack存在的必要性:
- stack作为容器适配器可以将支持适配器接口的底层容器转换为栈,这样就可以使用vector或者deque、list的为底层容器的栈。避免vector的模拟stack导致的繁琐出错。
- 提高可读性,明确的意图,stack的就是一个LIFO结构,比采用vector模拟stack调用vector的接口更加的清晰。
- 将底层实现细节进行封装,避免了对底层数据结构的管理,更加的省事。
stack的缺点
- 访问方式:只能访问栈顶元素(O(1)),不支持随机访问;
- 插入/删除:只能在栈顶插入或者删除;
- 迭代器:不支持迭代器。
- 底层实现:可以是list、vector和deque(默认)。
栈的工作方式,从底层存储上看和vector的结构类似,不过是删除了几个功能,也就是上述的缺点了:不可以随机访问,不可以在任意的位置插入。
综上,
- vector像一个开放书架,可以随时的取放或者在任何一层放入书籍。
- stack像是一个羽毛球筒,只能在开口处放球或者取球(最先放进去的先拿出来)。
1.2.stack的使用
stack的使用非常的直观,其核心接口就是为了LIFO设计的。
函数说明 | 接口说明 |
stack() | 构造空的栈 |
empty() | 检测stack是否为空 |
size() | 返回stack‘中元素的个数 |
top() | 返回栈顶元素的引用 |
push() | 在栈顶压入元素 |
pop() | 将栈顶元素弹出 |
比较简单,下面是一小段的测试程序
void test_stack()
{stack<int> st;//插入元素st.push(1);st.push(2);st.push(3);st.push(4);st.push(5);st.push(6);//栈中元素的数量cout << st.size() << endl;while (!st.empty())//判空{cout << st.top() << " ";//栈顶元素st.pop(); //出栈}
}
因为stack采用的时是其他的容器,因此其操作的时间复杂度取决于底层的容器。
底层容器 | push 复杂度 | pop 复杂度 | 备注 |
---|---|---|---|
vector | O (1) | O(1) | 扩容时为 O (n),但摊销后是常数时间 |
deque | O(1) | O(1) | 两端操作都为常数时间 |
list | O(1) | O(1) | 不需要移动元素,只需调整指针 |
这里留下一个问题,为什么stack的底层默认容器时deque,不是vector和list呢?
1.3.stack模拟实现
根据上述所描述的栈的特点和功能,可以把stack认为是阉割版的vector,当然,list也是可以的。
这里我把模拟实现的stack需要调用到的函数,在C++网站中找出(vector为例)。在stack的模拟实现中就是对vector等容器的接口复用,对底层容器进行封装,输出用户需要的数据结构。这样做既减少了重复代码,又保持了数据结构的特
namespace XLZ
{template <class T, class Container = deque<T>>class stack{public:void pop(){_c.pop_back();}void push(const T& val){_c.push_back(val);}const T& top()const{return _c.back();}size_t size() const{return _c.size();}bool empty() const {return _c.empty();}private:Container _c;//采用vector作为底层的容器};void test_stack(){//stack<int> st;// 用 vector 作为底层容器//stack<int, vector<int>> st;// 用 list 作为底层容器stack<int, list<int>> st;//插入元素st.push(1);st.push(2);st.push(3);st.push(4);st.push(5);st.push(6);//栈中元素的数量cout << st.size() << endl;while (!st.empty())//判空{cout << st.top() << " ";//栈顶元素st.pop(); //出栈}}
}
Container容器,用来存储数据的对象,它管理着一组元素的内存空间,并提供访问和操作这些元素的方法。
Container = 数据结构 + 操作方法
容器分为顺序容器(顺序存储元素,vector、list)、关联容器(按 键(key)存储和访问元素
set、map )、容器适配器(stack、queue)三类。
Container con;
Container :容器类型(模板参数),例如vector<int>、list<int>
con: 变量名 。定义一个名为 con 的 Container 类型的对象。Container为模板参数,占位符作用,等待具体类型替换。
2.queue
2.1.queue的介绍
核心要点:
工作方式:是一种容器适配器,遵循先进先出(FIFO)的原则,元素从容器的一端插入,从另一端提取。
底层容器: 默认使用deque<T>作为底层容器存储数据;也可以使用其他的容器,例如vector、list,但是这些底层容器要支持empty(判空)、size(取大小)、back(访问末尾元素)、push_back(后端插入元素)、pop_front(前端插入元素)、front(访问后端元素)这些操作。(使用前提)
操作特点:它通过封装特定的容器类对象作为底层容器来实现,提供一组特定的成员函数来访问元素,元素从底层容器的 “后端” 推入,从 “前端” 弹出。。
综上:deque是STL(标准模板库中的)中容器适配器,他提供了一种先进先出的数据结构;没有自己的数据存储,而是去封装一个已有的底层序列容器来实现功能,仅仅对外暴露符合FIFO的受限的接口。
queue存在的必要性:
- 同stack一样,避免其他底层容器的模拟实现栈的时候出现错误,减少bug。可以在同一个类模板下实现链队列和顺序表队列。避免程序的繁琐。
- 明确代码意图,同stack一样,让其他开发者明确代码的处理顺序。
- 避免效率的浪费,假如采用栈来模拟实现队列,那么头插和头删的效率都是O(n),而采用deque是时间复杂度为O(1).
queue的缺点
- 访问方式:只能访问队头或者队尾元素(O(1)),不支持随机访问;
- 插入/删除:只能在队尾插入元素,在从队头移除元素;不支持中间插入。
- 迭代器:不支持迭代器。设计遵从FIFO不需要对队列中的元素进行迭代。因此也不支持直接的遍历元素,若要遍历,可以借助底层的容器的迭代器,超出queue自身的能力。
- 底层实现:可以是list、vector和deque(默认),会继承底层容器的局限性,例如vector低下的头删(pop_front)。
综上,
- list像一个像一个双向链表的书架,你可以在任意位置插入或取下一本书,也可以从左到右或从右到左浏览所有书。
- queue像是一个单向通行的传送带,只能在起始位置放入,在传送带的尾端拿去,不能放在中间或拿去中间货物,因为是禁止的。
2.2.queue的使用
queue接口设计极简,核心围绕 “FIFO 的插、删、查”,需结合具体场景掌握用法。
接口 | 功能描述 | 注意事项 |
push(val) | 在队尾插入元素val | 调用底层容器的push_back() |
pop() | 删除队头元素 | 无返回值,需先判空(否则行为未定义) |
front() | 返回队头元素的引用 | 仅能访问队头,需先判空 |
back() | 返回队头元素的引用 | 仅能访问队尾,需先判空 |
empty() | 判断队列是否为空 | 判空是back()/pop()/front()的前置操作 |
size() | 返回队列中元素的个数 | 无迭代器,无法遍历,需通过size()/empty()判断循环次数 |
一段测试的程序,也可以用于模拟实现程序的测试
void test_queue()
{queue<int> q;q.push(1);q.push(2);q.push(3);q.push(4);q.push(5);cout << q.size() << endl;cout << q.back() << endl;//访问队尾元素while (!q.empty()){cout<< q.front()<<" ";q.pop();}
}
在上述的提到过容器适配器没有迭代器,无法遍历队列中的元素,那么我需要查看队列中的元素如何做。“遍历”(traversal)指的是在不修改原容器的前提下,按顺序访问所有元素。
- 将队列元素转移到支持遍历的数据结构中
- 使用底层容器
- 使用size()函数循环front再pop实现的遍历。(破坏性遍历垃圾,不可取)
这里也留下一个问题,为什么stack的底层默认容器时deque,不是vector和list呢?list的头插和头删都是O(1),为什么不用list作为底层容器呢?
2.3.queue模拟实现
根据上述所描述的栈的特点和功能,可以把stack认为是阉割版的list。
这里我也把模拟实现的queue需要调用到的函数,在C++网站中找出(listr为例)。
namespace XLZ
{template <class T,class Container = deque<T>>class queue{public:bool empty(){return _con.empty();}T& front(){return _con.front();}T& back(){return _con.back();}void pop(){_con.pop_front();}void push(const T& val){_con.push_back(val);}size_t size(){return _con.size();}private:Container _con;};void test_queue(){queue<int> q;q.push(1);q.push(2);q.push(3);q.push(4);q.push(5);cout << q.size() << endl;cout << q.back() << endl;//访问队尾元素while (!q.empty()){cout << q.front() << " ";q.pop();}}
}
没有什么难点,不做多余的赘述。容器模板参数给缺省参数的情况将会在下一篇博客中讲述;这里只要知道可以这么用就可以了。
3.deque
3.1.deque介绍
对于deque我们了解即可,使用较少。它是一个底层容器。
deque(双端队列),是标准库中的一种序列容器,其也是一个动态数组。大小是动态变化的,且两端(前端和后端)都能高效地插入/删除元素,时间复杂度为O(1)。
和vector的对比
相似点:都能随机访问元素(像数组那样通过下标快速取元素),接口也很像,能做类似的事。
不同点:
- 增长方式(扩容逻辑不一样):vector依靠单个数组存储元素,数组满了就重新分配一个更大的数组,复制元素,耗时间;deque把元素存在多个小块中,内部记录这些信息,所以超长序列下,deque的增长更高效(不用频繁的大规模复制)。
- 存储方式:和vector是本质的区别,deque的内存不是一块单一的连续空间,而是由多个不连续的、固定大小的内存块组成,通过一个 中控数组(map) 来管理这些内存块的指针。因此deque是不能通过指针偏移的方式进行乱访问的。所以其遍历的
缺点
- deque不适合在序列的中间插入和删除元素,deque的效率不如list/forward_list(前向链表)。
- 中间元素的改变会导致所有的迭代器失效,迭代器不稳当。
综上 deque 是“两端都灵活” 的动态容器,适合首尾操作多、序列很长的场景;但中间操作多的话,选list类容器更好。
上述是对C++官网上的翻译的介绍和解读。
3.2.deque的结构
deque的内存结构更像是一个二维数组。
1.分段连续存储的块(buffer数组)
deque的元素被拆分为多个固定大小的连续数组(块,每个块的大小由实现定义(通常足够大以减少内存分配开销)。这些块在物理内存中不必连续,但通过逻辑上的 “串联” 模拟成一个连续序列。
2.中央指针数组
deque使用二级指针数组(通常成为map),管理所有块的首地址,其中每个元素都是指针,指向某一块的起始地址。通过map,分散的块被 “虚拟串联”,使得外部可以通过索引快速定位到任意块。
- 当需要在首位扩展空间时,deque会新分配一个块,并将其指针插入到 map的头部(前端扩展)和尾部(后端扩展;
- 当map本身的容量不足的时候;(即存储块指针的数组满了),deque会重新分配一个更大的map数组将旧指针拷贝过去,再释放旧空间。
假设为前端插入,需要分配一个新的块(数组),并将此数组的首个元素的地址存储到中控数组中,要从中间开始向前放置数组指针;数组中存放从后向前放
假设为后端插入,需要分配一个新的块(数组),并将此数组的首个元素的地址存储到中控数组中,要从中间开始向后放置数组指针;数组中存放从前向后放。
3.3.deque的迭代器
deque 的迭代器需要处理 “跨块遍历”,因此比普通迭代器更复杂。
迭代器的结构,其迭代器和list一样需要专门的类封装。
T* cur; // 指向当前元素T* first; // 指向当前块的起始地址T* last; // 指向当前块的末尾地址(最后一个元素的下一个位置)T** node; // 指向 map 中当前块的指针(二级指针)
cur:定位当前元素。
first/last:标记当前块的边界,判断迭代器是否需要 “跨块”;
node:关联到map中对应块的指针,实现块之间的跳转。
deque中的首尾迭代器
start:指向第一个块的第一个元素;
finish:指向最后一个块的最后一个元素的下一个位置。
这两个迭代器标记了 deque 的整体范围,结合map可快速定位首尾的块。即begin()和end()。
综上:根据其结构总结一下deque的优势和代价。
优势
- 两端操作高效O(1):只需在map首尾添加 / 删除块指针,无需移动大量元素;
- 随机访问接近 O(1):通过 map定位块,再在块内偏移,时间复杂度接近vector;
- 内存利用更灵活:分段分配减少了大数组重新分配的开销。
代价
- 迭代器复杂:需处理跨块跳转,实现成本高;影响了数据的随机访问和遍历以及operator[ ]的效率。
- 中间插入 / 删除仍较慢:需移动块内元素,且可能触发map扩容。map就是中控数组。
deque 是用 “多个小连续数组 + 指针数组管理” 的方式,在 “两端操作效率” 和 “随机访问效率” 之间做了平衡。
图解
//迭代器遍历
auto it = dq.begin();
while (it != dq.end())
{cout << *it << " ";it++;
}
3.4.deque作为默认底层容器的原因
我们根据stack以及queue的特性,不需要遍历只需要在固定一端或者两端进行操作,而deque头插和头删操作丝毫不逊于list的效率,而且deque的空间利用率高于list。
相比于vector ,虽然deque的访问效率可能由于空间不连续导致的效率较低;但是对于头插和头删deque的效率较高。
综上:deque虽然访问上的效率低于vector,中间插入和删除数据的效率低于deque,但是无法确认时deque是适合stack和queue的底层数据结构。