多彩编程 多彩编程MZPH · CODE BLOG
ARTICLE DETAIL

文章详情

深耕前端与后端开发技术的一线实战笔记与踩坑复盘。

栈与队列深度解析:底层实现、高阶玩法与踩坑指南

栈与队列深度解析:底层实现、高阶玩法与踩坑指南 1. 为什么这两个结构值得重新走一遍不管是做业务开发、刷算法题还是搞竞赛栈和队列应该是所有人最早接触的一批数据结构。但有意思的是我这些年面试过的候选人里能把这两个结构真正讲透的人真不多。很多人能脱口而出栈是后进先出队列是先进先出可一旦往下追问底层怎么实现、动态扩容怎么处理、实际系统里哪些核心组件在用、线上出了问题怎么排查——就卡住了。这其实很可惜。栈和队列看起来简单但它们是理解很多复杂系统的地基。函数调用栈的创建与销毁、编译原理里的表达式求值、操作系统的任务调度、浏览器后退、undo/redo、线程池里的阻塞队列、分布式系统中的消息队列底层全是这两个基础结构的身影。换句话说栈和队列不是为了应付面试才存在的死知识它们几乎渗透进了软件开发的每一个角落。这篇文章我不打算讲教科书式的东西。我会用做项目的视角把这俩结构从实现到高级玩法完整拆一遍配合真实场景讲清楚为什么这么做以及踩过哪些坑。内容适合三类人刚学完数据结构、想真正吃透基础的后端或客户端开发初学者准备面试、想系统梳理知识体系的人以及写代码多年但习惯性用STL、没想过底层机制的老手。看完你会发现很多困扰你的疑难问题根子大概率就在这两个结构上。顺便说一句现在流行聊全栈从原生到跨端、从服务端到算法模型技术栈越铺越宽。但无论技术栈怎么变栈和队列这类基础结构永远是地基。地基牢不牢决定你能在上面盖多高的楼。2. 手写实现从零造轮子的实操细节2.1 先从为什么不能只看API说起很多初学者觉得栈和队列学完了反正有现成的std::stack、std::queue会调push、pop不就行了这话对了一半。日常开发确实不需要自己造轮子但你得知道轮子是怎么转的——因为所有容器的性能边界、内存行为、异常风险都是由底层实现决定的。举个我真实遇到的例子。有一次在服务端排查接口偶发抖动最终定位到有人用std::stack存了大量临时对象且初始化时没有预分配容量结果频繁发生内存重分配和对象拷贝导致GC压力暴增。如果只是会调用API这个问题永远查不出来但理解了数组栈的扩容机制一眼就能看出端倪。先明确定义。栈是后进先出LIFO的线性表只允许在同一端栈顶top进行插入和删除队列是先进先出FIFO的线性表队尾rear插入、队头front删除。就这么简单是的结构规则非常简单但实现细节里藏着不少门道。2.2 数组栈扩容策略是核心数组实现栈的关键是维护一个top指针指向栈顶元素。初始top -1表示空栈push时先把top加1再写入元素pop时返回top位置的元素并将top减1取栈顶元素peek则直接读top位置但不出栈。核心问题在容量。如果数组满了还继续push怎么办两个方案一是提前设定固定容量满了拒绝写入适合容器类组件比如ArrayBlockingQueue做的事二是动态扩容——当top capacity - 1时申请一块更大的内存通常翻倍把原有元素整体拷贝过去然后释放旧内存。为什么翻倍而不是每次加固定大小假设初始容量是4如果每次容量加1插入N个元素的平均时间复杂度是 (O(N))——每次扩大都要拷贝已有数据总拷贝次数为 (456\dotsN)这是 (O(N^2))。而翻倍扩容的总拷贝次数是 (4816\dotsN)等比数列求和是 (O(N))。均摊下来每次push依然是 (O(1))。这个细节笔试经常考实际项目里也直接影响性能尤其是存大对象时频繁扩容是灾难。C实现一个简单的动态数组栈template typename T class ArrayStack { private: T* data; int top; // 栈顶下标-1表示空 int capacity; void resize() { int newCap capacity 0 ? 4 : capacity * 2; T* newData new T[newCap]; for (int i 0; i top; i) { newData[i] data[i]; } delete[] data; data newData; capacity newCap; } public: ArrayStack() : data(nullptr), top(-1), capacity(0) {} void push(const T val) { if (top 1 capacity) { resize(); } data[top] val; } void pop() { if (top -1) throw std::out_of_range(stack underflow); top--; } T peek() const { if (top -1) throw std::out_of_range(stack is empty); return data[top]; } bool empty() const { return top -1; } int size() const { return top 1; } };注意边界条件top -1时空栈的push和pop都要处理扩容时机要在写入前判断否则数组下标越界。这些都是见真章的地方。2.3 队列的假溢出环形队列是必答题数组实现队列时最容易踩的坑是假溢出。入队时rear往后移出队时front往后移。看起来rear只是往后走可一旦rear到达数组末尾哪怕 front 前面空了大片区域也不能再入队了——因为 rear 已经出界了。这就是假溢出物理存储空间还有剩余但线性模型认为队列已经满了。出队操作会让 front 后移于是数组前半部分永远空着造成空间浪费。解决办法是环形队列也叫循环队列。把数组视作一个首尾相接的环通过取模让下标在到达末尾后自动回到开头入队rear (rear 1) % capacity然后写入data[rear]出队front (front 1) % capacity返回data[front]但取模带来了一个新问题如何区分队列空与队列满方案一浪费一个存储位以(rear 1) % capacity front作为满条件初始front rear 0时为空。此时实际能存capacity - 1个元素。方案二额外引入size变量每次入队加1、出队减1size 0为空size capacity为满。这样不用浪费空间但多一次维护开销。我的建议是如果是自己撸着玩或做算法题用方案一这是最常见的标准写法如果是真的要在项目里做有界缓冲用方案二更实用。下面给出方案一的完整实现template typename T class CircularQueue { private: T* data; int front, rear; int capacity; public: CircularQueue(int cap) : front(0), rear(0), capacity(cap) { data new T[cap]; } ~CircularQueue() { delete[] data; } bool enqueue(const T val) { if ((rear 1) % capacity front) { return false; // 队列已满 } rear (rear 1) % capacity; data[rear] val; return true; } bool dequeue(T out) { if (front rear) { return false; // 队列为空 } front (front 1) % capacity; out data[front]; return true; } bool empty() const { return front rear; } bool full() const { return (rear 1) % capacity front; } };这段实现里最反直觉的点是空与满的边界只差一个位置。你第一次用的时候很可能明明看着还有空位却返回满了——那时候恭喜你说明你真的绕明白了。2.4 链表实现什么时候优先选它链表实现栈和队列也是非常常见的。链表栈只需要一个头指针入栈就是头插法出栈就是删除头节点时间复杂度均为 (O(1))。链表队列则需要维护head和tail两个指针入队从tail后插出队从head删除。选择数组还是链表的判断依据对比维度数组实现链表实现随机访问能力支持(O(1))不支持内存局部性好缓存命中率高差每个节点是零散分配的动态扩容需要翻倍并拷贝数据有性能波动天然动态无拷贝空间利用率环形队列最多浪费一个槽位每个节点额外存指针内存开销大常见场景消息缓冲、算法题、次数可控的场景不确定上限、频繁大量插入删除重点提醒一下链表实现的栈和队列一定要注意delete节点的时机特别是队列出队时先保存旧头节点的next再释放否则会丢链。我见过不少人在这一步悬空指针调试一晚上最后发现是释放顺序错了。3. 栈的高阶玩法从调用栈到单调栈3.1 函数栈帧你写的每次调用都在用栈这是栈最隐身的应用之一。每次函数被调用操作系统或运行时都会在内存的栈区分配一段连续的栈帧Stack Frame用来存放这个函数的局部变量、参数、返回地址和保存的寄存器状态。函数执行完栈帧被销毁——整个过程就是一个完美的栈后调用的函数先返回。这也是递归为什么有深度限制的原因。每层递归都生成一个栈帧递归过深会把进程栈空间耗尽触发stack overflow。实际项目里我见过多次线上事故就是在处理高深度的目录树或树形结构时用了递归而不是迭代结果进程直接崩溃。排查这类问题时backtrace栈回溯是最直接的武器。在C/C里通过execinfo.h的backtrace()和backtrace_symbols()可以在程序崩溃时打出当前完整的调用链准确定位是哪一个调用序列导致问题。我曾经调试过一个崩溃日志只能打印出一串诡异地址的场景最后就是用backtrace把调用栈展开发现是某个回调函数被异步触发时状态已经被前一个调用释放。这种问题没有调用栈信息就是盲人摸象。3.2 算术表达式求值一个老派但极好的练手题编程题实训-实验2-基于栈的算术表达式求值算法这个题目名字听着像课程作业但它几乎覆盖了栈的所有核心操作。很多学生死磕这个实验就是因为没理解透操作数栈运算符栈双栈配合的原理。标准做法是双栈法一个栈存操作数一个栈存运算符。过程如下遇到数字直接压入操作数栈。遇到运算符与运算符栈栈顶比较优先级如果新运算符优先级更高压栈。如果新运算符优先级不高于栈顶先弹出栈顶运算符进行计算再将结果压入操作数栈然后重新比较新运算符。遇到左括号(无条件压入运算符栈。遇到右括号)持续弹出运算符并计算直到弹出左括号。表达式扫描完毕把运算符栈中剩余的运算符依次弹出计算。优先级规则是核心*、/同属高优先级、-属低优先级括号用来强行改变结合顺序。这个算法本质上就是把人的直觉翻译成机器逻辑。举个例子表达式3 4 * 2 - (1 2)压入3压入压入4读**优先级高于压栈压入2读--优先级低于*弹出*计算4*28压入8此时运算符栈为-与优先级相同再弹出计算3811压入11压入-遇(, 压入(压入1压入压入2遇), 弹出计算123压入3弹出(丢弃扫描结束弹出-计算11-38结果为8整个过程中操作数栈存的是中间结果运算符栈存的是等待执行的运算。这就是栈在编译原理中做语法分析时候的样子。3.3 单调栈把O(n²)压成O(n)的利器单调栈算栈这个结构在算法世界里的高光时刻。所谓单调栈就是栈内元素始终保持单调递增或单调递减。它的价值在于当你需要找左边/右边第一个比当前元素大/小的元素时单调栈能在 (O(n)) 时间内搞定而暴力做法的复杂度是 (O(n^2))。经典题目是每日温度给定每天温度求出每个位置之后多少天才会有更高温度。暴力双循环会超时单调栈解法只需一次遍历vectorint dailyTemperatures(vectorint temps) { int n temps.size(); vectorint res(n, 0); stackint st; // 存下标栈顶到栈底递减温度从低到高 for (int i 0; i n; i) { while (!st.empty() temps[i] temps[st.top()]) { int prev st.top(); st.pop(); res[prev] i - prev; } st.push(i); } return res; }核心技巧维护一个维护未找到更大值的递减栈。每来一个新元素就把栈里所有比它小的元素全部弹出并顺势填充答案。因为栈内的元素在数值上递减、在下标上递增所以一旦出现更大的值它必然是第一个比栈内元素大的值。单调栈在竞赛中出镜率极高c 栈 竞赛用的多吗答案是肯定的——不只是栈单调栈是省选和ACM入门阶段绕不过去的必修课很多看起来复杂的题一上单调栈就变成模板题。刷题的人一定要把维护逻辑吃透而不是死背模板。面试时能讲清楚单调栈为什么每个元素最多入栈一次、出栈一次这段基本就稳了。4. 队列的高阶玩法从BFS到消息队列4.1 阻塞队列线程池的咽喉要道队列在线程与并发领域扮演着核心角色。最经典的场景就是生产者-消费者模式生产者线程往队列里放任务消费者线程从队列里取任务执行。中间这一层队列承担着缓冲、削峰、解耦三合一的作用。如果生产者太快、消费者太慢队列就能暂存积压的任务避免直接打垮下游。但在多线程环境下普通队列有两个致命问题一是线程安全问题多个线程同时入队出队会数据错乱二是空转问题消费者取不到数据时如果一直循环查询白白消耗CPU。阻塞队列就是为这两个问题而生的——当队列为空时消费者的取操作会被阻塞直到有新元素入队才被唤醒当队列满时生产者的放操作也会被阻塞直到有空间腾出来。Java里BlockingQueue是经典抽象ThreadPoolExecutor的可选排队策略基本就是它队列类型特点适用场景LinkedBlockingQueue默认无界可指定有界最常用任务量平稳时选它ArrayBlockingQueue有界基于数组性能更稳定必须限制排队上限时选它SynchronousQueue不存储元素直接交接希望立刻把任务交给线程处理、不想排队时DelayQueue延迟出队元素到时间才能取延时任务、定时关单、缓存淘汰这里有个实际选择要点无界队列在流量高峰时可能导致任务无限堆积内存暴涨直到OOM有界队列在队列满时有多种拒绝策略比如AbortPolicy抛异常、CallerRunsPolicy由提交任务的线程自己执行。如果让我给建议线上系统默认用有界队列必要时候再根据业务量调大容量别贪图省事用无界。这是很多线上事故的源头我看到过太多团队因为队列选型不当流量一上来整个服务内存被打爆负责人还在查为什么内存突然飙升。阻塞队列自己做也是经典面试题。核心是要配合mutex和condition_variable实现等待-唤醒语义。如果不知道Linux里pthread的pthread_cond_wait为什么要在循环里等待防止虚假唤醒建议先去补一下操作系统课本。这块完全是实战硬货不容有失。4.2 消息队列跨机器的阻塞队列把普通队列放大到分布式系统就是消息队列MQ。Kafka、RocketMQ、RabbitMQ、Pulsar这些中间件本质上都在做一件事在不同服务之间传递消息削峰填谷、异步解耦、数据分发。消息队列在概念上依然是FIFO队列但实现复杂度完全不在一个量级消息要持久化到磁盘、要协调多副本、要处理消费者组、要保证消息不丢、还要尽量不重复。但无论系统多复杂消费者的核心模型依然是从队头取消息生产者的核心模型依然是往队尾写消息。好消息是理解了基础队列理解消息队列会顺畅很多。比如消费者消费速度跟不上生产者消息就会积压Kafka用消费者落后offset表示这种情况本质就是队列的满了。又比如PHP后端常配合RabbitMQ或Redis做异步任务队列php 队列其实大多数时候就是借助已有的队列中间件而不是自己从零实现一个可靠队列。需要特别警惕的消息队列问题是重复消费。由于网络超时重试、消费者处理成功后还没来得及提交offset就宕机等原因业务上可能收到重复消息。解决重复消费的唯一可行思路是幂等让同一个消息被处理两次的效果与处理一次完全相同。落地方案包括在数据库中用消息唯一ID做去重键、先查后写加唯一索引、或者在业务关键路径上做状态机判断。记住一点在分布式环境里要假设消息一定会重复不要心存侥幸。4.3 双端队列与滑动窗口的经典配合双端队列Deque的两端都能插入和删除是队列的灵活变体。它在算法题里的明星题是滑动窗口最大值。示例给定数组[1,3,-1,-3,5,3,6,7]窗口大小k3求每个窗口的最大值。暴力法是每挪动一次窗口就扫一遍窗口内的k个元素复杂度 (O(n \times k))。用双端队列做能将整体降到 (O(n))思路是队列里存数组下标。新元素入队前把队尾所有小于等于它的元素全部弹出——因为窗口内最大值永远不会是这些更小的值。检查队头是否已经滑出窗口超出窗口范围就弹出。窗口形成后队头就是当前窗口最大值。vectorint maxSlidingWindow(vectorint nums, int k) { dequeint dq; vectorint res; for (int i 0; i nums.size(); i) { while (!dq.empty() nums[dq.back()] nums[i]) { dq.pop_back(); } dq.push_back(i); if (dq.front() i - k) { dq.pop_front(); } if (i k - 1) { res.push_back(nums[dq.front()]); } } return res; }这段代码值得一行一行推敲。尤其是弹出队尾所有较小值这一步恰恰是单调队列的核心思想——维护一个递减序列保证队头总是最大值。实际项目中处理实时数据流、时间窗口统计时这种思路也很有用。5. 踩坑实录与排查思路5.1 调用栈信息崩溃现场的破案关键程序崩溃后日志里最容易被忽略但又最值钱的就是调用栈信息。比如常见报错Segmentation fault (core dumped)配合backtrace能看到完整的函数调用链条问题往往一眼就曝光。我调过一次奇怪的内存问题一个在线服务每隔几天就会随机崩一次进程退出前打印的函数栈总是五花八门看起来毫无规律。直到我把backtrace展开完整打出来后才发现所有崩溃路径都有一个共同的上游调用——某个全局缓存被提前析构了后续所有线程访问它时产生悬空指针。修复方法很简单把缓存的生命周期延长到进程结束即可。没有调用栈回溯这个问题可能永远查不出来。给做C/C服务端的人一个建议上线代码里一定要注册信号处理器在SIGSEGV、SIGABRT等信号里调用backtrace打印调用链到日志文件并立即刷新日志。关键时刻这行代码能省下你一整晚时间。同样的调用栈信息还能帮到你面试和实际工作里。问为什么递归深度太大栈溢出其实就对应函数栈帧的创建与销毁的机制理解。平时写代码时留个心眼没必要用递归的时候就用显式的栈 迭代替代既省栈空间又避免回调地狱。5.2 队列积压先分清是无人消费还是消费太慢消息队列或任务队列一旦积压第一时间不是去加机器而是要判断积压属于哪种类型。我自己排过多次这种故障总结下来大致三类第一类是消费者挂掉了或者拉起失败。现象是队列里消息数只涨不跌消费者没有任何日志。先用ps或控制台确认消费者进程是否存活再看看它有没有被OOM Kill掉。如果只是节点挂了移除故障节点新节点自动顶上去就能恢复。第二类是消费能力不足。现象是消费者有日志但处理速度瓶颈明显积压持续增加。此时要区分是下游依赖数据库、外部API响应变慢还是消费线程数量配置过少。通常的解法是扩容消费者实例或者调用方做批量处理接口来提升吞吐。第三类是死信消息卡住了队头。如果消费逻辑抛异常且没有配置重试策略某些消息会反复进入重试队列甚至阻塞后面的正常消息。排查方式是把队列中前几条消息的内容拉出来看看如果都集中在同一类业务基本就是带毒消息——打日志确认哪一条消费不下去单独把它擢出去丢弃或人工修正。顺带提一下队列对这个词在一些运维工具里指队列是否成对/匹配。比如某些消息中间件的消费组和订阅关系缺失会导致消息只进不出。遇到队列对不上的报错优先检查订阅关系和路由规则。5.3 内存与性能陷阱隐蔽但致命除了上面说的扩容问题还有几个隐蔽的坑值得单独拿出来说一个是栈上分配大量对象。局部变量默认在栈上分配但如果你在函数里new一个特大数组或者递归层数深到把栈撑爆那崩溃只是时间问题。排查办法很简单ulimit -s看栈大小限制再把可疑函数里的栈上大对象改成堆上分配。另一个是队列的无界增长。RingBuffer 实现消息缓冲时如果生产者持续快于消费者而队列又没有丢弃策略内存会一路涨到OOM。运行时加一个水位监控队列长度超过阈值就触发告警或丢弃策略是很必要的兜底习惯。还有一个是我个人经常提醒团队的错误在Java里使用LinkedList当做栈或队列容器时尤其是高并发环境下会产生大量节点对象GC压力极大。改用数组版栈或ArrayDeque、ArrayBlockingQueue这类基于数组的实现内存和性能表现都会好很多。这也是为什么网上有一堆关于为什么别用LinkedList当队列的讨论。别嫌这些细节琐碎线上服务性能差往往就是这类不起眼的小选择叠加起来的。最后再说一个工作时容易遇到的小细节有些队列中间件的控制台查看权限是通过bqueues之类的命令来管理的比如LSF集群环境里面能看到队列优先级、用户权限、运行限制。检查任务是不是一直无法提交时先看当前用户在目标队列是否拥有提交权限有时候不是任务本身有问题是权限配置把你卡住了。这类问题看似和数据结构无关但底层还是队列调度思维的应用——把资源当队列管优先级和权限都是队列策略的一部分。5.4 一组速查表问题现象、根因与解法现象可能的根因排查与解法递归程序栈溢出递归深度过大栈帧过多改用显式栈 迭代调大线程栈大小优化递归分治策略数组队列没满却返回满环形队列空/满边界没绕明白用size变量消除歧义或者接受浪费一个槽位的标准写法线程池任务不执行也不结束阻塞队列满AbortPolicy抛异常检查有界队列容量调整拒绝策略提升消费者线程数消息队列积压持续上涨消费者宕机或消费能力不足先看消费者进程存活再分析消费耗时瓶颈最后考虑扩容消息重复消费消费者处理成功后未提交offset业务逻辑设计成幂等用消息唯一ID做去重服务偶发抖动、GC频繁容器类使用不当频繁扩容或产生大量节点对象预分配容量优先数组实现减少链表容器使用崩溃日志地址栈指向不明未使用backtrace回溯注册信号处理器崩溃时打印完整调用链这一套速查表纯粹是实战总结出来的。很多问题初期看起来和数据结构毫无关系可一旦追根溯源全都落到这些基础结构的设计选择上。我个人在实际项目里的体会是栈和队列这两个结构从来不缺戏份缺的是把它们用对的意识。平时多手写几遍底层实现碰到线上疑难杂症时你能比那些只会调API的人多一层判断力。哪怕项目里全用成熟框架和中间件能看透它们底层排队和阻塞机制的细节排查问题时的方向感也会强出太多。这份手感值得花时间慢慢磨出来。
返回列表