写代码这些年,我越来越觉得“栈”是那种看着不起眼、实际无处不在的数据结构。平时聊“技术栈”的人很多,但真正理解数据结构里那个“栈”的人反而没那么多。一旦你把栈的原理吃透,再看函数调用、括号匹配、浏览器后退、表达式求值这些场景,全都能串起来。这篇文章就围绕“栈”展开,我会从最基础的概念讲起,用代码一步步实现它,再带你把几个经典应用场景逐个拆完。无论你是准备期末复习、考研看严蔚敏那本教材,还是工作中想补一补基本功,这篇都能给你一些可直接落地的思路。
1. 你每天都在用栈,只是可能没意识到
1.1 浏览器后退与编辑器撤销背后的共同答案
我经常在课上问学生一个问题:你在浏览器里点“后退”按钮的时候,浏览器是怎么知道要回到哪个页面的?答案就是栈。
它的模型极其简单。打开新页面,把旧页面压入一个栈;点击后退,从栈顶弹出最近一次访问的页面。这个行为恰好符合我们后来要讲的后进先出(LIFO)规则。
同样,编辑器里的 Ctrl+Z 撤销操作,本质也是一个栈。每次编辑,把“上一步操作”的记录放进栈里;按撤销键时,从栈顶把最近的那一步操作“拿出来回退”。如果你连续撤销了 5 步,你会发现撤销顺序和你当初的操作顺序是完全相反的。这背后就是栈。
1.2 一句话看懂栈的定义与操作
栈可以理解为一个“只允许在一端进出”的线性表。这一端叫栈顶,另一端叫栈底。它的核心规则只有八个字:后进先出,先进后出。英文缩写就叫 LIFO(Last In First Out)。
操作层面也就四个基本动作:
- push:把元素压入栈顶
- pop:把栈顶元素弹出
- peek / top:看一眼栈顶元素,但不弹出
- isEmpty:判断栈是否为空
你想象一摞盘子,往上放盘子是 push,从最上面拿走盘子是 pop,你永远只能碰到最上面那个盘子。这就是栈。
概念听上去太简单了,简单到很多初学者觉得“这有啥可学的”。但恰恰是这种“受限”的结构,在计算机世界里被用到了极致。
1.3 为什么一个“功能受限”的结构反而到处都在用
这里有一个很关键的点,很多人学的时候忽略了。线性表本身既可以头插,也可以尾插,还可以任意位置插入删除。而栈把自己限制成只能在一端操作,这不就少了很多灵活性吗?为什么还要用它?
我的理解是:限制操作范围,才能换来安全与清晰。 就好比现实中排队,如果所有人都可以在任意位置插队,整个队伍就乱套了。栈只允许在同一端进出,让“最近添加的数据最先被处理”这件事变成必然,这正是函数调用、递归、回溯等问题需要的天然模式。
再者,栈的设计让代码结构变得非常可控。你只需要关注栈顶,不需要管中间的数据。漏掉越界、错序这类低级问题概率就会大幅下降。所以它特别适合处理“一层套一层”的嵌套问题,比如括号匹配、HTML 标签闭合、函数调用链。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 手动实现一个栈,把每一个细节都弄明白
2.1 两种存法:数组还是链表
理解栈最好的方式是自己动手写一遍。栈的实现有两大流派:顺序栈和链栈。
顺序栈,本质是一个数组加一个指向栈顶的“指针”(或用整型下标)。所有的 push 和 pop 都发生在数组尾部,所以顺序栈的操作实际上就是操作数组的最后一个元素。
链栈,本质是一个单链表,并且只能在头结点处插入和删除。也就是说链栈的栈顶就是链表的头结点。
顺序栈的优点在于内存连续、CPU 缓存命中率高、访问效率稳定。缺点在于容量固定,满了要扩容。链栈不怕扩容,随时来一个元素就临时分配一个新节点,但每个节点都要额外存一个指针,内存开销偏大。
我自己写工程代码时,大部分情况下首选顺序栈,因为无论是 STL 的 deque 还是动态数组,都可以轻松实现一个不会“爆”的顺序栈。
2.2 C 语言实现一个能扩容的顺序栈
以严蔚敏版教材为底子,我调整成了一个更适合工程实践的版本。它支持动态扩容,避免一开始就把容量写死。
c复制#include <stdio.h>
#include <stdlib.h>
typedef struct {
int *data; // 动态数组
int top; // 栈顶下标,初始化为 -1 表示空栈
int capacity; // 当前容量
} SeqStack;
void initStack(SeqStack *s, int cap) {
s->data = (int *)malloc(sizeof(int) * cap);
if (!s->data) exit(-1);
s->top = -1;
s->capacity = cap;
}
int isEmpty(SeqStack *s) {
return s->top == -1;
}
void push(SeqStack *s, int value) {
if (s->top >= s->capacity - 1) { // 栈满,扩容
s->capacity *= 2;
s->data = (int *)realloc(s->data, sizeof(int) * s->capacity);
if (!s->data) exit(-1);
}
s->data[++(s->top)] = value;
}
int pop(SeqStack *s) {
if (isEmpty(s)) {
printf("栈已空,无法弹出\n");
return -1;
}
return s->data[(s->top)--];
}
int peek(SeqStack *s) {
if (isEmpty(s)) {
printf("栈已空,无法查看栈顶\n");
return -1;
}
return s->data[s->top];
}
void destroyStack(SeqStack *s) {
free(s->data);
s->data = NULL;
s->top = -1;
s->capacity = 0;
}
这里有一个很多初学者会懵的点:为什么 top 要初始化成 -1,而不是 0?
top 表示的是“当前栈顶元素所在位置”。如果是空栈,没有任何元素,那就不存在一个合法的下标,所以我们用 -1 表示“当前位置的前一个”。入栈时先自增再赋值,数据落在 0 号位置。出栈时先取数据再自减。这样 top 始终指向真实存在的栈顶元素。
这个细节面试里也很喜欢问,别只知道 push/pop,要能讲清楚下标变化。
2.3 链栈的核心逻辑
链栈的代码量其实更少,因为不需要扩容和搬移。关键就一句话:每一次 push 都是头插法。
c复制typedef struct Node {
int data;
struct Node *next;
} Node;
typedef struct {
Node *top; // 指向栈顶节点
} LinkStack;
void pushLink(LinkStack *s, int value) {
Node *newNode = (Node *)malloc(sizeof(Node));
newNode->data = value;
newNode->next = s->top; // 新节点指向原来的栈顶
s->top = newNode; // 新节点变成栈顶
}
int popLink(LinkStack *s) {
if (!s->top) return -1;
int value = s->top->data;
Node *tmp = s->top;
s->top = s->top->next;
free(tmp);
return value;
}
顺带提醒一句,链栈的节点如果是 malloc 出来的,pop 的时候千万记得 free,不然就会内存泄漏。C 语言的栈题目经常考这个。
2.4 顺序栈和链栈怎么选
| 对比维度 | 顺序栈(数组实现) | 链栈(链表实现) |
|---|---|---|
| 内存空间 | 连续,需要一次性预分配 | 分散,按需分配节点 |
| 扩容成本 | 需要 realloc,可能搬移数据 | 天然支持动态增长 |
| 操作效率 | 极高,数组尾插尾删 O(1) | O(1),但存在指针操作和内存分配开销 |
| 存储开销 | 较少,只要数组本身 | 每个节点额外一个指针 |
| 适用场景 | 栈大小可预估,追求性能 | 元素数量波动大,分布不确定 |
工程里如果对栈的最大深度有把握,我基本会选顺序栈。如果完全不确定,宁可先写顺序栈加扩容逻辑,也别偷懒写成链栈然后每个元素都 malloc 一次。后者在大量 push/pop 时性能损耗不小。
3. 栈的五个经典应用场景,笔试和面试都会遇到
3.1 括号匹配:一道题的完整踩坑复盘
先说一个最经典的入门题:给定一个只包含 (, ), {, }, [, ] 的字符串,判断括号是否正确闭合。这道题的解法就是栈。
过程是这样的:从左到右扫描字符串,遇到左括号就压栈;遇到右括号时,弹出栈顶元素,检查它是不是和自己匹配。如果匹配就继续,如果不匹配,或者扫描到右括号时栈已经是空的,直接判定不合法。最后扫描完了,还要检查栈是不是为空,如果栈里还有没配完的左括号,也是不合法。
我第一次写这道题时犯过一个特别低级的错:字符串很短,但我只做了弹出后的比较,忘记处理“栈已空但还遇到右括号”的情况。比如输入是 "]",我上来就弹栈,直接访问了空栈。这个错误在工程里的代价可能不只是报错,而是程序崩溃。
实际写代码时,一个重要经验:凡是涉及 pop 前,都先用 isEmpty 判断。 别嫌啰嗦,栈操作的空判断越早做,bug 越少。
下面是我推荐的书写方式(Python):
python复制def is_valid_brackets(s: str) -> bool:
stack = []
mapping = {')': '(', ']': '[', '}': '{'}
for ch in s:
if ch in mapping:
if not stack or stack[-1] != mapping[ch]:
return False
stack.pop()
else:
stack.append(ch)
return not stack
这里的关键是把右括号映射到左括号,遇到左括号就入栈,遇到右括号就比较栈顶。逻辑非常干净。
3.2 表达式求值:中缀转后缀的栈思路
另一个高频考点是表达式求值。正常人写数学题用中缀表达式,也就是运算符写在两个操作数中间,比如 3 + 4 * 2。但这种表达式对计算机来说并不友好,因为要考虑优先级和括号。
计算机更喜欢的格式是后缀表达式,也叫做逆波兰表达式,比如 3 4 2 * +。运算符跟在操作数后面,计算时从左到右扫描,遇到数字就压栈,遇到运算符就弹出两个数字计算,再把结果压回栈里。整个过程没有优先级的问题,天然适合用栈。
中缀转后缀的手工规则其实也很简单,你可以用一个运算符栈来模拟:
- 遇到操作数,直接输出
- 遇到左括号,入栈
- 遇到右括号,把栈里的运算符依次弹出并输出,直到遇到左括号为止,左括号弹出但不输出
- 遇到运算符,如果栈顶运算符优先级不低于当前运算符,就把栈顶弹出并输出,再把当前运算符入栈
- 扫描结束后,把栈里剩余运算符全部弹出输出
以 3 + 4 * 2 为例。3 输出;+ 入栈;4 输出;* 比栈顶 + 优先级高,直接入栈;2 输出;结束。把栈里剩下的 * 和 + 依次弹出,最终后缀式为 3 4 2 * +。
这个规则背下来不难,难的是理解它为什么要这样做。你可以把“优先级高的先计算”理解成“离栈顶更近的运算应该先出来”,而栈刚好把这种倒序关系管理得明明白白。
3.3 函数调用、递归与栈回溯
你在调试器里看到的那一长串调用路径,就是“调用栈”。当一个函数被调用时,系统会为它生成一个栈帧,里面保存着参数、局部变量、返回地址。当前函数执行完毕,栈帧就被弹出,控制权交还给上一个函数。这就是栈在操作系统层面的经典应用。
递归函数本质上也在用同一个系统栈。每递归调用一次,就多压入一层栈帧。如果递归没有终止条件,或者层数太深,系统栈空间被占满,就会报“栈溢出”(Stack Overflow)。那些注册了编程网站的人对这个名字应该不陌生,它本身就是程序世界常见的运行时错误。
例如这段代码:
c复制void recursive_print(int n) {
char buffer[1024]; // 每层递归占 1KB 左右
printf("%d\n", n);
recursive_print(n + 1); // 没有终止条件,无限递归
}
跑起来以后,栈区空间会在几秒钟之内被打满,程序直接崩溃退出。排查这种问题的方法就是把函数调用栈回溯出来,看看是哪个函数在不断压栈。很多语言的运行时在抛出未捕获异常时会把“调用栈”打印到终端或日志里,这就是你最直接的排查依据。
3.4 浏览器前进后退、图的遍历与显式栈
再回到文章开头说的浏览器场景。为了支持“前进”和“后退”,常规设计会使用两个栈:一个保存可以后退的历史页面,另一个保存可以前进的页面。
每次访问新页面,把当前页面压入后退栈,清空前进栈。点后退,从后退栈弹出页面显示,同时把这个页面压入前进栈。点前进,反向操作。
这里还有一个值得单独提的场景:图的深度优先搜索(DFS)。很多教材上 DFS 是用递归写的,实际上递归用的是系统栈。当递归深度可能非常深时,你可以把递归改成显式栈版本:
c复制void dfs(int start, vector<vector<int>>& graph, vector<bool>& visited) {
stack<int> st;
st.push(start);
while (!st.empty()) {
int node = st.top();
st.pop();
if (visited[node]) continue;
visited[node] = true;
// 处理当前节点
for (int neighbor : graph[node]) {
if (!visited[neighbor]) {
st.push(neighbor);
}
}
}
}
这样做的最大好处是规避系统栈的层数限制。系统栈通常只有几 MB,递归太深就爆;而堆上创建的显式栈只要你机器内存够,可以压很多数据。这也是我在处理超大图或深层嵌套结构时常用显式栈替代递归的原因。
3.5 进制转换、undo 和那些杂七杂八的经典场景
栈的其它经典应用还有不少。比如十进制转二进制,用“除 2 取余法”得到的余数顺序是从低位到高位,而最终结果需要从高位到低位读。你只要把每个余数依次压栈,最后全部弹出,顺序就正好反了过来。
再比如迷宫寻路中的深度优先搜索,路径的探索和回溯也依赖栈。走了一步,压栈;走到底发现死路,弹出这一层,回到上一个分岔口。这种“能进则进,进不了就退”的思路本质就是栈。
还有编辑器的 undo / redo。undo 本身是一个栈,redo 就是另一个栈。做一次新操作,会清空 redo 栈;执行 undo 的时候,把步骤从 undo 栈弹出,再压进 redo 栈。你看,和浏览器前进后退模型几乎一模一样。
栈的应用清单远不止这些,但核心规律就一条:只要遇到“需要逆序处理”“需要撤销最近操作”“递归与回溯”这类问题,第一时间想到栈。
4. 几个容易搞混的概念:栈、堆、队列和递归的关系
4.1 数据结构栈 vs 内存栈 vs 堆
很多初学者会把“栈是数据结构”和程序运行时的“栈区、堆区”混在一块。它们的名字都叫栈,关系却不完全一样。
程序运行时,内存被划分成栈区、堆区、全局区、代码区等区域。栈区是用来存放函数调用帧的地方,函数参数、返回地址、局部变量都塞在栈帧里。函数一调用就压栈,一返回就弹栈,分配和释放完全由编译器自动管理,速度很快。堆区则需要程序员自己申请和释放,例如 C 语言的 malloc/free、C++ 的 new/delete、Java 中由 GC 来自动回收的对象。
数据结构里的栈是一种组织和操作数据的方式,而内存里的“栈”是这种数据结构在系统运行时的具体应用。可以这样记:内存栈就是数据结构栈在函数调用机制中的体现。堆区一般不会用栈来管理,而是用专门的空闲链表或伙伴系统等机制。
如果面试官问你栈和堆的差异,你既要从内存管理角度对比,也要意识到这背后有一个数据结构层面的统一逻辑。
4.2 栈和队列的对比
栈(LIFO)和队列(FIFO)经常并列表述,它们都属于“受限线性表”,差别只在进出方向。
| 对比维度 | 栈 | 队列 |
|---|---|---|
| 操作规则 | 后进先出 | 先进先出 |
| 插入操作 | push / 压栈 | enqueue / 入队 |
| 删除操作 | pop / 弹栈 | dequeue / 出队 |
| 访问限制 | 只访问栈顶 | 队尾插入、队头删除 |
| 生活类比 | 一摞盘子 | 食堂排队打饭 |
| 典型应用 | 函数调用、括号匹配 | 任务调度、消息队列 |
强调一下,栈只在一端“进出同口”,队列是“一端进、另一端出”。这两个口的位置差异,决定了它们管理数据的顺序逻辑完全不同。
4.3 递归本质就是隐藏了系统栈
递归代码看着像没有用栈,其实每次递归调用都会在系统栈上压入一个新的栈帧。举例,求 n 的阶乘:
c复制int fact(int n) {
if (n <= 1) return 1;
return n * fact(n - 1);
}
调用 fact(5) 时会依次压入 fact(5)、fact(4)、fact(3)、fact(2)、fact(1) 的栈帧,然后从 fact(1) 开始逐层返回。如果 n 非常大,比如 100000,系统栈很快溢出。
理解了这一点,你就知道为什么有的情况下要“把递归改写成迭代”。改写成迭代的本质,往往就是用一个显式栈来模拟系统栈的压栈和弹栈过程。尤其对于树的遍历、图的搜索,显式栈是很好用的工具。
5. 栈相关的常见问题与调试心得
5.1 栈溢出排查思路
程序报“栈溢出”时,先别慌,我的排查顺序一般是这样的:
第一,先在代码里搜索递归函数,检查终止条件是否写全。递归里最容易出问题的就是忘记 return,或者终止条件判断写反了。
第二,检查循环里有没有不断向局部变量数组塞数据的逻辑。虽然大数组放在栈里能正常编译运行,但如果函数被频繁调用,每一层的栈帧都要占据大量空间。
第三,确认是不是递归层数本身太深。即使是合法的递归,数据量一大也会超过系统栈容量限制。这时候需要评估改写成显式栈或者非递归算法。
以 Linux 环境为例,可以用 ulimit -s 查看默认栈大小,通常是 8MB。如果你的递归每一层占用 1KB,那满打满算也只能支撑大约 8000 层递归。写算法题时,如果递归深度可能超过这个数量级,我就会改用显式栈或迭代。
5.2 空栈与越界的问题
空栈操作是新手最容易踩的坑。
我见过一个同学写括号匹配,输入字符串是 ")("。他判断到第一个右括号时直接做 pop,结果栈是空的,程序直接崩。后来他在所有 pop 之前都加了 isEmpty 判断,问题立刻解决。
越界问题常见于顺序栈。如果你初始容量设置成 10,却没写扩容逻辑,连续 push 11 个元素,数据就会写到数组边界之外。这种问题有时候不会立即崩溃,而是在某个看似无关的地方突发异常,很迷惑人。
我在工程里的做法是把 push 内部的扩容提前写好,而不是等到 segfault 被报出来再返工。同时,top 下标的变化一定是一边自增一边赋值,千万不要写反。顺序栈的经典错误就是把 push 写成 data[top++] = value 而 top 又从 1 开始,结果第一个元素永远占在错误的位置。
5.3 一个真实调试现场
有次我在处理 JSON 格式化文本时遇到了嵌套层级过深导致递归解析崩溃。那段代码是一个递归函数,每次遇到 { 就向下递归一层,直到遇到 }。看起来没什么问题,但用户上传了一个恶意生成的超深嵌套 JSON,层数到了几万层,系统栈直接顶不住。
当时我第一时间用调试器打印了调用栈,发现栈里全是同一个递归函数,而且深度已经接近十万层。排查思路就很清晰了:问题不是逻辑错误,是递归深度天生受限。最后我把递归解析改成了显式栈的迭代解析,每次手动把“当前状态”压栈,循环处理。程序再也没崩过。
这次经验让我养成了一个习惯:凡是可能处理不定深层级数据的地方,都尽量少用递归,优先考虑显式栈。 不是说递归不好,而是你无法预估用户会传什么数据。
6. 考试、刷题与工程实践:栈应该怎么学
6.1 期末或考研复习要抓的重点
如果按严蔚敏《数据结构(C语言版)》为主线复习,栈这一章需要掌握的知识点其实很集中:栈的定义与操作、顺序栈和链栈的表示与实现、栈与递归的关系、栈的应用(数制转换、括号匹配、表达式求值)、共享栈等。
考试中最常见的题型是:
- 给定入栈序列,判断某个出栈序列是否合法
- 手算中缀表达式转后缀表达式
- 手动模拟表达式求值过程
- 写出栈的插入删除操作代码
- 设计一个能返回最小值的栈
无论考题怎么变,本质都是考“后进先出”这一条规则。你在草稿纸上画一个栈,用一个小箭头指向栈顶位置,每个元素进栈和出栈都标上顺序,基本题就能做对。
6.2 入栈出栈序列的判断方法
这里专门讲一个高频考点:已知入栈顺序是 1 2 3 4 5,问 3 1 2 4 5 是否可能是出栈顺序。
很多同学遇到这种题就懵,其实原理很简单:出栈序列中任意一个元素后面的元素,如果在入栈序列中位于这个元素前面,那它们必须和这个元素保持相对倒序。但这样描述太抽象,我习惯用模拟法。
你拿一个空栈模拟入栈过程,同时盯着目标出栈序列。需要哪个元素出栈,就从入栈序列中依次入栈直到这个元素出现在栈顶,然后弹掉它。如果入栈序列全部处理完,栈顶还是不能和目标出栈序列匹配,那这个出栈序列就是非法的。
画图模拟几次,比死记硬背公式靠谱得多。
6.3 栈思维在工程与全栈学习里的价值
现在网上聊“全栈”“技术栈”概念的人很多。很多人听到“栈”字就以为是自己后端用什么语言、前端用什么框架的“技术栈”。这其实和数据结构里的栈是两个层面的东西。但数据结构栈的思维,对工程项目的帮助一点不少。
举一个前端框架的例子:中间件模型、路由中间件的洋葱圈模型,请求进来以后层层进入,处理完以后呼应事件,再一层层出来。这种“先进入的中间件最后收尾”的执行顺序,本质上就是一个栈式执行流程。再比如事件循环中,微任务和宏任务的调度不完全是栈,但函数调用栈的机制贯穿 JavaScript 引擎。你理解栈之后,再分析并发任务里的执行顺序,会清楚很多。
另外,像“栈回溯”这种能力,在系统崩溃、异常上报、日志分析里非常常见。你在日志里看到一段完整的调用栈,能直接定位到是哪个文件哪一行调的哪个函数。这个排查效率,远比一行行看代码高得多。
6.4 我的学习路线建议
如果是新手,我建议按照这个顺序来学栈:
第一,先弄懂栈的定义和操作,会手写顺序栈和链栈的代码。
第二,把括号匹配、进制转换、表达式求值三道经典题分别独立实现一遍,不要看题解,逼自己从空栈开始推导。
第三,掌握递归到显式栈的转换。拿二叉树的前序遍历练手,先写递归版本,再改成显式栈版本,体会两种方式的差异。
第四,有空就刷一下 LeetCode 上和栈相关的高频题,像是最小栈、用两个栈实现队列、每日温度、字符串解码、柱状图中最大的矩形等。
第五,在实际工程中多留意栈的影子。阅读开源项目时,看到调用栈、事件流、中间件,都思考一下它是不是基于栈的思路设计的。当你形成一个“栈思维”,很多代码一眼就能看穿结构。
如果你是在准备考试,建议先从教材习题入手,把栈的基本操作算法写熟,再做模拟卷里的进栈出栈序列判断和表达式求值。这些题目很机械,但很能检测你对栈规则的理解是否扎实。
最后想分享一点实战过程中的体会。栈是一个非常容易产生“我懂了”错觉的知识点。因为它的代码量少、逻辑简单,很多人看完就觉得自己会了,结果一到实际写代码就各种空栈、越界、漏判。真正让我彻底理解栈的,不是看书,而是一次次在调试中看到程序崩溃后的调用栈。那些密密麻麻的栈帧,其实就是栈最真实的样子。所以我的建议非常直接:别怕动手写代码,也别怕学着看栈回溯日志。你亲手把东西压进栈、弹出栈、让它溢出再修好它,远比盯着定义看十遍更有用。
