1. 先说清楚:栈和队列到底是什么东西
数据结构这个东西,很多初学者一开始就把自己绕晕了。我当年学的时候也这样,书翻了好几遍,严蔚敏那本C语言版都快翻烂了,链表、树、图一个个背得滚瓜烂熟,但一到问“这玩意儿到底拿来干嘛”就卡壳。尤其是栈和队列,名字听着特别抽象,什么先进后出、先进先出,上课时觉得自己懂了,回头写代码又不知道往哪儿用。
后来我慢慢想明白了一个道理:栈和队列不是“某种具体的技术”,而是两种极其基本的数据组织规则。它们不负责存储什么类型的数据——存整数、存对象、存消息都行——它们真正定义的是数据的出入顺序。栈规定了“后到的先走”,队列规定了“先到的先走”。就这么两句话的事,但这两条规则就是整个计算机世界里无数系统和算法的地基。
如果你去看招聘要求,栈和队列几乎是所有技术岗笔试里避不开的考点,从软考到算法面试,从消息队列到线程池,底层都跟这两个基础结构有关。栈的经典场景像函数调用、浏览器后退、表达式求值;队列的经典场景像打印任务排队、线程池任务调度、消息推送。你平时点的外卖、刷的视频、敲的代码,背后不知道跑着多少个栈和队列。
这篇内容我就按照自己做了多年技术、带过不少新人的经验,把这个话题完完整整拆开讲一遍。不管你是刚学数据结构的在校生,还是想补基础的自学党,又或者是工作中突然发现自己对某些框架原理说不清楚的开发者,这篇文章的定位就是帮你把栈和队列彻底搞透,并且知道它们在真实项目里到底是怎么被用起来的。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 栈:后进先出,一种“反悔”的智慧
2.1 从一摞盘子理解栈的核心规则
想象你家里厨房有个盘子收纳架,你洗完一个盘子就往上一放,要用的时候从最上面拿。后放上去的盘子,永远先被拿走。这就是栈的全部规则:后进先出(LIFO, Last In First Out)。
这个规则有两个关键动作:
- 入栈(push):把新元素放到栈顶。
- 出栈(pop):把栈顶元素取走。
还有一个常用的读取动作叫取栈顶(peek/top),只看看栈顶是谁,但不把它拿走。
为什么说栈是一种“反悔”的智慧?因为栈天然适合处理需要“回退”的场景。你写代码的时候,函数A调用了函数B,B执行完必须回到A刚才继续往下走的位置——这个“回去”的动作,靠的就是栈。浏览器里你从页面1跳到页面2再跳到页面3,点返回按钮能一级级退回去,也是因为浏览器把你的访问历史压进了一个栈。你编辑文档时按Ctrl+Z撤销,本质上也是一步步把之前的状态从栈里弹出来。
所以栈这东西,说白了就是帮计算机记着“我之前走到哪了,接下来该怎么回去”。它的结构和这种需求精确匹配,这就是为什么几乎所有编程语言的函数调用机制都在用栈,而不是用一个链表或者其他什么结构。
2.2 栈的底层实现:数组栈与链表栈怎么选
栈是一个规则层,它本身不规定底层必须用什么存储。用数组能做栈,用链表也能做栈,各有取舍。
数组栈是一种非常常见的实现方式。你可以预分配一块连续内存,用一个int类型的top变量记录栈顶位置。入栈时检查容量是否足够,不够就扩容;出栈时直接把top减一就行。数组栈的优势是内存连续、CPU缓存友好,访问速度极快,而且实现简单。缺点是容量固定,频繁扩容有开销,插入删除如果发生在栈顶则没这个问题,因为永远只动尾部。
链表栈则是用节点一个个串起来,每次入栈就是在链表头部插入一个新节点,出栈就是删除头节点。它的优势是没有容量上限,天然动态扩展,不存在扩容拷贝的问题,适合无法预估最大深度的场景。缺点是每个节点需要额外的指针空间,而且节点在内存里分散分布,遍历时对缓存没那么友好。
实操中,绝大多数通用场景我会建议直接用数组栈,因为效率高、代码简单,而且大部分情况下你完全知道或者可以估算出最大栈深度。只有在栈深度不可预估、可能极其大的场景下才考虑链表栈。比如自己写一个函数调用栈的模拟器,深度可能上千上万,这时链表栈更稳妥。
关于栈顶的方向,数组栈需要注意一个细节:你可以让栈顶在数组头部,也可以让栈顶在数组尾部。如果栈顶在头部,入栈出栈就要移动整个数组,代价是O(n);如果栈顶在尾部,入栈出栈就是纯O(1)。所以数组栈的正确姿势永远是:下标越大的位置越靠近栈顶,新元素从尾部追加。
2.3 手写一个数组栈:一次到位的代码范例
这里我用C语言写一个最朴素的数组栈,因为C语言没有内置的高级容器,反而能把底层逻辑看得更清楚。虽然现在写业务代码大概率用Java、Python或Go,不会用C去实现栈,但原理是通用的。
c复制#include <stdio.h>
#include <stdlib.h>
#include <stdbool.h>
typedef struct {
int *data;
int capacity;
int top; // 栈顶下标,空栈时 top = -1
} ArrayStack;
void stackInit(ArrayStack *s, int capacity) {
s->data = (int *)malloc(sizeof(int) * capacity);
s->capacity = capacity;
s->top = -1;
}
bool isEmpty(ArrayStack *s) {
return s->top == -1;
}
bool isFull(ArrayStack *s) {
return s->top == s->capacity - 1;
}
void push(ArrayStack *s, int value) {
if (isFull(s)) {
printf("栈已满,无法入栈: %d\n", value);
return;
}
s->data[++(s->top)] = value;
}
int pop(ArrayStack *s) {
if (isEmpty(s)) {
printf("栈为空,无法出栈\n");
return -1;
}
return s->data[(s->top)--];
}
int peek(ArrayStack *s) {
if (isEmpty(s)) {
printf("栈为空\n");
return -1;
}
return s->data[s->top];
}
void stackFree(ArrayStack *s) {
free(s->data);
s->data = NULL;
s->capacity = 0;
s->top = -1;
}
有几个细节必须提醒:
- top初始化为-1意味着空栈。入栈时先让top加1再写入数据,出栈时先取数据再把top减1。
- 判断空栈不能用top==0,因为top==0说明栈里还有一个元素,位置在0号下标。
- 扩容策略我没有写进去,实际项目中如果要自动扩容,一般按1.5倍或2倍扩容,和动态数组的扩容策略一致,推荐1.5倍的原因是可以复用之前释放的内存块,扩容后新容量小于已释放的相邻内存,内存分配器更可能直接复用旧空间。
- 使用完记得free,C语言中内存泄漏就是这么来的。
如果你用Java,直接使用java.util.ArrayDeque来当栈用,比Stack类更推荐。Stack类继承了Vector,所有方法都加了synchronized锁,单线程下白白浪费性能。ArrayDeque底层是循环数组,无锁,性能更好,而且接口也更现代,有push、pop、peek方法,语义清晰。
Python中可以用列表模拟栈,列表的append就是入栈,pop()就是出栈,这已经是Python中最常用的栈写法,不需要额外引库。
2.4 栈的经典应用:函数调用、括号匹配与表达式求值
栈的应用场景,我挑三个最典型的展开讲。
第一个是函数调用链。这是栈最底层的应用,但平时很多人看不见。当程序执行一个函数时,系统会把函数的参数、局部变量、返回地址压入系统栈,函数返回时再从栈顶弹出恢复现场。递归为什么容易栈溢出?就是因为每递归一层就往系统栈里压一层数据,递归太深,栈空间就爆了。这个概念理解透之后,你能解释很多实际问题,比如为什么无限递归会崩、为什么递归可以转换成循环。
第二个是括号匹配。检查一个字符串里的括号是否成对且嵌套正确,比如({[]})合法,([)]不合法。思
