资讯动态

C++栈与队列底层实现与STL适配器全解析

发布时间:2026/10/14 21:57:27 来源:尧图企业网站定制
1. 从一道经典面试题说起为什么数据结构总拿栈和队列开刀但凡认真刷过题、准备过面试的朋友大概率都被问过栈和队列有什么区别这种问题。很多人能背出栈是先进后出队列是先进先出但只要深问一句那你用 C 写过它们的底层实现吗为什么容器的适配器要用 deque 做底座就哑火了。我一开始也有这种困惑。明明数组和链表都能实现栈和队列STL 里却搞出一个 stack 和 queue 的封装层底层还要依赖别的容器。直到后来真正用栈和队列解决了几个实际问题才意识到这两兄弟的价值恰恰藏在数据结构之上的抽象层面。这篇博文我不打算灌概念我会从 C 的实现角度把顺序栈、链栈、环形队列、链式队列、STL 适配器这几层全部拆开结合实战代码讲清楚它们的设计意图和使用场景。内容偏向已经会基本 C 语法、想系统梳理数据结构的读者如果你刚入门也没关系我会在涉及指针、内存、模板这些基础点时多做解释。适合谁看三类人一是正在上数据结构课、对着教材的伪代码挠头的学生二是准备面试、需要把会背概念升级成能聊原理的求职者三是在实际工程中总纠结选哪种容器的开发者。看完之后你至少能回答这些问题栈和队列在计算机系统里到底藏在哪里环形队列入队和出队时为什么要浪费一个存储位标准库的 stack 为什么默认用 deque 而不是 vector这些问题搞明白了你对数据结构的理解才算真正落地。2. 先弄清楚这两兄弟的底层逻辑抽象、存储和操作2.1 栈和队列的本质是受限的线性表很多教材喜欢把栈和队列归类为特殊的线性表。这句话的完整含义是它们存储数据的方式依然是线性的——存在先后顺序每个元素都有唯一的前驱和后继。但它们在操作上被加了约束。栈stack只允许在同一端进行插入和删除操作这一端叫栈顶top另一端叫栈底bottom。因为只能在同一端操作所以后进去的元素先出来即 LIFOLast In First Out。你可以想象成一摞盘子你总是先拿最上面的那个最底下那个盘子反而是最后放的。队列queue则只允许在尾部插入入队也叫 enqueue在头部删除出队也叫 dequeue。所以先进入队列的元素先被处理即 FIFOFirst In First Out。排队买奶茶的队伍就是队列的完美例子先来的人先拿到。这两个约束直接决定了它们的适用场景。栈适合处理嵌套回溯撤销类的问题比如函数调用、括号匹配、表达式求值、浏览器的前进后退。队列适合处理按顺序排队缓冲异步类的问题比如任务调度、打印队列、消息队列、树的广度优先遍历。2.2 两个判别标准操作受限 线性存储怎么判断一个数据结构到底算不算栈或队列我总结了两个判别标准做题和设计时都管用操作是否受限制栈只能在同一端进出队列只能一端进另一端出。如果结构允许你在中间插入或删除它就不是栈也不是队列。存储是否呈线性元素的前驱后继关系必须是线性的。这一点决定了栈和队列可以用数组或链表实现因为数组和链表都是线性存储结构。有人会问那优先级队列priority_queue算队列吗严格说优先级队列的访问顺序不是基于入队次序而是基于优先级它属于广义队列基于堆实现输出顺序是最大/最小元素优先。STL 里的 priority_queue 又是一个独立的容器适配器这一点后面可以顺带提一句但面试时不要把它和普通队列混为一谈。2.3 C 内存模型下实现方式的分水岭C 里实现栈和队列存储载体只有两种大方向顺序存储数组和链式存储链表。顺序存储的优点是缓存友好元素在内存里连续排列遍历和随机访问很快缺点是容量固定扩容时可能涉及整个数组的拷贝且在头部插入删除会导致大量元素移动。链式存储刚好相反每个节点在堆上独立分配插入删除只需要改指针扩展非常灵活但节点要额外存指针内存开销更大而且缓存命中率偏低。这里我多说一句很多人觉得数组实现栈、链表实现队列就够了但实际工程里往往用数组实现队列环形队列用链表实现栈链栈或者直接用 STL 容器封装目的就在性能和易用性之间取平衡。下一节我会把四种组合全部写一遍别急。2.4 学会分析复杂度衡量一个实现到底行不行写代码之前先把分析工具准备好。不管用哪种存储方式入栈、出栈、入队、出队的操作基本都在 O(1) 时间复杂度内完成这是栈和队列作为基础结构最重要的优势。但要注意几个细节顺序栈的出栈O(1)只需要把 top 减一。顺序栈的扩容扩容时需要将旧数据拷贝到新数组平摊下来依然接近 O(1)但单次操作可能达到 O(n)。队列用数组非环形实现时出队如果采用删除头元素并整体前移的方式复杂度是 O(n)这是初学者最容易踩的坑。链式栈/链式队列的入栈、出栈、入队、出队全部是 O(1)。空间复杂度方面顺序存储会按最大容量预先分配链表则随元素数量动态增长但每个节点多出指针开销。3. C 手撕四种实现数组栈、链栈、环形队列、链式队列3.1 顺序栈最简单的入门实现但要思考扩容策略顺序栈本质就是一个数组 栈顶指针索引入栈时数据从下标 0 开始依次往上放top 始终指向栈顶元素的位置。第一步定义框架#include iostream #include stdexcept template typename T class ArrayStack { private: T* data; int capacity; int topIndex; // 指向栈顶元素初始为 -1 void resize(int newCapacity) { T* newData new T[newCapacity]; for (int i 0; i topIndex; i) { newData[i] data[i]; } delete[] data; data newData; capacity newCapacity; } public: explicit ArrayStack(int cap 8) : capacity(cap), topIndex(-1) { data new T[capacity]; } ~ArrayStack() { delete[] data; } void push(const T value) { if (topIndex 1 capacity) { resize(capacity * 2); // 扩容策略扩大到原来的 2 倍 } data[topIndex] value; } void pop() { if (empty()) { throw std::out_of_range(Stack underflow); } --topIndex; } T top() const { if (empty()) { throw std::out_of_range(Stack is empty); } return data[topIndex]; } bool empty() const { return topIndex -1; } int size() const { return topIndex 1; } };这段代码里有几个设计细节值得讲一是 topIndex 初始为 -1表示空栈。入栈时先加一再赋值确保第一个元素放在下标 0 的位置。出栈时直接把 topIndex 减一逻辑上丢弃该位置上的元素。可能有人会问要不要把弹出的元素清空对于 int、double 等基础类型没必要但对于需要析构的对象类型如果容器没有配合析构那就要考虑对象生命周期更推荐直接复用标准库的 vector 来做存储。二是扩容策略为什么用 2 倍而不是固定加一个常数。假设每次扩容只多 4 个空间那么连续 push 100 个元素需要扩容约 25 次每次都要拷贝全部已有数据时间开销呈 O(n^2) 增长。复制成 2 倍后虽然单次扩容偶尔会浪费一点空间但均摊下来每次 push 的时间复杂度是 O(1)。经典的时间换空间或者说用空间换时间这里就是典型的工程取舍。如果你还想做得更精细也可以参考 vector 的容量增长策略用 1.5 倍、2 倍甚至带最大容量限制的动态调整。三是没有提供 top() 和 pop() 分离的 C 语言版本接口。C 里为了符合异常安全尽量把访问栈顶元素和弹出栈顶元素拆成两个操作避免返回顶层对象后再弹栈时产生拷贝开销和生命周期问题。3.2 链栈没有容量上限用堆空间换灵活性链栈是以链表节点为基础的栈不需要预先分配大块连续空间每个节点在入栈时动态申请。我把节点定义和栈类合在一起写template typename T class LinkedStack { private: struct Node { T data; Node* next; Node(const T value, Node* n nullptr) : data(value), next(n) {} }; Node* head; // 头节点作为栈顶 int count; public: LinkedStack() : head(nullptr), count(0) {} ~LinkedStack() { while (head) { 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::out_of_range(Stack underflow); } Node* tmp head; head head-next; delete tmp; --count; } T top() const { if (empty()) { throw std::out_of_range(Stack is empty); } return head-data; } bool empty() const { return count 0; } int size() const { return count; } };链栈的精髓在于头插法入栈头删法出栈。头节点就是栈顶压栈时把新节点插在头部弹栈时把头节点摘掉。整个过程只需要修改指针不需要移动任何数据时间复杂度是 O(1)。这里我补充一个很多人忽略的点链栈的析构函数一定要写。如果不写每次 new 出来的节点都会泄漏程序跑得久了内存占用会不断上升。正确写法是逐个 delete 所有节点。这一步在面试手写代码时特别容易被追问准备过和没准备过高下立判。3.3 环形队列数组实现队列的正确姿势如果直接用数组实现队列出队时最朴素的做法是把 head 后面的所有元素往前移动一位这样出队复杂度就是 O(n)毫无亮点。正确的解法是环形队列circular queue也叫循环队列用两个指针分别标记队头和队尾让数组的物理空间在逻辑上首尾相接。template typename T class CircularQueue { private: T* data; int capacity; int head; // 队头下标 int tail; // 队尾下标指向下一个可用位置 int length; // 当前元素个数也可以不用 size 字段用 (tail-headcapacity)%capacity 判断 public: explicit CircularQueue(int cap 8) : capacity(cap), head(0), tail(0), length(0) { data new T[capacity]; } ~CircularQueue() { delete[] data; } void enqueue(const T value) { if (full()) { throw std::out_of_range(Queue is full); } data[tail] value; tail (tail 1) % capacity; length; } void dequeue() { if (empty()) { throw std::out_of_range(Queue underflow); } head (head 1) % capacity; --length; } T front() const { if (empty()) { throw std::out_of_range(Queue is empty); } return data[head]; } T back() const { if (empty()) { throw std::out_of_range(Queue is empty); } return data[(tail - 1 capacity) % capacity]; } bool empty() const { return length 0; } bool full() const { return length capacity; } int size() const { return length; } };环形队列的核心就一个公式(index 1) % capacity。这个取模运算让 index 到达 capacity-1 后再加一就回到 0实现首尾相接。head 指向队头元素tail 指向队尾元素的下一个空位。这里有一个教科书上的经典细节判断队列空和满的条件。如果只用 head tail 来判断空和满两种状态会混淆。常用解法有三种方法一额外加一个 size 字段记录元素个数上面代码用的就是这一种最直观也最不易出错。方法二牺牲一个存储单元约定当(tail 1) % capacity head时认为队列已满此时实际最多只能存 capacity-1 个元素。这种做法的好处是不需要额外字段但浪费一个位置。方法三加入一个 flag 标记最近一次操作是入队还是出队也能区分空和满但可读性稍差。我个人推荐在工程代码中用 size 字段简单可靠但在考试和面试场景中你要能默写浪费一个存储位的版本因为这个版本考查的恰恰是对取模和边界条件的理解。3.4 链式队列从头到尾只有指针操作链式队列用带头节点的单链表是最清晰的实现方式。队头指针指向头节点队尾指针指向最后一个节点。template typename T class LinkedQueue { private: struct Node { T data; Node* next; Node(const T value, Node* n nullptr) : data(value), next(n) {} }; Node* headNode; // 头节点不存数据 Node* tailNode; // 尾节点指针 int count; public: LinkedQueue() : count(0) { headNode new Node(T()); tailNode headNode; } ~LinkedQueue() { while (headNode) { Node* tmp headNode; headNode headNode-next; delete tmp; } } void enqueue(const T value) { tailNode-next new Node(value); tailNode tailNode-next; count; } void dequeue() { if (empty()) { throw std::out_of_range(Queue underflow); } Node* first headNode-next; headNode-next first-next; if (first tailNode) { tailNode headNode; // 队列变空时把 tail 重新指向头节点 } delete first; --count; } T front() const { if (empty()) { throw std::out_of_range(Queue is empty); } return headNode-next-data; } bool empty() const { return count 0; } int size() const { return count; } };链式队列的代码里最值得留意的是 dequeue 中那句判空分支当删掉最后一个元素时first 等于 tailNode如果不把 tailNode 重新指向头节点之后再入队时 tailNode 就指向一块已释放的内存产生悬垂指针程序崩溃属于轻的更可怕的是内存被复用后产生隐性数据损坏。这个 bug 我当年写完调试了很久才意识到所以特意写在代码注释里提醒大家。另外构造函数里Node(T())这段只是为了保证模板类型可以先默认构造出一个占位节点。对于 int、自定义类都能用。如果你不想要求类型必须支持默认构造也可以用无头节点双指针空判断的实现代码会稍微复杂一些。3.5 四种实现对比什么时候选哪种我把四种实现的特性整理成一张表方便参照实现方式入栈/入队出栈/出队空间特点适用场景顺序栈O(1) 均摊O(1)连续内存可能扩容拷贝元素量可预估、追求局部性链栈O(1)O(1)每个节点额外 8 字节指针元素量波动大、无容量上限环形队列O(1)O(1)固定容量不扩容缓冲区、滑动窗口、生产者消费者链式队列O(1)O(1)每个节点额外指针开销任务队列、消息队列动态增长工程上的建议是如果数据量比较稳定优先选顺序存储性能和缓存友好度都更好如果数据量无法预估且插入删除频繁优先选链式存储。而如果你要处理一边生产一边消费的场景环形队列几乎是最优解因为它不需要频繁 new/delete 节点内存复用率高还能利用连续内存的缓存优势。4. 不止是先进后出、先进先出栈和队列在系统与算法里的真实身份4.1 栈在编译器和操作系统中的地位栈在计算机系统里无处不在很多人没有意识到寻常的 C 函数调用背后全靠栈在撑。函数调用时系统会把当前函数的返回地址、参数、局部变量压入调用栈函数返回时再依次弹栈恢复现场。递归函数的调用过程尤其明显每次递归都会产生一个新的栈帧这就是为什么递归层级过深会爆栈stack overflow。你写一个没有终止条件或者递归深度几万层的函数程序直接崩溃本质就是系统调用栈的容量被耗尽了。编译器解析表达式时也是典型的栈应用。例如中缀表达式3 4 * 2转后缀表达式再用栈计算结果整个过程靠两个栈协同。栈还能用来做括号匹配遇到左括号入栈遇到右括号弹栈并检查是否匹配不匹配就报错。这个算法的代码量很小但却是很多编译原理课程的入门练习。应用层也足够常见文本编辑器里的撤销操作就是栈结构每次操作压入历史栈撤销时弹栈恢复浏览器的页面访问历史也是两个栈前进和后退就是在两个栈之间倒腾元素。这些场景都有一个共同特征关心的是最近的状态而不是最早的状态。只要能抽象成最近优先处理的逻辑大概率就适合用栈。4.2 队列在操作系统和网络中的应用队列的核心场景是排队处理。操作系统的进程调度就大量使用队列就绪队列里存放等待 CPU 的进程先来先服务FCFS调度算法的本质就是队列。打印任务提交给打印机后多个任务按顺序排队这也是标准的先进先出模型。更贴近现代开发的是消息队列和任务队列。后端服务里请求到达后会先放进队列再由工作线程异步消费。这样做既能削峰填谷避免瞬时高并发压垮数据源也能让生产者和消费者的速率解耦。实现上线程池的任务队列就经常用环形缓冲或者链式队列。网络协议里的数据包缓冲、键盘输入缓冲区也都是队列的变体。你在终端里快速敲击键盘字符并不会立刻全部传输系统先把它放进输入缓冲区然后按顺序读取这就是一个典型的队列过程。4.3 算法题里的黄金搭档DFS 用栈BFS 用队列图或者树的遍历算法里深度优先搜索DFS和广度优先搜索BFS是必学内容。如果用递归实现 DFS底层依赖的是系统调用栈如果显式用栈模拟可以避免递归深度受限的问题。而 BFS 的标准结构就是队列从起点开始把相邻节点依次入队弹出一个节点再把它未访问的邻居入队直到队列为空。正因为这种结构上的契合栈一出现多半意味着你要处理深度路径或最近关联问题队列一出现则多半意味着层级扩展或顺序扩散问题。比如二叉树的层序遍历用队列是天然解法每轮循环处理当前队列里的所有节点正好对应一层的元素。滑动窗口最大值这类问题也可以借助双端队列deque维护窗口内的候选最大值此时容器本身就不再是普通队列而是灵活变体。4.4 双端队列一个容器满足两种意图既然提到 deque我多说几句。双端队列double-ended queue允许在头尾两端都进行插入和删除听起来比栈和队列更自由但它和栈/队列并不是互相替代的关系。deque 是 STL 中一种独立的顺序容器而 stack 和 queue 在默认实现下都把它作为底层容器来使用。为什么默认底层是 deque原因是 deque 兼具 vector 和 list 的某些优点支持 O(1) 的随机访问、头尾插入删除都是 O(1)、内存按块分配而非完全连续。对 stack 来说只需要尾插尾删vector 也能胜任对 queue 来说需要头删尾插vector 头删需要移动元素list 又太浪费内存节点deque 就成了综合权衡下的最优选择。标准库为 stack 和 queue 提供第二个模板参数就是让你可以传入 vector、list 或自定义容器以满足不同场景的偏好。5. STL 里的 stack 和 queue适配器模式的最佳教学现场5.1 为什么说 stack 和 queue 不是容器是容器适配器翻开 C 标准库代码stack 的模板定义大致长这样template class T, class Container std::dequeT class stack { public: // ... bool empty() const; size_type size() const; reference top(); void push(const value_type value); void pop(); };queue 的模板定义类似只是默认容器同样是 deque但 pop 操作删除的是头部元素template class T, class Container std::dequeT class queue { public: bool empty() const; size_type size() const; reference front(); reference back(); void push(const value_type value); void pop(); };注意stack 和 queue 都没有迭代器。这是有意为之。它们暴露的接口刻意保持最小化栈只需要 top/push/pop队列只需要 front/back/push/pop。这种设计叫适配器模式——在底层容器之上重新包装接口把通用容器的能力约束成特定数据结构的行为。这带来的好处很明显代码只需要面向 stack 的接口编程就不会不小心在中间插一个元素。数据结构的语义约束从约定变成了类型系统强制编译阶段就能阻止误用。这一点在实际项目中价值极大因为人写代码时总会手滑容器越通用误操作的可能性就越大。5.2 stack 接口详解从 size 到 emplace 的讲究stack 的常用成员函数清单top()返回栈顶元素的引用不弹出。push(value)入栈拷贝或移动元素。pop()弹出栈顶元素无返回值。emplace(args...)在栈顶就地构造元素避免一次临时对象的拷贝。size()返回栈内元素个数。empty()判断是否为空。很多 C 新手第一次见 pop() 不返回元素会不习惯。明明想取出来还得先 top 再 pop。这个设计是有意为之如果 pop 返回元素就必须先构造一个返回值再删除栈顶对象这涉及额外的拷贝/移动开销还可能引发异常安全性问题。C 的标准做法就是 top 和 pop 分离让你自己决定怎么处理拿出来的元素。emplace 则是 C11 以后推荐的方式。如果你要压入一个较复杂的对象push(MyClass(args...))会先构造一个临时对象再拷贝进容器而emplace(args...)直接在容器的内存里构造对象少了一次临时对象的创建和析构。对简单类型int、double差别不大但对 contains string、vector 的对象性能差距能明显感知。5.3 queue 接口详解front 和 back 的双向可见性queue 的接口和 stack 有一些对称性front()返回队头元素引用。back()返回队尾元素引用。push(value)在队尾插入元素。pop()从队头移除元素。size()、empty()、emplace() 同 stack。front/back 都可以在 O(1) 时间内获得也就是说队列能同时看到队头和队尾的数据但不能看到中间元素。这非常符合排队的直觉你能看队伍头和尾是谁但队伍中间的人你没法直接点名。5.4 自定义底层容器的实验vector 和 list 各有什么表现因为 stack 和 queue 是适配器你可以显式指定底层容器。举几个例子std::stackint, std::vectorint s1; // 用 vector 做栈 std::stackint, std::listint s2; // 用 list 做栈 std::queueint, std::listint q1; // 用 list 做队列stack 用 vector 完全没问题因为 stack 只需要尾插尾删vector 的 push_back 和 pop_back 都是 O(1)。和 deque 相比vector 的局部性更好内存更紧凑但扩容时可能要整体搬移。stack 用 list 也可以不过节点式存储的缓存表现更差一般只在需要保证任何操作都不会因扩容而耗时的场景才考虑。queue 用 vector 就不行了因为 queue 需要头部删除vector 的 erase(begin()) 是 O(n)。queue 用 list 可以实现但 list 的节点内存不连续遍历时缓存不友好。所以 queue 默认用 deque 是经过充分权衡的。5.5 priority_queue队列家族里最特殊的成员还是这个题目范围内最容易延伸的一个点priority_queue优先级队列。它的声明是template class T, class Container std::vectorT, class Compare std::lessT class priority_queue;priority_queue 默认是一个大顶堆top() 返回的是最大的元素而不是最早入队的元素。它是每次从所有元素中取优先级最高者的数据结构底层基于二叉堆实现。入堆和出堆操作的时间复杂度都是 O(log n)比线性查找高出一个量级。适用范围包括任务调度按优先级取任务、Dijkstra 最短路径算法的堆优化、求动态数据流中的中位数等场景。面试时被问到队列的变种你如果能从 queue 聊到 deque再聊到 priority_queue并且说清它们各自底层的容器和堆结构往往能给面试官一个深刻的印象。6. 从理论到实战三个你必须掌握的经典应用案例6.1 括号匹配的栈实现一次遍历搞定代码量不到二十行问题描述给定一个只包含()[]{}的字符串判断括号是否有效。所谓有效是指每个左括号都有同类型的右括号闭合且闭合顺序正确。核心逻辑遍历字符串遇到左括号就入栈遇到右括号就检查栈顶是否匹配匹配则弹出不匹配或栈为空则直接判定非法。遍历完全部字符后如果栈仍非空说明有未闭合的左括号也判定非法。#include stack #include string #include unordered_map bool isValidParentheses(const std::string s) { std::stackchar st; std::unordered_mapchar, char pairs { {), (}, {], [}, {}, {} }; for (char c : s) { if (c ( || c [ || c {) { st.push(c); } else if (st.empty() || st.top() ! pairs[c]) { return false; } else { st.pop(); } } return st.empty(); }嵌套结构是这个问题的天然属性。比如{ [ ( ) ] }这种层层包裹的输入用栈处理简直是量身定制每次内层括号闭合时栈顶恰好就是配对的左括号。这个解法的时间复杂度 O(n)空间复杂度 O(n)。要注意的一个坑是只统计括号数量、不检查顺序的解法是错的。([)]这种输入左括号数量和右括号数量都匹配但括号没有正确嵌套。只有栈能在每一次闭合时确认最近的未闭合左括号是谁这正是栈处理嵌套结构的核心价值。6.2 表达式求值中缀转后缀并用栈计算原理一次看穿表达式求值是栈应用里的经典中的经典。为了去掉运算符优先级和括号的干扰通常先把人熟悉的中缀表达式转成后缀表达式逆波兰表达式再用栈求值。中缀转后缀的调度场算法描述遇到操作数直接输出。遇到左括号入栈。遇到右括号弹出栈中运算符并输出直到遇到左括号左括号出栈但不输出。遇到运算符弹出栈中所有优先级不低于当前运算符的运算符并输出再把当前运算符入栈。后缀表达式求值遇到操作数入栈。遇到运算符弹出两个操作数先弹出的是右操作数后弹出的是左操作数计算结果后压回栈。最终栈顶就是表达式的结果。这里我贴一段后缀求值的核心代码#include stack #include cctype #include string int evaluatePostfix(const std::string expr) { std::stackint st; for (char c : expr) { if (std::isdigit(c)) { st.push(c - 0); } else { int right st.top(); st.pop(); int left st.top(); st.pop(); switch (c) { case : st.push(left right); break; case -: st.push(left - right); break; case *: st.push(left * right); break; case /: st.push(left / right); break; default: break; } } } return st.top(); }注意减法和除法是有方向的left - right而不是right - left。因为后缀表达式中前面的操作数在栈的更深层后出现的操作数在栈顶。很多初学者在这里搞反导致结果完全错误。建议看到减法时在心里默念先入栈的是被减数后入栈的是减数。表达式求值这个例子最有意思的地方在于它把运算符优先级比较和遍历顺序控制这两件事化成了简单的栈操作左括号入栈相当于进入一个子表达式右括号弹栈相当于跳出子表达式。理解了这个案例你的栈就算真正入门了。6.3 二叉树的层序遍历队列让一层一层变得如此自然如果栈的经典题是深度优先那队列的经典题一定绕不开层序遍历。给定一棵二叉树要求按从上到下、从左到右的顺序输出每个节点。层序遍历的做法是先把根节点入队然后循环——每次取出队首节点访问它再把它不为空的左右子节点依次入队直到队列为空。#include queue #include vector struct TreeNode { int val; TreeNode* left; TreeNode* right; TreeNode(int x) : val(x), left(nullptr), right(nullptr) {} }; std::vectorint levelOrder(TreeNode* root) { std::vectorint result; if (!root) return result; std::queueTreeNode* q; q.push(root); while (!q.empty()) { TreeNode* node q.front(); q.pop(); result.push_back(node-val); if (node-left) q.push(node-left); if (node-right) q.push(node-right); } return result; }如果想要每一层的节点分组可以每轮循环先记录当前队列的 size然后只处理 size 个节点这样就能把同一层的节点收集到一起std::vectorstd::vectorint levelOrderGroup(TreeNode* root) { std::vectorstd::vectorint result; if (!root) return result; std::queueTreeNode* q; q.push(root); while (!q.empty()) { int levelSize q.size(); std::vectorint 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; }这个例子里队列之所以天然匹配是因为 BFS 的访问顺序就是先访问距离根更近的节点再访问距离更远的节点而队列的先进先出恰好保证了这个顺序。反过来看 DFS 的递归实现系统栈帮助程序先深入到一条路径最深处再回头处理分支。可以说数据结构的选择决定了算法的遍历味道。7. 实操中的常见问题与排查技巧这些坑我都踩过7.1 空栈空队列上调用 top/front/pop新手最容易踩的坑就是在容器为空的时候访问元素或弹出元素。标准库的 stack 和 queue 在空容器上调用 top/pop 属于未定义行为程序可能崩溃也可能静默产生错误结果。自己实现的类里一定要加 empty() 判断并抛出异常。排查技巧如果程序偶发崩溃但无法稳定复现优先怀疑栈/队列指针操作。可以在每次 pop 前打印 size配合条件断点定位首次出现空操作的位置。我曾经在写表达式求值时因为弹栈顺序错误导致栈提前变空程序每次跑到同一个字符附近就崩打日志后三分钟就发现了。7.2 队列的求模运算优先级错误环形队列里tail (tail 1) % capacity;这行代码本身没问题但如果你写成tail (tail 1 % capacity);由于%优先级高于实际计算变成了tail (1 % capacity)当 capacity 大于 1 时相当于没取模。小括号必不可少。类似的低级错误还有比较时把写成导致队满判断变成赋值操作这种 bug 编译器不一定会报错但运行逻辑全乱。建议打开编译器警告参数比如 GCC 的-Wall就能把这类问题提示得更明显。7.3 链式结构的悬垂指针和内存泄漏链式队列出队最后一个元素时忘记把 tail 指针更新回 headNode这是悬垂指针的经典场景。我前文代码里已经特别标注过实际工程中这种类型错误非常隐蔽因为它只在队列恰好变空的那一刻才会触发普通测试用例可能根本覆盖不到。内存泄漏则更容易出现在析构函数里。自建的链栈、链式队列如果没有正确遍历删除所有节点程序长时间运行后占用内存持续增长。排查工具上Linux 推荐 valgrindmacOS 推荐泄漏检测工具Windows 下可以用 DR 内存检测器。跑一遍几千个入队出队操作的用例配合工具就能很快定位。7.4 模板类型不支持默认构造和拷贝构造写自建容器时如果模板参数是复杂类型可能会触发无默认构造函数或拷贝构造被删除的编译错误。比如某个类只允许移动构造禁止拷贝你用new Node(T())初始化头节点就可能编译失败。一种规避思路是把存储类型改成std::optionalT另一种是改用指针节点但要求类型具备移动语义。如果只是做学习示例最省事的做法是限制模板参数类型在注释里注明约束条件。标准库容器之所以好用很大程度上就是因为它对各类对象的构造/析构/异常安全做了非常细致的设计这也是我建议工程中优先使用 STL 的原因。7.5 性能问题的两个幽灵扩容抖动和缓存命中率顺序栈的扩容策略如果选择不当在大量连续 push 的场景下会产生频繁的重新分配和数据拷贝造成卡顿。解决思路有两种一是提前 reserve 足够的容量二是采用倍增扩容策略。链式容器则在大量节点新增/删除时会产生频繁的堆内存分配释放内存碎片增多。此外链表节点在堆上分布不连续遍历时缓存 miss 严重。实测中同样数量级的操作链表实现往往比数组实现慢数倍。所以在性能敏感场景优先考虑数组/环形队列方案而不是无脑链表。7.6 问题排查速查表症状可能原因解决思路程序在 pop 时崩溃空栈/空队列操作检查 empty 判断打印调用日志队列元素个数异常取模运算写错检查 % 与 的优先级补括号队列无法判满空满条件混淆引入 size 字段或采用牺牲一个存储位的策略链式队列出队后入队异常tail 悬垂指针删除最后一个元素时重置 tail 到头节点内存持续增长析构函数未释放节点遍历 delete 或使用智能指针编译报错无法默认构造模板参数类型限制改用 optional 或限制类型要求8. 几点基于经验的选型心得以及这个小专题怎么继续深入8.1 我的选型原则优先 STL其次自建最后才谈手写很多教材鼓励学生手写栈和队列这没问题手写能加深理解。但工程上我强烈建议优先使用std::stack和std::queue。原因有三点标准库容器经过充分测试异常安全性、内存管理、边界条件都考虑周全你自己手写很难在短时间内达到同等质量。标准库接口设计简洁代码可读性高团队协作时沟通成本低。适配器模式让你可以在不同底层容器之间切换业务代码几乎不用改。那什么时候需要自建容器两种情况面试或考试要手写核心逻辑特定场景有性能定制需求比如需要固定容量的环形缓冲、需要带统计信息的队列、需要无锁并发队列。这时再写自己的实现基于前面讲的设计思路可以很快搭出来。8.2 理解 C 容器底层是进阶还是保底这个专题看似简单但它牵出的问题链条很长。从栈和队列出发你能顺畅地延伸到 deque 的分块存储原理、list 的节点结构、vector 的扩容机制、priority_queue 的堆算法再到 allocator 的内存分配策略。换句话说栈和队列是 C 容器知识体系的入口也是理解 STL 底层设计逻辑的抓手。我建议你在学完手写实现后再去读一遍所用编译器的 STL 源码重点看 stack 的底层容器模板参数和 queue 如何把 push/pop 映射到 deque 的 push_back/pop_back/push_front/pop_front。不用全看懂把大致结构摸清就足够受益良多。8.3 最后一小技巧调试容器时试着写一个 dump 函数调试栈和队列时我习惯给容器写一个临时 dump 函数把元素从头到尾打印出来。但因为栈只能看栈顶、队列只能看首尾打印方式受限我常用的办法是借一个临时容器把元素依次弹出并记录再恢复原样。这个技巧写起来有点绕但排查问题时特别好使尤其是在处理复杂对象的入栈出栈顺序时能一眼看出问题所在。void dumpStack(std::stackint s) { // 注意按值传递拷贝一份不改变原栈 std::vectorint tmp; while (!s.empty()) { tmp.push_back(s.top()); s.pop(); } for (auto it tmp.rbegin(); it ! tmp.rend(); it) { std::cout *it ; } std::cout std::endl; }有人会担心按值传栈是不是性能太差调试场景根本无所谓拷贝一份换来安全观察非常值得。等你调试完再把这段临时代码删掉即可。栈和队列看起来是两个简单结构但真正把它们用在合适的地方、理解它们为什么这样设计需要掌握的东西远比概念本身多。从手写一个顺序栈开始到读懂 STL 的适配器源码再到用它们解决括号匹配、表达式求值、层序遍历等问题每一步都是对数据结构与算法这个地基的加固。希望这篇内容能帮你把栈和队列背后的为什么彻底打通。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑