栈和队列这两个东西,我在刚学C++那会儿觉得挺枯燥的,不就是"后进先出"和"先进先出"嘛,背下来就完事了。直到后来写项目、刷题、面了好几家公司,才意识到这两个看似简单的容器,其实是整个软件工程的地基。函数调用、表达式求值、任务调度、消息传递、BFS遍历,底层全是它们在撑腰。所以这篇东西我不想写成教科书式的冷知识汇总,而是想从一个实际写代码的人的角度,把栈和队列的原理、C++里的使用姿势、手写实现、真实应用场景、还有那些不踩一次坑就不会记住的细节,全部捋一遍。
不管你是刚学数据结构的大学生,还是在准备实习面试的求职者,或者工作中需要用到队列做异步处理的开发者,这篇文章都能给你一些实在的东西。看完之后,你至少能回答这几个问题:为什么递归用栈?为什么BFS用队列?为什么循环队列要空一个位置?std::queue的底层为什么是deque?以及"阻塞队列在生产者和消费者之间到底扮演什么角色"。下面我直接开整。
1. 栈和队列的本质:先搞懂这两个容器到底在干什么
1.1 栈:后进先出的生活哲学
栈(stack)是一种只允许在一端进行插入和删除操作的线性表。允许操作的那一端叫栈顶(top),另一端叫栈底(bottom)。它的核心规则就一条:后进先出,Last In First Out,简称LIFO。
我常用一个非常生活化的例子来解释栈:你往一个弹簧弹匣里压子弹,最先压进去的那颗子弹在最底部,最后压进去的反而最先被射出去。或者说,你往一个桶里码盘子,你最后放进去的那个盘子,总是最先被拿出来。这就是栈。
这个"只在一端操作"的约束,在计算机世界里太重要了。因为函数调用本身就是嵌套的——你调用A函数,A函数里调用B函数,B函数里又调用C函数,那么C返回之后谁先往下走?肯定是B继续执行,然后A继续执行。这是一个天然的后进先出过程。所以,每个线程的底层都维护着一个调用栈(call stack)来记录函数调用的现场。这一点后面我展开讲。
1.2 队列:先进先出的规则秩序
队列(queue)是另一种受限的线性表,它只允许在一端(队尾)插入,在另一端(队头)删除。规则很简单:先进先出,First In First Out,简称FIFO。
生活里的例子更好找:食堂打饭排队,谁先到谁先打到饭;奶茶店做订单,先下单的先做。你把队列想象成一根水管,数据像水流一样从一端流入,从另一端流出,顺序完全不会乱。
在计算机里,这个顺序保证至关重要。比如一个Web服务器同时收到大量请求,它怎么决定哪个请求先处理?最公平、最朴素的方式就是按到达顺序排队处理。再比如操作系统的进程调度、打印机任务队列、消息队列,FIFO这个特性保证任务的"公平性"和"顺序性"。
1.3 为什么计算机需要这两种"受限制"的结构
很多初学者会问一个问题:数组和链表已经可以把数据存起来了,为什么还要搞出栈和队列这两个看似功能更少的东西?
这个问题的答案很关键:限制不是缺陷,而是特性。数组和链表是"通用存储工具",你可以随机访问任意位置,灵活是灵活,但在某些场景下,灵活反而意味着混乱。栈和队列通过严格的访问规则,把"数据存进去的顺序"和"数据取出来的顺序"之间的关系明确下来。当你看到栈,你就知道这里一定存在"后起的先结算"的语义;当你看到队列,你就知道这里存在"先来的先处理"的语义。
设计模式里有一句名言叫"约束即解放"。栈和队列把操作收敛到那么少的几个接口上,反而让代码的意图变得极其清晰,也让实现算法的时候不用去考虑"我是不是应该绕开中间的元素",从而把注意力集中在问题的逻辑本身。比如深度优先搜索天然就用栈来模拟回溯,广度优先搜索天然就用队列来保证层级顺序,你如果用数组硬写这些算法,还得自己维护指针和顺序,非常容易出bug。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. C++ STL中的栈和队列:开箱即用也要知其根底
2.1 std::stack 的基本使用
C++标准库直接把栈封装好了,头文件是 <stack>,常用接口就这么几个:
push(x):元素入栈pop():弹出栈顶元素(注意:这个函数不返回弹出值)top():返回栈顶元素的引用empty():判断栈是否为空size():栈中元素个数
一个简单的例子:
cpp复制#include <iostream>
#include <stack>
int main() {
std::stack<int> st;
st.push(10);
st.push(20);
st.push(30);
std::cout << "栈顶元素: " << st.top() << std::endl; // 30
st.pop();
std::cout << "弹出后栈顶: " << st.top() << std::endl; // 20
std::cout << "栈的大小: " << st.size() << std::endl; // 2
return 0;
}
这里有一个非常典型的坑刚接触C++的朋友都会踩:std::stack::pop() 在C++98/C++11里是没有返回值的。你可能在其他语言里习惯 int x = st.pop(); 这么写,但是在C++里,你如果写了 int x = st.pop(); 是编译不过的。必须先 int x = st.top(); st.pop(); 这么两步操作。之所以这么设计,一个说法是为了避免返回 void 时还要拷贝一次栈顶元素带来的性能损耗,另一个说法是pop时如果栈是空的,无返回值版本的异常安全更好处理。总之,习惯它就行。
另外注意,在栈为空的时候调用 top() 和 pop() 都是未定义行为(UB),你的程序可能崩溃,也可能不崩溃,但结果永远是未知的。所以规范操作是:
cpp复制if (!st.empty()) {
int val = st.top();
st.pop();
// 处理 val
}
2.2 std::queue 的基本使用
std::queue 在 <queue> 头文件里,接口如下:
push(x):入队,从队尾插入pop():出队,弹出队头元素(同样不返回弹出值)front():返回队头元素的引用back():返回队尾元素的引用empty()、size()和栈一样
实际写法:
cpp复制#include <iostream>
#include <queue>
int main() {
std::queue<std::string> q;
q.push("第一个任务");
q.push("第二个任务");
q.push("第三个任务");
std::cout << "队头: " << q.front() << std::endl; // 第一个任务
std::cout << "队尾: " << q.back() << std::endl; // 第三个任务
q.pop();
std::cout << "出队后队头: " << q.front() << std::endl; // 第二个任务
return 0;
}
在C++11之前,queue的遍历比较别扭,因为它的迭代器是受限的,不支持随机访问。到了C++11和之后,你可以这么基于range-based for循环去遍历一份拷贝来查看队列内容,但这会复制整个队列,如果元素是大对象会心疼内存。更常见的姿势是:如果只是为了调试或者临时查看,就复制一份队列,然后从头pop到空来输出:
cpp复制std::queue<int> copy = originalQueue;
while (!copy.empty()) {
std::cout << copy.front() << " ";
copy.pop();
}
2.3 为什么底层容器默认是 deque 而不是 vector 或 list
这是STL设计里一个非常有趣的点。std::stack 和 std::queue 在标准库里其实是"容器适配器"(container adapter),什么意思呢?就是它们自己不存数据,而是"套"在另一个底层容器之上,通过限制那个容器的接口来表现栈或队列的语义。
标准库允许你指定底层容器,比如 std::stack<int, std::vector<int>>、std::queue<int, std::list<int>>,默认不写就是 std::deque<T>。
为什么默认选择deque(双端队列)而不是vector或者list?我记得侯捷老师在《STL源码剖析》里提过这个设计的考量,关键原因大概有三点:
-
deque支持尾部插入和头部删除都很快。栈需要尾插尾删,vector完全能胜任;但队列需要头删,如果底层用vector,头删(erase(begin))会导致所有元素前移,时间复杂度是O(n)。如果底层用list,虽然头尾操作都是O(1),但list的节点是分散分配的,缓存不友好,而且每个节点还要额外存指针,空间开销更大。
-
deque在内存上是分段的连续空间,它在访问随机元素时比list快(虽然比vector慢一点但差距不大),同时避免了vector头删的昂贵代价。它是一个折中方案,对栈和队列的需求来说非常均衡。
-
deque扩容比vector优雅。vector扩容时要把所有旧元素搬到新内存,deque是分段扩容,只在某个缓冲区满了时开辟新的缓冲区,已有缓冲区不动。所以它不会像vector那样因为频繁扩容导致大量拷贝。
你如果自己写过基于数组的循环队列,就更能理解STL设计者的心思。当然,如果你明确知道只用一个栈且数据量比较小,也可以显式用 std::stack<int, std::vector<int>> 来减少内存碎片,这个属于微优化,在项目里除非有明确性能瓶颈,否则默认的deque就挺好。
2.4 优先队列:打破先进先出约束的特殊队列
严格来说优先队列 std::priority_queue 不再遵守FIFO规则,它每次出队的元素是优先级最高的那个,而不是最早入队的那个。它的底层实现是堆(heap),默认是大顶堆,也就是 top() 返回的是最大元素。
cpp复制#include <iostream>
#include <queue>
int main() {
std::priority_queue<int> pq;
pq.push(30);
pq.push(10);
pq.push(50);
pq.push(20);
while (!pq.empty()) {
std::cout << pq.top() << " "; // 50 30 20 10
pq.pop();
}
return 0;
}
如果你想要小顶堆,可以用:
cpp复制std::priority_queue<int, std::vector<int>, std::greater<int>> minPq;
我在实际项目里最常用的场景有两个:一个是定时任务的"最近到期时间优先",把任务按照到期时间戳放到小顶堆里,每次拿堆顶就是要执行的那个;另一个是Top-K问题,比如从一亿个整数里找最大的100个,维护一个大小为100的小顶堆,遍历一遍数据,比堆顶大就入堆并把堆顶淘汰,最后堆里就是最大的100个。这个我在后面"面试高频考点"里再提。
3. 手写实现:从零实现栈和队列到底要练什么
如果面试官只问"你用过STL的栈和队列吗",那考察深度基本等于零。通常他们会追问一句:"你能不用STL,自己实现一个栈或队列吗?" 或者干脆让你手写一个循环队列。这个环节考察的是你对底层存储和指针移动的理解。我给你拆解一下核心实现,顺带把常见坑点也说明白。
3.1 基于数组实现栈
数组实现栈是最简单的,只要维护一个 topIndex 指针即可。
cpp复制#include <iostream>
#include <stdexcept>
template <typename T>
class ArrayStack {
private:
T* data;
size_t capacity;
int topIndex; // 指向栈顶元素的下标,-1表示空栈
public:
explicit ArrayStack(size_t cap = 8) : capacity(cap), topIndex(-1) {
data = new T[capacity];
}
~ArrayStack() { delete[] data; }
void push(const T& value) {
if (topIndex == static_cast<int>(capacity) - 1) {
resize(capacity * 2);
}
data[++topIndex] = value;
}
void pop() {
if (empty()) {
throw std::runtime_error("pop from empty stack");
}
--topIndex;
}
T& top() {
if (empty()) {
throw std::runtime_error("top from empty stack");
}
return data[topIndex];
}
bool empty() const { return topIndex == -1; }
size_t size() const { return static_cast<size_t>(topIndex + 1); }
private:
void resize(size_t newCap) {
T* newData = new T[newCap];
for (size_t i = 0; i < capacity; ++i) {
newData[i] = data[i];
}
delete[] data;
data = newData;
capacity = newCap;
}
};
这里有两个需要注意的地方:
第一个是 topIndex 初始化成 -1。当栈为空时 topIndex 指向一个不存在的"下标-1";入栈第一个元素时,先 ++topIndex 得到0,然后把值放入 data[0]。如果你初始化为0,空栈判断逻辑就得改成 topIndex == 0,但那样会把 data[0] 预留给一个没元素的位置,实现上会绕一些。面试时顺手写成 -1 会更清爽。
第二个是扩容。resize 函数在栈满时把容量翻倍,然后把旧元素全部拷到新数组里。这个操作时间复杂度O(n),但均摊下来入栈仍是O(1),这就是vector的动态扩容思路。我在工作里有时候会写成固定容量栈来避免扩容开销,比如嵌入式环境或者性能敏感路径上,容量已知时固定栈是更好的选择。
3.2 基于链表实现栈
链表栈的核心思路是:每次入栈相当于链表头插,每次出栈相当于链表头删。这样入栈出栈都是O(1),而且不需要扩容。
cpp复制#include <iostream>
#include <stdexcept>
template <typename T>
class ListStack {
private:
struct Node {
T data;
Node* next;
explicit Node(const T& val, Node* nxt = nullptr) : data(val), next(nxt) {}
};
Node* head;
size_t count;
public:
ListStack() : head(nullptr), count(0) {}
~ListStack() {
while (head != nullptr) {
Node* tmp = head;
head = head->next;
delete tmp;
}
}
void push(const T& value) {
head = new Node(value, head);
++count;
}
void pop() {
if (empty()) {
throw std::runtime_error("pop from empty stack");
}
Node* tmp = head;
head = head->next;
delete tmp;
--count;
}
T& top() {
if (empty()) {
throw std::runtime_error("top from empty stack");
}
return head->data;
}
bool empty() const { return head == nullptr; }
size_t size() const { return count; }
};
链表栈在实际项目中唯一让我犹豫的点就是性能。每一个节点都是 new 出来的,访问时还要通过指针跳转,会导致频繁的malloc/free和cache miss。相比之下,数组栈的数据就是连续内存,CPU缓存利用非常高效。所以如果元素数量可预知或者并发要求不高,数组栈通常是更好的选择。链表栈更多是作为面试题出现,或者用在"内存不连续但需要频繁插入删除"的场景。
3.3 基于数组实现循环队列
队列用数组实现的时候有个经典难题:如果只靠两个指针 headIndex 和 tailIndex 一直往后移动,那么随着入队出队,数组前面的一部分空间会空出来但是用不上,最终导致"假溢出"——数组明明有空间,但因为tail已经到末尾了,新元素加不进去。
解决办法就是循环队列(circular queue):让两个下标在后移到末尾时重新绕回开头。
cpp复制#include <iostream>
#include <vector>
#include <stdexcept>
template <typename T>
class CircularQueue {
private:
std::vector<T> data;
size_t capacity;
int head; // 队头下标,指向第一个有效元素
int tail; // 队尾下标,指向下一个可以插入的位置
size_t count; // 当前元素个数
public:
explicit CircularQueue(size_t cap) : capacity(cap), head(0), tail(0), count(0) {
data.resize(capacity);
}
bool empty() const { return count == 0; }
bool full() const { return count == capacity; }
void enqueue(const T& value) {
if (full()) {
throw std::runtime_error("enqueue to full queue");
}
data[tail] = value;
tail = (tail + 1) % capacity;
++count;
}
T dequeue() {
if (empty()) {
throw std::runtime_error("dequeue from empty queue");
}
T val = data[head];
head = (head + 1) % capacity;
--count;
return val;
}
T& front() {
if (empty()) {
throw std::runtime_error("front from empty queue");
}
return data[head];
}
T& back() {
if (empty()) {
throw std::runtime_error("back from empty queue");
}
int backIndex = (tail == 0) ? capacity - 1 : tail - 1;
return data[backIndex];
}
};
这个实现有一个细节:我额外维护了一个 count 成员来区分空和满。这是因为当 head == tail 时,队列既可能是空也可能是满,如果不加 count,就必须通过"牺牲一个存储单元"的方式来判断满(满时 (tail + 1) % capacity == head)。牺牲一个位置的做法更省内存但代码有点绕;加 count 的做法更直观,缺点是每个实例多花4字节。面试时你最好两种写法都会,能讲清楚 head == tail 情况下空和满的判断歧义,这本身就是一个加分项。
另外注意 (tail + 1) % capacity 这个取模运算,它的目的是让下标在到达 capacity - 1 后能绕回0。在性能要求极高的场景里,取模运算的代价稍大,可以改成位运算优化——但前提是 capacity 必须是2的幂,比如 tail = (tail + 1) & (capacity - 1)。这种技巧在写实时通信缓冲区时会用到。
3.4 关于循环队列你一定会被问的"为什么"
面试官最喜欢问的一个问题是:"为什么循环队列判满要浪费一个位置?" 其实答案就藏在上面的代码里。当队列空时 head == tail;当队列满时,如果不用 count,那么 head 也会等于 tail(因为尾指针绕过一圈追上了头指针)。为了区分这两种情况,最简单的方法就是让队列最多存 capacity - 1 个元素,强制"满"的时候 head 和 tail 不可能相等。这个"浪费一个位置"的思路在很多底层实现里都会出现,比如有些地方会浪费一个bit作为标志位,本质都是一样的:增加一个不变量来消除歧义。
我自己的经验是,手写循环队列这类代码的时候,先在草稿纸上把下标移动画出来,尤其注意 front() 和 back() 在边界情况下的值。比如队列满了之后 back() 应该返回什么?在"牺牲一个位置"的写法里,满时 tail 指到的是被浪费的那个空位,所以 back() 要返回 data[(tail - 1 + capacity) % capacity],这个 +capacity 是为了防止 tail - 1 为负数时取模产生错误结果。这种细节,写的时候很容易漏。
4. 栈的实战应用:从函数调用到表达式求值
4.1 函数调用栈:递归为什么会爆栈
我们写程序,表面上是一行一行往下执行,但函数之间的调用关系其实是树状的。当 funcA 调用 funcB,funcB 调用 funcC,CPU要记住"C执行完应该回B的哪一行继续"、"B执行完应该回A的哪一行"。这些信息存在哪?就是栈上。
每个线程在启动时,操作系统会给它分配一块固定大小的栈区域,这块空间默认在Linux上通常是8MB,Windows上通常是1MB。每次调用一个函数,就会在栈上"压入"一块栈帧(stack frame),里面存了函数的局部变量、参数、返回地址等等。函数返回时,这块栈帧弹出。这个后进先出的结构,和栈是完全吻合的。
所以当你的递归没有终止条件,或者递归深度太深,栈空间被耗尽了,就会发生"栈溢出"(stack overflow)。很多人觉得递归很神秘,其实递归只不过是在不断压栈。这也是为什么尾递归在某些编译器里可以被优化成"跳转"而不会消耗额外栈空间的原因——优化器发现当前函数的栈帧已经没用了,直接复用而不是新压一帧。
写递归时我自己的习惯是:先确认深度可控(比如树的高度是log n的那种),再上递归;如果深度可能上万,我会主动改成显式栈实现非递归版本,或者用循环实现。不要等到线上 Segmentation Fault 了才后悔。
4.2 括号匹配:一道经典的栈应用面试题
括号匹配大概是每个学数据结构的人都做过的算法题。给定一个只包含 ( ) [ ] { } 的字符串,判断括号是否合法。用栈写极其顺手:
cpp复制#include <iostream>
#include <stack>
#include <string>
bool isValid(const std::string& s) {
std::stack<char> st;
for (char c : s) {
if (c == '(' || c == '[' || c == '{') {
st.push(c);
} else {
if (st.empty()) return false;
char top = st.top();
if ((c == ')' && top != '(') ||
(c == ']' && top != '[') ||
(c == '}' && top != '{')) {
return false;
}
st.pop();
}
}
return st.empty();
}
这个算法的核心思想是:遇到左括号就入栈,遇到右括号就检查栈顶是不是匹配的左括号,如果是就弹出,不是就说明不匹配。最后栈必须是空的,否则说明有左括号没找到配对。
这里面其实藏着一个"最近匹配"的直觉。栈存的永远是"等待被匹配的最近那个左括号",因为括号嵌套的时候,最后一个出现的左括号一定最先遇到它的右括号——这正是LIFO的应用场景。如果换成 [() 这种字符串,当遇到 ) 时栈顶是 (,匹配成功,但等到遇到 ] 时栈顶是 [ 吗?不对,因为中间那个 ( 已经被弹掉了。所以这个算法能正确给出 [() 不匹配的结论。
这道题变形无穷:可以问你"给一个字符串,至少加多少个括号才能让所有括号都匹配",也可以问你"带通配符的括号匹配",但核心都是维护一个栈去模拟配对过程。算法题喜欢考它,本质是考察你能不能识别出"嵌套最近匹配"这个模式。
4.3 表达式求值:中缀转后缀的经典操作
除了括号匹配,栈最经典的算法应用还有表达式求值。我们日常写 1 + 2 * 3,这是一个中缀表达式,运算符在操作数中间。但计算机执行时更喜欢后缀表达式(逆波兰式)1 2 3 * +,因为它不需要括号,也不需要运算优先级,只需要从左到右扫描,遇到操作数压栈,遇到运算符就弹出两个数计算再压回去。
中缀转后缀的过程也是用栈完成的。核心思路是:遇到操作数直接输出,遇到运算符时,把它和栈顶运算符比较优先级,如果栈顶的优先级更高或相等,就弹出栈顶到输出(因为栈顶的运算符应该先计算),然后把当前运算符压栈;遇到左括号直接压栈,遇到右括号弹栈直到弹出左括号。
我当年学这个算法时,最大的困惑是"为什么运算符要入栈,还要比较优先级"。后来想明白了:因为在中缀表达式里,1 + 2 * 3 中乘号虽然写在加号后面,但它必须先计算。把它压到栈里,等扫描到 3 后面的位置时,我们才需要决定 * 和 + 谁先输出。用一个栈暂存那些"还没轮到结算"的运算符,等到它们该出场时再弹出——这在计算机里是一种非常自然的"延迟决策"。
这个算法的变体非常多,很多笔试题让你实现一个"计算器",实际上就是中缀转后缀+栈求值的组合。如果你不想每次手写转换,也可以用"双栈法"直接对中缀表达式求值:一个栈存操作数,一个栈存运算符,扫描时遇到右括号或者优先级符合条件就弹出计算。工作里我虽然没有真的手写过计算器,但很多词法分析、命令解析的场景,思想都是这套。
4.4 浏览器的前进后退与撤销操作
聊点更接地气的。为什么浏览器能后退?为什么编辑器能撤销?背后的核心就是两个栈。
浏览器维持两个栈:历史栈和前进栈。你访问一个新页面时,把他压入历史栈,同时清空前进栈;点击后退,就是从历史栈弹出当前页面(压入前进栈),然后跳转到历史栈的新栈顶;点击前进,就是把前进栈的栈顶弹回到历史栈。
编辑器的撤销操作也是一样:每次操作压入一个"操作记录"栈,撤销时弹出最后一个操作,然后用一个"重做"栈保存刚撤销掉的操作。如果你撤销了几步之后又做了新操作,那"重做"栈就会被清空——因为历史的分支已经变了。
这种"双栈配合"的模式,比单一栈更好用。当你需要"撤销撤销"的时候,第二个栈就是很自然的解决方案。如果你以后写一些客户端工具、游戏编辑器,想做Undo/Redo系统,双栈方案是最容易上手的。
5. 队列的实战应用:任务调度、BFS、消息队列与线程池
5.1 生产者消费者模型:队列是缓冲区的心脏
如果说栈对应的是"递归与回溯"这类LIFO逻辑,那队列对应的就是"任务排队"这类FIFO逻辑。最经典的生产者消费者模型里,生产者和消费者之间共享一个"缓冲区",这个缓冲区就是一个队列。
为什么用队列而不是栈?因为任务需要被公平地按到达顺序处理。如果生产者在短时间内连续放入任务A、B、C,消费者应该在处理完A之后再处理B,最后处理C。如果用一个栈,后到的C先被处理,先到的A反而被饿死,这在大多数业务场景下是不公平的。
我举个实际例子:一个在线商店系统的订单处理服务,用户下单时订单消息被放到订单队列里,后台的订单处理程序从队列里取消息来做后续操作(扣库存、发短信、生成物流单)。如果服务宕机重启,新来的订单还是会进队列,之前积压的订单也会按顺序被处理。队列把这个"解耦"和"削峰"的工作给完成了——上游只需要使劲往队列里塞,下游只需要按自己的节奏往外取,两边各干各的,互不阻塞。
在实际落地中,生产者和消费者往往运行在不同线程、甚至不同进程/不同机器上。同一个进程内,可以直接用 std::queue 加一把互斥锁,或者用 C++ 标准库的 std::condition_variable 配合实现阻塞队列。跨进程、跨服务呢?这就是消息中间件的活了,比如 RabbitMQ、Kafka、Redis Stream,甚至 RocketMQ 这些。但不管它们多复杂,它们最核心的数据结构模型就是一个队列:先进先出。
5.2 广度优先搜索(BFS):为什么必须用队列
BFS是全栈开发里几乎必学的图遍历算法。从起点出发,一层一层地往外扩展,先访问所有距离为1的节点,再访问所有距离为2的节点,如此直至遍历完整个图。为了保证这个"一层一层"的顺序,就必须用队列。
过程是这样的:首先把起点放入队列。然后循环,每次从队列头部取出一个节点,访问它,并把它的所有未访问邻居都放入队列尾部。因为队列是FIFO,先进入队列的节点一定会先被取出,所以第1层的所有节点一定比第2层的节点先被访问。
如果改用栈做DFS(深度优先搜索)的话,顺序就会完全颠倒:从一个节点出发,会顺着一条路走到黑,再回头探索另一条分支,而不是一层一层铺开。所以,树的层序遍历用队列,图的"最短路径"(无权图)用BFS,就是顺着这个逻辑来的。
写BFS时有几个常见坑,我提一下:第一,入队时就要标记已访问,而不是出队时标记,否则同一个节点可能被多次重复入队;第二,如果你需要记录节点的层级(比如求"到起点的最短步数"),需要维护一个步数数组或者按层分批处理;第三,在内存受限的场景下,队列里可能会同时存在非常多的节点,要注意空间复杂度。这也是为什么在有的大数据量图上,BFS可能跑着跑着内存暴涨,这时需要权衡是否改成迭代加深DFS。
这里给一个标准的BFS模板,用于网格迷宫的"最短步数"问题:
cpp复制#include <iostream>
#include <queue>
#include <vector>
#include <string>
int bfs(const std::vector<std::string>& maze, int startX, int startY, int targetX, int targetY) {
int rows = maze.size();
int cols = maze[0].size();
std::vector<std::vector<bool>> visited(rows, std::vector<bool>(cols, false));
int dirs[4][2] = {{1, 0}, {-1, 0}, {0, 1}, {0, -1}};
std::queue<std::pair<int, int>> q;
q.push({startX, startY});
visited[startX][startY] = true;
int steps = 0;
while (!q.empty()) {
int levelSize = q.size();
for (int i = 0; i < levelSize; ++i) {
auto [x, y] = q.front();
q.pop();
if (x == targetX && y == targetY) {
return steps;
}
for (auto& d : dirs) {
int nx = x + d[0];
int ny = y + d[1];
if (nx >= 0 && nx < rows && ny >= 0 && ny < cols &&
!visited[nx][ny] && maze[nx][ny] != '#') {
visited[nx][ny] = true;
q.push({nx, ny});
}
}
}
++steps;
}
return -1; // 不可达
}
这个 levelSize 的小技巧非常好用:每次while循环单独处理当前层级的全部节点,然后再步数+1。这样你可以精确知道走了多少步,比在队列节点里额外记录步数更省内存。
5.3 消息队列在生产项目里的角色
近年来很多技术讨论都在提"消息队列"(Message Queue)。这已经不是一个简单的数据结构了,而是一个中间件产品(如 RabbitMQ、Kafka、Redis Stream)。但它名字里的"Queue"不是白叫的——它确实沿用了队列先进先出的核心语义:生产者把消息放进队列尾部,消费者从队头取出消息。
消息队列解决了这么几个问题:
- 解耦:订单系统不需要直接调用库存系统的接口,只需要向MQ发一条"订单创建"消息,库存系统自己去订阅。两边都不需要知道对方的地址和接口细节,改一边不影响另一边。
- 削峰:比如一次秒杀活动瞬间来了百万请求,订单系统扛不住。请求先全部进MQ,订单处理服务按自己的消费能力慢慢处理,不会直接被流量冲垮。
- 异步:用户下单后不需要等待短信通知、积分变更等全部完成才能看到结果,这些可以发到MQ里后台处理,用户快速看到"下单成功"。
使用消息队列时有个经典问题叫"重复消费"。比如消费者处理完消息、还没提交ack(确认)时宕机了,重启后MQ会把那条消息重新投递一次,这时消费者会处理两次。解决办法一般是"幂等设计"——即同一个操作重复执行的结果和只执行一次的结果一样。比如在消息里带上唯一的业务ID,处理前先查一下这个ID是否已经处理过。这个语义已经超出"队列数据结构"的范畴了,但它确实是队列在实际生产场景中的一个重要延伸。你看热搜词里也有"消息队列重复消费问题"、"spring boot redis stream 如何拉取队列消息"这种,说明这是大家在实际项目中真正关注的痛点。
5.4 阻塞队列与线程池:为什么线程池要用阻塞队列
在多线程编程里,线程池是一个常见组件。它的核心结构就是一个"任务队列"和若干个"工作线程"。主线程往任务队列里提交任务,空闲的工作线程从队列里取任务执行。这里如果直接用裸 std::queue,需要自己加锁,并且要处理好"队列空了工作线程该怎么办"的问题。
更好的选择是阻塞队列(Blocking Queue):当队列为空时,想取元素的消费者线程会被阻塞住,直到有新的元素入队;当队列满了时,想入队的生产者线程也会被阻塞住,直到队列有位置。这个机制用 std::condition_variable + std::mutex 就能实现,本质上就是对队列封装了线程同步逻辑。
结合热搜词"线程池的阻塞队列选择",我多说一句。很多框架的线程池里,任务队列并不总是简单的FIFO队列,可能会使用优先级队列(比如优先级高的任务先执行),或者延迟队列(定时任务)。实际选型时,你要问自己的问题是:任务是应该严格按照提交顺序执行,还是按优先级执行?任务可能积压吗?如果积压,队列容量上限是多少?满的时候应该拒绝新任务还是阻塞提交方?这些问题决定了你选哪种"队列"。
另外,在C++标准库里,C++11引入了 std::condition_variable,C++17又提供了 std::scoped_lock,用这些配合写一个简单阻塞队列并不难,核心代码大概长这样:
cpp复制#include <iostream>
#include <queue>
#include <mutex>
#include <condition_variable>
template <typename T>
class BlockingQueue {
private:
std::queue<T> queue_;
std::mutex mutex_;
std::condition_variable notEmpty_;
size_t maxSize_;
public:
explicit BlockingQueue(size_t maxSize = 100) : maxSize_(maxSize) {}
void push(const T& value) {
std::unique_lock<std::mutex> lock(mutex_);
while (queue_.size() >= maxSize_) {
notFull_.wait(lock);
}
queue_.push(value);
notEmpty_.notify_one();
}
T pop() {
std::unique_lock<std::mutex> lock(mutex_);
while (queue_.empty()) {
notEmpty_.wait(lock);
}
T value = queue_.front();
queue_.pop();
notFull_.notify_one();
return value;
}
private:
std::condition_variable notFull_;
};
注意我额外加了一个 notFull_ 条件变量,用来唤醒那些因队列满而被阻塞的生产者。有的实现只用 notEmpty_ 一个条件变量,那在队列满时生产者只能自旋或者随机等一会再尝试入队,效率不高。两个条件变量一个管"不空才能取",一个管"不满才能放",是标准的线程池内部实现技巧。
6. 常见问题与避坑指南:这些坑我替你踩过了
6.1 栈溢出:不只是递归的锅
一提"栈溢出",很多人第一反应是无限递归。但实际上栈溢出不只是递归引起的。我在项目里遇到过几次栈溢出,排查下来有这些原因:
- 大对象在栈上创建。比如
char buffer[1024 * 1024]这种在函数内部定义的大数组,它占的是栈空间。如果一个线程栈只有1MB,你这样一个局部数组就把栈快用完了。改进方案是把大对象设计成堆上分配,比如用std::vector<char>。 - 递归深度过大。哪怕递归逻辑是对的,深度如果达到几万层,每层栈帧即使只占几百字节,加起来也轻松超过栈空间限制。
- 无限递归,这个一般很好发现,但有一种隐蔽情况:析构函数里间接调用自身,导致栈溢出信息非常诡异,得靠core dump才能定位。
Windows下可以通过编译选项或 ulimit -s 设置栈大小,但实际工程里我更推荐"从源头避免":不要写出需要几十万深度的递归,不要在大函数里声明超大局部变量。
6.2 队列"假溢出":理解循环队列为什么存在
如果你自己用数组实现队列,而没用循环,就会遇到"假溢出"问题。什么意思呢?看这个场景:数组长度为5,先入队5个元素,然后出队3个元素,此时队列里明明只有2个元素,但 tail 已经指向下标5,新元素无法插入。这不是真正的溢出,而是数组头部空出来了但用不上。
解决办法之一就是循环队列(前面已经实现过)。还有一种动态扩容方案:当tail到底时但head还没到0,就做一次元素搬移,把现有元素整体挪到数组开头,腾出后面的空间。这种做法在 std::deque 的实现里也有关键应用:deque 会维护一段段的缓冲区,头尾都能扩展,所以它不会出现"假溢出"这个问题。这也是为什么标准库的 std::queue 默认用 deque 而不是 vector 的深层原因之一。
如果你用STL的 std::queue 一般不会遇到假溢出,但在自己动手实现队列、写底层组件时会遇到,一定要记住这个经典问题。
6.3 迭代器、引用失效问题
这个问题在 std::vector 里很常见:你往 vector 里 push 元素,如果容量不够,vector 会重新分配更大内存并搬移元素,旧迭代器就全失效了。而 std::stack、std::queue 默认基于 deque,deque 的迭代器稳定性和 vector 不太一样:在头部/尾部插入元素时,deque 的迭代器在大多数实现中仍然有效(但标准并没有保证所有操作都保留迭代器),不过你还是不该依赖这个行为。
一个更隐蔽的坑是:std::queue 的 front() 和 back() 返回引用,但如果你用 push() 之后立即调用 front() 的引用,在某些实现中可能因为重新分配导致引用失效。所以我的习惯是:从队列/栈里取元素时,要么立刻拷贝出来,要么用完后不要再保留引用。多线程场景更是如此,因为你操作队列时如果没有持锁,另一个线程的push/pop可能让引用变得悬空。
6.4 性能对比与容器选型:栈和队列用什么底层实现更好
整理一个表格,方便你以后选型:
| 需求 | 推荐容器 | 原因 |
|---|---|---|
| 函数调用模拟、括号匹配等简单栈 | std::stack 默认(deque底层) |
接口简洁,自带内存管理 |
| 大量入栈且容量明确 | std::stack<T, std::vector<T>> |
连续内存,缓存友好,扩容可控 |
| 大量入队出队 | std::queue 默认(deque底层) |
头尾操作O(1) |
| 强调整体公平顺序 | std::queue |
FIFO |
| 按优先级取任务 | std::priority_queue |
每次取当前最优 |
| 多线程生产者消费者 | 自己封装阻塞队列 | 需要同步控制 |
| 跨进程/跨服务传递消息 | 消息中间件(Kafka/RabbitMQ/Redis Stream) | 网络/持久化/分布式语义 |
我自己的经验是,大部分应用场景STL默认就够用了,不需要手动指定底层容器。只有在通过性能分析工具明确看到几个热点路径上,比如高频调用 queue 的 pop 和 push 时,才会考虑把底层换成 std::deque 之外的选择。性能优化一定先测量再动手,别凭感觉改,否则常常白忙活。
6.5 面试高频考点整理:栈和队列到底会怎么考
如果把栈和队列相关的面试题按出现频率排个序,大概是这样:
- 用两个栈实现队列。思路:入队时往
stackIn压,出队时如果stackOut非空就弹出它;如果stackOut为空,把stackIn全部倒入stackOut,再弹出。总体均摊O(1)出队。 - 用两个队列实现栈。思路:保持一个队列为空,入栈时把元素放入空队列,再把另一个队列的全部元素依次搬过来;出栈时直接从主队列队头取。每次入栈O(n)。
- 括号匹配。
- 最小栈。设计一个栈,支持
push、pop、top和getMin()都在O(1)时间。做法是维护两个栈,一个存数据,一个存当前最小值序列。每次入栈时,如果新值小于等于辅助栈栈顶,就同时压入辅助栈;出栈时如果弹出的值和辅助栈栈顶相等,辅助栈也弹出。 - 循环队列的判空判满。
- 逆波兰表达式求值。
- Top-K问题:用优先队列(堆)解决。
这里面"两个栈实现队列"几乎每次面试都有人问,因为它的思路非常巧妙,考察的是你能否打破"队列必须用数组/链表实现"的思维定式。我把它写成C++放在下面,供你参考:
cpp复制#include <iostream>
#include <stack>
template <typename T>
class QueueWithTwoStacks {
private:
std::stack<T> inStack;
std::stack<T> outStack;
void transfer() {
while (!inStack.empty()) {
outStack.push(inStack.top());
inStack.pop();
}
}
public:
void push(const T& value) {
inStack.push(value);
}
T pop() {
if (outStack.empty()) {
transfer();
}
if (outStack.empty()) {
throw std::runtime_error("queue is empty");
}
T value = outStack.top();
outStack.pop();
return value;
}
T front() {
if (outStack.empty()) {
transfer();
}
if (outStack.empty()) {
throw std::runtime_error("queue is empty");
}
return outStack.top();
}
bool empty() const {
return inStack.empty() && outStack.empty();
}
};
这个实现的核心是"倒置":inStack 接收新元素时,顺序是反的,把它们一个个倒到 outStack 后变成正的,然后 outStack 的栈顶就是最早入队的元素,弹栈就相当于出队。这种"一次倒腾,多次受益"的思路,和计算机里各种"缓存+批量搬运"的设计不谋而合。
7. 给初学者的练习路线:从"看得懂"到"写得出"
7.1 按这个顺序刷题,事半功倍
我知道有很多同学学了栈和队列之后,知道概念也看懂了我上面的代码,但真正自己动手的时候还是脑子空白。我的建议是,按下面的顺序来练习,每一步都在前面基础上加一点点东西:
- 第一梯队:用数组实现栈,用数组实现循环队列。不要看参考代码,自己写。写到
enqueue、dequeue时自然会把(tail + 1) % capacity这个取模绕清楚。 - 第二梯队:写括号匹配。先支持小括号,再扩展到中括号、大括号,最后允许字符串里夹杂普通字符。每扩展一步,你对"栈顶是最近未匹配项"的理解就更深一层。
- 第三梯队:用两个栈实现队列、用两个队列实现栈。这两个题做完,你会对"顺序"这件事非常敏感。
- 第四梯队:表达式求值。先做"后缀表达式求值",再做"中缀转后缀",最后直接做"计算器",支持
+ - * /和括号。 - 第五梯队:BFS 刷题,找经典题比如走迷宫、岛屿数量、二叉树的层序遍历。刷完你自然明白队列在层级遍历里的地位。
7.2 写实验报告时注意什么
如果你还是学生,正在写数据结构实验报告,那我要多嘴一句。很多同学实验报告最容易翻车的点不是代码,而是分析部分。老师要看的不是"代码能跑",而是你能说清楚:为什么栈用数组实现时扩容策略是翻倍而不是每次+1?为什么循环队列用取模操作?链表栈和数组栈的时间空间复杂度分别是多少?这些问题的答案我在前面其实都已经讲到了,你把它们用自己的话组织一下,报告会非常好看。
另外,实验报告里的"测试用例"一定要有边界情况:空栈调用 top()、空队列调用 pop()、循环队列满了再入队、大量随机入队出队后的状态验证。我在帮学弟学妹看代码时,见到最多的bug就是没考虑空容器操作,跑测试直接崩溃。别怕报错,报错代表你在接近真相,但提交之前一定要把这些边界情况都覆盖到。
7.3 从数据结构到工程能力:思维转变
最后想聊一个更宏观的东西。从学习数据结构到真正在工程里用好它,中间有一个思维转变过程:从"我会用这个接口"到"我知道什么时候该选它"。
栈和队列的接口都很简单,但选择它们的场景需要你理解业务的顺序语义。比如你在设计一个调度系统,任务的执行顺序取决于什么?如果后提交的任务需要先处理,那就是栈/优先级;如果先提交的任务应该先被处理,那就是队列。再比如你在做一个撤销系统,你天然需要"最近的操作最先被撤销",那栈就是不二之选。这种"语义驱动选型"的能力,不是刷几道题就能有的,得靠真实的项目一点点喂出来。
我建议你把手头的小项目做一次"数据结构审计":翻一翻自己写的代码,看看哪里用了 vector、哪里用了 queue、哪里用了 stack,想一想换成别的容器会不会更好?比如你用一个 vector 存的缓存数据,是不是频繁头删?如果答案是yes,那换成 std::deque 甚至 std::queue 可能会更好。这种主动思考做得多了,你对数据结构的理解就完全不是课本上的死知识了。
我个人在做侧边栏浏览历史、消息转发模块时,都因为提前选对了栈或队列而省了很多事。相反,我在早期写过一个任务调度模块,当时图省事用 std::vector 存任务,每次取第一个任务时 erase(begin()),数据量小的时候没感觉,后来任务量上来直接卡成PPT。换成 std::queue 之后,通量提升了几个数量级。那种"原来数据结构真的能决定性能上限"的顿悟,我觉得每个开发者都应该亲身体验一次。
