栈和队列这俩数据结构,在 C++ 里实在太常用了,但很多人学完就忘,或者只在考试和面试里见过它们。实际上,函数调用栈、undo/redo、浏览器的前进后退、打印任务排队、消息队列、线程池任务调度,背后全是这两个结构在撑腰。我写这篇文章,想把我在实际项目里对栈和队列的理解、踩过的坑、以及一些“原来还能这么用”的体会一次说清楚,也帮准备面试或者正在啃数据结构的朋友把这块彻底搞透。
1. 栈和队列的“本来面目”:两种最朴素的万物秩序
1.1 栈:后进先出的“一摞盘子”
栈(Stack)的核心规则就四个字:后进先出(LIFO, Last In First Out)。你往一个桶里放盘子,最先放进去的在最底下,最后放进去的反而最先被拿出来。
在 C++ 里,一个栈的基本操作就那几样:
- push:把元素压入栈顶
- pop:把栈顶元素弹出(注意:C++ 的 pop 不返回被弹出的元素)
- top:访问栈顶元素(但不弹出)
- empty:栈是否为空
- size:栈中元素个数
我第一次写代码的时候,最不习惯的就是 C++std::stack的pop()居然不返回元素。想拿到栈顶元素再弹出去,必须两行代码:
#include <stack> std::stack<int> st; st.push(42); int value = st.top(); // 先取 st.pop(); // 再弹这个设计初看很别扭,但只要你知道pop()在 C++ 标准里被声明为void返回值,就明白这是出于强异常安全的考虑——如果pop()直接返回元素,拷贝/移动构造返回值时一旦抛异常,元素就丢了,栈的状态也无法回滚。分两步走,每一步的失败都能清晰暴露出来,调用方可以自己做补救。这个细节能理解的话,你对 C++ 的“异常安全”概念会有一层很实在的体感,而不只是背概念。
1.2 队列:先进先出的“奶茶店排队”
队列(Queue)的核心规则也四个字:先进先出(FIFO, First In First Out)。你去奶茶店点单,先来的人先做,后来的人排队等着。先到先得,公平得很。
C++ 里std::queue的基本操作同样朴素:
- push:从队尾入队
- pop:从队头出队(同样不返回元素)
- front:访问队头元素
- back:访问队尾元素
- empty / size:判空和长度
#include <queue> std::queue<int> q; q.push(1); q.push(2); int head = q.front(); // 1 q.pop(); // 弹出队头 int tail = q.back(); // 2这里的front()和back()同时可用,是因为队列天然就需要两头操作:消费端看队头,生产端看队尾。和栈只能动一端完全不同。
1.3 “C++ 里那么容易混淆的另两对词”:别把栈和堆栈混为一谈
说到栈,很多人立刻想到内存里的“堆栈”概念——局部变量存在栈上,动态分配的内存存在堆上。这个“栈”和数据结构里的“栈”有一个潜在联系:函数调用的执行环境(栈帧)本身就是用“栈”这种数据结构来管理的,所以内存栈区才叫“栈”。本质上,编译器和运行时在背后用一个调用栈(call stack)来管理函数的嵌套调用。如果你写过汇编或者看过 backtrace,会发现一层一层往回追溯调用链时,确实是在弹栈。
而堆(heap)在数据结构里其实是“完全二叉树”那种东西(也叫堆,比如优先队列底层实现就是堆);内存里的“堆”则是指动态分配内存区域。两个“堆”也不是一个概念。这个知识死角特别多,很多人项目写了不少还是一锅粥。
我的建议是:学数据结构的时候,一定把“逻辑结构(栈/队列/链表/树)”和“内存布局(栈区/堆区/代码段/数据段)”分开理解,否则后面看backtrace栈回溯、看stack overflow错误、看调用约定,全会迷路。
2. C++ 栈与队列的容器底层选型:为什么默认是 deque
2.1std::stack和std::queue的本质是“容器适配器”
这是 C++ 里一个特别容易被人忽略的事实:std::stack和std::queue并不是独立的数据结构实现,它们是在某个底层容器之上,把接口“阉割”成只有栈/队列语义的容器适配器(container adapter)。
默认情况下,它们都建立在std::deque(双端队列)之上。deque是一个两边都能高效插入和删除的序列容器,完美适配栈和队列的双面需求。
你可以通过第二个模板参数换底层容器:
// 用 vector 做栈 std::stack<int, std::vector<int>> vecStack; // 用 list 做队列 std::queue<int, std::list<int>> listQueue;为什么默认是 deque 而不是 vector?
因为vector的头部插入/删除是 O(n) 的,必须挪动所有元素;而栈可能在持久化时被扩展为双端操作需求(比如某些需要在两头操作的场景),deque的两头 O(1) 操作是真正的 O(1),不涉及大量元素搬运。deque在中间插入是 O(n),但栈和队列根本不需要中间操作。
为什么不用 list?
如果只是为了栈/队列,list也可以做,但list的每个节点需要额外的指针开销(至少两个指针),而且内存不连续,cache 友好性差。deque本质上是一段段连续内存块拼接成“看起来连续”的容器,遍历和访问的效率比list高得多。
我自己写长期运行的服务端程序时,如果知道元素数量极其有限、以固定小规模为主,且需要保存底层数据快照,我会用vector做栈底——因为vector的reserve可以一次性分配好内存,减少多次分配的开销,而且对局部性极其友好。但绝大多数场景我直接用默认的deque,省心又不容易出错。
2.2 如何选择 vector、deque 还是 list
这里我直接给一个非常实用的判断标准,是我在真实项目里总结出来的:
| 底层容器 | 栈/队列操作效率 | 内存连续性 | 额外开销 | 适合场景 |
|---|---|---|---|---|
deque | 两端 O(1) | 分段连续 | 较小 | 默认首选 |
vector | 尾部 O(1),头部 O(n) | 完全连续 | 极小 | 只需栈功能,且要reserve预分配 |
list | 两端 O(1) | 不连续 | 每节点至少两个指针 | 元素频繁增删且规模不可预测 |
唯一一次我用到list做队列,是在一个**“撤销栈”**里——每次操作都动态分配一个命令对象指针,而且数量基本不会超过几十个,此时节点分配开销可以忽略,内存碎片也可控。用list的好处是不用担心容量扩充带来的拷贝(虽然指针拷贝也便宜),代码写起来很自然。
2.3 双端队列 deque:栈和队列的“合体超集”
既然std::queue和std::stack的默认底层都是deque,那可以直接用deque当一个既能栈又能队列的结构来用。这在很多竞赛和实战中特别关键:一个任务队列可能需要双向操作,比如窗口滑动、最近的请求淘汰。
#include <deque> std::deque<int> dq; dq.push_front(1); dq.push_back(2); int a = dq.front(); // 1 int b = dq.back(); // 2 dq.pop_front(); dq.pop_back();这个deque其实才是“隐藏的 MVP”。你知道它是什么,写起代码来会多出很多灵感和选择空间。
3. 栈的实战应用:调用栈、表达式求值与单调栈
3.1 函数调用栈:每次递归都在“压栈”
你每次调用一个函数,编译器生成的代码就会在内存栈区压入一个栈帧(stack frame)。栈帧里保存的是:函数的返回地址、传入参数、局部变量、保存的寄存器状态等。函数返回时,栈帧弹出,控制权回到调用者。
backtrace栈回溯工具,其实就是沿着调用栈的栈帧链,从当前函数一层层往回走,把每个函数名和地址打印出来。在 C++ 里写崩溃日志时,用backtrace()抓到的调用栈可以用来定位问题根源:
#include <execinfo.h> #include <cxxabi.h> #include <cstdlib> #include <string> void print_backtrace() { void* buffer[64]; int frames = backtrace(buffer, 64); char** symbols = backtrace_symbols(buffer, frames); for (int i = 0; i < frames; ++i) { std::string symbol(symbols[i]); // 处理一下符号名,已经足够定位崩溃点了 std::fprintf(stderr, "%s\n", symbol.c_str()); } std::free(symbols); }尤其是 arm 平台上的嵌入式开发,arm 调用栈回溯常常需要手动通过fp(帧指针)寄存器链式查找,因为部分编译器为了省寄存器会omit-frame-pointer。我在一个嵌入式音频项目中遇到过access violation崩溃,靠栈回溯定位到是一个回调函数里用了已经释放的this指针。所以,学会看调用栈,是 C++ 开发者的保命技能。
递归和栈溢出:递归深度过深时,栈帧不断压栈,最终把栈区空间耗尽,触发stack overflow。这不是数据结构上的栈“满”了,而是内存栈区容量不够。你可以用ulimit -s查看/调整栈大小,但更好的做法是:把递归改写为显式栈 + 迭代。
3.2 表达式求值:中缀转后缀的“教科书级栈应用”
编译器里做算术表达式求值,常用“调度场算法”(Shunting-yard)把中缀表达式1 + 2 * 3转成后缀表达式1 2 3 * +,然后用栈求值。
我写过一个简单的表达式计算器,核心理念就是两个栈:操作数栈和操作符栈。
#include <stack> #include <cctype> #include <string> int precedence(char op) { if (op == '+' || op == '-') return 1; if (op == '*' || op == '/') return 2; return 0; } int apply_op(int a, int b, char op) { switch (op) { case '+': return a + b; case '-': return a - b; case '*': return a * b; case '/': return a / b; // 注意:实际项目要处理除零 } return 0; } // 假设 tokens 已经切分好,包含数字和运算符 int evaluate(const std::vector<std::string>& tokens) { std::stack<int> operands; std::stack<char> ops; for (const auto& tok : tokens) { if (std::isdigit(tok[0])) { operands.push(std::stoi(tok)); } else if (tok[0] == '(') { ops.push('('); } else if (tok[0] == ')') { while (!ops.empty() && ops.top() != '(') { int b = operands.top(); operands.pop(); int a = operands.top(); operands.pop(); char op = ops.top(); ops.pop(); operands.push(apply_op(a, b, op)); } ops.pop(); // 弹出左括号 } else { while (!ops.empty() && precedence(ops.top()) >= precedence(tok[0])) { int b = operands.top(); operands.pop(); int a = operands.top(); operands.pop(); char op = ops.top(); ops.pop(); operands.push(apply_op(a, b, op)); } ops.push(tok[0]); } } while (!ops.empty()) { int b = operands.top(); operands.pop(); int a = operands.top(); operands.pop(); char op = ops.top(); ops.pop(); operands.push(apply_op(a, b, op)); } return operands.top(); }这个代码看着简单,但足够让你理解:运算符优先级通过“栈内比较”来自然的控制执行顺序。高优先级的操作符在栈顶先被弹出计算,低优先级的先压在底下稍后处理。
这里有个实战细节:做减法或除法时,弹出的顺序非常重要。b是栈顶(后出现的操作数),a是次顶(先出现的操作数),一定要注意运算顺序。我当年在这个地方翻过车,a - b和b - a的结果完全不同。
3.3 单调栈:一种“变态”但极其高效的栈用法
单调栈是栈的高级用法:保持栈内元素单调递增或递减。典型应用是“下一个更大元素”问题。
比如在一个数组里,要找每个元素右边第一个比它大的元素,暴力做法是 O(n²),但单调栈可以做到 O(n):
#include <vector> #include <stack> std::vector<int> nextGreater(std::vector<int>& nums) { int n = nums.size(); std::vector<int> result(n, -1); std::stack<int> st; // 存下标,保证下标对应元素单调递减 for (int i = 0; i < n; ++i) { while (!st.empty() && nums[st.top()] < nums[i]) { result[st.top()] = nums[i]; st.pop(); } st.push(i); } return result; }单调栈的直观理解:你维护一个“候选最大值”的栈,每当新元素能“打败”栈顶时,栈顶的答案就确定为当前新元素,然后弹出,继续比较。可以做“接雨水”“柱状图中最大矩形”等经典题。
实用场景:我在做一个“热点股票提醒”的后端服务时,就用单调栈快速给每只股票计算“未来N天内第一个涨停日”,避免了上百万次循环的双重遍历,上线后接口性能压力很小。单调栈看起来很绕,但想通“牺牲一部分元素换取更高的查询效率”后,你会对它爱不释手。
4. 队列的实战应用:从 BFS 到消息队列再到线程池
4.1 广度优先搜索(BFS):队列的“天然主场”
BFS 的本质是逐层扩散,先访问的元素先产生下一层的邻居,天然就是 FIFO 顺序。用队列实现 BFS 几乎是数据结构教材默认操作:
#include <queue> #include <vector> int bfs(std::vector<std::vector<int>>& graph, int start, int target) { int n = graph.size(); std::vector<bool> visited(n, false); std::queue<int> q; q.push(start); visited[start] = true; int steps = 0; while (!q.empty()) { int levelSize = q.size(); for (int i = 0; i < levelSize; ++i) { int node = q.front(); q.pop(); if (node == target) return steps; for (int neighbor : graph[node]) { if (!visited[neighbor]) { visited[neighbor] = true; q.push(neighbor); } } } ++steps; } return -1; }这段代码里有个我反复强调的“层序遍历”技巧:在 while 循环里先取q.size(),再只循环levelSize次,这样每一轮 while 都恰好处理一层,steps就可以准确表示层数。这个写法的好处是:做题也好、实际业务里算“最小步数”也好,都不用额外在队列里塞层号。
走过的坑:BFS 里visited标记的时机一定要在push的时候,而不是pop的时候。如果你把visited[neighbor] = true写在pop之后再检查,同一个节点可能会被多个邻居重复加入队列,导致队列爆炸和死循环。这是我调试图形界面 bug 时发现的,因为数据量小根本看不出来,等数据量一上去,内存直接飙升。
4.2 消息队列:跨线程跨进程的“数据邮局”
很多人一说队列,想到的第一件事就是消息队列——Kafka、RabbitMQ、RocketMQ。它们本质上就是一个“分布式版本的队列”:生产者把消息投递到队列,消费者从队列里取消息处理,实现了生产者和消费者的解耦。
我参与过国内某厂的消息中间件选型对比,也踩了不少坑,这里把骨干经验写出来:
| 消息队列 | 吞吐量 | 可靠性 | 延迟 | 适用场景 |
|---|---|---|---|---|
| Kafka | 极高(百万级/秒) | 高(基于磁盘追加写+副本) | 毫秒级 | 日志收集、大数据流处理、削峰填谷 |
| RabbitMQ | 中(万级/秒) | 高(Ack 机制完善) | 微秒~毫秒 | 企业应用内部异步解耦、RPC、复杂路由 |
| RocketMQ | 高(十万级/秒) | 极高(事务消息) | 毫秒级 | 电商订单、金融交易、事务一致性要求高的业务 |
选型对比的几个核心要点:
- 如果你要处理海量日志流,需要超高吞吐和“重放”能力,Kafka 是首选,但Kafka的运维复杂度偏高,磁盘和分区设计需要认真对待。
- 如果你的业务里有复杂的路由规则(比如按订单类型分发到不同处理队列),RabbitMQ 的 Exchange + Binding 机制非常灵活。
- 如果业务对消息事务有强一致要求,比如“扣库存”和“创建订单”必须同时发生或同时失败,RocketMQ 的事务消息设计更贴合。
避坑指南:消息队列最常见的坑是重复消费问题。绝大多数消息系统都是“至少一次”(at-least-once)语义,也就是说,消费者处理完消息后如果还没来得及 Ack,消息会被重投。所以,消费端业务必须做幂等处理:用唯一业务ID去重。我在一个订单系统里吃过亏,消息重投导致同一订单被重复发货。所以后来我在消费接口里都会先查一遍 Redis 或者数据库的唯一索引,再执行核心业务操作。
消息积压也是一个面试高频坑:消费者处理速度跟不上生产者速度时,队列消息数量剧增。遇到这种情况,不要盲目扩容消费者,先看消费者处理逻辑里有没有串行调用外部服务、有没有大事务,能不能拆成小任务并行处理。很多情况下瓶颈在消费者的同步等待,异步化改造后积压问题能缓解大半。
4.3 线程池的阻塞队列:生产/消费模型的“心脏”
线程池的本质是:一堆工作线程阻塞在一个队列上等待任务到来。这个队列的选择直接决定线程池的性能和语义。
在 C++ 里,无锁队列(基于原子操作)常常用于极高性能场景。比如你可以用std::atomic实现一个 SPSC(单生产者单消费者)环形队列:
#include <atomic> #include <vector> template <typename T, size_t Capacity> class RingBuffer { public: bool push(const T& value) { size_t currentTail = tail.load(std::memory_order_relaxed); size_t nextTail = (currentTail + 1) % Capacity; if (nextTail == head.load(std::memory_order_acquire)) { return false; // 满 } buffer[currentTail] = value; tail.store(nextTail, std::memory_order_release); return true; } bool pop(T& value) { size_t currentHead = head.load(std::memory_order_relaxed); if (currentHead == tail.load(std::memory_order_acquire)) { return false; // 空 } value = buffer[currentHead]; head.store((currentHead + 1) % Capacity, std::memory_order_release); return true; } private: std::vector<T> buffer{Capacity}; alignas(64) std::atomic<size_t> head{0}; alignas(64) std::atomic<size_t> tail{0}; };这段代码很简单,但体现了无锁队列的两个关键点:一是用环形缓冲区避免动态扩容;二是用内存序控制可见性。release保证 push 的数据对其他线程可见后才更新 tail,acquire保证读取到 tail 更新后,能看到在此之前写入的全部数据。
注意:这个 SPSC 实现只能单生产者单消费者。多生产者多消费者必须用 CAS(compare-and-swap)或者std::mutex保护,否则数据竞争会导致未定义行为。
线程池的阻塞队列选择上,如果任务本身带有优先级,可以用优先队列(std::priority_queue)作为队列底层。我在一个后台任务调度系统里,把紧急任务和普通任务混合进同一个线程池执行,就靠优先队列让紧急任务“插队”优先执行,效果非常直观。
5. 从栈和队列到优先队列:有一种“队列”可以插队
5.1 优先队列与堆的关系
std::priority_queue是 C++ 标准库里的容器适配器,它和普通队列最大的区别是:出队时总是弹出当前优先级最高的元素,而不是最早入队的元素。它的底层结构是二叉堆,用一棵完全二叉树来维护“最大值在树根”的性质。
#include <queue> #include <vector> std::priority_queue<int> pq; // 默认大顶堆 pq.push(10); pq.push(20); pq.push(5); int top = pq.top(); // 20 pq.pop(); top = pq.top(); // 10如果你想用做小顶堆(最小值优先),可以:
std::priority_queue<int, std::vector<int>, std::greater<int>> minPq;面试题里的“TopK 问题”最常用小顶堆:维护一个大小为 K 的小顶堆,遍历元素,每次都和堆顶(当前最小)比较,比堆顶大就把堆顶弹出,把当前元素压进去,最后堆里就是最大的 K 个元素。这种做法的时间复杂度是 O(n log K),空间复杂度 O(K),比排序后取前 K 个(O(n log n))在大规模数据上高效得多。
5.2 单调队列与滑动窗口
单调队列是队列的高级形态:它内部既保持 FIFO 的出队顺序,又通过“淘汰尾部无效元素”来保持单调性。典型应用是滑动窗口最大值问题。
比如在一个长度为 n 的数组中,用一个大小为 k 的窗口从左向右滑动,求每个窗口内的最大值。暴力法是 O(n*k),单调队列可以做到 O(n):
#include <deque> #include <vector> std::vector<int> maxSlidingWindow(std::vector<int>& nums, int k) { std::deque<int> dq; // 存下标,保持单调递减 std::vector<int> result; for (int i = 0; i < nums.size(); ++i) { // 淘汰不在窗口内的队头 if (!dq.empty() && dq.front() <= i - k) { dq.pop_front(); } // 从尾部淘汰所有小于当前元素的下标 while (!dq.empty() && nums[dq.back()] <= nums[i]) { dq.pop_back(); } dq.push_back(i); if (i >= k - 1) { result.push_back(nums[dq.front()]); } } return result; }这里dq.front()始终是当前窗口的最大值下标。新元素进来时,把所有比它小的“老元素”全部从队尾淘汰,因为它们在新元素的“生命周期”内再也不可能成为最大值了(新元素更晚过期,值又更大)。这个“淘汰劣质候选者”的思路是单调队列的精髓,也是在设计高性能实时数据处理系统时,我经常用到的一种思维。
6. 实操排查与避坑总结:这些坑我替你踩过了
6.1 常见问题速查表
| 现象 | 可能原因 | 解决方案 |
|---|---|---|
stack.pop()报错 butstack.top()正常 | 没有判空就 pop | 每次 pop 前检查!st.empty() |
| 队列莫名变长,内存暴涨 | BFS 里visited标记位置不对,节点重复入队 | 在push时立刻标记已访问 |
deque遍历很慢 | 误在中间频繁 insert | 改用 list 或重设计数据结构 |
| 栈溢出(段错误) | 递归太深或局部变量过多 | 改用显式栈/迭代;用ulimit -s临时调大 |
priority_queue排序结果不对 | 自定义结构的比较器写反 | 仔细检查operator<语义(priority_queue 是“最大的优先”) |
线程间用std::queue互相读写 | 没有加锁 | 用std::mutex包裹,或改用无锁队列 |
6.2 自定义类型的栈和队列,要留神拷贝开销
如果你往std::stack或std::queue里放的是重量级对象(比如std::string、自定义结构体),push的时候会发生拷贝。解决办法:
- 使用
std::move移动语义:
std::queue<std::string> q; std::string data = "hello"; q.push(std::move(data)); // 避免拷贝- 使用
emplace直接构造:
std::queue<std::pair<int, std::string>> q; q.emplace(1, "one"); // 直接在容器内构造,零拷贝这条经验在性能敏感的服务里非常关键。我做过一个实时图像处理管道,一秒钟要处理几十帧图像,如果队列里塞的是图像对象的拷贝,内存带宽直接被吃满。换成emplace和移动语义后,带宽占用大幅下降,GC 压力小了一半(虽然 C++ 没有 GC,但内存分配的次数确实是实打实的开销)。
6.3 “栈帧”和“堆”的其他经典混淆点
很多人在看 crash 日志时遇到access violation c0000005(Windows 下的访问违规错误),第一反应是“内存越界”。这个错误其实就是访问了不该访问的内存地址。如果把调用栈回溯打好,通常能看到是哪个函数、哪一行代码访问了野指针。
我在 C# 调用 C++ 原生库时也踩过access violation c0000005的坑。最常见原因是 C# 侧把委托传给 C++,C++ 侧保存了这个函数指针,但 C# 侧委托对象被 GC 回收了,导致 C++ 调用时指针已经成为悬空指针。解决方案是让 C# 侧保持对委托的强引用,或者用GCHandle.Alloc固定住委托对象。
判断一个崩溃到底是栈溢出还是野指针,最简单的办法是看栈回溯:如果是栈溢出,backtrace 会看到大量重复的递归函数名;如果是野指针,backtrace 会停在某个特定的调用点,而且当前函数的地址往往“不合理”。
6.4 实战经验:写一个带“容量限制”的阻塞队列
线程池里常用的阻塞队列实际上是一个“有界队列”:当队列已满时,生产者线程应该被阻塞而不是丢掉任务。这里我用 C++ 的std::condition_variable实现一个简易版:
#include <queue> #include <mutex> #include <condition_variable> template <typename T> class BoundedBlockingQueue { public: explicit BoundedBlockingQueue(size_t capacity): capacity_(capacity) {} void push(const T& item) { std::unique_lock<std::mutex> lock(mutex_); notFull_.wait(lock, [this] { return queue_.size() < capacity_; }); queue_.push(item); notEmpty_.notify_one(); } T pop() { std::unique_lock<std::mutex> lock(mutex_); notEmpty_.wait(lock, [this] { return !queue_.empty(); }); T item = std::move(queue_.front()); queue_.pop(); notFull_.notify_one(); return item; } private: std::queue<T> queue_; std::mutex mutex_; std::condition_variable notEmpty_; std::condition_variable notFull_; size_t capacity_; };这个实现里最需要注意的是:任何时候都必须用unique_lock,不能直接用lock_guard,因为condition_variable的wait需要临时解锁和重新加锁,unique_lock才支持这种操作。另外一个细节:wait必须传入谓词,防止“虚假唤醒”,这是面试常考点,也是实战中非常隐蔽的 bug 来源。
使用示例:
BoundedBlockingQueue<int> tasks(128); // 生产者线程 std::thread producer([&] { for (int i = 0; i < 1000; ++i) { tasks.push(i); } }); // 消费者线程 std::thread consumer([&] { for (int i = 0; i < 1000; ++i) { int task = tasks.pop(); // 处理 task } }); producer.join(); consumer.join();这个“有界阻塞队列”天然可以作为线程池的任务队列。如果你把队列换成std::priority_queue并在 push 时按优先级插入,就做成了一个“优先级阻塞队列”,很多实时调度系统里就是这么干的。
6.5 一行命令快速判断容器记忆体占用
如果你在写代码时想快速知道queue<int>到底占了多少内存,可以写个小程序:
#include <queue> #include <deque> #include <iostream> int main() { std::deque<int> dq; std::queue<int, std::deque<int>> q; std::cout << sizeof(q) << std::endl; // 通常与 deque 本身的大小相关 for (int i = 0; i < 1000000; ++i) { dq.push_back(i); } std::cout << "deque 占用约 " << dq.size() * sizeof(int) / 1024.0 / 1024.0 << " MB" << std::endl; return 0; }这个程序对所有容器都一样——sizeof(容器)本身通常只是几个指针和长度字段,真正大头是堆上动态分配的元素内存。所以,一个queue对象拷来拷去开销不大,但容器里的数据量才是关键。
7. 学习路径与延伸:从“会用”到“会设计”
7.1 从理论上吃透数据结构的三板斧
学任何数据结构(不只是栈和队列),我都建议走这三步:
- 逻辑结构——它描述什么规则?插入/删除发生在哪里?顺序如何?
- 物理存储——它是用连续内存(数组)还是链式节点(链表)存储?为什么这样选?
- 性能边界——每种操作的时间/空间复杂度是多少?瓶颈在哪个环节?
栈和队列的规则很简单,但真正考验人的是:你能不能在合适的场景里识别出“这应该用栈/队列”,而不是事后再用 if-else 硬堆。我在面试候选人时,经常出一道“用两个栈实现一个队列”的题,目的不是考记忆,而是看 TA 能不能从“两个 LIFO 结构拼装出 FIFO 行为”这个信息里看到结构之间的转换关系。
#include <stack> class MyQueue { public: void push(int x) { input_.push(x); } int pop() { int value = peek(); output_.pop(); return value; } int peek() { if (output_.empty()) { while (!input_.empty()) { output_.push(input_.top()); input_.pop(); } } return output_.top(); } bool empty() { return input_.empty() && output_.empty(); } private: std::stack<int> input_; std::stack<int> output_; };这个实现的思路很经典:入队时只管压入input_,出队时如果output_为空,就把input_里的元素全部倒到output_里,此时output_的栈顶恰好是最早入队的元素。摊还复杂度 O(1),和直接使用队列一样高效。
7.2 数据结构实验报告与复习建议
如果是学生,要写数据结构实验报告,我建议不要只贴代码。老师真正想看的,是你的设计思路和对比分析。比如栈的实验报告可以写:
- 使用
vector和deque分别实现栈后,100万次 push/pop 的耗时对比 - 为什么
std::stack默认选deque而不是vector - 自己实现的链表栈在内存碎片上的表现
如果能把这样的对比写进报告,不仅是得高分的问题,你实际动手之后对这些结构的感觉会完全不一样。
准备期末考试时,别死记那些复杂度表格。动手画图:画出一个栈里 push 1、push 2、pop、push 3、pop 的全过程;画出一个队列里入队、出队的指针移动。画过一次,你就再也忘不掉了。
7.3 骨架之外:怎样从“栈和队列”延伸到更大的体系
栈和队列是很多复杂算法和系统设计的基石。学完这两个结构,我建议按这个顺序往下扩展:
- 优先队列:它在栈/队列之上加了“优先级”维度,是 Dijkstra、Huffman 编码、任务调度的基础。
- 双端队列 deque:它同时是栈和队列的超集,理解了它,你就明白了“灵活”和“代价”的权衡。
- 单调栈/单调队列:这是把“栈/队列”和“贪心思想”结合的典范,刷算法题时价值极高。
- 阻塞队列/无锁队列:这是并发编程的基本组件,理解了它你才能写线程池、消息队列。
- 消息队列中间件:Kafka/RabbitMQ/RocketMQ 本质上就是把单机队列扩展成了分布式队列,并加了持久化、复制、重试等机制。
- 调用栈与栈帧:这是连接“数据结构”和“计算机系统原理”的桥梁,理解了栈帧你才能真正看懂汇编、调试崩溃、理解虚拟内存。
我自己的经验是,把栈和队列搞透彻之后,再看很多其他东西会忽然觉得“原来都是它”。比如递归的隐式栈、浏览器的前进后退、括号匹配、四则运算求值、CPU 的中断处理、操作系统的任务调度……你见到一个场景,先试着想“这里能不能抽象成栈或者队列”,这个习惯对解决问题的能力提升特别大。
8. 最后给你一段可以直接抄的“自查清单”
如果你看完这篇文章,想确认自己真的掌握了栈和队列,可以对照下面这份清单自测:
- [ ] 能徒手写出
std::stack的常见操作,并解释为什么pop()不返回元素 - [ ] 知道
std::queue和std::deque的区别与联系 - [ ] 能说出
std::stack底层默认容器是什么、为什么选它 - [ ] 能解释函数调用栈和数据结构栈的关系
- [ ] 能完整写出用栈求中缀表达式值的过程
- [ ] 能写出 BFS 的模板代码,并正确标记
visited - [ ] 能说清楚 Kafka、RabbitMQ、RocketMQ 在选型上的核心差异
- [ ] 知道消息队列重复消费的原因和幂等解法
- [ ] 能实现一个最简单的有界阻塞队列
- [ ] 能说清楚栈、堆(数据结构)、堆(内存区域)三个概念的区别
这些内容看起来多,但如果你能对着这份清单一个个打勾,那你对栈和队列的理解,已经超过绝大多数只背了几个 API 的开发者了。我写这篇文章的过程中翻了不少自己以前的代码笔记,最深的感触是:数据结构不是背出来的,是“用”出来的。只有在真实的项目里踩过坑,你才会真正理解为什么默认容器是 deque、为什么 pop 不返回值、为什么 BFS 要立刻标记 visited。希望你也能在动手写代码的过程中,体会到这些东西的实用价值。