栈和队列是数据结构里最基础也最容易被轻视的两个结构。很多人学的时候觉得简单:一个是先进后出,一个是先进先出,背完定义、写完数组模拟就丢到一边了。结果到了真正写代码的时候,要么在递归调用栈溢出时一脸懵,要么在处理消息队列重复消费时不知道怎么下手,要么在刷算法题时连什么时候用栈、什么时候用队列都判断不清楚。这篇博文就把栈和队列从知识点到代码实现完整梳理一遍,结合我在实际项目里用到的场景和经验,帮你看透这两个结构的本质,并且能在C、Java、Python这些常用语言里写对、用对。
1. 栈和队列到底在解决什么问题
很多教材一上来就讲栈和队列的定义、操作、存储结构,学完只记住一堆名词。我更喜欢反过来看问题:计算机在没有栈和队列的时候,某些场景根本没法高效处理,这两种结构是为了解决特定的顺序控制问题才被设计出来的。
1.1 栈:后进先出的“撤销器”
栈的本质是限制操作位置的线性表,只能在一端进行插入和删除,这个端叫栈顶(top)。这种约束看起来像自废武功,却带来一个非常重要的特性:最近进入的数据最先被取出。翻译成人话就是,你最后放进去的东西,下次拿的时候第一个拿到。
这个特性和现实中的撤销操作完全对应。你在编辑器里敲了一串字符,又删了几个字,又粘贴了一段代码,这时候想撤销最后一步操作,计算机需要知道“最近一次修改是什么”。如果把所有操作记录按时间顺序压进一个栈里,撤销时只需要弹出栈顶元素,一步到位。函数调用也是同样的道理:函数A调用函数B,B调用C,C执行完必须先返回B,B执行完才能返回A,这种嵌套回退的顺序只有栈能完美支持。JVM的虚拟机栈、C语言的函数调用栈,底层都是这个模型。
1.2 队列:先进先出的“缓冲区”
队列和栈正好相反,它在一端插入、在另一端删除:入队(enqueue)从队尾进,出队(dequeue)从队头出。这个模型解决的是“先到先处理”的问题。
最典型的场景是打印机任务队列。你提交了3个打印任务,系统应该先打印先提交的那个,而不是后提交的先打。操作系统的进程调度、网络请求的排队处理、消息队列的消费顺序,本质上都是队列的变体和扩展。我早期在写串口数据接收时也踩过类似的坑:单片机中断里收到的数据如果直接处理,会占用中断时间导致丢数据;正确做法是先把数据压进队列,主循环空闲时再从队列里取出来处理,这就是典型的缓冲机制。队列天然适合做生产者和消费者之间的缓冲,C语言里用数组模拟的循环队列是嵌入式开发的基本功。
1.3 为什么这两个结构是算法和系统设计的基石
因为很多算法的执行顺序本身就带有“回退”或“先来先服务”的特征。深度优先搜索(DFS)的递归实现依赖栈来保存回溯路径,广度优先搜索(BFS)依赖队列来逐层扩展节点;表达式求值、括号匹配、浏览器的前进后退、函数调用、操作系统的中断处理,全部建立在栈或队列的逻辑之上。换个角度说,如果你能在合适的场景里准确选出用栈还是用队列,代码的复杂度会明显下降。
我自己带新人的时候经常问一个问题:给你一摞盘子,要你每次拿最上面那个,你会不会把整摞盘子翻过来?不会。这个直觉就是栈。再问你食堂排队打饭,你会不会让后来的人插到最前面?不该插队,这就是队列。数据结构不是玄学,它是对日常顺序约束的形式化描述。理解了这一层,下面所有代码都有了解释。
2. 核心细节解析:顺序实现与链式实现
栈和队列逻辑上都很简单,真正考验基本功的是代码实现。常见的存储方式有两种:顺序存储(用数组)和链式存储(用链表)。两种方式各有优劣,我分开讲,并给出可以直接运行的C语言代码。
2.1 栈的顺序存储实现
顺序栈用数组存储元素,用一个整数变量 top 记录栈顶位置。入栈时先判断是否满,出栈时先判断是否空,这两步判断是写对栈代码的第一道关卡。
#include <stdio.h> #include <stdlib.h> #include <stdbool.h> #define MAX_SIZE 100 typedef struct { int data[MAX_SIZE]; int top; // 栈顶指针,top = -1 表示空栈 } SeqStack; // 初始化 void initStack(SeqStack *s) { s->top = -1; } // 判断栈空 bool isEmpty(SeqStack *s) { return s->top == -1; } // 判断栈满 bool isFull(SeqStack *s) { return s->top == MAX_SIZE - 1; } // 入栈 bool push(SeqStack *s, int value) { if (isFull(s)) { printf("栈已满,无法入栈 %d\n", value); return false; } s->data[++(s->top)] = value; return true; } // 出栈 bool pop(SeqStack *s, int *value) { if (isEmpty(s)) { printf("栈为空,无法出栈\n"); return false; } *value = s->data[(s->top)--]; return true; } // 查看栈顶元素 bool peek(SeqStack *s, int *value) { if (isEmpty(s)) { printf("栈为空,无栈顶元素\n"); return false; } *value = s->data[s->top]; return true; } int main() { SeqStack s; initStack(&s); push(&s, 1); push(&s, 2); push(&s, 3); int val; while (pop(&s, &val)) { printf("弹出: %d\n", val); } return 0; }这段代码里最关键的是++(s->top)和(s->top)--这两个操作。入栈时先移动指针再赋值,出栈时先取值再移动指针,顺序反了数据就会错位。我见过不少新手把这两步顺序写反,导致栈顶数据错乱,排查半天才发现是自增自减的先后问题。
顺序栈的优点是实现简单、缓存友好,数组元素在内存里是连续的,遍历和访问都快。缺点是容量固定,超过 MAX_SIZE 就会溢出,而且一旦扩容需要整体搬移数据,成本较高。对于已知最大深度的场景,比如括号匹配,顺序栈完全够用;对于深度不确定的场景,更适合链式栈。
2.2 栈的链式存储实现
链式栈用链表节点存储元素,top 指针指向链表头节点。入栈相当于在链表头部插入节点,出栈相当于删除头节点。因为只操作头部,时间复杂度仍然是 O(1)。
#include <stdio.h> #include <stdlib.h> #include <stdbool.h> typedef struct Node { int data; struct Node *next; } StackNode; typedef struct { StackNode *top; } LinkStack; // 初始化 void initStack(LinkStack *s) { s->top = NULL; } bool isEmpty(LinkStack *s) { return s->top == NULL; } // 入栈:头插法 void push(LinkStack *s, int value) { StackNode *newNode = (StackNode *)malloc(sizeof(StackNode)); if (newNode == NULL) { printf("内存分配失败\n"); return; } newNode->data = value; newNode->next = s->top; s->top = newNode; } // 出栈:删除头节点 bool pop(LinkStack *s, int *value) { if (isEmpty(s)) { printf("栈为空,无法出栈\n"); return false; } StackNode *temp = s->top; *value = temp->data; s->top = temp->next; free(temp); return true; } int main() { LinkStack s; initStack(&s); push(&s, 10); push(&s, 20); int val; while (pop(&s, &val)) { printf("弹出: %d\n", val); } return 0; }链式栈每次入栈都要 malloc,性能上比顺序栈差一点,但好处是没有容量限制,内存够就能一直压栈。在实际工程中,如果栈的最大深度可以估算且不会太大,优先用顺序栈;如果深度不可控(比如某些递归转非递归的场景),链式栈更安全。我自己在解析复杂表达式时更倾向于链式栈,因为表达式的嵌套深度很难提前预测。
2.3 队列的循环顺序存储
顺序队列的坑比顺序栈多一个:数组实现队列时,如果队头出队后 front 指针不断后移,数组前部会留下大量空闲位置,但队尾已经指到数组末尾,新元素入队时报“队满”。这就是经典的“假溢出”问题。解决办法是把数组看成一个环形,逻辑上首尾相连,也就是循环队列。
循环队列的核心是取模运算,用(rear + 1) % MAX_SIZE移动指针。为了区分队空和队满,常用做法是牺牲一个存储单元:队满条件为(rear + 1) % MAX_SIZE == front,队空条件为front == rear。
#include <stdio.h> #include <stdbool.h> #define MAX_SIZE 6 // 实际最多存储 5 个元素,留一个空位区分空/满 typedef struct { int data[MAX_SIZE]; int front; // 队头下标 int rear; // 队尾下标 } CircularQueue; void initQueue(CircularQueue *q) { q->front = 0; q->rear = 0; } bool isEmpty(CircularQueue *q) { return q->front == q->rear; } bool isFull(CircularQueue *q) { return (q->rear + 1) % MAX_SIZE == q->front; } bool enqueue(CircularQueue *q, int value) { if (isFull(q)) { printf("队列已满,无法入队 %d\n", value); return false; } q->data[q->rear] = value; q->rear = (q->rear + 1) % MAX_SIZE; return true; } bool dequeue(CircularQueue *q, int *value) { if (isEmpty(q)) { printf("队列为空,无法出队\n"); return false; } *value = q->data[q->front]; q->front = (q->front + 1) % MAX_SIZE; return true; } int main() { CircularQueue q; initQueue(&q); enqueue(&q, 1); enqueue(&q, 2); enqueue(&q, 3); enqueue(&q, 4); enqueue(&q, 5); enqueue(&q, 6); // 这个会失败,因为 MAX_SIZE=6 时最多存5个 int val; while (dequeue(&q, &val)) { printf("出队: %d\n", val); } return 0; }循环队列代码容易出错的点有两个:一是忘记取模,导致指针越界;二是队满判断条件没留空位,导致队空和队满无法区分。我在做嵌入式串口缓存时用的就是这个结构,MAX_SIZE 往往设成 2 的幂次(如 256、512),这样取模运算% MAX_SIZE可以直接用位运算& (MAX_SIZE - 1)替代,速度更快。同时记住一个原则:循环队列的最大元素个数是 MAX_SIZE - 1,不是 MAX_SIZE,这是最常见的初学者误区。
队列还有链式实现,思路和链式栈类似,区别在于链表头部做删除、尾部做插入。链式队列的好处是没有容量限制,很多语言的标准库底层都用链表实现队列。代码和链式栈高度相似,我就不重复贴了,重点理解“头删尾插”的顺序即可。
3. 多语言实战:Java、Python、C++ 里的现成轮子
手写一遍底层实现很重要,但在实际项目中,大部分时候不需要自己从零实现。各语言标准库都提供了现成的栈和队列,关键是要知道每个类/接口的正确用法和性能特点,避免拿错轮子。
3.1 Java:Deque 优先于 Stack
Java 官方早期提供了Stack类,但它继承自Vector,所有方法都是同步的,性能较差,而且设计上不太符合栈的语义。官方文档自己也推荐用ArrayDeque或LinkedList来实现栈。Java 集合框架中,Deque接口代表双端队列,既可以当栈用,也可以当队列用。
import java.util.ArrayDeque; import java.util.Deque; public class StackQueueDemo { public static void main(String[] args) { // 用 ArrayDeque 当栈用 Deque<Integer> stack = new ArrayDeque<>(); stack.push(1); stack.push(2); stack.push(3); System.out.println("栈顶: " + stack.peek()); while (!stack.isEmpty()) { System.out.println("弹出: " + stack.pop()); } // 用 ArrayDeque 当队列用 Deque<Integer> queue = new ArrayDeque<>(); queue.offer(100); queue.offer(200); queue.offer(300); System.out.println("队头: " + queue.peek()); while (!queue.isEmpty()) { System.out.println("出队: " + queue.poll()); } } }ArrayDeque底层是循环数组,效率很高。当栈用的时候用push/pop/peek,当队列用的时候用offer/poll/peek。如果用LinkedList实现队列也可以,它支持addLast/removeFirst,但对初学者来说接口语义不够清晰。还有一个容易忽视的点:ArrayDeque不允许存放 null 元素,如果你需要放 null,只能换 LinkedList,或者存一个包装对象。我在实际开发中遇到过一次 NPE,排查半天才发现是往ArrayDeque里塞了 null。
Java 并发包里还有BlockingQueue接口和一堆实现类,比如ArrayBlockingQueue、LinkedBlockingQueue、SynchronousQueue。这些是线程安全的阻塞队列,是线程池、生产者-消费者模型的核心组件。ThreadPoolExecutor的阻塞队列选择直接决定任务的排队策略:有界队列可以保护系统不被大量任务压垮,无界队列(如LinkedBlockingQueue默认不设置容量)则可能导致内存暴涨。选型时要结合业务对丢弃策略的容忍度来判断。
3.2 Python:collections.deque 是首选
Python 的列表list可以用append和pop()模拟栈,但如果用pop(0)模拟队列,时间复杂度是 O(n),因为列表删除头部元素时所有后续元素都要前移。正确的做法是用collections.deque,它内部是双向链表/块状结构,头尾操作都是 O(1)。
from collections import deque # 栈 stack = deque() stack.append(1) stack.append(2) stack.append(3) print("栈顶:", stack[-1]) while stack: print("弹出:", stack.pop()) # 队列 queue = deque() queue.append(10) queue.append(20) queue.append(30) print("队头:", queue[0]) while queue: print("出队:", queue.popleft())Python 的deque还支持rotate、extendleft等操作,在某些算法题里很好用。另外 Python 标准库的queue模块提供了Queue(线程安全队列)、LifoQueue(后进先出队列,即栈)、PriorityQueue(优先级队列),多线程生产者-消费者场景直接用这些。PriorityQueue的底层是堆结构,弹出的元素总是最小的那个,适合任务调度和定时任务场景。
有个细节:Python 内建的heapq模块实现的是最小堆,优先级队列可以基于它实现。PriorityQueue本身也是线程安全的,单线程场景直接用heapq更快一点。我处理海量日志中的 Top K 问题时,就用heapq维护一个固定大小的堆,比PriorityQueue少了锁开销,性能提升明显。
3.3 C++:stack、queue、priority_queue 三件套
C++ 标准库中的std::stack和std::queue都是容器适配器,它们默认基于std::deque实现,也可以显式指定底层容器。std::stack可以用vector作为底层容器,std::queue一般就用deque。
#include <iostream> #include <stack> #include <queue> #include <vector> int main() { // 栈 std::stack<int> st; st.push(1); st.push(2); st.push(3); std::cout << "栈顶: " << st.top() << std::endl; while (!st.empty()) { std::cout << "弹出: " << st.top() << std::endl; st.pop(); } // 队列 std::queue<int> q; q.push(10); q.push(20); q.push(30); std::cout << "队头: " << q.front() << std::endl; while (!q.empty()) { std::cout << "出队: " << q.front() << std::endl; q.pop(); } // 优先级队列,默认大顶堆,弹出最大值 std::priority_queue<int> pq; pq.push(5); pq.push(1); pq.push(9); while (!pq.empty()) { std::cout << "出队: " << pq.top() << std::endl; pq.pop(); } return 0; }C++ 的std::priority_queue默认是大顶堆,如果要改为小顶堆,需要传入比较器,写法是std::priority_queue<int, std::vector<int>, std::greater<int>>。这个语法很容易忘,我自己一般会封装一个别名,避免每次写的时候查文档。
用适配器模式实现栈和队列是 C++ 标准库一个非常有代表性的设计:不重复造轮子,而是基于既有容器裁剪出受限的数据结构。理解这一点对读源码很有帮助。
4. 常见问题与排查技巧实录
写栈和队列的代码,逻辑本身不难,但出错的时候排查起来很隐蔽。我把这些年遇到的高频问题整理成一个速查表,并展开讲讲每个问题的排查思路。
| 问题 | 典型表现 | 排查思路 |
|---|---|---|
| 栈溢出 StackOverflow | 递归调用层数过多或循环入栈无终止条件 | 检查递归终止条件;考虑递归转循环;评估调用栈深度 |
| 队列假溢出 | 数组头部有空闲但提示队列已满 | 确认是否用了循环结构;检查 front 是否持续后移 |
| 循环队列空满判断失败 | 入队或出队结果不符合预期 | 检查是否留出一个空位;检查取模运算是否对所有移动生效 |
| 优先级队列顺序不对 | 弹出顺序不是我想要的 | 确认是大顶堆还是小顶堆;自定义类型的比较器是否正确 |
| 线程安全使用错误 | 多线程环境下数据错乱 | 确认是否使用进程内共享同一个实例;考虑加锁或使用并发容器 |
| 栈和队列误用 | 结果元素顺序反转 | 重新审视业务语义是后进先出还是先进先出 |
4.1 栈溢出:不只是递归的问题
提到栈溢出,大多数人第一个想到递归。递归确实容易爆栈,每次函数调用都会在调用栈上分配栈帧,递归过深就耗尽内存。但还有一个常见的隐蔽场景:手动实现的栈没有优雅处理溢出。比如基于数组的顺序栈,如果入栈前不检查容量,数据写入越界,轻则数据被覆盖,重则程序直接崩溃。这种错误在LeetCode刷题里不常见,因为判题环境一般给够空间,但在嵌入式开发或内存受限的环境中非常致命。
我的建议是:所有手动实现的栈和队列,入栈/入队操作前必须先判满,出栈/出队前必须先判空。这个习惯能避免八成以上的隐蔽 bug。如果递归深度确实很大,考虑改用显式栈模拟:把递归函数中的状态压入自己控制的栈里,循环处理。虽然代码变复杂,但内存可控,不会因为数据规模变化而突然崩溃。
4.2 循环队列的假溢出与空满判断
顺序队列最经典的坑就是假溢出。假设数组长度是5,你入队了5个元素,然后出队2个,front 变成2,此时数组前两个位置空着,但 rear 已经在数组末尾,如果按普通顺序数组的逻辑判断,队列已经满了,没法插入新元素。可明明还有空闲空间。
循环队列通过取模运算把数组首尾连接起来,正是为了解决这个问题。但取模会引入新的难点:front == rear既能表示队空,也能表示队满(如果允许所有位置都存数据的话)。所以经典的实现是牺牲一个存储单元,让队满条件变成(rear + 1) % MAX_SIZE == front。还有一种方案是额外加一个计数器或标志位,但牺牲一个单元的方案最简洁、最常用。
4.3 消息队列重复消费问题
这个话题在分布式系统里经常被讨论。队列本身保证的是先进先出,但消息队列的消费端如果处理完消息之后、提交确认之前挂了,这条消息就会被重新投递,导致重复消费。这不是队列结构本身的问题,而是分布式环境下“至少一次投递”语义带来的副作用。
处理思路通常是消费幂等:在消费端做去重,比如利用数据库唯一键、Redis 的 set 去重、或业务状态机判断。如果从数据结构的角度看,这就是在队列外面再加一层集合结构做辅助过滤。很多人以为消息队列重复消费是队列的 bug,其实是生产者和消费者之间的确认机制设计问题,和用哪种队列实现没有直接关系。
我建议所有采用消息队列的系统,在设计阶段就假设消息一定会重复,提前做好幂等处理。等到线上真的出现重复消费再补救,排查成本高很多,而且可能已经产生了脏数据。
4.4 调试技巧:可视化状态变化
调试栈和队列代码最有效的办法不是打印那一行输出,而是把每一步的内部状态打印出来。比如循环队列调试时,我把 front、rear、元素数组、以及每个下标的位置全部打印:
初始: front=0 rear=0 data=[_, _, _, _, _] 入队 1: front=0 rear=1 data=[1, _, _, _, _] 入队 2: front=0 rear=2 data=[1, 2, _, _, _] 出队: front=1 rear=2 data=[1, 2, _, _, _]这样一眼就能看出指针移动是否符合预期。如果直接用 IDE 的调试器,也可以在断点处查看变量状态,但数组内容不够直观。我习惯在代码里临时写一个printQueue()函数来辅助调试,定位完问题再删掉。在 LeetCode 这类平台上,刷题时也可以把console.log打进去,运行失败时会直接输出中间状态,省去很多推导时间。
还有一个细节:Java 的idea里有时候看调用栈不如 Eclipse 直观,很多人觉得是工具的问题。其实是 IDEA 默认的调试窗口把栈帧、变量、线程混在一起,记得打开调试窗口的 “Frames” 面板,每次断点停下来都意识一下当前线程的调用栈位置,这个习惯对排查递归和嵌套调用的问题特别重要。
5. 应用场景延伸:从算法题到系统设计
栈和队列学完了,如果只停留在能写出来这一层,价值有限。我重点讲几个真实场景,帮你看清楚它们在算法题、系统设计和日常开发中是怎么发挥作用的。
5.1 括号匹配、快速排序与单调栈
括号匹配是栈的经典应用:遍历字符串,遇到左括号就入栈,遇到右括号就检查栈顶是否匹配,匹配则弹出,不匹配则报错。算法时间复杂度 O(n),空间复杂度 O(n)。快速排序的非递归实现也是用栈:手动保存待排序的子区间边界,循环弹出区间执行 partition。很多人以为所有递归都能简单改成循环,其实中间状态多的递归改成循环时,栈是保存状态最自然的容器。
单调栈是栈进阶应用,可以高效解决“下一个更大元素”这类问题。核心思想是维护一个单调递增或单调递减的栈,遍历过程中把不符合单调性的元素弹出,从而得到每个元素左右两侧第一个比它大/小的元素。我第一次接触单调栈时觉得思路很绕,多写几道题后发现核心就一句话:什么时候该弹出栈顶,取决于你需要的下一个更大元素还是下一个更小元素。
5.2 广度优先搜索、滑动窗口与阻塞队列
BFS 算法天然依赖队列:从起点开始,把相邻节点依次入队,然后按队列顺序一层层处理。树的层序遍历、图的最短路径(无权图)都是同一个模板。代码模板很简单,但要特别注意入队时立即标记已访问,否则同一个节点可能被重复入队,导致死循环或结果错误。这个细节是我帮别人 review 代码时发现的高频 bug。
滑动窗口最大值问题是单调队列的经典应用:维护一个双向队列,入队时把队尾所有比新元素小的元素弹出,保证队头永远是当前窗口最大值。这比每次扫描整个窗口的时间复杂度 O(n*k) 优化到 O(n),数据量大时差距非常明显。
系统层面,线程池的阻塞队列选择也是队列应用的重要场景。ArrayBlockingQueue(有界)配合CallerRunsPolicy拒绝策略,可以在任务过多时让提交任务的线程自己执行,起到天然限流的作用;LinkedBlockingQueue无界队列配合DiscardOldestPolicy,则更适合允许丢弃部分旧任务的场景。选型没有银弹,核心是明确业务对背压、延迟和丢弃策略的需求。
5.3 全栈视角:数据结构是通用语言
现在很多招聘JD都写“全栈开发”“全栈工程师”,前端、后端、算法、运维都有各自的技术栈。但数据结构是所有技术栈的公共底座。前端浏览器历史记录用栈管理路由,后端请求队列用消息队列削峰填谷,嵌入式设备的串口缓存用循环队列,AI训练数据预处理时也常需要队列做流式处理。不管技术栈怎么变,这些底层逻辑始终稳定。
我建议学习栈和队列时多做跨语言对照:同一道题用 C 写一遍,用 Java 写一遍,再用 Python 写一遍。不是为了证明谁更厉害,而是通过对照理解“结构思想”和“语言特性”的边界。比如deque在 Python 里的用法、ArrayDeque在 Java 里的用法,都是同一个循环队列的封装。你把底层结构搞明白了,每换一门语言只是换个 API 写法而已。
另外,很多人问数据结构学了有什么用,尤其是栈和队列这么基础的东西。说实话,它们不会像框架那样让你立刻写出酷炫页面,但它们是构建复杂系统的地基。遇到嵌套括号的解析问题、多级撤销功能、任务调度的队列设计,数据结构基础扎实的人能一眼看穿本质,直接找到最优方案;基础不扎实的人只能上网搜现成代码,出了问题完全无法定位。这个差距会随着项目复杂度增加而越来越明显。
我个人在实际操作中的体会是:栈和队列的代码写起来很快,真正难的是识别场景。每接手一个新模块,先问问自己:这里的数据处理顺序是先进后出还是先进先出?有没有需要回退的嵌套逻辑?有没有生产速度大于消费速度的缓冲需求?搞清楚这几个问题,选型和实现就水到渠成了。多写几道相关算法题、多拆几个系统设计案例,你会慢慢形成这种条件反射,那时候这两个结构才算真正吃透了。