C++里最容易上手、也最容易被误用的容器,我觉得就是这三个:stack、queue、deque。说它们容易上手,是因为接口少到可以两分钟全记住;说它们容易被误用,是因为很多人不清楚deque到底是干什么的,也不知道stack和queue这俩“适配器”背后到底站的是谁。这篇文章我就用做项目的视角,把这三个容器的基本用法、底层机制、典型场景和踩坑经验一次讲透,适合刚开始学STL的同学,也适合想补齐容器细节的C++开发者。
1. 先把容器家族理清楚:stack、queue、deque 到底处在什么位置
1.1 STL容器的三种类别,别再说“C++容器有几种”就只能报出一堆名字
很多新手学容器,第一步就被一堆名词劝退:vector、deque、list、set、map、unordered_map、stack、queue……其实不用死记硬背。C++标准库里的容器按组织方式大致分三类:
- 序列式容器(sequence containers):vector、deque、list、array、forward_list。它们按元素插入的先后顺序线性排列,你能用位置直接访问。
- 关联式容器(associative containers):set、map、multiset、multimap,以及C++11引入的无序版本。它们不是按线性顺序组织,而是按关键字组织,适合快速查找,底层一般用红黑树或哈希表。
- 容器适配器(container adapters):stack、queue、priority_queue。它们本质上是“包装器”,默认拿某个序列式容器当底座,对外只暴露特定的接口,从而模拟出栈、队列、优先队列这样的行为。
这里最关键的认知是:stack和queue并不是“从头到尾自己管内存的容器”,它们只是接口裁剪。真正的数据存储和内存管理,是依赖底层的序列式容器完成的。很多人忽略这个适配层,结果后面很多问题想不明白。
1.2 适配器模式:为什么stack和queue默认都站在deque肩膀上
很多面试题里会问:std::stack和std::queue的默认底层容器是什么?答案是:都是std::deque。这不是随手写的默认值,而是经过权衡的结果。
- stack只需要在“同一端”压入和弹出,deque的push_back和pop_back都是O(1),而且扩容时不像vector那样需要搬动全部元素。
- queue需要在一端加入、另一端移除,deque的push_back和pop_front也都是O(1),这正好匹配“先进先出”的模型。
vector在尾部插入是摊销O(1),但在头部插入是O(n),所以不适合当queue的底座;list虽然两端插入都是O(1),但每个节点都要额外的指针和分配开销,缓存局部性也差,实际跑起来大多不如deque“性价比高”。
当然,stack和queue的第二个模板参数是可以替换的。比如你想用list当queue的底层,可以写成std::queue<int, std::list >。这在某些特殊场景(比如你需要稳定的迭代器)是有意义的。但大多数情况下,默认deque就够了,别折腾。
1.3 三个容器的核心区别和应用场景速查
下面这张表是我自己项目里常用来对照选型的,也建议你收藏:
| 容器 | 数据结构特性 | 核心接口复杂度 | 典型场景 |
|---|---|---|---|
| stack | 后进先出(LIFO) | push/pop O(1),top O(1) | 函数调用栈模拟、括号匹配、表达式求值、撤销操作 |
| queue | 先进先出(FIFO) | push/pop O(1),front/back O(1) | BFS、任务调度、消息队列、生产者-消费者模型 |
| deque | 双端队列,两端都可进可出 | 双端push/pop均为O(1),支持随机访问 | 滑动窗口、双端处理的场景,作为stack/queue的底层 |
有些新手会追问:deque和vector都能用下标访问,是不是能互相替代?不是。deque的内存不是一整块连续空间,而是“分段连续”,这既是它的特点,也带来了随机访问比vector稍慢的代价。后面第4章我会专门讲它的底层结构。另外要记住,stack和queue不支持迭代器遍历,这不是缺陷,是设计——需要遍历的时候,你应该考虑换容器了。
2. stack实战:后进先出的三板斧
2.1 stack接口速览,以及新手最容易忽略的两个细节
stack的接口少得可怜,核心就五个:
- push():压入元素
- pop():弹出栈顶元素
- top():返回栈顶元素的引用
- empty():判断是否为空
- size():返回元素个数
C++11之后还多了emplace(),可以在栈顶直接构造元素,省掉一次拷贝或移动。比如:
#include <stack> #include <string> std::stack<std::string> st; st.emplace("hello"); // 直接构造,等价于 st.push(std::string("hello"));我见过不少新手在开写之前就默认top()返回的是“栈顶元素的值”,这没问题,但要注意它返回的是引用,所以你可以直接修改它。另外还有个极易踩的坑:top()和pop()是分离的。很多语言里弹栈就是“返回并弹出”,但在C++里不是。pop()返回void——它只负责删除,不负责把值给你。所以你要用栈顶,必须先top()再pop()。
注意:对空栈调用top()或pop()都属于未定义行为,程序可能崩溃,也可能“看起来正常”,但结果毫无意义。任何栈操作前先判empty()。
2.2 stack的底层实现与模板参数
std::stack的定义可以理解为这样:
template <class T, class Container = std::deque<T>> class stack;第二个模板参数就是底层容器。默认是deque,但你也可以显式指定vector或list。比如在某些内存敏感、频繁扩容的场景下,std::stack<int, std::vector >可能表现更好,因为vector连续存储且分配粒度更合理;而在需要链表节点稳定地址的场景,可以用list。但说实话,绝大多数项目不需要换底座,默认deque已经很平衡。
它的内部实现其实就是把Container的成员函数再包一层。你可以把stack想象成“只给你一个入口的容器”:所有操作都被限制到back端,其它能力全部挡在门外。这就是适配器模式的精髓——不是增加功能,而是裁剪能力,让使用者无法绕过规则。
2.3 实操案例:用stack写一个括号匹配检查器
括号匹配是栈的经典入门题,也是编译器语法分析的基础。我直接把一个能跑通的代码贴出来,用的是C++17风格:
#include <iostream> #include <stack> #include <unordered_map> #include <string> bool isBalanced(const std::string& s) { std::stack<char> st; std::unordered_map<char, char> pairs = { {')', '('}, {']', '['}, {'}', '{'} }; for (char c : s) { if (c == '(' || c == '[' || c == '{') { st.push(c); } else if (pairs.count(c)) { if (st.empty() || st.top() != pairs[c]) { return false; } st.pop(); } // 其它字符直接忽略 } return st.empty(); } int main() { std::string test = "{[()()]}"; std::cout << (isBalanced(test) ? "balanced" : "not balanced") << std::endl; return 0; }逻辑很直观:遇到左括号就压栈,遇到右括号就检查栈顶是否匹配,如果栈顶不对或者栈为空,就直接失败。最后还要检查栈是否为空——这个最容易被漏,比如输入"(((",中间判定全过,但栈里还压着三个左括号,说明括号没闭合。
我实际写这道题时踩过一次坑:把“空栈时不判empty直接top”的代码交上去,用例里有个单独的右括号,程序直接未定义行为,排查了半天。后来学乖了,先判empty再访问top,这也是每个写栈代码的人必须养成的肌肉记忆。
3. queue实战:先进先出的处理链路
3.1 queue接口速览,以及和deque的关系
queue的核心接口同样是五个:
- push():队尾入队
- pop():队头出队
- front():返回队头元素引用
- back():返回队尾元素引用
- empty() / size():判空和长度
和stack一样,front()和back()都返回引用,可以修改。pop()同样是void,只删除不返回。底层容器默认也是std::deque。
有一个细节很多人没注意:queue没有提供“遍历全部元素”的接口。这其实暴露了它的设计哲学——队列就是一段流水线,你只要盯住头和尾就行。如果你需要从头到尾看一遍,说明你处理的数据结构不是队列,应该用deque或list。
3.2 BFS场景实战:用queue做迷宫最短路径
广度优先搜索(BFS)是queue最常见的应用。我来写一个非常简化的迷宫最短路径demo:给定一个二维迷宫,0表示空地,1表示墙壁,求从起点到终点的最短步数。
#include <iostream> #include <queue> #include <vector> #include <utility> int bfsShortestPath(std::vector<std::vector<int>>& maze, std::pair<int, int> start, std::pair<int, int> end) { int rows = maze.size(), cols = maze[0].size(); std::vector<std::vector<int>> dist(rows, std::vector<int>(cols, -1)); const int dirs[4][2] = {{0, 1}, {0, -1}, {1, 0}, {-1, 0}}; std::queue<std::pair<int, int>> q; q.push(start); dist[start.first][start.second] = 0; while (!q.empty()) { auto [x, y] = q.front(); q.pop(); if (x == end.first && y == end.second) { return dist[x][y]; } for (auto& d : dirs) { int nx = x + d[0]; int ny = y + d[1]; if (nx >= 0 && nx < rows && ny >= 0 && ny < cols && maze[nx][ny] == 0 && dist[nx][ny] == -1) { dist[nx][ny] = dist[x][y] + 1; q.push({nx, ny}); } } } return -1; // 走不到终点 } int main() { std::vector<std::vector<int>> maze = { {0, 1, 0, 0, 0}, {0, 1, 0, 1, 0}, {0, 0, 0, 1, 0}, {1, 1, 0, 0, 0}, {0, 0, 0, 1, 0} }; std::cout << bfsShortestPath(maze, {0, 0}, {4, 4}) << std::endl; // 输出 8 return 0; }这里的关键点:dist数组不只记录“到没到过”,还记录“从起点走到这要几步”。第一次到达某个位置时,一定是最短步数,所以不会出现需要“更新更短路径”的情况。如果你用DFS做这件事,就得反复回溯、反复比较,效率差很多。这就是queue配合BFS的天然优势。
实测下来,这个算法的时间复杂度是O(rows * cols),每个格子最多进队一次,空间复杂度也是O(rows * cols)。在面试里,这个思路基本是必考题。
3.3 queue的一个限制和应对方案:没有clear、不能遍历
std::queue没有clear()方法。想清空一个queue,最经典的做法是直接swap一个空对象:
std::queue<int> q; // ... 塞了一堆数据 std::queue<int> empty; q.swap(empty); // 清空或者更省事的写法是:
q = std::queue<int>(); // 用空对象覆盖还有一点,queue不支持clear、不支持遍历,这经常让刚接触的开发者很困惑。其实解决思路很简单:如果你真需要遍历或清空,就把数据放到deque里处理,处理完再决定要不要转成queue。这不是绕路,而是选对工具。
4. deque:被低估的“双端战士”
4.1 deque的接口和性能特性
deque是个很有特点的容器。它可以像vector一样用下标访问,又可以在头部插入删除,且双端操作都是O(1)。核心接口包括:
- push_back / push_front:两端插入
- pop_back / pop_front:两端删除
- operator[] / at():随机访问
- insert / erase:任意位置插入删除(代价较高)
- front / back:访问首尾元素
很多人的第一反应是:这不就是vector加了个front吗?其实不对。deque在头部操作的性能不是vector能比的;但它的随机访问要经过两级跳转,实际速度比vector略慢。所以在“需要频繁随机访问”的场景下,vector更优;在“需要双端插入删除”的场景下,deque更优。
我自己的经验是:当你不确定该用vector还是list的时候,可以先试deque。很多场景下它都能给出合理的性能,代价也不会太离谱。它是一个很实用的“中庸之选”。
4.2 揭秘deque底层:中控器指针数组 + 一段段缓冲区
deque的底层实现方式是理解它性能特征的关键。
deque的内存不是一整块,而是由一段段固定大小的缓冲区块构成。标准库内部维护一个“中控器”(map),它其实是个指针数组,每个指针指向一块缓冲区。当缓冲区不够时,中控器会整体扩容,但已经分配出去的缓冲区块不会搬动,元素本身的地址也不会改变。
简单类比:vector像是一间连续的大房子,扩容时要整体搬到更大的房子;deque则像一排相邻的小仓库,仓库之间靠着一张“索引表”来定位。在头部插入元件时,vector要推着所有家具往后挪,deque只需要在索引表前面加一个仓库。
这个结构带来的结果:
- 双端插入删除都是O(1),且不会使已有元素搬移。
- 随机访问需要先通过中控器定位到对应的缓冲区,再做下标偏移,所以比vector稍慢。
- 迭代器不是简单的指针,还包含了缓冲区信息,所以deque的迭代器失效规则比vector复杂。
4.3 实操案例:滑动窗口最大值(单调队列)
滑动窗口最大值是一道高频题,最常见的解法就是“单调队列”,正是deque的show time。我贴一个通解的代码:
#include <iostream> #include <deque> #include <vector> std::vector<int> maxSlidingWindow(const std::vector<int>& nums, int k) { std::deque<int> dq; // 存下标,下标对应的值单调递减 std::vector<int> result; for (int i = 0; i < (int)nums.size(); ++i) { // 1. 清理窗口外的下标 if (!dq.empty() && dq.front() <= i - k) { dq.pop_front(); } // 2. 从队尾弹出所有不大于当前元素的下标 while (!dq.empty() && nums[dq.back()] <= nums[i]) { dq.pop_back(); } // 3. 当前下标入队 dq.push_back(i); // 4. 窗口形成后,队头就是最大值 if (i >= k - 1) { result.push_back(nums[dq.front()]); } } return result; } int main() { std::vector<int> nums = {1, 3, -1, -3, 5, 3, 6, 7}; auto result = maxSlidingWindow(nums, 3); for (int v : result) std::cout << v << " "; // 3 3 5 5 6 7 std::cout << std::endl; return 0; }核心思路是:始终保持deque里的元素“单调递减”。队头是当前窗口的最大值;新元素进窗口时,把队尾所有比它小或等于它的值全部弹出,因为它们在新元素存在的情况下永远不可能成为最大值了。
这里有个细节值得注意:deque里存的是下标,不是值。如果你只存值,窗口滑动时无法准确判断“队头元素是否已经滑出窗口”。存下标,就能用下标判断过期。这是我调这道题时遇到的最典型误区。
5. 别踩这些坑:空容器、迭代器失效、清空问题
5.1 空栈/空队列访问top/front:未定义行为的典型来源
我在好几个项目里都见过这样的bug:用户输入的序列里混进一个多余的操作,代码没判empty,直接调top()或front()。结果有时程序马上崩,有时过了很久才在另一个地方暴露问题——这就是未定义行为的可怕之处。你以为“没事”,其实状态已经乱了。
这种bug几乎无法从崩溃现场看出根因。所以我建议在每个入口处都封一层自己的接口:
template <typename T> bool safePop(std::stack<T>& st, T& out) { if (st.empty()) return false; out = st.top(); st.pop(); return true; }这样至少不会因为忘记判空直接把程序搞崩。
5.2 stack/queue没有clear(),清空容器的三种办法
这个问题上面提到过swap的解法。再总结一下:
stack<int>().swap(st);或者st = std::stack<int>();- 用局部变量触发析构,比如
{ std::stack<int> temp; st.swap(temp); } - 如果是deque,可以直接调用
clear(),然后再用。
第三种针对deque比较简单,deque本身有clear()方法。
5.3 deque的迭代器失效规则:哪些操作会“炸”
deque的迭代器失效规则比vector宽松,也比list复杂:
- 在中间插入或删除元素,会导致所有迭代器失效,因为元素位置移动了。
- 在两端push/pop,插入会让所有迭代器失效,但已有的元素引用不一定失效;删除端部元素时,指向被删除元素的迭代器会失效,指向其它元素的迭代器和引用仍然有效。
这在标准里写得很细,但在实际工程里,我记住一个重要原则就够了:如果你需要在遍历中修改容器的结构,那就先把操作收集起来,遍历完再统一改。这样不会踩到迭代器失效的雷。
5.4 新旧代码差异:C++11的emplace系列和C++17的CTAD
stack、queue、deque在C++11之后都支持emplace系列的构造式插入。这个改动很实用:省去临时对象的构造和拷贝。比如:
std::deque<std::pair<int, std::string>> dq; dq.emplace_back(1, "one"); // 不产生临时pair,直接在容器内构造另外C++17的类模板实参推导(CTAD)也能用在stack、queue上:
std::deque<int> base = {1, 2, 3}; std::stack st(base); // 推导出 std::stack<int, std::deque<int>>在我编译代码时,一般会把标准直接开到C++17。如果你还在用老标准,emplace不可用,就老老实实push构造好的对象。
6. 综合实战:用stack、queue、deque搭一个迷你任务调度器
6.1 需求设计
我之前在做一个简单的“任务调度中心”教学项目时,正好把三个容器都用上了。需求是这样的:
- 任务从外部进入,先放在queue里,等待分配。
- 每个任务到达调度模块时,先进入一个deque作为“待办缓冲”,调度器可以从两端取任务,用来模拟优先级调整(比如紧急任务插队到前面)。
- 每个任务被处理后,把信息压入一个stack,作为“历史记录”,需要时可以撤销或回溯。
6.2 代码实现和运行流程
我写一个简化的运行版本:
#include <iostream> #include <queue> #include <deque> #include <stack> #include <string> struct Task { int id; std::string name; }; int main() { std::queue<Task> incoming; // 入站队列 std::deque<Task> pending; // 待办缓冲 std::stack<Task> history; // 处理历史 // 1. 任务到达 for (int i = 1; i <= 5; ++i) { incoming.push({i, "task_" + std::to_string(i)}); } // 2. 从入站队列进入待办缓冲 while (!incoming.empty()) { pending.push_back(incoming.front()); incoming.pop(); } // 3. 模拟插队:把 id=5 的紧急任务放到前面 for (auto it = pending.begin(); it != pending.end(); ++it) { if (it->id == 5) { Task urgent = *it; pending.erase(it); pending.push_front(urgent); break; } } // 4. 逐个处理(从队头取) std::cout << "processing order:" << std::endl; while (!pending.empty()) { Task current = pending.front(); pending.pop_front(); // 模拟处理 std::cout << " " << current.name << std::endl; // 压入历史栈 history.push(current); } // 5. 展示撤销顺序(后进先出) std::cout << "history (LIFO):" << std::endl; while (!history.empty()) { std::cout << " " << history.top().name << std::endl; history.pop(); } return 0; }运行结果会先按“task_5, task_1, task_2, task_3, task_4”的顺序输出处理,随后history按逆序输出。这个例子把queue的入站缓冲、deque的双端调整、stack的历史记录都串了起来。你看,三个容器不是孤立的,它们在同一个系统里可以各司其职。
6.3 扩展思路:这个例子还能怎么改
如果想让调度器更接近真实世界,可以加很多东西:
- 给Task加priority字段,用std::priority_queue替换queue,实现按优先级出队。
- 把deque换成带时间戳的消息队列,处理超时任务。
- 给history栈加一个容量上限,超过上限就丢掉最旧的记录,防止内存膨胀。
这里要提醒你,priority_queue和普通queue是两回事。前者默认是大根堆,复杂度是O(log n),适合“每次取最大/最小”的场景,而不是严格的先进先出。如果你的需求是“既要排队又要优先级”,一般会拆成多个队列或者用带有优先级控制的deque。
最后分享一点我的真实体会
写C++这些年,我认为stack、queue、deque这三个容器最容易被轻视,但它们在实际工程中的出现频率极其高。很多看起来复杂的系统,拆开看,底层各种队列、缓冲区、历史栈,其实都能用这三个容器搭建。我自己写代码时有一个小技巧:凡是遇到“后进先出”的需求,先想stack;遇到“先进先出”的需求,先想queue;遇到“两头都要操作”的需求,就直接上deque。别嫌它们简单,简单的东西组合起来,也能撑起非常复杂的架构。你可以拿第6章的调度器做例子,试着给它加上优先级和超时处理,跑一遍,你会对这三者的分工有更深的体感。