ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

C++栈和队列从原理到STL实践:函数调用、任务调度与避坑指南

C++栈和队列从原理到STL实践:函数调用、任务调度与避坑指南 在C初阶学习过程中栈和队列是我最想推荐你先啃下来的两块硬骨头。很多初学者把这两个结构当成“背概念、记操作”的考点但真正写起代码来栈的入栈出栈、队列的入队出队很容易搞混甚至不知道为什么需要这两种东西。其实栈和队列在C里不只是两个类模板它们是理解函数调用机制、表达式计算、缓存淘汰、任务调度等一系列工程问题的起点。这篇博客我会从数据结构本身出发先讲透原理再给出自己手写的数组栈和链表队列然后对照STL中的stack和queue最后把实际开发中常见的坑和排查思路都摊开聊一聊。无论你是刚学完指针和类的新手还是想复习一下容器适配器的老手这篇文章都能给你一些启发。1. 从零理解栈和队列核心概念与生活类比1.1 栈后进先出的“叠盘子”栈这种结构最传神的生活类比就是叠盘子。你往一摞盘子上放新盘子永远只能放在最上面你要拿盘子也只能从最上面拿。最后放上去的盘子最先被拿走这就是“后进先出”缩写叫LIFOLast In First Out。栈的操作就两个核心动作压栈push和弹栈pop另外还有一个查看栈顶但不取出的操作top或peek。栈的所有操作都限制在栈顶这一端其他位置的数据在栈顶被移走之前你是碰不到的。为什么C函数调用天然依赖栈因为函数调用就是一个典型的后进先出过程。main函数先执行接着调用foofoo又调用bar那么bar执行完必须回到foofoo执行完再回到main后调用的函数先返回。编译器为每次函数调用分配一块栈帧里面存放局部变量、参数、返回地址函数返回时栈帧被回收控制权交还上一层。这块栈帧就是一次叠盘子的过程理解了栈函数调用的现场保存和恢复机制也就不难懂了。1.2 队列先进先出的“排队伍”队列的生活类比就是排队打饭。先来的人排在最前面先轮到他后来的人只能排在队尾。这种“先进先出”称为FIFOFirst In First Out。队列的操作同样有入队enqueue和出队dequeue分别对应排到队尾和队首离开。队列天然适合处理“按序到达、按序处理”的场景比如打印机任务队列、操作系统进程调度、网络数据包缓冲。需要特别提醒的是队列和栈不同栈只需要一个指针记录栈顶而队列至少需要两个位置标记一个队头front一个队尾rear。队头负责出队队尾负责入队。如果只有队头没有队尾入队就得遍历整个队列效率立刻变成O(n)如果只有队尾没有队头出队也会陷入同样的麻烦。所以写队列时一定要把两个指针的职责理清楚。1.3 为什么先学这两种线性结构很多初学者觉得数组和链表已经能存储数据了为什么还要专门学栈和队列这就要说到“结构”的价值。数组和链表解决的是“数据怎么存”栈和队列解决的是“数据按什么顺序被访问”。它们是对存储结构的进一步约束这种约束不是限制而是给使用者一个明确的行为契约。你告诉别人这是栈他就知道只能从栈顶操作告诉别人这是队列他就知道数据会按到达顺序被处理。这种语义上的确定性在工程里非常重要它能帮我们写出更安全、更容易推理的代码也能让算法在特定场景下获得天然的性能优势。2. C中的栈和队列实现方式2.1 自己动手数组实现栈数组实现栈的思路非常朴素用一个数组加一个整型变量toptop记录当前栈顶元素的下标。push时先判断栈是否已满然后把元素写入top1的位置最后更新toppop时直接返回top位置的元素并把top减1top等于-1时表示空栈。#include iostream #include stdexcept templatetypename T, size_t N class ArrayStack { public: ArrayStack() : top_(-1) {} void push(const T value) { if (full()) { throw std::overflow_error(stack overflow); } data_[top_] value; } void pop() { if (empty()) { throw std::underflow_error(stack underflow); } --top_; } T top() { if (empty()) { throw std::underflow_error(stack is empty); } return data_[top_]; } bool empty() const { return top_ -1; } bool full() const { return top_ static_castint(N) - 1; } size_t size() const { return static_castsize_t(top_ 1); } private: T data_[N]; int top_; };这段代码有几个细节值得琢磨。第一模板参数N是容量上限我们在编译期就固定了栈的大小push时检查full避免越界写入这是最基础的防御性编程。第二top_初始化为-1表示空栈这个设计很常见因为下标0正好是第一个元素的位置。第三pop时元素本身并没有被销毁只是逻辑上不再属于栈后续push会覆盖它的值这是一种空间换时间的取舍。2.2 自己动手链表实现队列数组实现队列有一个麻烦如果队头不断后移数组前面的空间就浪费了所以工程上常用循环队列来复用空间。但对初学来说链表实现队列更直观也更贴合队列的语义。我们需要维护一个头节点指针和一个尾节点指针入队时在尾部插入新节点出队时在头部删除节点。#include iostream #include stdexcept templatetypename T class LinkedQueue { private: struct Node { T data; Node* next; Node(const T value) : data(value), next(nullptr) {} }; Node* head_; Node* tail_; public: LinkedQueue() : head_(nullptr), tail_(nullptr) {} ~LinkedQueue() { while (head_ ! nullptr) { Node* temp head_; head_ head_-next; delete temp; } } void enqueue(const T value) { Node* newNode new Node(value); if (tail_ nullptr) { head_ tail_ newNode; } else { tail_-next newNode; tail_ newNode; } } void dequeue() { if (head_ nullptr) { throw std::underflow_error(queue is empty); } Node* temp head_; head_ head_-next; if (head_ nullptr) { tail_ nullptr; } delete temp; } T front() { if (head_ nullptr) { throw std::underflow_error(queue is empty); } return head_-data; } bool empty() const { return head_ nullptr; } };链表队列最关键的一步是出队后要检查头指针是否变为空如果为空尾指针也要置空。很多初学朋友只更新head_忘了tail_结果队列为空后再次入队时tail_还指向已经被delete的旧节点导致悬空指针。这个细节是你调试链表队列时最先要排查的地方。2.3 STL容器适配器stack和queue自己实现一遍是为了理解底层原理但实际工程里我们直接用C标准库的std::stack和std::queue就够了。它们被称为容器适配器意思是它们本身不存储数据而是包装一个底层容器默认是deque也可以用vector或list来提供栈和队列的接口。#include iostream #include stack #include queue int main() { std::stackint s; s.push(1); s.push(2); std::cout stack top: s.top() std::endl; // 2 s.pop(); std::cout stack size: s.size() std::endl; // 1 std::queueint q; q.push(1); q.push(2); std::cout queue front: q.front() std::endl; // 1 std::cout queue back: q.back() std::endl; // 2 q.pop(); std::cout queue size: q.size() std::endl; // 1 return 0; }注意STL的stack和queue都没有push_back这种暴露底层容器的方法它们只提供受限的接口这正是容器适配器的意义所在不许你在用栈的地方随手从底部插入元素从而保证栈的行为始终匹配它的语义。默认底层容器是deque它是一个双端队列既能从头部也能从尾部快速插入删除所以既能适配栈只操作一端也能适配队列尾插头删。如果你的栈需要在底层用vector可以在声明时显式指定std::stackint, std::vectorint这样能得到更紧凑的缓存局部性但要注意vector扩容的拷贝开销和数据量大时的内存碎片相比需要你根据场景权衡。3. 核心操作与典型应用场景解析3.1 栈的典型应用括号匹配、表达式求值与函数调用括号匹配是最经典的栈应用。给你一个字符串({[]})怎么判断括号是否合法思路是遇到左括号就压栈遇到右括号就把栈顶弹出来看是否匹配。如果遇到右括号时栈是空的说明右括号多了如果遍历完栈还有剩余说明左括号多了。这个算法的时间复杂度是O(n)空间复杂度也是O(n)比每次扫描子串的暴力方法高效得多。表达式求值也用得着栈。以“中缀表达式转后缀表达式”为例从左到右扫描表达式遇到数字直接输出遇到运算符根据优先级决定是压栈还是弹栈遇到左括号无条件压栈遇到右括号弹栈直到左括号。整个过程就是在维护一个运算符栈栈顶永远是当前优先级最高的运算符。我曾经用这个原理实现过一个支持加减乘除和括号的计算器调试过程中最有意思的一个错误是忘了在弹出左括号时继续处理下一个运算符导致括号内的运算顺序错乱。函数调用栈前面已经提过。每当一个函数被调用系统在运行时栈上分配一块栈帧保存返回地址、参数、局部变量。递归函数尤其依赖栈因为每一层递归都会产生新的栈帧。如果递归层数过深栈空间耗尽程序就会栈溢出stack overflow。这个案例能帮我们把“栈”从抽象结构落地到内存布局上也解释了为什么局部变量不能返回指向它的指针——函数返回后栈帧就失效了。3.2 队列的典型应用任务调度、缓冲区和层序遍历队列在系统里的角色是“公平调度”。操作系统中的进程就绪队列、打印机任务队列、网络数据包缓冲区都是典型的先进先出机制。比如某个后台服务需要限制同时请求外部API的数量就可以用一个阻塞队列把超过阈值的任务暂时挂起等前一个任务完成后再从队列头取出下一个。这里队列充当了削峰填谷的中间层天然避免了突发流量打爆下游服务。在算法中队列广泛用于宽度优先搜索BFS。二叉树的层序遍历就用队列先把根节点入队然后循环执行“队头节点出队并访问它再把它的左右子节点入队”。这个过程中队列里的节点始终保持“按层次依次等待”的状态先入队的节点总是先被访问正好契合广度优先的语义。无向图的最短路径计算也常用队列配合距离数组一层一层向外扩散。队列还有一种特殊形态叫双端队列deque它允许从两端插入和删除结合了栈和队列的能力。C标准库中的std::deque就是这种结构但它的接口比stack和queue更宽。在一些滑动窗口算法中比如求区间最大值我们可以用deque维护一个单调递减队列入队时从队尾弹出比当前元素小的值这样队头永远是当前窗口的最大值。这套思路也被称为单调队列优化也是很多面试题的考点。3.3 性能对比与选型建议从底层操作来看数组实现的栈在入栈出栈时只需要移动一个指针并复制数据时间复杂度均为O(1)链表栈还需要new节点频繁分配释放会有额外开销。队列方面数组实现的循环队列同样能实现O(1)的入队出队但需要处理容量满时的扩容问题链表队列不需要扩容但每个节点都要动态分配缓存局部性不如数组好。工程实践中我的选型建议很简单能用std::stack和std::queue就用它们默认deque底层已经兼顾了性能和灵活性。如果你明确知道数据量很大且主要操作是栈顶元素可以考虑指定vector做底层容器因为vector的连续内存对CPU缓存更友好。如果你需要频繁遍历队列内部元素那普通queue就不合适了因为它只允许访问首尾你应该考虑直接用std::list或std::deque。总之不要把一个容器适配器用到不合适它的语义里否则代码迟早变畸形。4. 常见问题与排查技巧实录4.1 栈溢出与未定义行为栈溢出是初学者最容易碰到的程序崩溃原因之一。除了递归过深另一种常见情况是局部数组太大。比如你在函数里定义int a[1000000];一个int是4字节这个数组就要占近4MB而默认栈空间一般只有几MB很容易直接爆掉。正确做法是把大数组放到堆上用std::vector动态分配或者做成全局变量。排查这类问题时调试器会提示访问非法地址或栈溢出错误你优先检查函数里有没有超大局部变量再看看递归层数是不是失控。栈相关的未定义行为还有一种返回指向栈上局部变量的指针或引用。代码是能编译的但函数返回后栈帧被释放那个指针指向的内容随时可能被别的函数调用覆盖表现就是有时候值对有时候值乱非常难查。正确的做法是返回std::string、std::vector等值对象依赖移动语义或者把结果拷到堆上再返回。这也是C初阶务必养成的一个肌肉记忆。4.2 链表队列的指针细节链表队列的bug主要集中在head和tail的同步上。我在前面已经提过清空后要重置tail这里再补充几个场景入队时如果队列为空要让head和tail都指向新节点出队时如果删除的是唯一节点必须把tail置空。还有析构函数也要遍历所有节点逐一手动delete否则内存泄漏。用智能指针做节点时还要注意别让head和tail两个unique_ptr同时管理同一个节点否则析构时会双重释放。如果你真用裸指针规范只有一条谁new的谁delete节点归谁管要摆在明面上。4.3 STL栈和队列的使用误区和避坑经验STL里有个特别容易踩的坑是top()和front()在空容器上的调用。空栈调用top()、空队列调用front()都是未定义行为程序可能崩溃也可能返回一个垃圾值。因此每次取栈顶或队首之前一定要先判断empty()。还有pop()方法的返回值是void它不返回被弹出的元素这一点和Java等语言不同。想弹出并拿到值得先把top()存下来再pop()。很多刚转C的朋友在这里找半天返回值最后发现是设计如此。另一个经验是stack和queue都不支持遍历底层容器也一样。如果你在调试时需要看中间元素要么在调试器里展开底层容器的内部数据默认是deque的底层缓存在内存里不连续看起来比较麻烦要么临时把适配器换成可以直接遍历的std::vector。我在实际开发中会为调试封装一个小工具函数专门输出stack和queue的内容方法是复制一份容器逐个取出而不是直接破坏原结构。以下是我常用的一份调试辅助代码templatetypename Adaptor void dumpStack(Adaptor s) { // 传值拷贝不修改原对象 while (!s.empty()) { std::cout s.top() ; s.pop(); } std::cout std::endl; } templatetypename Adaptor void dumpQueue(Adaptor q) { while (!q.empty()) { std::cout q.front() ; q.pop(); } std::cout std::endl; }传值拷贝意味着我们只操作临时副本原容器不受影响非常适合在断点处穿插调用。4.4 消息队列与栈回溯的延伸思考栈和队列不只是教科书的练习题它们的身影贯穿整个后端架构。比如消息队列Kafka、RabbitMQ本质上是一个持久化的、分布式的队列生产者往队列尾写入消息消费者从队列头按序消费只是它比内存队列多了分区、副本、持久化等机制。理解基础队列的先进先出模型后再看消息队列的消费组、Offset管理就能更容易抓住本质。同样热词里的“backtrace栈回溯”也是在利用栈帧信息当程序崩溃时系统沿着栈帧链打印出调用路径帮我们定位崩溃位置。这些都是基础数据结构在真实系统里的延伸我建议你在学完栈和队列后带着这些场景再回头想想它们的意义会比单纯做题更有收获。踩过几次坑之后我的体会是学栈和队列别死记“后进先出”这几个字要亲手写一遍数组栈、链表队列再写几个括号匹配、层序遍历的小程序最后回到STL的stack和queue里感受标准库的封装。这样一轮下来你不仅掌握了两个容器还能把函数调用、内存布局、任务调度这些知识串在一起。后面学到树、图、动态规划时你会经常发现栈和队列又在各种算法里出现了到时候你会感谢自己今天把基础打得这么扎实。
返回列表