C++ STL 容器适配器深度解析:stack、queue 与 deque 的底层原理和选型
2026/9/9 16:38:52 网站建设 项目流程

我最早学 C++ 标准库容器时,干过一件蠢事:想实现一个“栈”,先自己写了个链表,花了大半天调指针,最后发现标准库里的std::stack几行代码就解决了一切。后来带新人,又看到同样的情况发生。很多人知道有stackqueuedeque这三个东西,但要么只会在 LeetCode 里用,要么压根分不清它们之间的区别——甚至误以为它们是三个平级的“兄弟容器”。这篇文章我把这三者的用法、底层逻辑、适用场景和最容易踩的坑一次说透。内容面向 C++ 初学者,也适合想把 STL 容器关系理清楚的同学。

这三个容器里,std::stackstd::queue其实不是真正的容器,而是“容器适配器”;真正干活的是底下的std::deque(双端队列)。不搞清楚这层关系,你用它们的时候心里会一直发虚:到底内部是怎么存的?为什么queue的底层默认不用list?为什么deque支持随机访问,但中间插入却很慢?这篇文章会从“适配器”讲起,把每个容器拆开看。

1. 先弄清楚一件事:stack 和 queue 不是容器,是适配器

1.1 适配器是什么意思:底层容器由模板参数决定

看名字,std::stackstd::queue看起来像容器,但 C++ 标准里对它们的归类是“container adaptors”,也就是容器适配器。什么叫适配器?通俗说,它自己不存数据,数据存在底层的另一个容器里,它只是把那个容器的某些接口“包装”一下,暴露成栈或队列的样子。

std::stack的模板定义是这样:

template< class T, class Container = std::deque<T> > class stack;

注意第二个模板参数Container,默认值是std::deque<T>。这意味着你写std::stack<int> s时,它内部实际上用了一个deque来存数据。你也可以手动改成std::vector<int>std::list<int>

std::stack<int, std::vector<int>> s1; // 底层用 vector 实现栈 std::stack<int, std::deque<int>> s2; // 默认,底层用 deque std::stack<int, std::list<int>> s3; // 底层用 list

std::queue也一样:

template< class T, class Container = std::deque<T> > class queue;

默认底层同样是deque。这个“默认”是很多资料里一笔带过、但实际很关键的地方。为什么不是vector?因为queue需要频繁从容器的头部弹出元素,而vector在头部插入/删除的时间复杂度是 O(n),不适合这个场景。为什么不是listdeque的内存分配和迭代器开销通常比list更适合这种“只从两端操作”的需求,这一点我在后面的章节会展开讲。

1.2 底层容器要满足什么条件

不是随便拿哪个容器都能当底层。适配器要求底层容器支持特定接口,不同适配器的要求不一样,这是理解它们设计的关键。

std::stack要求底层容器支持:

  • empty()
  • size()
  • back()
  • push_back(const T&)
  • pop_back()

也就是说,只要一个容器支持在尾部读取、插入和删除,理论上就能当栈的底层。vectordequelist都满足。

std::queue要求底层容器支持:

  • empty()
  • size()
  • front()
  • back()
  • push_back(const T&)
  • pop_front()

要求多了一个front()pop_front(),也就是要从头部读元素、删元素。vector没有pop_front(),所以它不能直接作为queue的底层容器。这个约束条件非常直观,你只要把接口一列,就明白为什么默认是deque而不是vector了。

提示:C++11 之后,std::stackstd::queue都增加了emplace(),效果是在底层容器上直接构造元素,减少一次不必要的拷贝或移动。后面讲用法时我会给出示例。

2. stack 基本用法:接口不多,但细节不少

2.1 最常用的成员函数和一次完整代码

std::stack的成员函数很少,核心就这几个:

函数作用
push(const T& val)在栈顶压入一个元素
pop()弹出栈顶元素,不返回被弹出的元素
top()返回栈顶元素的引用
empty()判断栈是否为空
size()返回栈中元素个数
emplace(Args&&... args)在栈顶直接构造元素,避免拷贝
swap(stack& other)与另一个栈交换内容

有一个点新手经常搞错:pop()不返回被删除的元素,要拿栈顶元素得先top()pop()。如果你写int x = s.pop();,编译器会直接报错。原因很简单,C++ 设计者认为“返回被删元素”会带来额外拷贝开销,而且和异常安全互相冲突,所以干脆拆成两个操作。

看一个最简单的完整例子,用stack逆序输出字符串:

#include <iostream> #include <stack> #include <string> int main() { std::string str = "hello"; std::stack<char> s; for (char c : str) { s.push(c); } while (!s.empty()) { std::cout << s.top(); // 输出栈顶 s.pop(); // 弹出栈顶 } std::cout << std::endl; return 0; }

输出是olleh。逻辑没什么难度,但请注意我把s.pop()写在了读取之后,顺序不能反过来。不少人会顺手写成s.pop(); s.top();,然后发现拿到了一个奇怪的值或者直接崩溃——因为弹空栈的行为是未定义。

2.2 top() 返回的是引用:修改栈顶的正确方式

top()返回的是T&(非 const 版本的栈)或const T&(const 版本的栈)。这意味着你可以直接通过top()修改栈顶元素:

#include <iostream> #include <stack> int main() { std::stack<int> s; s.push(10); s.top() = 99; // 直接修改栈顶 std::cout << s.top() << std::endl; // 99 return 0; }

如果你是从 Java 或 Python 转过来的,这点可能不太习惯。Java 的peek()只能读,要改得先弹出来再压回去;C++ 里直接改引用就行,省事得多。但也正因为返回引用,你很容易写出一段错误代码:

const int& ref = s.top(); // 不要这样长期保存引用 s.push(100); // 之后 ref 可能失效 std::cout << ref; // 未定义行为风险

stack底层是deque,push 可能引发内部缓冲区重新分配,导致之前获得的引用失效。虽然对deque来说,在两端插入不会使已有元素的内存地址失效,但在某些实现下迭代器会失效,这个细节我放在 deque 章节再细说。总之,top()返回的引用适合“立刻使用”,不要长期持有。

2.3 指定底层容器:vector 与 deque 怎么选

std::stack的默认底层是deque,但面试和实战里经常有人问std::stack<int, std::vector<int>>和默认写法有什么区别。

vector作为底层时,栈的内存是连续的一段数组,内存局部性好,缓存命中率高,遍历(虽然栈通常不遍历)快。代价是当元素数量超过当前容量时需要整体搬移。deque作为底层时,内存是分段连续的,扩容时不需要搬移已有元素,所以按需扩容的开销更平滑,但在一些小元素的频繁访问场景下,缓存命中率通常不如vector

如果你能预估栈元素规模,或者你知道元素量少且访问频繁,显式指定std::vector可能会更快。测试起来也很简单:

#include <iostream> #include <stack> #include <vector> #include <deque> int main() { std::stack<int, std::vector<int>> sv; std::stack<int, std::deque<int>> sd; for (int i = 0; i < 5; ++i) { sv.push(i); sd.push(i); } std::cout << "vector stack size: " << sv.size() << std::endl; std::cout << "deque stack size: " << sd.size() << std::endl; return 0; }

实际项目里如果不做性能剖析,用默认的就足够了。STL 默认值往往就是“综合最好的选择”。我这里提这个主要是想让你明白,模板参数不是写死的,遇到性能瓶颈时你至少有替换的选项。

3. queue 基本用法:FIFO 场景和背后的容器要求

3.1 queue 的成员函数和示例代码

std::queue的成员函数和stack高度对称,核心区别是栈从“顶”进从“顶”出,队列从“队尾”进从“队头”出。

函数作用
push(const T& val)在队尾加入元素
pop()弹出队头元素
front()返回队头元素的引用
back()返回队尾元素的引用
empty()判断队列是否为空
size()返回队列中元素个数
emplace(Args&&... args)在队尾直接构造元素
swap(queue& other)与另一个队列交换内容

来一个模拟打印机任务队列的例子:

#include <iostream> #include <queue> #include <string> int main() { std::queue<std::string> tasks; tasks.push("print page 1"); tasks.push("print page 2"); tasks.push("print page 3"); std::cout << "队头: " << tasks.front() << std::endl; std::cout << "队尾: " << tasks.back() << std::endl; while (!tasks.empty()) { std::cout << "处理: " << tasks.front() << std::endl; tasks.pop(); } return 0; }

输出会按“先到先处理”的顺序打印三条任务。这里注意front()back()都返回引用,但用途不同:front()用于读队头,pop()删除队头;back()读队尾,但不该随便改,因为队尾是下次 push 的插入位置。

一个很容易犯的错是pop()之后立刻调用front()。空队列调用front()是未定义行为,不会抛异常,程序可能直接崩溃,也可能返回垃圾值。所以标准做法永远是先检查empty()

3.2 从“底层容器接口要求”理解为什么 list 也能做底层

我在第 1 小节已经说过,queue要求底层容器支持front()pop_front()push_back()等操作。std::list当然满足,所以你也可以显式指定:

std::queue<int, std::list<int>> q;

什么时候会想用list当底层?如果你的队列元素是超大对象,且deque默认分配多个缓冲区导致内存碎片让你不满意,可以试试list。但要注意,list的每个节点多一个前后指针,内存开销反而更大,而且链表节点通常分散在堆中,遍历访问的缓存命中率低,性能不见得更好。所以这个选项通常只在特殊场景下才有意义。

与之相对的,std::vector不能作为queue底层,因为缺少pop_front()。这个接口约束本身就是一个很好的学习工具:你只要看到一个容器缺少某个接口,就知道它不能适配哪个容器适配器。

3.3 不要把 queue 和 priority_queue 混为一谈

很多初学者会在网站搜索“queue 用法”时看到std::priority_queue,然后以为队列能自动排序。实际上priority_queue是另一个独立的容器适配器,它的底层默认是std::vector,内部维护一个堆结构,top()返回的是“优先级最高”的元素,而不是最先入队的元素。

#include <iostream> #include <queue> int main() { std::priority_queue<int> pq; pq.push(3); pq.push(1); pq.push(2); std::cout << pq.top() << std::endl; // 输出 3,不是 1 return 0; }

它和queue名字像,行为完全不同。queue是严格 FIFO,priority_queue是按优先级出列。区分它们的最直接方法:记住queue默认底层是dequepriority_queue默认底层是vector,接口上也少了front()/back(),多了一个top()

4. deque 的内部结构:为什么头尾都很快,但中间插入要小心

4.1 接口一览:比 vector 多了一头一尾

std::deque是这三者中真正的容器,名字是 double-ended queue(双端队列)的缩写。它的核心优势是支持在头部和尾部都能快速插入删除,同时保留了随机访问能力:

操作时间复杂度
push_back(val)O(1)
push_front(val)O(1)
pop_back()O(1)
pop_front()O(1)
operator[](size_t)O(1)
insert(pos, val)O(n),取决于位置到最近一端的距离
erase(pos)O(n)

常用成员函数很好记,vector有的它基本都有,比如begin()end()size()resize()clear()empty(),此外多了push_front()pop_front()emplace_front()。看一个例子:

#include <iostream> #include <deque> int main() { std::deque<int> dq; dq.push_back(1); dq.push_front(2); // 现在 dq 是 [2, 1] dq.push_back(3); // 现在 dq 是 [2, 1, 3] std::cout << "front: " << dq.front() << std::endl; // 2 std::cout << "back: " << dq.back() << std::endl; // 3 std::cout << "dq[1]: " << dq[1] << std::endl; // 1 dq.pop_front(); // 现在 dq 是 [1, 3] std::cout << "size: " << dq.size() << std::endl; // 2 return 0; }

这里最值得说的是dq[1]能工作。一个支持双端插入的容器,居然还能下标访问,这正是deque特殊的地方。list做不到随机访问,vector做不到头插,deque两头都占。

4.2 分段连续存储:中控器与缓冲区的设计

我当年第一次写deque时很好奇:它到底怎么做到两边插入都 O(1),又不牺牲随机访问?这就要说到它著名的“分段连续”结构了。

可以这样理解:deque内部不是一个连续的大数组,而是由若干个固定大小(不同标准库实现里大小可能不同)的连续缓冲区组成的,这些缓冲区的地址记录在一张“中控表”里。这张表中每个元素指向一段连续的存储区,当你从头部插入元素、头部缓冲区满了,就新开一段缓冲区挂到中控表前面;从尾部插入也是类似。

用生活化的类比:你有一栋楼的房间列表(中控器),每个房间是一段连续数组(缓冲区)。你要找房间号,先查这个列表定位到某一段,再在段内偏移访问。这就是为什么随机访问也能 O(1)——只是常数比vector大,因为多一次间接跳转。但又因为每段内部是连续的,缓存局部性比真正的链表好得多。

deque的迭代器实现也因此比vector复杂。标准库里它通常包含四个指针:指向当前缓冲区的位置、指向缓冲区起始、指向缓冲区末尾、以及一个指向中控器中当前节点(即缓冲区在表中的位置)的指针。这就是为什么sizeof(std::deque<int>::iterator)通常比vector迭代器大得多,而且deque对象本身内部要多维护一个中控器指针数组。

提示:不同的标准库实现细节不同,libstdc++ 和 libc++ 对缓冲区大小、中控器扩容策略都有差异,但“中控器 + 分段缓冲区”这个设计思路是大致一致的。你在写代码时不要依赖具体的缓冲区大小,跨编译器移植时才不会踩坑。

4.3 什么时候适合用 deque,什么时候不合适

deque最适合的场景就是“只能在两端操作”的队列或栈,这就是为什么stackqueue默认拿它当底层。但deque并不是万能的。

如果你需要频繁在中间插入或删除元素,比如维护一个有序列表,那deque不一定比vector好,甚至可能更差。无论vector还是deque,中间插入都需要移动该位置之后的所有元素;deque还要考虑到可能涉及跨缓冲区移动,逻辑更复杂。这种场景应该优先考虑std::list

如果你需要大量随机访问,deque可以工作,但性能上限通常不如vector。因为每次下标访问都要先经过中控器定位缓冲区,虽然也是 O(1),但常数更大。

如果你比较关心内存占用,也要意识到deque有碎片化开销。中控器本身要存指针数组,缓冲区不满时还可能有内存浪费。一个空deque对象的大小往往大于一个空vector对象。

总结对比:

容器尾部插入/删除头部插入/删除中间插入/删除随机访问内存连续性
vectorO(1) 摊还O(n)O(n)O(1) 常数小连续
dequeO(1)O(1)O(n)O(1) 常数稍大分段连续
listO(1)O(1)O(1)(已知迭代器)O(n)不连续

这个表能回答大多数“我该选哪个容器”的问题:只要需要头尾都操作,优先deque;只要需要大量随机访问且不头插,优先vector;只要需要大量任意位置插入且不在意随机访问,优先list

5. 三个实战案例:括号匹配、层序遍历、滑动窗口最大值

5.1 用 stack 做括号匹配

括号匹配是栈最经典的入门应用,思路很简单:遇到左括号入栈,遇到右括号时检查栈顶是否匹配,匹配则弹出,否则报错。最后栈必须为空。

#include <iostream> #include <stack> #include <string> bool isMatching(char left, char right) { return (left == '(' && right == ')') || (left == '[' && right == ']') || (left == '{' && right == '}'); } bool isValid(const std::string& s) { std::stack<char> st; for (char c : s) { if (c == '(' || c == '[' || c == '{') { st.push(c); } else if (c == ')' || c == ']' || c == '}') { if (st.empty() || !isMatching(st.top(), c)) { return false; } st.pop(); } } return st.empty(); } int main() { std::cout << isValid("()[]{}") << std::endl; // 1 std::cout << isValid("([)]") << std::endl; // 0 std::cout << isValid("((") << std::endl; // 0 return 0; }

这个例子里有个细节值得多说一句:遇到右括号时,我首先检查st.empty(),再检查匹配关系。这个顺序不能反,因为st.top()在栈为空时是未定义行为,一旦直接访问很容易导致程序崩溃。很多初学者在刷题时能想出“栈”这个思路,但写出的代码在"[)]"这种用例上崩了,原因就是忘了空栈判断。

5.2 用 queue 做二叉树的层序遍历

二叉树层序遍历,也就是广度优先搜索(BFS),是queue最经典的应用。核心思路是:根节点先入队,然后循环从队头取出节点,访问它,再把它的左右孩子入队。通过记录当前层节点数,可以精确控制每层的输出。

#include <iostream> #include <queue> #include <vector> struct TreeNode { int val; TreeNode* left; TreeNode* right; TreeNode(int x) : val(x), left(nullptr), right(nullptr) {} }; std::vector<std::vector<int>> levelOrder(TreeNode* root) { std::vector<std::vector<int>> result; if (!root) return result; std::queue<TreeNode*> q; q.push(root); while (!q.empty()) { int levelSize = q.size(); std::vector<int> level; for (int i = 0; i < levelSize; ++i) { TreeNode* node = q.front(); q.pop(); level.push_back(node->val); if (node->left) q.push(node->left); if (node->right) q.push(node->right); } result.push_back(level); } return result; } int main() { // 构造一棵简单的树: // 1 // / \ // 2 3 // / \ // 4 5 TreeNode* root = new TreeNode(1); root->left = new TreeNode(2); root->right = new TreeNode(3); root->left->left = new TreeNode(4); root->left->right = new TreeNode(5); auto levels = levelOrder(root); for (auto& level : levels) { for (int v : level) { std::cout << v << " "; } std::cout << std::endl; } return 0; }

输出是三行:12 34 5。这里有一个非常关键的小细节:int levelSize = q.size();必须在循环之前取值。如果你直接在for (int i = 0; i < q.size(); ++i)里写,q.size()会在pop()之后动态变化,你就会发现每层只处理了一部分节点。这是 BFS 写错的常见原因之一。

5.3 用 deque 维护滑动窗口最大值(单调队列)

deque最强的地方不是简单的头尾增删,而是可以在 O(n) 时间内维护一个“滑动窗口最大值”。思路是让deque中的元素保持单调递减,队头永远是当前窗口的最大值,新元素入队时,把队尾所有比它小的元素弹出。

#include <iostream> #include <vector> #include <deque> std::vector<int> maxSlidingWindow(const std::vector<int>& nums, int k) { std::vector<int> result; std::deque<int> dq; // 存下标,保证队列内值单调递减 for (int i = 0; i < (int)nums.size(); ++i) { // 1. 移除已经滑出窗口的元素 while (!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}; int k = 3; std::vector<int> result = maxSlidingWindow(nums, k); for (int v : result) { std::cout << v << " "; } std::cout << std::endl; // 3 3 5 5 6 7 return 0; }

这段代码里,deque存的是数组下标,而不是值,这样在判断“是否滑出窗口”时直接用下标比较就可以了。为什么队尾所有更小的元素可以安全弹出?因为它们虽然现在比新元素小,但在它们还没被滑出窗口之前,这个新元素一直在窗口里,而且比它们大,所以“最大值”永远轮不到它们。这就是单调队列的核心思想,也是deque在算法题里常见的用武之地。

6. 选型、性能与初学者最容易踩的坑

6.1 四种容器选型对照

把 C++ 里常用的顺序容器放到一起看,选择逻辑就清晰了。这里说的“四种”是vectordequelist和两个适配器stackqueue。适配器通常不参与选型竞争,因为它们解决的是“限定接口”问题,而不是“底层性能”问题。

我的个人选型策略是:

  • 默认用vector。连续内存、随机访问快、内存占用最紧凑。
  • 明确需要频繁在头部插入/删除时,换成deque
  • 需要频繁在任意位置插入/删除且不关心随机访问时,选list
  • 需要严格“后进先出”或“先进先出”语义时,用stack/queue,并默认接受deque作为底层。

这个策略不复杂,但能覆盖绝大多数日常需求。我见过不少项目为了省事,把所有场景都塞给vector,结果遇到大量头删头插时性能刹不住;也见过反过来的,明明只做随机访问,却为了一个头插把整个数据结构换成list,随机访问直接从 O(1) 变成 O(n)。选型不是背表格,而是看你的操作分布。

6.2 空容器访问元素、迭代器失效、clear 这些细节

这几个坑几乎每个初学者都会踩一次。

空容器访问元素。stack::top()queue::front()deque::operator[]在容器为空时都是未定义行为。注意是未定义行为,不是抛异常。换句话说,程序可能崩溃,可能返回垃圾值,也可能碰巧正常工作,但你不能依赖任何结果。防御性写法就是先判空:

if (!s.empty()) { std::cout << s.top() << std::endl; }

迭代器失效。vector的迭代器失效规则是:插入或删除导致重新分配时,所有迭代器失效;中间插入/删除时,插入点之后的所有迭代器失效。list的规则最宽松:除了指向被删除元素的迭代器外,其他迭代器都不受影响。deque的规则比较复杂:

  • 在头尾插入元素,所有迭代器可能失效,但元素引用(reference)是否失效因标准库实现而异;
  • 在中间插入元素,所有迭代器与引用都会失效;
  • 删除头尾元素时,只有被删除元素及相关迭代器失效。

所以如果你在deque中保存了某个元素的地址或引用,又在两端插删,务必重新确认引用是否仍然有效。这个问题在 debug 版可能不明显,release 版可能间歇性出 bug,是最难查的那类问题。

没有clear()stackqueue没有clear()成员函数,想清空它们可以逐个pop(),也可以用交换空容器的技巧:

std::stack<int> s; // ... 压入一堆元素 ... std::stack<int>().swap(s); // 用空临时栈换掉 s

dequevector都有clear(),用起来更直接。

6.3 多线程环境下不能依赖 STL 容器本身

stackqueuedeque的标准库实现都不是线程安全的。这意味着如果你在多线程程序里共享一个queue,一个线程往里面push,另一个线程同时pop,数据竞争会导致未定义行为。这不是 STL 的 bug,是设计如此。

实际做法通常是加锁。比如用std::mutex包一层:

std::mutex mtx; std::queue<int> q; void producer() { std::lock_guard<std::mutex> lock(mtx); q.push(42); } bool consumer(int& value) { std::lock_guard<std::mutex> lock(mtx); if (q.empty()) return false; value = q.front(); q.pop(); return true; }

也可以继续封装成线程安全队列类。如果你追求无锁高性能,C++ 标准库没有现成方案,一般用第三方库实现。这里提这个是提醒你:刷 LeetCode 时单线程随手用queue没问题,真实多线程项目中别想当然。

这些容器的特性差异,光靠背表格是不够的。我的建议是自己写几段小程序,实际测一下:把一百万个数push_frontdequevector里对比时间;用list做同样的事;再看deque的迭代器大小和vector差多少。做完这几个实验,你才算真正理解了它们的设计取舍。这也是我当年入门 STL 时最受益的学习方式。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询