ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

C++栈与队列从手写实现到工程应用:函数调用、循环队列与消息队列实战解析

C++栈与队列从手写实现到工程应用:函数调用、循环队列与消息队列实战解析 先说我的一次真实经历。前两年排查一个线上崩溃问题进程直接挂掉同事让我先跑一下backtrace栈回溯。日志打出来是一串十六进制地址我盯着看半天也没定位到具体逻辑旁边老同事指着其中一层帧说你看这个栈帧是某某函数留下的返回地址指向它的调用者顺着这条调用链就能找到问题源头。那一刻我才意识到平时在课本上背的栈根本不是刷题专用它就在每一次函数调用、每一帧栈帧里。后来带新人学C我基本都会把栈和队列当成第一个必讲的数据结构不是因为它们简单而是因为它们是理解程序运行方式、系统设计、消息架构的最短路径。这篇内容就围绕C里栈和队列怎么学、怎么手写、怎么用、坑在哪里展开适合刚学C的初学者也适合想补数据结构基础、准备面试或做项目的人。1. 把栈和队列当作带规则的线性表来看学习曲线会平一半很多初学者第一次接触栈和队列时容易把它们当成两个孤立的概念去背栈是先进后出队列是先进先出。背完就去做题做完就忘。我个人觉得这样学效率很低。栈和队列的本质其实是线性表——就是一连串数据排成一条线——只不过在操作上加了严格限制。数组和链表是自由的线性表你可以在任意位置插入、删除、访问而栈只让你在一端操作队列只让你在两端各做一件事。理解了这个前提你就能明白为什么说栈和队列是受限的线性表。这个限制不是坏事恰恰是它们最大的价值。拿栈举例它只允许在栈顶压入push和弹出pop后进栈的元素一定先出栈这个LIFOLast In First Out规则让调用关系变得完全可预测。你调用一个函数系统就把当前函数的状态压入调用栈函数返回系统就弹出这一帧。如果没有这种后进先出的约束函数嵌套调用和递归根本无法可靠实现。队列也一样FIFOFirst In First Out保证先到的请求先被处理这在线程池任务调度、网络请求排队、生产者消费者模型里是硬性要求。用生活场景类比就很好懂。栈就是一摞盘子你每次只能拿走最上面那个新盘子也总是放在最上面队列就是食堂打饭的排队先来的人先打到饭后来的人排在队尾。C里标准库直接提供了std::stack和std::queue但我的建议是学习阶段一定不要只停留在调用接口。你得先自己动手用数组和链表实现一遍把容量、指针、边界条件这些底层问题摸透再看标准库的容器适配器你才会真正明白它为什么这样设计。这也是老生常谈的学习路径先手写再用封装。2. 手写栈的两种姿势数组扩容版和链表节点版我见过不少新手直接跳去用std::stack然后碰到两个问题答不上来栈内存不够了怎么办频繁push和pop会不会有性能问题。这些问题的答案都在手写实现里。栈的底层实现无非两种基于连续内存的数组和基于节点指针的链表。2.1 数组栈的关键top到底从-1还是0开始用数组实现栈时需要维护一个指向栈顶的索引习惯上叫top。不同教科书有两种约定top初始为-1表示空栈push时先top再写入top初始为0表示空栈push时先写入再top。我推荐第一种因为判空直接看top -1判满看top capacity - 1逻辑更顺。下面是一份带动态扩容的数组栈实现#include iostream template typename T class ArrayStack { private: T* data; int capacity; int top; // 初始 -1指向栈顶元素 public: explicit ArrayStack(int cap 16) : capacity(cap), top(-1) { data new T[capacity]; } ~ArrayStack() { delete[] data; } void push(const T value) { if (top 1 capacity) { resize(capacity * 2); } data[top] value; } void pop() { if (top 0) { --top; } } T peek() { return data[top]; } bool empty() const { return top -1; } int size() const { return top 1; } private: void resize(int newCap) { T* newData new T[newCap]; for (int i 0; i top; i) { newData[i] data[i]; } delete[] data; data newData; capacity newCap; } };这份代码里有个值得注意的地方扩容因子我选了2倍。你可能听过均摊复杂度amortized time这个概念虽然单次扩容要拷贝全部元素看起来是O(n)但每次扩容后容量翻倍n次push里只有log n次触发扩容平摊下来每次push还是O(1)。这个结论在很多面试里会被直接问到理解扩容机制之后就不怕了。2.2 链表栈不要容量限制但代价藏在细节里链表实现栈的好处是理论上没有容量上限每次push动态分配一个新节点插入到链表头部即可。因为栈只在一端操作所以链表的头部就是栈顶不需要尾指针。下面这份代码我想强调两个细节一是节点定义里的next指向旧栈顶二是析构函数必须逐个释放节点否则会内存泄漏。template typename T struct StackNode { T value; StackNode* next; }; template typename T class LinkedStack { private: StackNodeT* head; public: LinkedStack() : head(nullptr) {} ~LinkedStack() { while (head) { StackNodeT* tmp head; head head-next; delete tmp; } } void push(const T value) { StackNodeT* node new StackNodeT{value, head}; head node; } void pop() { if (!head) return; StackNodeT* tmp head; head head-next; delete tmp; } T peek() { return head-value; } bool empty() const { return head nullptr; } };2.3 选数组还是链表不是谁更好而是谁更合适我把两种实现的差异整理成了一张表方便你对照理解维度数组栈链表栈内存分布连续内存CPU缓存友好节点分散缓存命中率低容量限制有靠扩容解决理论无上限push开销均摊O(1)扩容时有拷贝每次都要new分配内存空间浪费扩容后可能有空闲容量每个节点多存一个next指针适合场景栈大小可预估、追求性能栈大小不可预估、嵌入式有内存碎片顾虑数组栈在绝大多数工程场景下是首选原因很简单连续内存访问速度更快动态扩容虽然偶尔有拷贝但均摊成本可以接受。链表栈的优势是灵活性但代价是每次push都要进行一次堆内存分配在高频调用场景下分配器可能成为瓶颈。你在实际项目里如果拿不准优先数组实现准没错。3. 循环队列里的环形思维从假溢出到rear和length的配合队列用数组实现的时候会产生一个经典的工程问题假溢出。假设你开了一个长度10的数组当队列front指向队头、rear指向队尾的下一个位置。入队三个元素出队三个元素rear和front都往后移了数组前三个位置空出来但rear已经走到下标3。看起来队列满了可实际上空位明明在前面。如果继续入队就会在后半段越界如果简单把所有元素往前搬又会让每个出队操作变成O(n)。解决方案就是用环形数组也就是循环队列。3.1 环形数组的取模操作是核心循环队列把数组首尾相接rear和front移动时通过取模回绕rear (rear 1) % capacityfront (front 1) % capacity。这样前面空出来的位置可以被重新使用。我曾经带过的学员里很大一部分人第一次写循环队列都会忘了取模导致越界还有人会把取模用在数组下标上但忘记修改front。要记住凡是移动指针的地方都要考虑绕一圈的问题。这也是网上那个常见考题的用意假设以数组q[m]存放循环队列中的元素同时以rear和length分别指示环形队列中的队尾和长度——用的就是取模回绕的思路。3.2 判空判满的两种方案各有利弊循环队列的难点在于判空和判满。如果不加额外信息单纯用front和rear两个指针会遇到一个尴尬空队列时front rear满队列时front也可能等于rear。怎么区分有两种常见方案。方案一牺牲一个存储单元。约定rear指向队尾的下一个位置并始终保留一个空位那么判空条件仍是front rear判满条件变成(rear 1) % capacity front。这方案不需要额外成员但实际可用空间比数组容量少1。方案二额外记录length或count成员。这就是上面题目里rear和length配合的场景每次入队length出队length--判空用length 0判满用length capacity。缺点是多个成员需要同步维护优点是空间不浪费逻辑也直观。我自己写生产代码时更偏向方案二因为多一个int的成本远低于少一格容量带来的边界判断复杂度。下面是完整的循环队列实现采用方案二template typename T class CircularQueue { private: T* data; int capacity; int front; int rear; int count; public: explicit CircularQueue(int cap) : capacity(cap), front(0), rear(0), count(0) { data new T[capacity]; } ~CircularQueue() { delete[] data; } bool enqueue(const T value) { if (count capacity) { return false; // 队列已满 } data[rear] value; rear (rear 1) % capacity; count; return true; } bool dequeue(T out) { if (count 0) { return false; // 队列为空 } out data[front]; front (front 1) % capacity; --count; return true; } bool empty() const { return count 0; } bool full() const { return count capacity; } int size() const { return count; } };3.3 环形数组扩容比普通数组麻烦但可以绕开如果你在循环队列上做扩容有一个隐藏的坑元素在逻辑上是环形的物理存储却是从数组开头连续排列的。扩容时如果只是简单地把旧数组拷贝到新数组元素顺序会错乱。你需要先确定front的实际位置按逻辑顺序把元素重新排列到新数组的头部。这也是许多教材和面试官喜欢深挖的细节。我的建议是如果队列规模可预估直接用固定容量加入队失败返回false的策略如果必须动态扩容就写个专用的reallocate函数把front到rear之间的元素按顺序搬到新数组。3.4 队列的两个经典用武之地循环队列学完别扔一边。在实际工程里最典型的场景是BFS广度优先搜索遍历树和图以及生产者和消费者模型。BFS时你用队列保存待访问节点先入队的先处理正好是FIFO语义生产者消费者模型里任务队列天然就是队列。哪怕你以后写嵌入式程序环形缓冲区也几乎就是循环队列的别名。所以这个结构不是考试专属是真的贯穿前后端和底层开发。4. 栈的应用不止刷题函数调用栈、括号匹配与表达式求值我见过有人把栈的所有应用都理解为括号匹配和表达式求值这其实太小看栈了。栈最深刻的应用场景是你每天运行程序时就在发生的函数调用。4.1 函数调用栈与栈帧形成过程每一门高级语言实现函数调用时底层都会依赖系统栈。每次函数调用系统会压入一个栈帧stack frame栈帧里至少包含三样东西返回地址函数结束后跳回哪里、调用者的一些寄存器状态、局部变量和参数。函数执行过程中可能还会继续压入新的栈帧形成一条完整的调用链当函数返回时系统弹出对应栈帧恢复到调用前的状态。这就是栈帧形成过程。理解这个机制对排查崩溃问题特别重要。比如程序出现段错误或断言失败时调试器能打印backtrace栈回溯把当前时刻的调用链展示出来。你在Linux下用gdb调试执行bt命令就能看到从最内层函数到main的每一帧在Windows里用Visual Studio调试调用堆栈窗口也是同一个原理。甚至在做ARM嵌入式开发时芯片的异常处理机制也会利用栈来记录现场通过调用栈回溯定位异常发生在哪个函数。所以栈不是一个抽象概念它是程序运行的基础设施。4.2 递归过深为什么会栈溢出了解了函数调用栈你就能解释一个经典问题为什么递归几万层会崩。每个函数帧在栈上占的内存是有限的默认线程栈空间在Linux下通常8MBWindows主线程通常1MB。如果递归深度太大栈帧不断压入最终会突破栈空间上限触发栈溢出程序直接崩溃。这也是为什么很多递归实现需要改成循环或者尾递归优化以及为什么嵌入式开发里要专门考虑增大栈空间——比如RP2040这类单片机在Pico SDK里可以通过配置去调整栈大小。这些知识都来源于同一个原理。4.3 括号匹配一个教科书式的栈应用括号匹配问题虽然基础但很能说明栈为什么适合这类场景你需要把最近的左括号和当前的右括号配对这种最近匹配语义天然和栈的LIFO一致。实现思路是扫描字符串遇到左括号入栈遇到右括号就弹栈比对如果不匹配或者栈为空则判定非法。我写了个顺手版本bool isValidBrackets(const std::string s) { std::stackchar 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(); }这段代码我提醒一个小细节扫描结束后要检查st.empty()。很多新手只处理了右括号匹配忘了如果最后一个字符是左括号遍历结束后栈里还有残留按理应该返回false。这一类边界条件正是数据结构题里最常见的失分点。4.4 表达式求值操作数栈让计算过程一目了然表达式求值也是栈的经典战场。用栈计算后缀表达式逆波兰表达式时遇到数字就压栈遇到运算符就弹出两个操作数计算再把结果压回去。整个过程没有任何括号也不需要运算符优先级完全靠栈的顺序控制。中缀表达式转后缀表达式也不需要单独背算法你只要用符号栈去处理运算符优先级遇到运算符时如果栈顶运算符优先级不低于当前运算符就弹栈输出直到满足优先级条件。当初我把手动模拟的步骤在纸上画了几遍才彻底理解为什么这个算法是对的。动手模拟比盯着代码死记硬背有效得多。5. 从内存中的队列到消息系统阻塞队列、无锁队列与MQ选型如果说栈撑起了函数调用那队列撑起的就是系统与系统之间的协作。在工程实践里队列概念从单机内存延伸到了分布式系统。这一节聊聊我实际项目里接触过的三种队列形态。5.1 线程池为什么要用阻塞队列写多线程代码时线程池是常见的基础设施。线程池内部维护一个任务队列生产者线程往队列里投递任务消费者线程从队列里取出任务执行。如果消费者处理速度跟不上生产者任务就需要排队缓冲。直接拿std::queue裸用就有同步问题需要加锁。线程池里的阻塞队列通常用互斥锁加条件变量实现入队后通知等待中的消费者队列为空时消费者挂起等待。我用C11的标准库写过一份简化版#include queue #include mutex #include condition_variable template typename T class BlockingQueue { private: std::queueT q; std::mutex mtx; std::condition_variable cv; public: void push(const T item) { { std::lock_guardstd::mutex lock(mtx); q.push(item); } cv.notify_one(); } T pop() { std::unique_lockstd::mutex lock(mtx); cv.wait(lock, [this] { return !q.empty(); }); T item q.front(); q.pop(); return item; } };这个阻塞队列的语义和std::queue区别很大pop在没有元素时会阻塞等待而不是直接返回。条件变量加上lambda判断条件能有效避免假唤醒。线程池选阻塞队列时有界队列和无界队列的选择也要注意无界队列可能导致任务无限堆积拖垮内存有界队列则会触发拒绝策略需要根据业务决定是丢弃、阻塞还是抛异常。5.2 无锁队列、原子操作与它的使用边界有锁队列在锁竞争激烈时会有上下文切换和缓存失效的开销所以出现了无锁队列。无锁队列依赖CPU提供的原子指令例如CASCompare And Swap在C里封装为std::atomic的compare_exchange_weak/compare_exchange_strong。单生产者单消费者SPSC场景下甚至可以用环形缓冲区加两个原子变量实现无锁队列这也是许多高性能日志库的做法。但我要提醒你无锁队列的正确性非常难验证涉及内存序、ABA问题、多生产者多消费者的竞争窗口绝不是简单套个std::atomic就完事。工程上如果业务能容忍微秒级锁开销优先用锁只有锁竞争成为明确瓶颈时才考虑无锁方案而且要经过严谨的压测和并发验证。5.3 消息队列三兄弟Kafka、RabbitMQ、RocketMQ选型对比队列思想延伸到分布式系统就成了消息队列Message Queue。从运维、后端到全栈项目消息中间件几乎绕不开Kafka、RabbitMQ、RocketMQ这三者。很多人在选型时被术语淹没我整理了一张偏实战的对比表维度KafkaRabbitMQRocketMQ核心定位高吞吐日志与流数据管道灵活路由、可靠消息投递电商业务削峰、事务消息吞吐能力极高顺序写盘中等偏高高消息路由能力弱按Topic与分区强支持多种Exchange中基于Topic与Tag顺序消息单分区内有序弱支持严格/松散顺序事务消息不原生支持弱强二阶段提交变体消费者确认offset提交手动/自动ack消费位点管理选型上我的经验是如果核心诉求是日志采集、流式处理、大数据管道选Kafka如果是业务系统之间做灵活的消息路由需要复杂的交换机绑定选RabbitMQ如果是电商或金融场景需要事务消息、延迟消息、大量消息堆积与重试能力选RocketMQ。这个选择不是比功能多少而是比哪个模型的语义更贴合你的业务。5.4 消息队列实战中的三个深坑这部分是我踩过的坑必须写出来。第一个是重复消费。Kafka的消费者通过offset记录消费进度如果消费者处理完消息但还没来得及提交offset就崩溃重启后会从旧offset重新消费造成重复。解决办法是消费端做幂等设计比如用唯一业务ID去重。第二个是顺序消息。Kafka只保证单分区内有序多分区全局顺序无法保证要保证某个订单的消息按顺序处理需要按业务键哈希到同一个分区。第三个是ack机制。RabbitMQ使用手动ack时务必在业务处理成功后再确认否则消息可能丢失也不要太早ack因为进程崩溃时消息就真的没了。这些坑如果你不去实战看再多文档也记不住。6. STL容器适配器教给我们的最后一课学完手写实现之后再回头看标准库的std::stack和std::queue你会发现它们非常薄。这是因为它们本质上是容器适配器container adaptor自己不管理底层内存而是包装一个现成的容器只对外暴露受限的接口。这个设计思路很有启发栈和队列的限制在意的是对外接口而不是底层存储。6.1 为什么默认底层是deque而不是vectorstd::stack和std::queue默认底层都是std::deque双端队列。很多人不理解为什么不用vectorvector在尾部插入删除是O(1)但头部就不行了而queue需要头部出队stack虽然只用尾部但deque同时支持两端高效操作还避免了一次性分配大块连续内存。deque的内部结构是分段连续内存维护一个中控器来管理多个缓冲区所以两端插入删除都是均摊O(1)又比list缓存友好。这个设计让deque成了容器适配器最安全的默认选择。6.2 如何更换底层容器以及一个常见的编译错误你可能想知道能不能给stack指定vector当底层容器可以只要满足接口要求就行。例如下面这两行std::stackint, std::vectorint vectorStack; // 合法 std::queueint, std::vectorint vectorQueue; // 编译错误stack只需要push_back、pop_back、back这些vector已有的方法所以能编译queue要求push_back和pop_front而vector没有pop_front所以编译直接失败。这个小例子完美地说明了容器适配器的约束条件它能配什么底层容器取决于你使用的接口。同理用std::list当queue的底层容器完全没问题因为list既有push_back又有pop_front。6.3 priority_queue藏在队列名下的堆还有一个容易混淆的结构是std::priority_queue优先队列。它名字里有queue底层却是std::vector靠堆算法维护元素的优先级关系。默认是大顶堆每次top()取到最大元素push和pop都是O(log n)。优先队列在任务调度、Top K问题、Dijkstra最短路里非常常用。它和普通队列的唯一共同点就是只能从一端取元素这个约束。所以学习时看到容器适配器一定要看它的底层实现别被名字带偏。6.4 一个实用的调试建议用VSCode把数据结构的运行过程看透学栈和队列时我强烈建议你配好本地的C调试环境并实际打断点。我自己的习惯是用VSCode加上C扩展配置好调试任务然后用g编译调试版本。调试器里watch窗口可以随时查看top、front、rear这些变量的变化单步执行时能亲眼看到push之后top从-1变成0循环队列出队后front如何取模回绕。这种直观的反馈比死记硬背公式有用得多。尤其是手写循环队列时加几个断点观察rear绕回的过程你会对环形结构产生真正的直觉。学到这里栈和队列的轮廓应该很清楚了往小里说它们是两道基础数据结构题往大里说它们是理解函数调用、并发协作、分布式消息系统的一条条线索。我没有给什么高深的技巧但有一点我必须强调不要只读不写。把数组栈、链表栈、循环队列都亲手敲一遍再用标准库适配器做对比然后带着这些理解去看线程池源码、看消息队列的消费模型你会发现自己对C的掌握真正上了一个台阶。
RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进