ARTICLE DETAIL

资讯详情

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

栈与队列:从函数调用到消息队列的完整应用指南

栈与队列:从函数调用到消息队列的完整应用指南 如果你刚好学到“栈与队列”这一周先别急着背“后进先出”“先进先出”这两句口诀。我第一次学栈和队列时觉得这无非就是两个简单的容器看一遍就懂做一遍就会。直到后来在真实项目里排查崩溃堆栈、给线程池调参、和消息队列的重复消费问题搏斗的时候我才意识到这两样东西几乎渗透进了计算机世界的每一个角落——函数调用靠栈返回异步任务靠队列排队程序崩溃要靠栈回溯定位现场高并发流量要靠消息队列削峰。这篇文章就是把我在项目里对栈和队列的完整理解重新梳理给正在学这块内容的你既有原理也讲应用还会给一条可以直接照做的实操路线。1. 先别背定义抓住“时间顺序”这四个字1.1 栈和队列本质上是“谁能先走”的游戏食堂打饭时先来的人先打到饭这是队列往一摞盘子里放盘子、取盘子只能从最上面拿这是栈。为什么这两个规矩值得专门拿出一周来研究因为现实世界里的计算流程天然就有顺序约束程序调用需要按“后进先出”的方式返回任务处理需要按“先进先出”的方式消费。你去看操作系统、网络协议、应用框架到处都能见到这两种顺序约束。递归函数调用的返回次序必须是最里面那层函数先返回外层后返回这是栈CPU对多个进程的调度一般倾向让先就绪的进程先上CPU这是队列。栈和队列就是把这两类最常见的顺序约束抽象成了两种数据结构。所以学习栈和队列第一步不是背定义而是建立一种敏感度看到一个问题先问自己——“数据应该在什么时间顺序下被处理”后到的先被处理就是栈先到的先被处理就是队列。1.2 为什么要用栈和队列而不是“数组一把梭”很多人心里有过这个疑问数组这么灵活什么都能存为什么还要专门搞出栈和队列举一个我实际见过的例子某段业务代码需要一个“最近N条操作记录”有人直接拿数组写每次插入都从头插入随机访问也随手就来结果代码越写越乱出问题时根本分不清哪个位置的数据是合法的。栈和队列的本质是对线性表做“接口瘦身”——只允许在固定的位置操作。栈只能在栈顶进出队列只能在队尾进、队头出中途不能插入也不能随意访问某个中间元素。约束变多了反而让行为变得可预测、可分析、不易出错。栈和队列所有操作的时间复杂度都是O(1)这句话成立的前提就是操作位置被限制死了。用约束明确的数据结构比用自由度太大的数组代码会好维护得多——这是我项目里最深的一条体会。1.3 底层其实是同一家数组或链表第七周学习时一定要意识到栈和队列是“抽象接口”不是“底层存储”。它们既可以用数组实现也可以用链表实现。数组实现的优点是缓存友好、空间紧凑缺点是扩容麻烦、出队时如何复用空间需要设计链表实现的优点是动态扩容自然缺点是每个节点有额外的指针开销而且链表节点分散在内存里遍历和访问的局部性较差。我给你的建议是同一个队列分别用数组和链表各实现一遍。写完之后“抽象接口”和“底层存储”这两个概念在脑子里就再也分不开了。2. 栈不只是容器它是程序的“执行骨架”2.1 函数调用栈代码为什么能“原路返回”这是栈在计算机系统里最核心的应用也是很多人学完栈之后第一个“啊原来如此”的瞬间。当我们调用一个函数时编译器会把当前函数的返回地址、局部变量、参数压入一个“栈帧”当被调用的函数返回时又从栈里弹出上一帧继续原来的执行。整个程序的执行路径本质上就是一个不断压栈、出栈的过程。递归为什么能一层层“回来”因为每一次递归调用都压了一帧直到满足结束条件再逐层弹出。递归写不好会栈溢出原因是每一层调用都占用真实的内存递归深度无上限时栈空间会被吃光。Python默认递归深度大约是一千层左右超出后直接抛异常C语言更干脆直接报Segmentation fault。注意排查线上问题时“栈溢出”这种报错经常是第一个被怀疑的对象。常见原因不是无限递归就是函数里声明了一个超大局部数组。这个报错字面意思是程序用的调用栈空间超出了限制跟堆内存不足不是一回事。2.2 backtrace栈回溯用调用链还原事故现场程序挂掉之后第一手证据往往是“调用链”。在C/C里用gdb输入bt在Java里看Exception堆栈在Python里看traceback本质都是在读“栈”——把从当前执行点一直到最外层调用的所有栈帧逐层打印出来。我在实际项目里排查过不少崩溃问题经验是拿到堆栈后不要从栈底看起要从栈顶往下找“第一段业务代码”。因为栈底的框架代码几乎总是通用的要么是线程池调度要么是网络连接等待真正出错的位置往往在中间偏上的业务帧里。有一次线上服务报空指针堆栈底层全是NioEventLoop这类线程调度代码很多人盯着看半天看不出问题其实往上翻几行就能看到一个业务Service方法的调用帧问题就从那里进来。顺便说一句程序里如果手动打印backtracePython的traceback.format_exc()、Java的printStackTrace()、Golang的runtime.Stack()都是常用手段。学会读栈回溯等于学会让程序告诉你“它是怎么走到这一步的”。2.3 栈帧之外内核栈、中断栈又是怎么一回事热搜词里有个“中断栈针”我猜大概率是指“中断栈”或者“中断栈帧”。这个概念听起来很深其实道理很简单CPU正在执行某个任务时突然来了一个中断比如网卡收到了数据包、键盘被按下了它需要立刻暂停当前任务保存“现场”——当前寄存器内容、返回地址——再跳去执行中断处理程序。保存现场要用栈用完恢复现场也要用栈。Linux里内核态和用户态是分开的每个线程至少有两个栈用户栈和内核栈。中断处理时还会用到专门为中断上下文准备的栈。为什么要在操作系统课里反复强调这一点因为中断处理程序里几乎不允许调用可能阻塞的函数其中一个重要原因就是栈的使用非常受限容不得复杂的操作。第七周学到这里先有一个印象就好栈是系统在“紧急情况”下也得依赖的设施。2.4 单调栈面试高频但很多人没真正搞懂单调栈是栈这个数据结构里最值得深入研究的变体面试高频而且很多初学者只看名字就觉得难。单调栈就是栈内元素保持单调递增或单调递减。它最经典的应用是解决“下一个更大元素”“接雨水”“柱状图中最大矩形”这类问题。核心思想一句话当新元素让栈不再满足单调性时就把栈顶逐一出栈出栈的那一刻当前这个新元素就是那些被弹出元素的“下一个更大元素”。为什么复杂度是O(n)因为每个元素最多入栈一次、出栈一次总共只遍历一遍数组。给你一个最简单的Python示例求每个元素右边第一个比它大的下标def next_greater(arr): n len(arr) ans [-1] * n stack [] # 栈里存下标保持 arr[下标] 单调递减 for i in range(n): while stack and arr[stack[-1]] arr[i]: ans[stack.pop()] i stack.append(i) return ans这段代码很短但如果你不看题解自己推演一遍会对“出栈时做决定”这个套路印象极深。单调栈之所以强大是因为它把原本可能O(n²)的两两比较压缩成了每个元素只和旁边元素比较一次。2.5 编译器里的中缀转后缀栈的另一处老巢表达式3 2 * 4在计算机里不会按照“从左到右”直接算要先转成后缀表达式3 2 4 * 再用栈逐步求值遇见数字入栈遇见运算符弹出两个操作数算完结果再入栈。整个过程就是两个栈操作。大一学编译原理的时候老师课上用粉笔在黑板上一步一步推演后缀表达式求值我直到那一刻才真正明白“栈是程序员的基本功”不是考试背一背就够的。如果你第七周学完栈之后自己能动手写一个中缀转后缀的小程序那栈这部分才真正扎稳了。3. 队列的工程版本从循环数组到消息队列3.1 循环队列数组实现里的“环”是怎么绕出来的如果拿普通数组做队列每次出队时如果只是把队头下标往前移那么队头之前的空间就白白浪费了如果每次出队都把后面所有元素往前挪复杂度又会退化成O(n)。循环队列就是解决这个问题的经典方案让队头指针front和队尾指针rear“绕着数组走”入队时rear往后走出队时front往前走走到数组末尾就折回开头。循环队列最关键的是区分“队空”和“队满”——因为front和rear相遇时既可能是空也可能是满。常见方案有两个加一个size字段记录当前元素个数或者干脆留一个空位不存数据。热搜词里提到的场景很典型“以数组q[m]存放循环队列中的元素同时以rear和length分别指示环形队列中的队”。这种结构里不需要额外的front指针因为front可以直接算出来front (rear - length m) % m注意length可能会大于rear所以单纯用rear - length会得到负数必须加一个m再取模。这个负号取模的坑我见过不少人在笔试里栽过。队空条件是length 0队满条件是length m逻辑非常清晰。用Python写一个基于这个思路的循环队列class CircularQueue: def __init__(self, capacity): self.capacity capacity self.data [None] * capacity self.rear 0 self.length 0 def enqueue(self, val): if self.length self.capacity: raise OverflowError(queue full) self.data[self.rear] val self.rear (self.rear 1) % self.capacity self.length 1 def dequeue(self): if self.length 0: raise IndexError(queue empty) front (self.rear - self.length) % self.capacity val self.data[front] self.data[front] None self.length - 1 return val每次出队时按rear和length现算front省掉一个指针的维护代码更简洁。唯一要注意的是取模运算在Python里对有符号负数的处理方式和C语言不完全一样写通用代码时尽量保证取模前的值是非负的。3.2 Python里queue.Queue到底什么时候“堵”什么时候“不堵”热搜词里有一条“python队列queue不堵塞”我估计很多人遇到的问题是写了队列但程序并没有按预期停下来等待。原因通常有两种。第一种你用的是collections.deque。deque是高效的双向队列本身完全没有阻塞语义入队出队都是立刻返回队列空时强行取元素只会抛异常。如果你需要生产者消费者模式里的“等待”必须用queue.Queue。第二种你确实用了queue.Queue但是调用了非阻塞接口。比如get(blockFalse)或者get(timeout0)队列为空时立刻抛queue.Empty不会等待。queue.Queue内部其实是用条件变量Condition管理两个状态not_empty——队列里有数据了唤醒等待的消费者not_full——队列有空位了唤醒等待的生产者。想真正理解“阻塞”最好的办法是自己基于threading.Condition写一个迷你阻塞队列。写完一次之后你就再也不会搞混“为什么不堵”了。另外提醒一个常见搭配消费者线程里通常是while True: item q.get(); do_work(item); q.task_done()主线程再调q.join()等待所有任务完成。task_done()的位置不能放错我见过有人把它放在do_work前面结果任务还没处理完join就认为干完了。3.3 线程池为什么要配一个阻塞队列以及选型坑线程池的本质是“一组干活线程 一个任务队列”。当所有工作线程都在忙碌时新任务先进队列排队队列满了才会触发拒绝策略。这个队列的选型直接决定线程池在压力下的行为。Java里最常见的对比是这三种队列队列类型是否有界适用场景LinkedBlockingQueue默认无界任务量平稳不想写拒绝策略ArrayBlockingQueue有界需要保护内存配合拒绝策略SynchronousQueue不缓存任务来一个任务立刻交给线程吞吐优先很多生产环境不敢直接用Executors.newFixedThreadPool()原因就是它默认挂了一个无界队列。一旦任务生产速度追上消费速度队列会无限膨胀直到把内存拖垮。正确做法通常是自己创建ThreadPoolExecutor显式指定一个有界队列和合理的拒绝策略。Python的ThreadPoolExecutor内部也类似任务队列没有上限高流量场景下同样存在堆积风险。实践建议宁可一开始就选有界队列并预设一个合理上限也不要等线上内存告警再回头改参数。等你看到堆内存曲线一路向上爬的时候再造队列参数已经来不及了。3.4 消息队列把队列思想放大到整个系统跨进程之后队列就变成了消息队列。它解决的问题是解耦、削峰、异步。比如订单系统创建订单后不直接同步调用库存系统而是把一条消息写入MQ里库存系统自己去消费。好处是两边互不阻塞突发流量时也能先把请求缓存在队列里让下游慢慢消化。但消息队列会带来一个新问题重复消费。这是热搜词里频繁出现的一条——“消息队列重复消费问题”。根源在于消息中间件普遍采用“至少一次”的投递语义网络抖动、消费者宕机重启、消费超时重投都可能让同一条消息被处理两遍。解决思路不难核心是做幂等设计。消费者处理消息时拿业务唯一键订单号、请求ID去查重重复的直接丢弃或者用Redis的SETNX做一个“只处理一次”的标记位。我实际见过一个线上事故消费端没做幂等补偿任务重复跑直接把用户账户余额加了两遍。这种问题跟数据结构课上学的队列有什么关系数据结构里的队列是“容器”工程里的消息队列是“协约”——消息不一定严格FIFO但处理逻辑必须能扛住重复。学第七周的时候至少要在心里留一个概念队列从内存走向分布式之后会多出很多可靠性和语义上的考虑。3.5 别忘了队列家族的另外两个重要成员普通队列之外双端队列和优先级队列也值得放在一起对比。双端队列deque在两端都能以O(1)复杂度进出。Python的collections.deque就是典型实现常用来做滑动窗口、缓存淘汰。你在LeetCode刷“滑动窗口最大值”时很多解法都依赖deque。优先级队列也叫堆Python里对应heapq。它不再遵守“先来先处理”而是“谁最紧急谁先出”。定时任务调度、Dijkstra最短路、海量数据TopK全是它的地盘。普通队列、优先级队列、栈三者放一起比一比区别其实只在“出队顺序的规则”上先到先出是队列后到先出是栈最紧急先出是堆。规则一旦清楚用起来就不会拿错工具。4. 第七周实操路线建一遍、刷一遍、拆一遍4.1 建一遍丢掉库函数从零手写学习栈和队列最忌讳“只会用Python的list和queue”. 建议第一天把所有库函数放一边按下面的顺序手写一遍用Python list实现栈支持push/pop/peek/is_empty用数组实现循环队列用rearlength维护状态验证队空队满用链表实现队列注意维护head和tail两个指针用两个栈实现一个队列手写一遍之后你才算真正知道“队列在底层是怎么转的”。写完还不够一定要自己补几组边界测试空队出队、满队入队、入出交替最容易写崩的就是这些边界。我自己带新人的时候最喜欢让他们写循环队列几乎每个人第一次都会在队空和队满的判断上出问题。写错不可怕可怕的是不去写只在草稿纸上画图。4.2 刷一遍三道能打通任督二脉的题第一题是括号匹配。遍历字符串遇到左括号就压栈遇到右括号就弹栈并校验是否匹配遍历完栈为空才算合法。这题能秒杀说明你已经理解了“最近匹配”的场景。第二题是用栈实现队列、用队列实现栈。做完之后你对“数据结构之间的互相转换”会有一个全新的认识也能真正理解两个结构在操作顺序上的差异。第三题是单调栈或单调队列的问题比如接雨水、下一个更大元素、滑动窗口最大值。这题难度明显上一个台阶也是热搜词里“单调栈揭秘”指向的内容。我的建议是每题先自己磕一个小时再去看题解看完题解把页面合上自己重写一遍隔一天再默写一次。三次下来解题套路会刻进脑子里比刷十道重复题都管用。4.3 拆一遍读真实系统的栈和队列源码上课用伪代码工程用真代码。推荐三个值得拆的真实实现Python的collections.deque虽然名字叫deque底层其实是一个双向块状链表两端增删都是O(1)Linux内核的kfifo一个无锁环形队列为了性能用index (size - 1)代替index % size前提是size必须是2的幂Java的ArrayDeque用循环数组实现扩容时容量翻倍同样按2的幂对齐看这些源码的目的不是让你背API而是帮你建立“数据结构的工程现实感”课本里的边界判断、取模操作在真实系统里经常会因为性能优化而被改头换面但核心逻辑永远是循环、指针、满与空的判断。我看完kfifo之后才知道取模运算在一些场景下真的会被优化掉编程里的“看似正确”和“高效正确”是两回事。4.4 组合实验一个迷你任务调度器把所有知识点串起来的最好方式是做一个小项目。我推荐做一个简化版任务调度器用heapq维护定时任务谁的执行时间最近谁先出堆用一个普通队列暂存待执行任务用两个栈模拟“撤销/重做”历史每一步操作压入undo栈撤销时把弹出来的操作压入redo栈这个项目几十行代码就能写完但能覆盖堆、队列、栈三种结构做完之后你对“为什么要学多种数据结构”会有一个非常直观的感受。import heapq import queue # undo/redo 用双栈 undo_stack [] redo_stack [] # 定时任务用堆 timer_heap [] # 普通任务用队列 task_queue queue.Queue()题不在多在于把知识串成一个闭环。第七周如果能把上面这套走完栈和队列这部分基本就吃透了。5. 学完第七周真正留下的是“顺序敏感度”5.1 什么时候该用栈什么时候该用队列先问这一句算法题里看到“嵌套关系”“最近匹配”“回溯”“撤销”这些关键词大概率是栈看到“按到达顺序”“缓冲”“层级遍历”“先来先服务”这些词大概率是队列。但比套路更底层的问题是我的数据在时间上应该被怎样处理后到的要先被处理就是栈先到的要先被处理就是队列最紧急的优先就用堆。这个问题想明白了面对一堆看似不同的题目时你就能快速选出数据结构而不是靠刷题量硬堆感觉。5.2 一次HTTP请求里的栈与队列把视角放到一个真实系统上请求先进入网络层的缓冲区这是一个队列业务代码层层调用形成一个调用栈异步任务被投递到消息队列供下游服务消费。再看前端页面路由的返回记录是栈事件循环里的回调任务也是队列。可以说栈定义的是“程序执行的深度”队列定义的是“系统协作的广度”两个维度合在一起才是完整的程序运行秩序。我后来在读各种框架源码时凡是看到undo、backtrack、recursion脑子里自动浮现栈凡是看到buffer、pool、queue自动浮现队列。这个条件反射就是第七周开始建立起来的。5.3 一句掏心窝的话第七周如果只做一件事我建议你把栈和队列从“概念”变成“手上的工具”自己实现一遍刷两三道题再读一点真实源码。不需要背下所有变体但一定要在脑子里留下一个印象——面对任何数据处理问题先判断“处理顺序到底是什么”。我后来面试新人时最常问的一个问题就是“一个浏览器的后退按钮让你设计你会用什么数据结构”。大多数背过概念的人会说栈但只有少数人能解释清楚为什么后退历史是后进先出、以及栈在这里解决的到底是容量问题还是顺序问题。这两者之间的距离就是第七周真正要跨过的距离。
返回列表