栈和队列:从数据结构基础到工程实战的全面解析
栈和队列是数据结构里最基础的两块基石这一点几乎所有教材都会强调。但说实话第七周学到这两个东西的时候很多人会有一个共同的困惑栈我会写了队列我也会写了可它们到底有什么用为什么计算机系统到处都在用这两个结构如果你也卡在这个地方那这篇文章就是为你准备的。我会从课堂知识出发把栈和队列在真实工程里的底层逻辑、常见应用、以及我自己踩过的一些坑全部串起来讲看完你会觉得这两个结构一点都不“基础”反而是理解整个计算机系统的一把钥匙。先说清楚今天这45分钟第七周的学习时间我们到底要解决什么问题第一把栈和队列的本质吃透不只是会写代码而是理解它们为什么存在第二搞清楚它们和内存、函数调用、消息系统、线程池这些东西是怎么关联起来的第三掌握几个最高频的实战场景从单调队列优化到阻塞队列再到消息队列的选型对比一步到位。这篇内容适合正在学数据结构的学生、准备面试的开发者以及想夯实内功的后端、客户端、嵌入式从业者。栈和队列看似简单但深挖下去几乎能串起半本操作系统和半本网络编程。1. 栈和队列到底在解决什么问题先抛开教科书上的定义我们从三个完全不同的视角看这两个结构你会发现它们的本质惊人地统一。从数据结构定义的视角看栈是“后进先出”LIFOLast In First Out队列是“先进先出”FIFOFirst In First Out。这八个字是几乎所有教材的核心也是笔试面试的必考点。但如果你的理解停留在这个层面那和没学没什么区别。关键在于思考一个问题为什么现实世界需要这两种截然不同的规则从内存和程序运行的视角看栈解决的是“嵌套调用”的问题。你调函数AA调函数BB调函数C。C执行完了得回到B继续执行B执行完了得回到A继续执行。这种“后调用的先返回”的天然结构就是栈。函数调用栈Call Stack因此得名。你写的每一次递归、每一个函数调用底层全靠栈帧Stack Frame的压入和弹出在支撑。所以栈不是一个“数据结构题”它是程序能够运行起来的物理基础。从系统设计的视角看队列解决的是“速度不匹配”和“解耦”的问题。生产者产生的数据消费者处理这些数据两者的速度通常不一致。如果生产者太快消费者忙不过来数据就会堆积如果消费者太快生产者又供应不上。队列放在中间就像一条传送带把生产和消费两个环节隔开。这是消息队列、线程池、操作系统的IO请求队列、网络数据包缓冲区的共同底层逻辑。所以你可以这样理解栈是计算机管理“执行过程”的工具队列是计算机管理“任务流转”的工具。一个管纵向的调用关系一个管横向的流转关系。搞清楚了这一层后面所有的实战场景都只是在这两个基本模型上做文章。第七周为什么重要因为从这一周开始你学的将不再是“怎么写代码”而是“计算机怎么思考”。栈对应的是计算机的执行模型队列对应的是计算机的协作模型。这两个模型是后面学习操作系统、计算机网络、甚至分布式系统的地基。1.1 栈的三种物化形态调用栈、中断栈、协议栈栈这个概念在计算机里其实出现了很多次容易把人绕晕。我在这里帮你做一个区分三种最常见的栈调用栈Call Stack这是程序运行时维护的栈记录了当前活跃的函数调用序列。每个函数调用压入一个栈帧函数返回时弹出栈帧。你在调试器里看到的“调用堆栈”就是这个东西。中断栈Interrupt StackCPU处理中断时使用的专用栈保存中断现场的寄存器信息。它和普通调用栈通常是隔离的避免中断处理程序破坏被中断任务的现场。这也是为什么有些嵌入式系统里你会看到“中断栈针”这种说法。协议栈Protocol Stack网络通信中各层协议的集合比如TCP/IP协议栈。这里的“栈”和数据结构里的栈关系不大了更多是“分层叠加”的隐喻。但底层协议处理中数据包的封装和解封装依然符合后进先出的逻辑——你发送数据时是从应用层往下层层加头接收数据时是从物理层往上层层解头最后拿到应用层数据。这个“层层剥洋葱”的过程本质就是栈。如果你后续要做嵌入式开发、内核开发或者网络协议相关的工作这三种栈会以各种形式出现在你面前。理解它们是同一个数据结构思想在不同场景下的变形能帮你少走很多弯路。1.2 队列在计算机系统中的全面渗透队列比栈渗透得更广它几乎是“系统架构”的代名词。我随便列几个你一定会碰到的场景CPU的任务调度队列操作系统维护的就绪队列、等待队列本质都是队列。磁盘IO请求队列读写请求排队等待磁盘响应Linux内核里有专门的电梯调度算法在维护这个队列。网络数据包缓冲网卡收到的数据包进入环形队列Ring Buffer等待协议栈处理。线程池任务队列提交给线程池的任务先进入阻塞队列再由工作线程取走执行。消息中间件Kafka、RabbitMQ、RocketMQ这些消息队列本质是把“队列”这个数据结构做成了分布式系统。你会发现一旦理解了队列的“先进先出”规则你就能看懂这些系统的基本轮廓。但每个系统在实现队列时都会针对自己的场景做优化比如环形队列、阻塞队列、优先级队列、无锁队列、单调队列。这一周的学习就是把最核心的几种队列变体搞清楚。2. 栈的实操拆解从数组实现到调用栈回溯栈的实现本身不难但真正的价值在于应用。这一节我们从最基础的数组栈写起一路走到函数调用栈和栈回溯把这些知识点真正打通。2.1 用数组手写一个栈边界条件的细节数组实现栈是最直观的。核心就四个操作初始化、入栈push、出栈pop、取栈顶top。但实际操作中有几个细节非常容易出错#include stdio.h #include stdlib.h #include stdbool.h #define STACK_SIZE 128 typedef struct { int data[STACK_SIZE]; int top; // 栈顶指针指向上一个元素的位置 } Stack; void stack_init(Stack *s) { s-top -1; // 空栈时 top 为 -1 } bool stack_push(Stack *s, int value) { if (s-top STACK_SIZE - 1) { printf(stack overflow\n); return false; } s-data[s-top] value; return true; } bool stack_pop(Stack *s, int *value) { if (s-top 0) { printf(stack underflow\n); return false; } *value s-data[s-top--]; return true; } bool stack_peek(Stack *s, int *value) { if (s-top 0) return false; *value s-data[s-top]; return true; }注意几个关键点top初始化为-1这是C语言风格的经典写法top指向的是当前栈顶元素的下标。空栈时没有任何元素所以top为-1。如果你初始化为0那么入栈时要先赋值再移动逻辑就变成了“top指向下一个空位”两种风格各有优劣但混用是灾难。溢出和下溢检查这是很多新手忽略的。数组栈有固定容量满了就不能再入栈了空了也不能再出栈。在嵌入式或底层开发中栈溢出检查是保命级别的代码。用size_t还是int在很多代码里会用size_t top来定义栈顶但size_t是无符号的-1初始值不好处理。建议这里用int或者用“top 1表示元素个数”的方式设计。我之前在实际项目里写过不少栈最深的体会是栈的实现不是难点难的是你知不知道什么时候该用它。比如后面要讲的括号匹配、表达式求值、递归转非递归都是栈的典型应用。2.2 栈帧的形成过程函数调用的底层真相这一节是我觉得第七周最该深入理解的知识点因为理解了栈帧Stack Frame你就理解了递归、调试、甚至栈溢出的根因。当一个函数被调用时系统会做以下几件事把函数的参数压入栈或放入寄存器具体看调用约定。把返回地址压入栈也就是“调用者下一条指令的地址”这样函数返回时才知道回到哪里继续执行。保存调用者的栈帧基址EBP/RBP建立新的栈帧。为新函数的局部变量分配栈空间。这个过程就是栈帧形成过程。每个函数调用都会在栈上“长”出一块区域这块区域里放着局部变量、参数、返回地址、保存的寄存器值。函数返回时这块区域被弹出一并销毁。我画不出来图但你可以这样想象栈就像一摞叠起来的盘子。每调用一层函数就往上面放一个盘子压栈。每返回一层就取走一个盘子弹栈。递归函数为什么容易栈溢出因为它一直往里放盘子却迟迟不往外取盘子越叠越高最后超出栈的大小限制程序就崩了。我第一次写递归的时候写了一个无终止条件的阶乘函数结果栈溢出直接崩溃当时完全不明白怎么回事。后来理解了栈帧形成过程才真正明白函数的每一次递归调用都在消耗栈空间栈是有限的递归深度也是有限的。所以“递归改循环”或者说“尾递归优化”本质都是在节省栈空间。2.3 栈回溯backtrace当程序崩溃时你只有一张调用图栈回溯backtrace是栈帧最直接的应用场景也是每个开发者迟早要面对的刚需。当你写的程序崩溃了比如段错误、非法访问内存操作系统会给进程发信号终止程序。这时候你抓到的core dump文件里最重要的信息就是调用栈回溯——它记录了崩溃时程序“正在哪里执行”以及“是怎么走到这一步的”。以Linux下的C程序为例最常用的工具是gdbgdb ./my_program core # 进入gdb后输入 btbtbacktrace命令会打印出完整的调用栈从当前崩溃的函数开始逐级往上直到main函数。每一行显示一个栈帧包括函数名、参数值、文件行号。这就是栈回溯的核心能力。在嵌入式或Linux内核开发中栈回溯几乎成了必修课。内核崩溃时打印的一大堆call trace就是这么来的。ARM平台上做栈回溯要更麻烦一些因为ARM的栈帧布局和x86不同但你只要掌握了栈帧的基本原理理解回溯工具的工作原理就不难。我在实际开发中踩过一个非常有代表性的坑程序不定期崩溃毫无规律。后来在gdb里跑bt发现崩溃点在一个非常诡异的函数里再一看调用链是一个全局数组越界写导致栈被破坏了。栈里的返回地址被覆盖程序返回时就跳到了一个非法地址。这就是经典的“栈被踩”问题。栈回溯不仅仅告诉你程序死在哪里更关键的是帮你推演它是怎么被破坏的。2.4 栈变量、全局静态变量和堆三种生存期三种分配方式学习栈的另一个重要产出是彻底分清三种变量的生存期问题栈变量局部变量函数内定义生命周期跟随函数调用。函数返回变量销毁。空间由系统在栈帧中自动分配和释放速度极快但大小有限。全局变量和静态变量程序开始时就分配程序结束时才释放。存放在数据段和BSS段不属于栈也不属于堆。它的生命周期是整个程序运行期。堆变量动态分配程序员手动申请malloc/new手动释放free/delete如果不释放就内存泄漏。堆空间大但分配速度比栈慢很多因为要查找空闲内存块。我在实际写C代码的时候特别注意大数组不要放在栈上。我之前见过同事在函数里直接定义了一个int buffer[1024 * 1024]结果一调用就栈溢出。这个数组占了4MB而Linux默认的栈大小通常是8MB。看起来还有4MB余量但加上其他局部变量和调用开销实际可用的栈空间远小于理论值。这种问题静态分析工具一查一个准。用ulimit -s可以查看和调整栈大小内存栈大小这个命令在Linux排查栈溢出问题时非常实用。但更重要的是养成习惯大块内存用堆分配栈上只放小对象。3. 队列的实操拆解从循环队列到消息队列选型栈讲完了接下来是队列的大世界。这一段我会从最基础的循环队列讲起然后走到线程池的阻塞队列再扩展到消息队列的选型和避坑。内容量比较大但条理足够清晰。3.1 循环队列的经典实现一个空间换来的优雅设计队列如果用普通的数组实现出队后队头指针后移前面的空间就浪费了。怎么解决答案是循环队列Circular Queue。这也是教材里那个经典的“假设以数组q[m]存放循环队列中的元素同时以rear和length分别指示环形队列中的队尾和队列长度”的由来。为什么题目要强调“rear和length”而不是“rear和front”因为循环队列的判空判满有一个经典的二义性问题当rear front时队列是空还是满两种解法。第一种是牺牲一个存储单元规定“队尾指针的下一个位置是队头”时就算满。这样队列满的条件是(rear 1) % MAX_SIZE front空的条件是rear front。这种方法浪费了一个格子但逻辑简单。第二种就是用length字段当length 0时为空当length MAX_SIZE时为满。这样rear和front的关系就不用纠结了。我直接给出完整实现#include stdio.h #include stdbool.h #define MAX_SIZE 10 typedef struct { int data[MAX_SIZE]; int front; // 队头下标 int rear; // 队尾下标的下一个位置 int length; // 当前队列长度 } CircularQueue; void q_init(CircularQueue *q) { q-front 0; q-rear 0; q-length 0; } bool q_is_full(CircularQueue *q) { return q-length MAX_SIZE; } bool q_is_empty(CircularQueue *q) { return q-length 0; } bool q_enqueue(CircularQueue *q, int value) { if (q_is_full(q)) return false; q-data[q-rear] value; q-rear (q-rear 1) % MAX_SIZE; q-length; return true; } bool q_dequeue(CircularQueue *q, int *value) { if (q_is_empty(q)) return false; *value q-data[q-front]; q-front (q-front 1) % MAX_SIZE; q-length--; return true; }这个实现的妙处在于入队时先放数据再移动rear出队时先取数据再移动front。而% MAX_SIZE实现了“越过数组末尾后回到开头”的循环效果。我在学习这一块时最大的教训是取模运算不要写错。如果数组下标从0到MAX_SIZE-1入队时rear应该先算出新位置再存数据还是先存数据再移动rear按照上面的写法是先存再移因为当前rear位置是空闲的。如果你反过来写就会覆盖掉还没出队的元素。这种细节刷几道题就刻在骨子里了。3.2 单调队列与滑动窗口最优化问题的经典套路队列的进阶应用里单调队列Monotonic Queue是从竞赛到面试都绕不开的高频考点。它本质上是在“先进先出”的基础上加入了“单调性”维护用来快速求解滑动窗口内的最大值或最小值。最经典的题是给定一个数组求每个长度为k的滑动窗口中的最大值。暴力方法的时间复杂度是O(nk)数据量大时直接超时。单调队列可以做到O(n)而且代码量很少原理却不容易理解。核心逻辑维护一个双端队列Deque里面的元素下标对应的值从队头到队尾是单调递减的求最大值时。每次滑动窗口时移除队头超出窗口范围的下标。新元素入队前从队尾弹出所有比新元素小的元素的下标因为它们不可能成为后续窗口的最大值了。新元素的下标入队。窗口右端到达k-1后每次队头下标对应的值就是当前窗口的最大值。这背后有一个非常聪明的淘汰逻辑假设窗口内有两个元素a和b它们的下标分别是i和j且i j。如果a b那么当窗口滑动到j位置时a永远不可能比b更优所以a可以安全地从队尾弹出。这个淘汰过程保证了队列的单调性也保证了每个元素最多入队出队一次。我在学习“单调队列-滑动窗口”这个知识点时最大的收获不是记代码而是明白了“为什么从队尾比较大小”。很多人以为是新元素和队头比较其实不对。新元素要淘汰的是“在它之前入队但是比它更弱”的元素这些元素都在队尾方向上。一个我常用的记忆方法单调队列的入队操作很像“擂台赛淘汰”队尾就是当前队列里的“弱者”新元素来了弱者直接被淘汰。强者留到最后就形成了单调递减队列。这个思维模型对你理解后续的单调栈优化也有很大帮助。3.3 阻塞队列与线程池生产者和消费者模型的现代实践队列在并发编程里最经典的应用就是阻塞队列Blocking Queue。它和普通队列的区别在于队列满时入队操作会阻塞直到队列有空间队列空时出队操作会阻塞直到队列有新元素。这种设计解决的问题非常朴素生产者和消费者的速度不匹配。如果生产者太快队列满了就让它停下来等一等如果消费者太快队列空了就让它停下来等一等。这样生产者和消费者就不需要直接协调只需要和队列打交道。在现代编程里线程池几乎都依赖阻塞队列来管理任务。以Java的ThreadPoolExecutor为例它内部就有一个BlockingQueueRunnable线程池提交的任务会先进这个队列然后由工作线程从队列中取出执行。线程池的阻塞队列选择是面试高频题之一不同队列的适用场景差异巨大队列类型特性适用场景无界队列LinkedBlockingQueue默认队列不设上限任务可以无限堆积任务量平稳、峰值不高的情况有界队列ArrayBlockingQueue队列有容量上限满时触发拒绝策略需要保护系统不被任务量打爆同步队列SynchronousQueue不存储任务直接转交要求每个任务都被立刻执行的场景优先级队列PriorityBlockingQueue按优先级出队需要任务分级的场景我做后端开发时的经验是默认不要用无界队列。无界队列看起来方便但实际上任务堆积会导致内存暴涨最终OOM。生产环境通常用有界队列容量设置成“正常负载下的峰值任务数乘以1.5~2倍”同时配合合理的拒绝策略比如CallerRunsPolicy让提交任务的线程自己执行被拒绝的任务这样才能保证系统在突发流量下不会崩溃。阻塞队列的另一个衍生产品是无锁队列在高性能场景下经常提到。无锁队列用CAS原子操作替代锁避免了线程阻塞和唤醒的开销。C11里的std::atomic配合环形缓冲区可以实现单生产者单消费者的无锁队列。这里有一个关键点无锁队列的ABA问题一个值从A变成B再变成A需要用计数或版本号来解决否则会错误地判定队列为空。我自己在写C原子操作加上无锁队列的时候第一次实现出来的版本在高并发下偶发数据错乱。排查了很久最后发现是内存序的问题默认的seq_cst内存序太保守改成acquire/release语义后性能大幅提升但正确性需要更严格的推理。这是无锁编程最难的坎——不是代码难写是并发推理难做。3.4 消息队列选型实战Kafka、RabbitMQ、RocketMQ对比与避坑说到队列必须要提消息队列Message Queue。这是队列在分布式系统层面的终极形态。很多同学学到这一周时会问我都学了队列了消息队列到底是什么简单说就是把“队列”做成一个独立部署的服务让多个进程甚至可以跨机器通信。市面主流的三款消息中间件我在项目里都用过可以直接给结论维度RabbitMQKafkaRocketMQ编程语言ErlangScala/JavaJava吞吐量万级/秒百万级/秒十万级/秒消息延迟微秒级毫秒级批量发送毫秒级消息可靠性高支持确认机制高但配置复杂高路由灵活性极强多种交换机类型弱基于Topic中Tag过滤适用场景企业应用、金融服务、复杂路由日志采集、流式计算、大数据管道金融交易、订单系统、电商行为数据选型的核心逻辑其实就三条第一看吞吐量需求。如果你的系统每秒要处理几十万条日志RabbitMQ会先扛不住这时候选Kafka。反过来如果你的业务量很小一天就几万条消息引入Kafka这种重组件反而浪费RabbitMQ就够了。第二看消息的时效性和可靠性要求。金融和交易场景消息不能丢不能重复消费也不能少消费RocketMQ在事务消息和消息轨迹方面做得最好。日志和监控场景消息偶尔丢几条问题不大Kafka的At Least Once 手动提交就能满足。第三看团队的技术栈和运维能力。RabbitMQ基于Erlang出了问题不好排查Kafka依赖ZooKeeper新版本逐渐去ZK化RocketMQ是阿里开源的中文资料多。选一个团队能hold住的比选一个“性能最强”的更重要。在实际使用中我踩过的坑里最典型的是消息队列的重复消费问题。Kafka消费端默认是At Least Once语义意味着一条消息可能被消费多次。如果你的业务系统不做幂等处理重复消费就会导致重复下单、重复转账这类严重事故。解决办法通常是在消费端做幂等控制用一个唯一的业务ID去数据库查重如果已经处理过就直接跳过。我提供一个简单的幂等处理模板伪代码public void onMessage(Message msg) { String bizId msg.getBizId(); if (redis.setIfAbsent(consumed: bizId, 1, 10, TimeUnit.MINUTES)) { // 第一次消费正常处理业务逻辑 process(msg); } else { // 重复消息直接丢弃 log.info(duplicate message ignored: {}, bizId); } }这个方案的核心是“用Redis的原子性保证唯一一次处理”。但要注意一个细节Redis的setIfAbsent只有在key不存在时才设置成功配合过期时间可以防止key长期堆积。如果消息处理时间超过过期时间就有再次重复消费的风险所以过期时间要大于业务处理的最坏耗时。3.5 双端队列Deque的妙用不只是数据结构双端队列DequeDouble-Ended Queue同时支持从队头队尾入队出队是栈和队列的“合体”。Java里的ArrayDequeC STL里的dequePython里的collections.deque都是非常常用的工具。在实际工作中双端队列有几个典型的应用方向实现撤销和重做功能一个栈管undo一个栈管redo但用双端队列可以把这两个栈的操作合并管理。实现滑动窗口前面讲的单调队列本质就是用双端队列的因为它需要同时从队头和队尾操作。这也是Deque出现频次最高的场景。实现任务调度中的“工作窃取”Work Stealing线程池中某个线程把自己的双端队列的任务分给另一个空闲线程。这个模型在ForkJoinPool里就是核心机制。我在刷题时的一个习惯是遇到需要“两端都能操作”的场景优先考虑双端队列而不是自己用两个栈拼。因为双端队列的底层实现通常针对两端操作做了优化性能更好代码也更易读。4. 进阶实战栈与队列的难点、坑点和面试高频题学完基础我们要进入实战阶段。这一节把我这些年做题和工程实践中积累的高频考点、易错点、以及排查技巧全部整理出来方便你直接“抄作业”。4.1 从递归到非递归栈的标准用法栈最重要的应用之一就是把递归算法改写成非递归算法。笔试和面试里经常考工程里也会遇到栈深度不够的情况。以经典的二叉树中序遍历为例递归写法很简洁void inorder(TreeNode *root) { if (root NULL) return; inorder(root-left); printf(%d , root-val); inorder(root-right); }但这个递归在二叉树深度很大时会栈溢出。改成非递归就用栈显式模拟void inorder_iterative(TreeNode *root) { TreeNode *stack[1000]; int top -1; TreeNode *cur root; while (cur ! NULL || top ! -1) { // 一直往左走把路径上的节点压栈 while (cur ! NULL) { stack[top] cur; cur cur-left; } // 弹栈访问 cur stack[top--]; printf(%d , cur-val); // 转向右子树 cur cur-right; } }核心思想一目了然递归调用时系统帮你做的事保存现场、压栈现在由你手动用栈完成。这个过程非常直观地展示了“递归的本质就是栈”。我在做题时发现一个规律几乎所有能用递归解决的问题都可以用栈改成迭代版本。但并不是所有都需要改。只有当递归深度可能很大、或者递归压栈开销成为性能瓶颈时才值得改成非递归。工程上追求可读性优先写递归性能敏感时才做非递归化。4.2 栈溢出排查实录一次线上事故的分析过程接下来分享一个我记忆非常深刻的实际案例。有一次上线了一个功能模块开服后收到告警某个进程内存暴涨最终被杀掉。我拿到core dump文件gdb加载后用bt查看栈回溯(gdb) bt #0 0x00007f8f4a3a2b0f in __libc_free (mem0x7f8f...) #1 0x00007f8f4a35d6a8 in std::string::_M_destroy (...) #2 0x0000000000401234 in task_process (data...) #3 0x00007f8f4a2f60aa in thread_pool_worker (...) #4 0x00007f8f4a3b1a8f in start_thread (...)第一眼看上去崩溃在__libc_free里看起来像“释放了非法指针”或者“double free”。但再往上看崩溃点来自task_process函数里面有一个std::string的析构。于是我开始怀疑是不是这个string的栈对象被破坏了我找到task_process函数代码里面有这样一段void task_process(char *data) { char buf[256]; char *p data; // 注意这一行 strcpy(buf, p); // 危险操作p 指向未知长度的外部数据 std::string name(buf); // ... 其他逻辑 }问题瞬间清楚了strcpy把外部超长数据拷贝到栈上的buf[256]导致栈缓冲区溢出把后面函数的栈帧、返回地址全部覆盖了。当std::string name(buf)试图析构时栈上的元数据已经被破坏于是崩溃在free里。这个案例告诉我们两件事。第一栈上的缓冲区溢出是极其严重的隐患它会破坏栈帧导致程序崩溃在“看起来毫无关系”的位置。第二排查这种问题backtrace栈回溯是最高效的武器但栈回溯只能告诉你“程序死在哪”不能直接告诉你“为什么死”。你需要结合崩溃点的上下文、函数调用链一步步推演根因。后来我用ASanAddressSanitizer重新编译这个模块在测试环境立刻复现并定位到了strcpy那一行。所以我的建议是求稳定编译选项中加-fsanitizeaddress -fno-omit-frame-pointer并且压测时保持开着。这类工具能帮你提前几十年发现潜在内存问题。4.3 队列在算法中的经典应用广度优先搜索BFS队列在算法中最著名的应用就是广度优先搜索BFS。一般教材会把BFS放在图论里讲但从数据结构角度看BFS的本质就是“用队列维护待访问节点的顺序”。from collections import deque def bfs(graph, start): visited set([start]) queue deque([start]) while queue: node queue.popleft() print(node) # 访问节点 for neighbor in graph[node]: if neighbor not in visited: visited.add(neighbor) queue.append(neighbor)为什么BFS要用队列而不是栈因为“先发现的节点要先访问它的邻居”。先入队的节点先出队正好符合FIFO。如果你用栈DFS的迭代版本就会变成“深入”而不是“扩张”那就是深度优先搜索了。BFS的应用场景很多最短路无权图、拓扑排序、迷宫寻路、社交网络层次分析。第七周把这个“队列BFS”的对应关系搞清楚后面学图论会轻松很多。4.4 常见问题与排查技巧速查表我整理了一个问题速查表都是初学者到中级开发者最容易遇到的问题现象可能原因解决方案程序栈溢出崩溃递归深度过大或栈上定义超大数组递归改非递归、大数组改堆分配、ulimit -s查看栈限制栈回溯信息杂乱无章编译时未加帧指针优化选项编译加-fno-omit-frame-pointer用addr2line转换地址函数返回地址被篡改栈缓冲区溢出用strncpy替代strcpy开ASan编译核心逻辑检查边界入队后元素被覆盖循环队列rear计算错误或满判断错误检查入队顺序先存数据再移动rear阻塞队列一直卡住队列空间不足或生产者消费者逻辑有误检查队列容量设置、确认拒绝策略、排查死锁消息重复消费消费者未做幂等处理用业务ID加Redis原子写做幂等数据库层面加唯一索引单调队列结果错误入队时淘汰条件写反重新确认是单调递增还是单调递减、是求最大值还是最小值最后一列的解决方案看起来很简单每一条背后都是血淋淋的教训。这里我要特别强调“编译选项”这件事很多同学在本地跑程序不崩溃上线就跑飞很大程度上是因为本地开了调试模式而线上是Release模式栈布局和优化行为完全不同。排查崩溃问题时用带帧指针的编译选项重新编译能给你省一半的查错时间。4.5 第七周学习路线和刷题建议最后给一个本章节的实操建议也就是我作为“课后复盘”最推荐的学习顺序先能手写栈和队列的数组实现和链表实现各写一遍把边界条件刻在骨子里。用栈做三道经典题括号匹配LeetCode 20、表达式求值LeetCode 150、最小栈LeetCode 155。这三题覆盖了栈的大部分考点。用队列做三道经典题用队列实现栈LeetCode 225、用栈实现队列LeetCode 232、循环队列LeetCode 622。进阶题滑动窗口最大值LeetCode 239——它是单调队列和双端队列的集大成者做完这一题你对队列的理解会上一个台阶。再进阶单调栈相关的题目每日温度LeetCode 739、柱状图中最大矩形LeetCode 84把“栈 单调性”的思想打通。第七周能把这份清单完成70%数据结构的地基就稳了。剩下的是持续熟练的过程。我在学习“第七周学习 栈与队列”时最大的体会是这一周的知识不是用来“背”的而是用来“调”的。你写的每个程序几乎都隐式地用了栈每个协作系统几乎都显式地用了队列。当你能在一个函数调用崩溃时自然而然地想到“栈帧结构”在一个流量洪峰到来时条件反射地说“用有界队列 拒绝策略”来保护系统这一周的学习就完全值了。最后分享一个我个人坚持了很久的习惯学完一个数据结构不要只停留在刷题而是去你正在做的项目里找它。栈就在函数调用链里队列就在你的线程池和消息系统里。找到它们你才真正理解为什么教材会花一整个章节来讲这两个“看起来很简单”的结构。学完这一周你很难再用“基础”这两个字来形容栈和队列了——它们是整个软件世界的骨架。
上一篇/下一篇内容由系统自动关联
返回资讯列表 →