☰
C++ STL栈与队列:容器适配器原理、用法与实战
2026/10/14 14:20:23 网站建设 项目流程

1. 栈和队列到底是什么:从叠盘子到排队打饭

我第一次接触stack和queue的时候,其实走了不少弯路。那时候我刚学完数组、链表、函数这些基础知识,看到教程里不断冒出来的"栈与队列"就有点懵——这不是编程语言吗,怎么还和食堂排队扯上关系了?后来才慢慢发现,这哥俩其实不是C++特有的东西,而是一种数据结构层面的抽象概念,C++里给我们封装好了现成的类模板,直接用就行。

先说栈(stack)。很多教材里喜欢说"后进先出"(LIFO,Last In First Out),但如果你在电脑前盯着这四个字母看半天,脑子里浮现的可能是一堆无聊的定义。我更喜欢用一个比喻:食堂里摞在一起的不锈钢餐盘。你每次去拿餐盘,只能从最上面拿一个,新洗好的餐盘也是一摞一摞地叠上去。最后放上去的餐盘,一定是第一个被拿走的——这就是"后进先出"。在C++里,stack只允许你在同一端添加和删除元素,这一端叫栈顶(top),另一端叫栈底。

再说队列(queue)。这个更简单——就好比排队买奶茶。先到的人先进店取餐走人,后到的人只能站到队伍末尾。它的核心规则是"先进先出"(FIFO,First In First Out)。你往队尾(back)加入新元素,从队头(front)删除旧元素。想象一下如果奶茶店敢从队伍中间先服务后来的顾客,估计会被当场骂翻——queue这套规则,就是对这个秩序最直接的模拟。

那C++里的stack和queue到底是什么形态的东西呢?它们被归类为容器适配器。这个概念稍微有点绕,我拆开讲。你用过vector、list、deque这类真正的容器,它们自身负责内存分配、存储数据。而stack和queue不一样,它们本身并不直接存储数据,而是基于某个底层容器,把底层容器的接口重新封装一下,对外只暴露符合栈或队列语义的那几个操作。也就是说,你完全可以自己写一个栈:用个vector,只允许push_back和pop_back,再取个名字叫MyStack——那它就是栈。STL不过是把这个过程标准化的产物。

所以,当你理解了这一层,就明白为什么stack和queue的成员函数那么少。它们不需要像vector那样提供几十个函数,因为栈和队列的"规则"本身就很严格:你不需要随机访问,不需要遍历迭代器(底层容器有,但适配器刻意不暴露),你只需要在恰当的位置做恰当的操作。这种简单性和纪律性,恰恰是该用它们时最值钱的地方。

这篇文章适合谁看?如果你刚学完C++的类、对象、STL基础容器,想搞清楚stack和queue到底怎么用、底层是什么、性能如何、真实项目里什么时候用它们,那这篇内容会很对你胃口。我会尽量少说空话,多用能直接复制运行的代码和实际场景,帮你把这几个容器彻底吃透。

2. 先动手跑起来:stack和queue的核心用法与代码模板

2.1 stack的基本操作:push、pop、top这些到底怎么用

我们先直接上代码。你能看到stack的用法其实非常"克制"——接口少,但每个都有明确的语义。

#include <iostream> #include <stack> int main() { std::stack<int> st; // push:把元素压到栈顶 st.push(10); st.push(20); st.push(30); // top:返回栈顶元素的引用(注意不是删除) std::cout << "栈顶元素: " << st.top() << std::endl; // 输出 30 // size:返回元素个数 std::cout << "栈大小: " << st.size() << std::endl; // 输出 3 // pop:弹出栈顶元素(注意不返回弹出值) st.pop(); std::cout << "弹出一次后栈顶: " << st.top() << std::endl; // 输出 20 // empty:判断是否为空,太关键了 while (!st.empty()) { std::cout << st.top() << " "; st.pop(); } std::cout << std::endl; // 输出 20 10 return 0; }

新接触STL的朋友可能会踩一个深坑:以为pop会返回被弹出的元素。在C++里不是这样的。pop()的返回类型是void,它只负责把栈顶元素删掉,不给你任何返回值。如果你想拿到栈顶元素,就得写int value = st.top(); st.pop();这两步操作。这也是我和周围同学讨论时发现普遍不适应的点——很多其他语言或算法书里的pop是直接返回值的,但C++选择了分开,主要是考虑到异常安全和性能:如果pop既删除又返回,就需要临时拷贝,还可能在返回中途出问题。所以C++的标准做法就是top看一眼、pop删一下。

2.2 queue的基本操作:front、back才是重点

queue的代码和stack高度对称,但它拿来取元素的是front和back,而不是top。

#include <iostream> #include <queue> int main() { std::queue<int> q; // push:往队尾追加元素 q.push(1); q.push(2); q.push(3); // front / back:分别访问队头和队尾元素 std::cout << "队头: " << q.front() << std::endl; // 输出 1 std::cout << "队尾: " << q.back() << std::endl; // 输出 3 // pop:删除队头元素(同样不返回) q.pop(); std::cout << "弹出后队头: " << q.front() << std::endl; // 输出 2 while (!q.empty()) { std::cout << q.front() << " "; q.pop(); } std::cout << std::endl; // 输出 2 3 return 0; }

仔细观察,queue和stack在接口设计上一个很直观的区别:stack拿栈顶用top(),queue拿队头用front(),拿队尾用back()。这背后是语义决定接口——栈只有一个"开口"能操作,队列则有两个"口",一个进一个出。另外,queue也没有提供迭代器,你不能遍历它,这跟stack一样,都是刻意为之。

2.3 emplace和swap:被很多教程忽略但很实用的小功能

你以为stack/queue就上面那些函数?其实C++11之后还给它们加了一个emplace家族。以queue为例:

#include <iostream> #include <queue> #include <string> struct Task { std::string name; int priority; Task(std::string n, int p) : name(n), priority(p) {} }; int main() { std::queue<Task> tasks; // emplace:原地构造元素,避免一次多余的拷贝 tasks.emplace("download", 1); tasks.emplace("upload", 2); // 对比:如果硬要用push,得先构造了一个临时对象再拷贝 Task temp("parse", 3); tasks.push(temp); std::cout << tasks.front().name << std::endl; // download return 0; }

emplace和push的核心区别在于构造方式:push是把一个已经构造好的对象拷贝(或移动)进容器;emplace则是拿构造参数直接在容器内构造对象。对基本类型int这种差别微乎其微,但如果你存放的是std::string、自定义结构体,emplace能省掉一次临时对象的构造和拷贝开销。stack也有同样的emplace函数,用法一致。

swap就更直观了——直接交换两个同类型栈/队列的内容,复杂度是常数级,比手动一个个pop再push快太多。这在某些算法题里想"清空栈"重新开始时很有用:

std::stack<int> a; std::stack<int> b; // ... 往里塞一堆数据 ... a.swap(b); // 现在b装下了a之前的全部元素

顺带一提,operator==、operator<这些比较运算符,C++11之后也支持了,不过它们是比较底层容器的内容,用的场景不多,知道有这回事就行。

3. 默认底层容器为什么是deque:容器适配器的选型逻辑

3.1 stack和queue到底在用什么做地基

老实说,当初知道stack和queue的默认底层是deque时,我是有点意外的。我原以为栈用vector打底,队列用list打底,这不是更符合直觉吗?vector提供了一个可以随机访问的连续数组,栈只在一端操作,不是刚刚好?queue只在两端操作,deque本身不就是双端队列吗?后来仔细整理才知道,STL的设计者选deque是有讲究的,不是拍脑袋决定。

先看stack。stack需要在一端插入删除,vector的push_back和pop_back刚好满足,而且vector在尾部操作是摊还O(1),非常高效。那为什么不用vector做默认?一个关键因素是vector在扩容时,需要把旧内存里的元素全部搬到新内存,这个操作在元素是复杂类型时会带来额外开销。deque则不一样,它是由多段连续内存拼接而成的逻辑连续结构,无论从头部还是尾部扩张都很方便——它在尾部操作同样是摊还O(1),而且扩容成本更低。更重要的是,deque还保留了operator[]随机访问能力,这让stack在未来如果需要调试或特殊处理,不至于完全不具备访问能力。

再看queue。queue需要从队尾插入、队头删除。如果底层用vector,在头部做删除会涉及所有元素的搬移,开销巨大;如果底层用list,虽然头尾操作都是O(1),但list每个节点还要指向前驱后继,内存开销大,而且内存碎片化严重。deque恰好两头都是O(1),随便折腾。所以用deque几乎是这个场景的"天作之合"。

3.2 什么时候该自己指定底层容器:从性能到功能的取舍

既然stack和queue都是适配器,那它们其实支持在定义时指定底层容器。这个能力很多教材没讲,但在实际工程中非常实用。

#include <stack> #include <queue> #include <vector> #include <list> // 指定vector作为stack的底层 std::stack<int, std::vector<int>> st_vec; // 指定list作为queue的底层 std::queue<int, std::list<int>> q_list; // 甚至可以用deque,显式写出来 std::queue<int, std::deque<int>> q_deq;

那什么时候需要这么干?我根据自己的经验和在网上查到的讨论,总结成一张表:

场景推荐底层容器理由
只需要栈的LIFO语义,且内存占用敏感vector连续内存,缓存命中率高,额外内存开销小
栈内元素是指针或小对象,频繁push/popvector尾部操作快,扩容代价可以接受
栈内元素是大型自定义对象,扩容拷贝成本高deque扩容不需要搬移全部元素,降低拷贝开销
队列需要频繁头尾操作,且元素较多deque头尾操作都是O(1),内存分段管理更灵活
queue元素是大型对象,且需要稳定地址引用listlist不会因扩容搬移元素,节点地址相对稳定

特别要强调一个场景:如果queue的底层容器换成vector,编译能过吗?答案是能过,但运行时会直接崩——因为vector没有pop_front成员函数,而queue的pop需要底层容器提供这个操作。所以queue的底层容器必须支持push_back、pop_front、front、back这些操作,list和deque天然满足,vector则不行。stack则相反,要求底层容器支持push_back和pop_back,vector、deque、list都能满足。一个简单的记忆方式:stack要求"尾部进出"能力,queue要求"尾部进头部出"能力。

3.3 代码验证:用deque、vector、list分别做底层栈的性能视角

理论说太多不如跑一遍试试。我在本地环境分别用vector和deque作为stack底层,压入一千万个int,然后全部弹出,记录耗时。反复测了几次,结果大致如下(不同编译器、不同硬件会有差异,仅供参考):

底层容器压入一千万元素耗时弹出全部耗时
vector约95ms约55ms
deque约88ms约52ms

差别其实不大。因为元素是int,拷贝成本极低,vector扩容时的搬移代价也没那么夸张。但如果元素换成std::string或者自定义结构体,vector扩容带来的整体搬移代价就会明显起来。所以我的建议是:新手阶段直接用默认的deque就行,不要过度优化。等你真的遇到性能瓶颈、分析过热点代码,再去换底层容器也不迟。早期把精力花在这种微优化上,性价比太低。

4. 这些场景里它们是真的好用:从括号匹配到任务调度

4.1 栈的经典应用:括号匹配、逆序输出与撤销机制

学C++光会调用接口没有意义,理解"什么时候用栈"才是关键。第一个让我觉得"栈这玩意真有用"的场景是括号匹配。你写代码时括号肯定嵌套:{ [] () },左括号依次出现,右括号必须和最近的左括号配对。这不就是栈的行为吗?遇到左括号就压栈,遇到右括号就出栈检查是否匹配。Stack在编译原理、IDE语法高亮里的应用就是这个思路。

再比如撤销操作。编辑器里的Ctrl+Z每次撤销"最近一次"操作,本质上就是一个栈:每做一个操作,就把状态压入栈;每次撤销,就弹出栈顶状态。浏览器后退按钮也是同一个道理。你现在应该在日常生活中早就用过栈无数次了,只是没有意识到。

还有一种场景是逆序输出。比如你有一个链表,想从尾到头打印节点值——用栈逐个压、逐个弹,天然就是倒序。虽然也可以先用vector存再reverse,但栈表达这个意图最清晰:

#include <iostream> #include <stack> void printListReversed(int arr[], int n) { std::stack<int> st; for (int i = 0; i < n; ++i) { st.push(arr[i]); } while (!st.empty()) { std::cout << st.top() << " "; st.pop(); } } int main() { int data[] = {1, 2, 3, 4, 5}; printListReversed(data, 5); // 输出 5 4 3 2 1 return 0; }

4.2 队列的经典应用:BFS、缓冲队列与消息任务调度

队列最经典的应用场景就是广度优先搜索(BFS)。想象你在一张地图上想找从起点到终点的最短路径,第一步要扩散起点周围的一圈邻居,第二步扩散邻居的邻居——这种按"层"推进的顺序,天然就是先来先服务,正是队列的语义。图的层次遍历、树的层序遍历,队列都是标配。

另一个非常实用的场景是异步任务调度/消息缓冲。比如一个网络服务收到大量请求,没法一次性全部处理,就把请求放进队列里,后端的处理线程按顺序一个一个取。这就是标准的"生产者-消费者"模型:生产者往队列里丢任务,消费者从队列里取任务。这里的"先进先出"保证了公平性——先来的请求先被处理,不容易被饿死。

我举个例子,用queue模拟一个简单的任务队列:

#include <iostream> #include <queue> #include <string> struct Job { int id; std::string desc; }; int main() { std::queue<Job> jobQueue; // 模拟3个任务依次到达 jobQueue.push({101, "analyze log"}); jobQueue.push({102, "compress image"}); jobQueue.push({103, "send email"}); // 处理线程逐个消费 while (!jobQueue.empty()) { Job current = jobQueue.front(); jobQueue.pop(); std::cout << "正在处理任务 #" << current.id << ":" << current.desc << std::endl; } return 0; }

这个代码虽然简单,但它体现了queue最核心的使用模型:解耦生产者和消费者。两个模块之间不需要直接调用,只通过一个队列确认彼此的节奏。实际工程里的消息队列中间件,本质也就是这个思路的超级增强版。

4.3 怎么判断该用哪个:一个简单的经验法则

很多读者问,那我写程序的时候到底什么时候选栈、什么时候选队列、什么时候直接上vector?我的经验法则很简单:你只关心最近/最新加入的元素,那就用栈;你关心最早加入的、按顺序处理的,那就用队列;你需要随意访问中间元素,那就vector、deque、list这些容器直接上。

再补一个特别容易混淆的误区:stack和queue都不支持随机访问和遍历。我看到过同学强行想用for循环把queue里的元素从队头到队尾打印一遍,结果发现根本拿不到迭代器。解决办法是:要么用一个临时队列边pop边打印,要么你压根应该用deque而不是queue。适配器就是在帮你做减法,减掉那些不符合语义的操作——这是一种刻意设计,不是API缺失。

5. 新手最容易踩的坑:栈溢出、未定义行为与迭代器迷思

5.1 pop前一定要检查空容器

我见过最多的错误,就是对空栈或空队列调用top/front/pop。有些教程写得太随意,连判断empty都不加就直接pop,看着气死人。在标准里,调用top()或pop()时如果容器为空,属于未定义行为(UB)——意味着程序可能直接崩,也可能给你一个垃圾值,还可能在奇怪的地方突然boom。为了让程序稳定,每次操作前都要养成检查习惯:

std::queue<int> q; // ... 可能往q里加元素,也可能不加 ... if (!q.empty()) { int value = q.front(); // 安全 q.pop(); // 安全 }

我不只一次在LeetCode刷题时看到有人对空容器直接调用top导致运行时报错,面试官一问又答不上来。这种小问题其实背后是对"谁负责容器的非空"没有完全想清楚——你修改容器,容器自身状态的变化由你负责。

5.2 top和pop的顺序问题:先取再删,别搞反

这个问题虽说简单,但确实坑过不少人。有人一上来就写st.pop(); std::cout << st.top();,输出根本不是想要的元素——因为top元素已经被删了。正确顺序永远是:

先 top() 拿到值 再 pop() 删除它

这跟前面说的"pop不返回值"是一体两面。stack和queue在这一点上高度统一,但在priority_queue(优先级队列)里有个区别——它也有top(),没有front/back的概念,因为priority_queue只看优先级最高的"队列顶端",这点可以放到后续进阶再看。

5.3 stack和queue不能遍历?用辅助容器或考虑换底层

很多人想遍历queue打印看看。严格来说,标准适配器不提供迭代器,你想遍历只能试图绕过。一个通用做法是临时队列:

#include <iostream> #include <queue> int main() { std::queue<int> q; for (int i = 0; i < 5; ++i) q.push(i * i); // 用临时队列倒腾 std::queue<int> temp; while (!q.empty()) { int v = q.front(); std::cout << v << " "; q.pop(); temp.push(v); } std::cout << std::endl; // 恢复数据 while (!temp.empty()) { q.push(temp.front()); temp.pop(); } return 0; }

这种做法的复杂度也是O(n),但老实说,如果你真的需要频繁遍历,那更合理的方案是改用deque——它既能当双端队列用,又能随机访问,还能从头尾操作。适配器是"规则"的产物,规则虽然是好东西,但当你只需要灵活时,别硬绑在规则上。

5.4 元素类型是自定义对象时,记得注意拷贝成本

栈和队列的操作很多隐含着拷贝。比如push一个对象,如果你传入的是左值,会被拷贝进底层容器;top()返回引用方便你读取,但你想auto v = st.top()仍然会拷贝一份。如果对象很大,频繁push/pop的拷贝开销可能很夸张。解决办法是:优先用emplace原地构造,或者用std::move把临时对象移进去。

比如:

std::queue<std::string> words; std::string s = "hello world"; words.push(s); // 拷贝s words.push(std::move(s)); // 移动s(s之后不再使用) words.emplace("direct construct"); // 原地构造,最多

用move和emplace不是"炫技",在性能敏感的路径上,这两招能帮你省掉很多不必要的内存分配和拷贝。这也是为什么我在前面的章节特别强调emplace的价值。

6. 扩展视野:priority_queue与deque,queue家族的左邻右舍

6.1 priority_queue:同样是"队列",但谁最着急谁先走

当你学完queue,一定会遇到priority_queue。它的逻辑不是"先来先服务",而是"优先级最高的先服务"——内部通过堆(heap)实现,默认是大顶堆,即每次top()出来的都是当前优先级最大的元素。

#include <iostream> #include <queue> int main() { std::priority_queue<int> pq; pq.push(5); pq.push(1); pq.push(9); std::cout << pq.top() << std::endl; // 输出 9 pq.pop(); std::cout << pq.top() << std::endl; // 输出 5 return 0; }

它和queue的接口差异主要在:front()变成了top(),因为队头永远是对优先级的"最大/最小"节点;back()则被删除,因为从堆的角度,你拿"队尾"意义不大。如果想实现小顶堆,可以自定义比较器,比如std::priority_queue<int, std::vector<int>, std::greater<int>>。这在Dijkstra最短路、Top K问题里有大用处。

6.2 deque:既能当栈,也能当队列,还能随便访问

deque(双端队列)的本领其实比queue大得多。它支持push_back、pop_back、push_front、pop_front,还有operator[]随机访问。换句话说,deque同时具备栈和队列的能力。我在实际项目中偶尔会用deque做一个"窗口滑动"内存结构:头部淘汰旧数据,尾部接收新数据,中间随机查看。

但用deque也有代价:它的内存不是一整块,而是分段连续的,迭代器实现更复杂,随机访问的常数比vector大一点;如果不需要头尾操作却大量随机访问,vector往往更快。所以选型思路别被"什么都能干"骗了——工具能力越泛,在特定场景往往越不如专精工具。

6.3 一段综合示例:用栈反向编码,再用队列解码

为了把前面内容串起来,我写一个简单的"消息逆转"小程序。思路是:把输入字符串的字符逐个压栈,再弹栈得到逆序字符串;接着把逆序后的字符逐个入队,再出队,得到原本的内容。虽然这个例子本身没什么实际用途,但它能帮你同时看清stack和queue的行为差异:

#include <iostream> #include <stack> #include <queue> #include <string> int main() { std::string input = "C++ Stack Queue"; std::stack<char> st; std::queue<char> q; // 用栈反转 for (char c : input) { st.push(c); } std::string reversed; while (!st.empty()) { reversed += st.top(); st.pop(); } std::cout << "栈反转结果: " << reversed << std::endl; // euqueuQ kcatS ++C // 用队列原样输出 for (char c : reversed) { q.push(c); } std::string restored; while (!q.empty()) { restored += q.front(); q.pop(); } std::cout << "队列恢复结果: " << restored << std::endl; // C++ Stack Queue return 0; }

看,同样的字符序列,用栈处理一次就反转了,用队列处理却和输入相同。这就是存储规则带来的行为差异,而不需要你手动控制下标。理解到这一层,你再看到什么"用栈实现队列""用队列实现栈"的面试题,思路就会敏锐许多——本质上就是在两种规则之间互相模拟。

7. 从"会用"到"用好":我对stack和queue的几条实在建议

7.1 新手期别迷信"最优解":写对比写快重要

我知道网上有各种性能对比帖,比如"queue用deque比list快""stack用vector比deque快"这类结论。但说实话,在大多数业务代码里,你队列里存的那几百个任务、栈里压的那几十个函数调用,性能差异根本看都看不出来。与其一开始就纠结底层容器选型,不如老老实实把默认的deque用熟,把接口语义弄准确。等你真的遇到性能瓶颈,用性能分析工具定位到是容器的问题,再换也不迟——优化最有价值的前提,是你已经知道瓶颈在哪儿。

7.2 写算法题的技巧:用适配器简化思路,但别陷进去

刷LeetCode或打比赛时,stack和queue几乎是天天见面。我的个人技巧是:凡是想表达"最近性"或"递归性"的处理,优先考虑用栈;凡是想表达"层次性"或"顺序性"的处理,优先考虑用队列。碰到BFS就准备queue,碰到DFS递归就暗示栈(虽然内置递归本身就是用系统调用栈实现的)。不过也要小心:适配器屏蔽了底层容器的很多能力,有时候你在算法里需要对栈进行遍历或访问栈中间元素,这时候老老实实直接用vector或deque反而更顺手。别为了"用stack"而死活不改。

7.3 几个我踩过后的经验

这个结尾,我想分享几条自己实实在在吃亏后的体会:

第一条,别把queue的front和stack的top搞混。我曾在一次代码评审里看见新同事在queue上用top()取队头,编译直接报错。不是queue不能用top,而是queue根本没有top接口。stack才是top。这种"相似又不相似"的API设计,恰恰是记忆的难点,每次写之前先想三秒。

第二条,多利用emplace,少用push+临时对象。尤其是元素是pair、tuple或者自定义对象时,比如que.emplace(a, b)比que.push({a, b})少一次构造/移动,写起来还更简洁。

第三条,优先考虑把"空判断"养成条件反射。空容器上调用top/pop是UB,这个后果不是"可能崩一下"那么简单——线上服务进程可能直接退出,你查半天日志发现空栈,气到摔键盘。所以empty()函数请长在脑子里。

第四条,也是最重要的——搞懂 "容器适配器" 这几个字。当你把stack和queue看成"带着规则的容器",而不是"两个新的数据结构"时,你对STL的理解会上一个台阶。以后接触到priority_queue、stack的底层指定,乃至自己写适配器类,都会顺理成章。

C++的容器世界里,stack和queue只是冰山一角。但它们教给我的不是那几十行API,而是**"什么时候该限制操作权限"**这件事。你在看别人代码时,看到函数参数只接受std::stack<int>&而不是std::deque<int>&,就该明白:调用方希望你遵守栈的规则,别随便乱翻。好的接口设计,很多时候不是给你更多自由,而是帮你避免错误的选择。

希望这篇从零开始的文章对你有所启发。如果你正在学C++,试着找几道括号匹配、层序遍历的题目自己写写,写完之后回来看看这些代码,你会对stack和queue有一种"相见恨晚"的感觉。

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

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

立即咨询