写这个系列到现在,顺序表、链表都带着大家完整撸过一遍了。今天轮到栈,它算是数据结构里最“简单又最不简单”的一个结构:从代码量上看,栈的实现在整个课程里几乎是垫底的;但从应用广度上看,从编译器的括号匹配、表达式求值,到操作系统里的函数调用,再到编辑器里的撤销按钮,到处都有它的影子。这一篇我会把顺序栈和链栈两种实现方式完整过一遍,从结构设计到可运行的C语言代码,再补上几个经典应用场景和常见坑位,适合正在跟着课程学数据结构、准备考研笔试、或者工作中发现基础不牢想回来补这块的人。
栈的定义很干净:它就是一个只能在表的一端进行插入和删除操作的线性表,这一端叫栈顶(top),另一端叫栈底(bottom)。数据从栈顶压进去叫入栈(Push),从栈顶弹出叫出栈(Pop)。因为只有一个口子能操作,所以后进去的数据反而先出来,这就是栈的核心特性——后进先出(LIFO,Last In First Out)。
1. 为什么先讲栈:这个“受限的线性表”到底好在哪
1.1 从一叠盘子到浏览器返回:后进先出是怎么来的
理解栈的最快方式,是回想食堂里的一叠盘子。你不可能从中间抽一个盘子出来,只能从最上边取;洗好的盘子放回去,也只能往最上边摞。这个“只能从顶上放、从顶上取”的规则,就是栈的全部本质。
再比如浏览器的后退按钮。你一路点了A、B、C三个页面,想回退的时候,浏览器一定是先回到C,再回到B,最后才是A。操作系统在底层维护的就是一个页面访问栈,每次打开新页面就压栈,每次点后退就弹出栈顶页面。同样的道理,Ctrl+Z撤销操作也是把“最近一次操作”放在栈顶,撤一次弹一次,撤到最底层就是最初的文档状态。
这三个例子体现的是同一个规律:只要业务逻辑里存在“保存历史、按时间倒序恢复”的需求,栈就是最自然的模型。这也是为什么栈在数据结构的教学顺序里紧跟在线性表之后——你不需要重新学一堆复杂概念,只需要接受“线性表 + 单端操作限制”,然后所有利用“倒序恢复”的算法场景就有了一个统一表达方式。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
1.2 栈的抽象接口与复杂度保证
普通线性表有增删改查一二十个操作,但栈只保留了一小部分核心接口,这是有意为之。我写栈的时候,一般只实现这五个,已经能覆盖绝大多数场景:
| 操作 | 含义 | 复杂度 |
|---|---|---|
| InitStack | 初始化栈 | O(1) |
| Push | 入栈,往栈顶压入一个元素 | O(1) |
| Pop | 出栈,弹出并返回栈顶元素 | O(1) |
| GetTop | 读栈顶元素,但不弹出 | O(1) |
| IsEmpty | 判断栈是否为空 | O(1) |
把操作集合刻意收窄,换来的是极其清晰的行为边界。你在任何地方看到栈,只需要确认这五个动作怎么表现,就能完全掌握它,不会像正经线性表那样出现“在中间插入一个节点”之类的二义性。后面的排序算法、树的深度优先遍历、图的最短路径实现里,凡是要“记住一堆候选状态并倒序处理”的地方,都会直接用这五个接口,所以现在把接口背熟、把这几个函数写成肌肉记忆,后面会非常省事。
顺带一提,栈和队列是常被放在一起比较的两个结构。队列是先进先出(FIFO),队尾进、队头出,像排队买奶茶;栈是后进先出,只从栈顶进、栈顶出。“单口进出”和“双口进出”这一字之差,决定了它们在不同算法里担任的角色完全不同,别搞混。
1.3 栈顶指针的两种约定,必须选一种写到底
写栈的代码之前,先解决一个所有初学者都会遇到的基本问题:栈顶指针到底怎么定义。C/C++课程和考研教材里常见两种约定:
- 约定一:top = -1,表示栈空,top 指向栈顶元素。入栈时先 ++top,再写入;出栈时先取 base[top],再 top--。
- 约定二:top = 0,表示栈空,top 指向栈顶元素的下一个空位。入栈时先写入,再 top++;出栈时先 top--,再取元素。
两种都没有错,但混着用就会出大问题。比如你教材里学的是约定一,网上抄了段约定二的判空代码,那空栈判断直接就不对了,程序跑起来莫名其妙越界。我在后面的完整代码里统一采用约定一,也就是 top = -1 表示空栈,top 指向实际栈顶元素。原因有两个:调试时打印 top 的值就能直接读取 base[top],视觉上更直观;另外《数据结构(C语言版)》(严蔚敏)这本流传最广的教材用的就是这个约定,考试时也最不容易踩坑。
2. 顺序栈:基于动态数组的实现与扩容方案
2.1 结构定义和初始化
顺序栈的本质,是把数组的一端当作栈顶,用一个整型变量记下栈顶的位置。我的结构体喜欢写成这样:
c复制#include <stdio.h>
#include <stdlib.h>
#define INIT_CAPACITY 4
typedef struct {
int *base; // 动态数组基址
int top; // 栈顶指针,-1 表示空栈
int capacity; // 当前容量
} SeqStack;
初始化时分配一块初始内存,把 top 置为 -1。这里有个我见过很多次的马虎点:初始容量不要给 0。如果给 0,第一次 Push 时就会触发满栈判断,然后被迫走扩容分支,虽然逻辑上也能处理,但会白白增加一次 realloc,初学者还容易在 realloc 那里写错。
c复制void InitStack(SeqStack *s) {
s->base = (int *)malloc(sizeof(int) * INIT_CAPACITY);
if (s->base == NULL) {
printf("内存分配失败\n");
exit(-1);
}
s->top = -1;
s->capacity = INIT_CAPACITY;
}
2.2 入栈、出栈、取栈顶的完整实现
入栈的流程是:先判满,满了就扩容,然后把新元素写到 top 的下一个位置。出栈的流程是:先判空,空栈直接报错返回,否则取出 base[top] 再让 top 减一。
c复制int Push(SeqStack *s, int v
