ARTICLE DETAIL

资讯详情

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

栈和队列的工程实践:从函数调用栈到消息队列

栈和队列的工程实践:从函数调用栈到消息队列 去年有个转行做开发的朋友问我栈和队列不就是“后进先出”和“先进先出”嘛背完定义就没下文了。我说你错了这个“下文”恰恰是整个数据结构课里最值得展开的部分。如果你手边有严蔚敏老师的《数据结构》C语言版翻到栈和队列那一章再看看那些翻PDF刷题时永远画不出来的图——函数调用栈一层层叠上去、循环队列一圈圈转起来它们才是真正让这两个抽象结构“活”过来的地方。这篇《初识数据结构栈和队列二》我不打算再复述一遍 push 和 pop 的定义而是直接聊四个东西函数调用背后的栈帧和回溯、循环队列为什么必须留空位、栈在括号匹配和表达式求值里的关键戏份以及从阻塞队列一路升级到消息队列之后工程选型到底在选择什么。适合学过基本概念、想补上“应用和实现”这一环的读者也适合期末复习前把零散知识点串起来的人。1. 函数调用背后的栈栈帧、调用栈与回溯排查1.1 函数一层层调用为什么不会迷路很多教材讲栈举的例子不是“括号匹配”就是“浏览器后退”但栈真正的主场其实是每一次函数调用。当程序从 main 调进 funcA又从 funcA 调进 funcB 时CPU 怎么知道 funcB 执行完该回到哪一行怎么知道 funcA 的局部变量还有效答案就是“栈帧”每当一个函数被调用系统为它分配一块连续内存栈帧里面存放参数、返回地址、上一层函数的帧基址和局部变量。所有这些帧一个叠一个最新调用的在最顶上所以叫调用栈。栈帧的形成过程以常见的 x86-64 系统为例ARM 原理类似一个函数从被调用到开始执行栈上大概会发生这几件事参数准备好。如果参数较多先压入栈或写进寄存器具体由 ABI 决定把函数的下一条指令地址返回地址压入栈把当前帧的基址rbp保存下来然后让 rbp 指向新帧的底部把栈指针 rsp 往下挪腾出足够空间放局部变量函数体跑完撤销栈帧恢复 rbp 和 rsp接着按返回地址跳回去。这一套“压栈、改帧、分配局部变量、回退、跳转”的动作就对应了数据结构教材里 stack.push() 和 stack.pop()——只不过压进去的是一个完整的栈帧而不只是一个整数。有些现代编译器开启优化后不保存帧基址但“栈上有返回地址和局部变量”这个基本模型仍然成立。顺带一提排序算法里也有栈的影子快速排序的非递归版本就是要自己维护一个栈来模拟函数递归调用。你在数据结构和算法笔记本上看到“栈模拟递归”这几个字背后正是刚才说的调用栈思想。1.2 backtrace 栈回溯崩溃日志里那串倒序函数名是怎么来的程序崩溃时日志里打出从崩溃点一路回到 main 的函数调用链就是 backtrace。原理很简单从当前栈帧开始顺着保存的“上一层帧基址 返回地址”往前遍历每拿到一个返回地址就对照符号表翻译成函数名和行号。你看到日志最上面的是最内层的函数越往下越靠近 main恰恰就是栈“后进先出”的顺序。这里有个很常见的坑在 x86 上因为有明确的帧指针回溯很直观在 ARM 上如果函数用了 -fomit-frame-pointer 把帧指针优化掉了就需要依赖 .ARM.exidx / .eh_frame 这类异常展开表来恢复调用关系。很多同学用嵌入式工具链看 ARM 崩溃日志时发现 stack backtrace 只有一行多半就是帧指针被优化掉了或者根因是栈被踩坏导致回溯链断裂。这个经验在实际干活时很救命定位段错误、排查 stack overflow、分析线上程序卡死的堆栈都要用这个思路。1.3 栈溢出局部变量太大、递归没有出口、栈空间怎么调递归函数没有终止条件会无限压栈直到栈不够用这是 Stack Overflow 这个名字的来历。另一种更隐蔽的情况是函数里定义了一个很大的局部数组比如 int buf[1024 * 1024]虽然编译能过但一调用就崩。这就要理解一个问题——“C语言局部变量越少所占栈空间越小”答案是方向对但不完全线性。局部变量总大小直接影响单个栈帧的大小换言之局部变量越多、越大函数同一时刻占的栈就越多能容忍的嵌套深度就越小。但编译器会做寄存器分配、栈槽复用、尾调用优化等所以不是简单的 112。函数内部的局部变量才在栈帧中全局变量和 static 变量放在数据段不占栈空间。如果你需要一个很大的缓冲区又不想爆栈除了 malloc/new 放到堆上也可以声明成 static 或全局变量——代价是它的生命周期变成整个程序运行期间。动态内存malloc/new在堆上堆空间相对大且要手动管理函数局部变量在栈上容量小而自动回收。嵌入式平台比如 RP-2040 pico-sdk如果需要手动增大栈空间通常是在链接脚本或 SDK 配置里调整栈尺寸这在裸机和 RTOS 任务场景里是常规操作。这些不是教材重点但排查崩溃时会救你一命。2. 顺序队列的假溢出与循环队列解法2.1 朴素顺序队列空间明明空着为什么塞不进去用数组实现队列时你可能会这样写front 指向队头元素的下标rear 指向队尾的下一个空位入队做 data[rear] x出队做 x data[front]。初看很自然但跑几轮就发现 bug连续入队 4 个、出队 2 个此时 front2, rear4数组下标 0 和 1 都空着可 rear 已经走到数组末尾再想入队就“数组越界”了。这不是真满而是“假溢出”。最粗暴的修复是每次出队把元素往前搬但每次 O(n) 搬运显然不划算。这也是为什么顺序队列不搞“搬移”而要引入循环队列。2.2 循环队列的取模与“空一格”设计循环队列把数组想象成首尾接起来的圆环。front 和 rear 都只在 0 到 capacity-1 之间转圈移动下标统一用(index 1) % capacity。这样 rear 到末尾后可以回到开头把假溢出的空间利用起来。但引入一个新的问题空队列和满队列时front 都等于 rear。解决办法很多最经典的是“牺牲一个存储单元”规定队列中至少要留一个空位判断满的条件是 (rear 1) % capacity front。这样队列最多能存 capacity - 1 个元素。如果你想让 capacity 个元素都能存就额外维护一个 size 计数器二者选一没有绝对优劣。#define MAX_SIZE 6 typedef struct { int data[MAX_SIZE]; int front; int rear; } CircularQueue; void initQueue(CircularQueue *q) { q-front 0; q-rear 0; } int enqueue(CircularQueue *q, int x) { if ((q-rear 1) % MAX_SIZE q-front) { return 0; // 队满 } q-data[q-rear] x; q-rear (q-rear 1) % MAX_SIZE; return 1; } int dequeue(CircularQueue *q, int *x) { if (q-front q-rear) { return 0; // 队空 } *x q-data[q-front]; q-front (q-front 1) % MAX_SIZE; return 1; } int queueSize(CircularQueue *q) { return (q-rear - q-front MAX_SIZE) % MAX_SIZE; }注意几个新手必踩的坑入队和出队时忘了对 MAX_SIZE 取模判满时写成 rear 1 front正确写法是 (rear 1) % MAX_SIZE front把 MAX_SIZE 当成最大元素个数实际最大元素个数是 MAX_SIZE - 1打印队内元素时要从 front 一路循环到 rear而不是简单地 for (i 0; i size; i) 读 data[i]因为元素可能绕到了数组开头。实际工程里这种 ring buffer 到处可见串口环形缓冲、音频采样缓冲、Linux 内核的环形队列、Go channel 底层也是类似思路。数据结构课本上讲循环队列绝不是为了应付考试而是为后面读源码打底。2.3 链式队列没有容量上限但指针管理更考人链式队列意思是每个节点都动态分配队头出队时删除头节点队尾入队时追加尾节点。因为空间来自堆只要内存充足就不会“假溢出”。它的核心是维护两个指针front 指向队头节点rear 指向队尾节点。typedef struct QNode { int data; struct QNode *next; } QNode; typedef struct { QNode *front; QNode *rear; } LinkedQueue;入队时新建节点 node如果队列为空front 和 rear 都指向 node否则 rear-next node再让 rear node。出队时如果队列为空报错保存 old frontfront front-next如果出队后 front 为 NULL说明队列已空必须把 rear 也置 NULL最后 free(old)。这里有两个高频错误一是只有 rear 在动front 忘了更新二是队列出空后 rear 还指向已经释放的节点下次入队时 rear-next node 直接写入野指针。所以链表实现最考验的是“三种状态”——空、一个节点、多个节点每个状态都要分别处理。3. 栈的经典舞台括号匹配、表达式求值与单调栈3.1 括号匹配别再漏掉“栈空了”和“栈没空”括号匹配的算法一句话读入一个左括号就入栈读入一个右括号就弹栈顶并检查它是否和当前右括号配对。之所以用栈是因为最近的右括号一定匹配最近的左括号正好是后进先出。我见过不少人写出来能过基础用例比如 ()[]{}但处理 ([)] 时漏判——这个字符串括号数量对但交叉嵌套是错的。关键要加两步校验遇到右括号时栈不能为空遍历完整个字符串后栈必须为空。下面这个 Python 版本虽然短但这两个条件都在def is_valid(s: str) - bool: stack [] pairs {): (, ]: [, }: {} for ch in s: if ch in ([{: stack.append(ch) elif not stack or stack.pop() ! pairs[ch]: return False return not stack这段代码如果用 C 写注意栈的实现要能判断空别写出“空栈也 pop”的崩溃。括号匹配在 IDE 的括号着色、JSON/XML 校验、编译器的语法分析第一阶段都有应用别把它只当成 LeetCode 入门题。3.2 中缀转后缀与后缀求值计算机靠栈摆脱优先级人类习惯阅读“3 4 * 2 - 1”这样的中缀表达式但机器直接处理优先级和括号很费劲。经典方案是先把中缀转成后缀逆波兰式数字直接输出看到运算符就把栈里所有优先级不低于它的运算符弹出来输出再把它自己入栈看到左括号直接入栈看到右括号一直弹栈顶直到弹出左括号结束后把栈里剩余运算符全部弹出。3 4 * 2 - 1 转后缀的过程是数字 3 输出 入栈数字 4 输出* 入栈数字 2 输出看到 - 时弹出 * 和 然后 - 入栈数字 1 输出最后弹 -。结果是3 4 2 * 1 -。后缀表达式的求值就简单了遇到数字入栈遇到二元运算符弹出两个数字计算结果压回栈。这里有个经典翻车点尤其是除法后缀表达式 8 2 /先弹出 2右操作数再弹出 8左操作数正确计算是 left / right 8 / 2如果代码里写成 right / left就会得到 2 / 8 0。很多人在 C 语言里实现计算器一遇到除法就算错原因就在这个弹出顺序上。这类问题看着像是竞赛题但规则引擎、数据库表达式、电子表格公式、很多脚本语言的解释器底层都还在用这套思想。3.3 单调栈与单调队列淘汰没有希望的元素把 O(n²) 变成 O(n)单调栈指栈内元素保持单调递增或单调递减。它解决的最经典问题是“每个元素左边/右边第一个比它大/小的元素”。举“右边第一个更大元素”的例子从右往左扫维护一个单调递减栈。扫描到一个新元素时把栈中所有小于等于它的元素弹掉剩下的栈顶就是右边最近更大元素。为什么弹掉不心疼因为这些被弹的元素对左边更远处的元素来说有当前这个更大元素挡着永远不会再被当作答案。这个“淘汰没有希望的元素”的思想和 LRU、单调队列一样都是为了让每个元素只进出一次从而把暴力 O(n²) 降到 O(n)。热词里还有“单调队列优化dp”。典型场景是滑动窗口最大值窗口每移动一格就有一个元素入队、一个元素出队用双端队列维护窗口内的候选最大值。老元素从队头淘汰新元素入队前从队尾把所有小于它的值弹掉。这样每个元素至多入队出队各一次均摊 O(1)。很多动态规划题目比如“切出合法区间的最少次数”这类一旦状态转移里出现 max/min 窗口就可以用这个套路优化维度。我建议初学者别先硬啃排序、堆和一堆 DP 优化把单调栈/单调队列的定义和两道经典题吃透比刷十道模板题更有用。4. 队列的现代形态双端队列、阻塞队列与消息队列4.1 双端队列栈和队列的合体普通队列只允许一边入、一边出栈只允许一边入、一边出。双端队列把这两个约束放开两端都可以插入和删除。听起来只是小改动但工程价值很大。C STL 的 std::deque 是双端队列随机访问常数级两端操作几乎都是 O(1)Python 的 collections.deque 用双向链表或动态数组实现两端 append/pop 都高效一些调度器会用双端队列做工作窃取work-stealing空闲线程从别的线程队列尾部偷任务从而减少竞争。在算法题里双端队列是“滑动窗口最大值”的标准装备窗口右端入新值左端淘汰过期值。你可以不用单调队列但双端队列作为容器工具几乎是必备的。注意deque 这个缩写全称是 double-ended queue不是 dequeue出队写代码和读文档时别搞混。4.2 线程池的阻塞队列容量、锁与拒绝策略线程池的工作方式本质上是一个生产者-消费者队列提交任务的一方是生产者工作线程是消费者。任务放不进队列、队列空了取不到任务时就需要阻塞语义——这就引出了阻塞队列。以 Java 为例常见实现有ArrayBlockingQueue基于数组的循环队列需要有界容量LinkedBlockingQueue基于链表默认无界可指定容量SynchronousQueue队列长度为 0生产者和消费者必须直接交接不缓存任务。选型逻辑很实在想给系统限流、保护下游就选有界队列并配拒绝策略任务峰值高、能接受积压才考虑无界但要监控内存对吞吐要求高且任务很轻量可以用 SynchronousQueue 减少排队等待。这里每一个选择背后都是数据结构课本里循环队列和链队列的差异数组连续内存、锁竞争方式不同链表动态扩容、内存不连续但插入删除不用搬移。理解底层面试答“为什么要选这个阻塞队列”才能举一反三。4.3 消息队列选型Kafka、RabbitMQ、RocketMQ 到底比什么消息队列本质上是分布式的、可持久化的 FIFO 队列——生产者把消息 enqueue 到 broker消费者 dequeue 消费只是加了路由、ack、重试、副本和高可用。但选型时要注意“消息队列”未必保证全局严格 FIFOKafka吞吐极高分区内有序跨分区无序。适合日志收集、流量削峰、事件流管道。追求吞吐优先功能相对“朴素”连 Topic 都要按分区分片。RabbitMQAMQP 协议出身路由灵活、功能全面死信、延时、镜像队列单机吞吐不如 Kafka但适合复杂业务处理和低延迟交互。RocketMQ阿里开源事务消息和延时消息做得好普通消息吞吐高适合金融支付、订单这类要求可靠性强的场景。选型维度KafkaRabbitMQRocketMQ核心定位分布式日志/事件流企业级消息中间件可靠事务消息吞吐量极高中高消息可靠性高需配置 acks高非常高有序性分区内有序单队列有序普通消息可达有序路由/死信/延迟队列弱强较强典型场景日志、埋点、大数据业务消息、异步解耦交易、订单、对账避坑上最常被问的是“重复消费问题”消费者处理完、还没提交 ack 时挂了集群会把消息重新投递给另一个消费者消息就重复了。这不是某个组件特有的 bug而是分布式环境下 at-least-once 投递的直接结果。解决办法不是让队列保证只投一次而是消费端做幂等——用消息唯一 ID 去重、数据库主键/唯一索引兜底或者状态机校验。这个启发同样适用于线程池哪怕队列里只有一条消息业务代码也要能扛得住“重试”。别看这是老话题真上了生产还是会撞到。4.4 队列远比想象中普遍从网络请求到 AI 调度队列的现代应用不止消息队列。Android 的 OkHttp 分发器里就有两个 DequereadyAsyncCalls 和 runningAsyncCalls控制并发、排队和取消前端有些 canvas 导出场景连续往渲染队列里塞帧异步顺序乱了就会导出白图再往大了说大模型调度平台里的推理请求也按任务队列管理——请求先排队再被调度到空闲 GPU 上执行队列优先级、排队等待时间、超时重排都是工程重点。这些系统的内核都是你学的先进先出或多级优先级队列只是包装了一层业务概念。所以不要觉得“队列只是数据结构里那一小节”它是整个系统并发、缓冲、削峰、流控的地基。5. 手写栈和队列时要命的边界条件与易错点5.1 数组栈的 top 语义一个决定所有写法的细节数组栈的实现细节最常见的坑是 top 指针的语义没有统一。有两种习惯top 指向栈顶元素初始 top -1入栈 data[top] x出栈 x data[top--]判空 top -1判满 top MAX_SIZE - 1top 指向下一个空位初始 top 0入栈 data[top] x出栈 x data[--top]判空 top 0判满 top MAX_SIZE。两种都没问题但混用就崩。写代码前先注明我这边的 top 是“栈顶元素下标”还是“下一个空位下标”。最好再配套一个测试用例入栈 3 个出栈 1 个查一下 top 是否正确。数组栈访问越界检查也很关键如果直接 data[top] 可能访问未初始化位置要严格约束边界。5.2 链式队列的三种状态与 free 陷阱链式队列的坑在 2.3 已经展开了一部分这里再提炼成行为准则初始化front rear NULL入队新建节点 node若 front NULL则 front rear node否则 rear-next noderear node出队若 front NULL报空保存 old frontfront front-next若 front NULL说明队列空了必须把 rear 置 NULL最后 free(old)。除了数据结构本身还要注意内存释放。有些人写链表队列出队时只移动了 front 却没有 free 掉旧节点造成内存泄漏另一些人会把栈内存里的一个结构体指针拿去 free比如类似“free(c_tmenu stack_menu); menu_pointer stack_menu”这样的写法——stack_menu 如果是在栈上定义的局部变量对它调用 free 是未定义行为轻则能过但脏内存重则直接 crash。记住原则free 只用于 malloc/calloc/realloc 返回的堆内存栈上的变量永远不能 free。5.3 数组 vs 链表除了复杂度还有缓存与并发把两种实现做一个常用对比对比项顺序栈/队列数组/循环数组链式栈/队列链表操作复杂度push/pop/enqueue/dequeue 均 O(1)同样均 O(1)空间固定容量动态扩容要搬移按需分配无固定上限缓存友好连续内存局部性强节点分散Cache Miss 更多主要开销扩容时 O(n)每次 malloc/free 的开销很多人以为时间都是 O(1) 就无所谓了实际工程上差异很大。连续内存的循环数组对 CPU 缓存友好多线程下用原子操作改 front/rear 指针就能做成无锁队列很多高性能框架都选环形缓冲链表虽然动态自如但每次节点分配都可能触发锁、内存碎片无锁实现还要处理 ABA 问题。所以“选数组还是链表”不是一个数学问题而是取决于你项目的容量预估、内存模型和并发要求。有兴趣的话可以从“C 原子操作与无锁队列”这个话题切入但先把循环队列写明白再上 CAS否则很容易一上来就被 ABA 绕晕。5.4 自测用例与调试习惯栈和队列的正确性怎么验证不管是自己练习还是刷题我建议写完一个栈或队列后至少跑一组完整自测空栈 pop / 空队列 dequeue应该返回错误码而不是越界或段错误循环队列反复入队出队 10000 次front/rear 始终在合法范围内size 计算正确链式队列出空后再入队front 和 rear 都能正确复位括号匹配测试数组全通过用例 ([)] 这类陷阱用例 只有左括号/只有右括号。再用两个调试技巧。第一把 top/front/rear 的中间值打印出来很多人一眼就能看出取模问题第二用纸笔把队列在数组里的占用格子画出来凡是循环队列的 bug图画出来了几乎都迎刃而解。我自己带过不少新人他们卡住的地方九成不是算法不会而是某个边界条件漏了判断。最后再说一点个人体会。栈和队列确实是最基础的两个容器但正是这种“基础”会以各种各样的面孔反复出现在函数调用、线程池、消息中间件、操作系统调度里。我见过不少人背概念很熟一碰到线上 backtrace 却看不懂一调线程池参数就靠猜。如果你看这篇时还在学习阶段我的建议是别停在会背定义动手把数组栈、循环队列、链式队列各写一遍再把一次三层的函数调用栈画出来你后面读任何框架源码都会顺畅很多。之后再往上走就是 LRU、无锁队列、单调队列优化 DP 这些事情了入门台阶就在眼前。
返回列表