十年匠心定制 · 商业建站与技术教学双线并行 咨询热线:400-886-1026 service@lmnt.cn
ARTICLE DETAIL

资讯详情

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

栈与队列深度解析:从底层实现到工程实战

栈与队列深度解析:从底层实现到工程实战 1. 先说结论为什么栈和队列永远值得再聊一遍栈和队列这两个词刷过题的人闭着眼都能写出几个操作背八股的人张口就是后进先出、先进先出。但真到项目落地的时候能把它们用得漂亮的人其实没那么多。我看了一圈最近的数据结构相关热搜栈和堆、循环队列、消息队列重复消费、线程池的阻塞队列选择这些词反复出现说明大家不只是想背概念而是想知道这东西在真实代码里到底怎么玩。这篇就把我这些年实际写过的、踩过的、给别人讲过的栈和队列一次性梳理清楚。这篇文章适合三类人一是刚学数据结构的在校生需要一个能把概念-实现-应用串起来的主线二是准备面试的开发者需要把高频考点和实际工程场景对上路三是在做架构设计的朋友消息队列、任务调度、调用栈优化这些场景里栈和队列的思想无处不在。我会从底层实现讲到工程应用代码以 C 为主关键场景会补一些 Java/Python 的视角尽量做到每个结论都能落地。2. 栈一台严格按后进先出运转的回溯机器2.1 核心概念与基本操作栈的本质就是一个线性表但它的插入和删除被限制在同一端进行。这一端叫栈顶另一端叫栈底。你可以把它想象成一摞盘子后放上去的盘子一定先被拿走这就是后进先出LIFOLast In First Out。这个朴素的限制听起来很蠢但它恰恰是计算机系统里最重要的一条约束——函数调用、递归回溯、表达式求值全都在靠它撑场子。栈的核心操作就五个入栈push、出栈pop、取栈顶top/peek、判断是否为空empty、获取大小size。特别注意pop 和 top 在多数实现里是分开的top 只读不移除pop 只移除不返回。很多新手踩过这个坑用 top 取完值以为元素没了实际上还在栈里导致逻辑错乱。C 里 pop 不返回值Java 的 pop 会返回并移除Python 的 list 直接用 pop() 返回并移除——语言差异就在这写代码时一定要清楚自己用的是哪种语义。栈的空间利用上也分两块一个是栈本身作为数据结构占用的内存另一个是系统为每个线程分配的调用栈空间。这两个栈概念经常被混在一起聊热搜里那个c 栈空间和栈和堆指向的就是后者。实际开发中递归过深导致的栈溢出本质就是系统调用栈被打满了。2.2 顺序栈实现数组版数组实现栈是最直观的方案。核心思路是维护一个数组和一个栈顶指针push 时把数据写到指针位置指针加一pop 时指针减一。这里有一个关键点pop 之后数组里的旧数据并没有被真正清除只是通过缩小逻辑范围把它屏蔽了。如果数组里存的是指针或对象引用释放前最好把对应槽位置空避免内存无法被回收。class ArrayStack { private: int* data; int capacity; int topIndex; // 指向当前栈顶元素的下一个位置 public: ArrayStack(int cap) : capacity(cap), topIndex(0) { data new int[capacity]; } ~ArrayStack() { delete[] data; } void push(int val) { if (topIndex capacity) { // 实际工程中这里应做扩容下面有说明 throw stack overflow; } data[topIndex] val; } int pop() { if (topIndex 0) throw stack empty; return data[--topIndex]; } int top() const { if (topIndex 0) throw stack empty; return data[topIndex - 1]; } bool empty() const { return topIndex 0; } int size() const { return topIndex; } };上面这份代码是定长数组版本适合容量预先可知的场景。工程里更常见的是动态扩容当 topIndex 等于 capacity 时申请一个两倍大小的新数组把老数据搬过去再释放旧空间。均摊时间复杂度仍然是 O(1)这一点和 Java 的 ArrayList、C 的 vector 扩容思路完全一致。不过扩容搬数据是一次性 O(n) 的操作对实时性要求高的系统要谨慎处理。数组栈的优点是缓存友好数据在内存中连续分布访问和写入都非常快。缺点是容量受限于连续内存块的大小如果单个栈需要存很大的数据量数组扩容时会有一次明显的卡顿。2.3 链式栈实现链表版链表实现栈的思路是把链表头当作栈顶每次 push 就是在头部插入一个新节点每次 pop 就是摘掉头节点。因为只在头部操作天然就是 O(1) 复杂度完全不需要考虑扩容问题。struct Node { int val; Node* next; Node(int v) : val(v), next(nullptr) {} }; class LinkedStack { private: Node* head; int count; public: LinkedStack() : head(nullptr), count(0) {} ~LinkedStack() { while (head) { Node* tmp head; head head-next; delete tmp; } } void push(int val) { Node* node new Node(val); node-next head; head node; count; } int pop() { if (!head) throw stack empty; Node* tmp head; int val tmp-val; head head-next; delete tmp; count--; return val; } int top() const { if (!head) throw stack empty; return head-val; } };链式栈的缺点是每个节点要多存一个指针内存开销比数组大而且节点是散落分配在堆上的访问时不连续缓存命中率差。所以同一个栈在数据量小、操作频繁的场景数组版几乎总是赢链表版只有在数据量无法预估、或者需要频繁动态创建销毁多个栈实例时才更有优势。我在项目里还见过一种混合方案用链式结构存大块数组每个块内部连续块与块之间用指针相连兼顾了两者的优点但实现复杂度也上去了一般用不到这么重。3. 队列一条按先进先出运转的流水线3.1 核心概念与基本操作队列和栈正好相反插入在队尾tail进行删除在队头head进行也就是先进先出FIFOFirst In First Out。它像超市收银台排队先来的先结账走人。核心操作是入队enqueue/push/offer和出队dequeue/pop/poll以及查看队头front/peek。如果你用数组直接实现队列会立刻遇到一个问题队头出队后数组前面的空间就空出来了但如果只把 head 指针往后移后面入队总会有到头的时候。这时候就需要循环队列出场逻辑上把数组首尾相接当 tail 走到数组末尾时如果数组开头还有空位就绕回去继续用。3.2 循环队列数组实现的灵魂循环队列的实现有这么几个关键点每个都是面试官爱挖的细节第一判空和判满的区分。如果你只用一个 head 指针对应队头、一个 tail 指针对应队尾后面的位置那么空队列时 head tail满队列时 tail 绕一圈也会追到 head两者状态一样就分不清了。常见的解法有三一是牺牲一个存储单元规定tail 1 head才算满这样最多只能用 capacity - 1 个位置二是加一个 size 变量记录元素个数三是加一个 flag 标记最后一次操作是入队还是出队。工程上我最推荐维护 size逻辑直白几乎不会出边界 bug。第二取模运算的效率。循环队列的指针移动不能简单地 而是要用(tail 1) % capacity完成回绕。如果 capacity 是 2 的幂可以把取模优化成位运算(tail 1) (capacity - 1)性能会好一些。这也是为什么很多高性能环形缓冲区的容量都设计成 2 的幂。class CircularQueue { private: int* data; int capacity; int head; // 队头索引 int tail; // 队尾下一个位置索引 int size; public: CircularQueue(int cap) : capacity(cap), head(0), tail(0), size(0) { data new int[capacity]; } ~CircularQueue() { delete[] data; } bool enqueue(int val) { if (size capacity) return false; // 队列已满 data[tail] val; tail (tail 1) % capacity; size; return true; } bool dequeue(int out) { if (size 0) return false; // 队列为空 out data[head]; head (head 1) % capacity; size--; return true; } int front() const { if (size 0) throw queue empty; return data[head]; } bool empty() const { return size 0; } bool full() const { return size capacity; } };这段代码我实际在好几个项目里用过包括串口数据缓冲、日志异步写入、音视频帧缓存等等。循环队列最打动人的地方是它可以在不移动任何元素的情况下完成入队出队时间复杂度稳定在 O(1)而且不需要频繁申请释放内存。对于嵌入式开发和实时系统这几乎是零成本的缓冲方案。3.3 链式队列与阻塞队列链表实现的队列链式队列思路和链式栈相似但需要同时维护头指针和尾指针。入队时在尾部接新节点出队时摘头节点。相比循环队列它不受容量限制但每个节点有指针开销。真正值得展开的是阻塞队列BlockingQueue。这是 Java 并发包里的一等公民。它的特殊之处在于当队列为空时消费者线程执行 take() 会被挂起直到有数据入队当队列满了的时候生产者线程执行 put() 也会被挂起直到有空位。这个机制避免了忙等待空转是线程池、生产者-消费者模型的核心发动机。Java 里常见的阻塞队列实现有这几种我简单对比一下实现类底层结构特性适用场景ArrayBlockingQueue循环数组有界、公平性可配线程池队列、限流缓冲LinkedBlockingQueue链表默认可无界也可指定容量任务队列、生产者消费者SynchronousQueue无存储槽每个 put 必须等一个 take直接交接、无缓冲场景PriorityBlockingQueue堆按优先级出队定时任务、优先级调度DelayQueue优先队列元素到期才能取出延时消息、订单超时处理这里插一句热搜里的java中的延时队列。DelayQueue 的本质是优先队列 延迟时间比较器出队时会检查队首元素的延迟时间是否已到没到就阻塞等待。很多人对它的理解停留在定时任务层面其实它最经典的场景是订单超时关闭每个订单入队时带上超时时间系统后排一个线程不断从 DelayQueue 里 poll取到哪个就说明哪个订单到期了。比每分钟扫一遍数据库高效太多。4. 典型应用从源码到架构的真实战场4.1 栈的经典应用函数调用、表达式求值和单调栈先说函数调用栈。每次函数调用系统都会在调用栈上压入一个栈帧里面保存了局部变量、参数、返回地址等信息。函数返回时栈帧弹出控制权回到调用方。递归能工作、断点调试能看到调用链、异常抛出能一层层往上抛全都依赖这个机制。理解了这一点你就能明白为什么递归太深会栈溢出——每个栈帧都占空间栈帧累积多了系统栈撑不住。所以我在实际开发里凡是递归深度可能上千的场景都会优先改成显式栈 循环。比如树的遍历用栈模拟递归既能控制内存又能随时中断调试还更直观。再说表达式求值。中缀表达式转后缀表达式逆波兰表达式以及后缀表达式的计算是栈的经典考题。我自己写过不下五遍核心套路就一句话遇到数字就进栈遇到运算符就弹出两个操作数计算结果再压回栈。中缀转后缀的核心则是维护一个运算符栈通过比较运算符优先级决定是压栈还是输出。这类题掌握套路后就是一马平川但重点不是背而是理解栈在这里充当了记忆最近上下文的角色。单调栈是栈的一个进阶玩法。它维护栈内元素单调递增或单调递减用来解决一类找左边/右边第一个比当前元素大/小的问题。经典题目包括柱状图中最大的矩形、每日温度、接雨水等。单调栈的威力在于它能把暴力解法 O(n^2) 的时间复杂度降到 O(n)而且代码不长。我面试别人时只要对方能自己推出单调栈的维护逻辑基本就认可了他的数据结构功底。4.2 队列的经典应用BFS、消息队列与全栈场景树的层序遍历、图的广度优先搜索BFS标准做法就是维护一个普通队列。每一层先入队出队一个就把它下一层的子节点入队直到队列为空。队列在这里保证了一个非常重要的性质按距离源点远近的顺序访问节点。最短路径、连通块计数、拓扑排序全都是 BFS 队列思想的不同变体。再往工程层面走消息队列MQ是队列思想的集大成者。热搜里反复出现的消息队列的三大作用我理解下来就是这三点解耦、异步、削峰。解耦是 A 系统不需要关心下游谁要数据异步是调用方发完消息立刻返回不用干等下游处理完削峰是突发流量先在队列里排队消费者按自己的处理能力慢慢消费。这三个作用能解决分布式系统里的很多痛点但代价是引入了一致性问题和运维复杂度。顺带说一个工程上极容易被问到的点消息队列的重复消费问题。为什么会出现重复消费因为消费者处理完消息后还没来得及上报确认就宕机了消息被重新投递就会再处理一次。解决方案就是消费侧做幂等——用业务唯一标识去重比如订单号、消息 ID处理之前查一下有没有处理过。这个问题不是 MQ 独有的Kafka、RocketMQ、RabbitMQ 都会遇到核心思想都是消费者必须幂等。我还注意到热搜里有小程序页面栈大于10怎么处理。小程序页面栈本质上就是一个页面栈结构栈顶是当前页面路由跳转是入栈返回是出栈。小程序限制页面栈最多 10 层超过之后 navigateTo 会失效。解决办法无非是换用 redirectTo 重定向替换当前页或者 reLaunch 重新启动清空栈再进新页关键是要理解页面栈的语义不要在深层链路里一直往里叠页。4.3 线程池阻塞队列的选择逻辑线程池的阻塞队列选择是队列知识在并发编程里的典型应用。先说结论再展开解释追求任务不丢失、内存充足选 LinkedBlockingQueue无界队列缺点是极端情况下内存可能被打爆。追求资源有上限、快速失败选 ArrayBlockingQueue有界队列配合拒绝策略使用。希望任务按紧急程度处理选 PriorityBlockingQueue。希望任务能延迟执行选 DelayQueue。这里有一个常见的误区很多人以为线程池核心线程数满了任务就会进队列队列满了才会开非核心线程。实际上要看你用的是哪个线程池构造方法。Java 的 ThreadPoolExecutor 里当核心线程忙碌时新任务先尝试进队列而不是开新线程如果用的是 SynchronousQueue它根本不会缓存任务而是直接尝试创建非核心线程来执行。这个差别直接影响系统的吞吐和线程数量选型前一定要想清楚任务的特征是 CPU 密集还是 IO 密集是可积压还是必须即时处理。5. 常见问题与排查技巧实录5.1 栈溢出递归陷阱与隐式栈改造我在实际项目中遇到过的最典型的栈溢出场景就是递归处理树形结构。比如组织架构树、菜单树、评论回复树业务深度一上来递归就爆了。排查方法也很直接先看异常栈如果深度超过几万层基本可以断定是无限递归或数据成环如果深度在几千层就崩那就是系统栈空间设置太小。解决方向有三个第一检查递归终止条件避免成环和无限循环这是最基础也最重要的一步。第二把递归改成显式栈迭代。第三如果递归一定要保留可以用尾递归优化或手动调整线程栈大小比如 Java 启动参数 -Xss 可以加大线程栈但这只是把问题延后不是根治。这里我想多说一句显式栈的写法。很多人觉得用栈模拟递归很难其实核心就两步把递归的参数打包成栈帧结构体把递归调用换成压入子任务的栈帧。以二叉树前序遍历为例用栈实现的逻辑非常清爽先压根节点循环里弹出一个节点处理然后把右子树压栈、再压左子树因为栈是后进先出右子树先压就能保证左子树先处理。这样写出来的代码不怕深度爆栈还方便做剪枝和分支限界。5.2 循环队列判空判满边界到底怎么算循环队列最容易被问崩的就是空和满怎么区分。如果你用的是牺牲一个空间的方案判空条件是 head tail判满条件是(tail 1) % capacity head如果你用的是 size 方案判空是 size 0判满是 size capacity。两种方案我都写过实际使用中强烈建议选 size 方案理由有三个第一判空判满逻辑对称不容易把条件写反。第二size 可以直接用于遍历、统计剩余空间省得每次现算。第三调试的时候打印 head、tail、size 三个值很容易定位问题。牺牲一个空间的方案在内存极度紧张时有用但现代开发环境下这个优化意义不大反而多了个最多只能存 capacity - 1 个元素的隐藏约束容易埋雷。还有一个小细节循环队列遍历时不能像普通数组一样用 for 循环从 head 到 head size因为越过数组末尾时要回绕。正确写法是for (int i 0; i size; i) { visit(data[(head i) % capacity]); }。这个知识点对刷题和写底层缓冲都很重要。5.3 消息队列重复消费与堆积两大高频故障重复消费的问题前面提到了核心是消费者幂等。但我再补充一个工程细节幂等不能只在业务代码里查一下再写要用事务或唯一索引锁住否则并发情况下两条相同消息同时处理还是会重复写入。比如数据库表加一个 message_id 唯一索引重复插入会直接失败这才叫真正的幂等兜底。消息堆积是另一个高频问题。队列积压越来越多消费者来不及消费。排查思路可以按这个顺序来先看消费者是否有人挂了。比如几个消费实例里一个实例宕机剩余实例接不住全部流量就会堆积。再看消费逻辑是否变慢。比如数据库慢查询、下游接口超时。最后看有没有毒丸消息即某条消息每次都被消费失败并重试卡在队列头部导致后面的消息全部堵车。还有一个容易被忽略的坑消费者消费失败后如果直接返回重试而没有做重试次数限制那这条失败消息会在队列里反复打转把消费者线程耗死。正确做法是设置失败重试上限超过就投递到死信队列等人排查。死信队列这个概念非常实用算是对普通队列的一个必要补充大家在设计消息系统时一定要预留这个兜底下水道。5.4 双端队列和优先队列变体选型要看清场景栈和队列还有一些近亲结构选型时常被混淆。双端队列deque允许两端都插入和删除Java 里的 ArrayDeque、C STL 里的 deque 都是这个结构。它能当栈用也能当队列用还能实现滑动窗口最大值这类需要两头维护的算法题。优先队列priority queue内部实现通常是一棵二叉堆入队出队复杂度都是 O(log n)但它并不保证严格 FIFO而是按优先级排序。很多人在面试时会把队列和优先队列搞混普通队列先进先出优先队列优先级高者先出。一个典型的例子是操作系统进程调度就绪队列如果按优先级维护其实用的是优先队列而非普通队列。在设计任务调度系统时一定要明确自己的需求到底是严格的时序保证还是优先级保证选错结构会带来完全不同的行为。单调队列则是解决滑动窗口类问题的利器它和单调栈是一对思路相似但维护的是队列。核心技巧是入队前把队尾所有比当前元素“更差”的元素弹出还要处理窗口边界把过期的队头弹出。这样每次窗口滑动后队头就是当前窗口的最值。这一类结构虽然名字里带单调但它不是某个标准库类而是你基于双端队列自己维护的一套逻辑掌握它对刷算法题和理解用数据结构维护状态集合的思路都很有帮助。6. 一点个人体会栈和队列之所以值得反复琢磨不是因为它们是考纲里必背的两个名词而是因为它们背后代表了两种最基本的调度秩序栈是回溯队列是缓冲。回溯让我们能在迷宫里走回头路缓冲让我们能在大流量和慢消费之间找到一个平衡点。我在自己做的每一个稍微像样的系统里几乎都能找出这两样东西的影子——线程池是队列递归遍历是栈调用链是栈异步削峰是队列。最后分享一个小技巧当我需要快速判断一个场景该用栈还是队列时我会问自己一个问题——“先来的要优先处理还是后到的要优先处理”先来先服务就是队列后到先覆盖就是栈。想清楚了再动手写代码基本不会选错。这个习惯帮我少走了很多弯路希望你也能用上。
返回列表