写这个系列到现在,顺序表、链表都带着大家完整撸过一遍了。今天轮到栈,它算是数据结构里最“简单又最不简单”的一个结构:从代码量上看,栈的实现在整个课程里几乎是垫底的;但从应用广度上看,从编译器的括号匹配、表达式求值,到操作系统里的函数调用,再到编辑器里的撤销按钮,到处都有它的影子。这一篇我会把顺序栈和链栈两种实现方式完整过一遍,从结构设计到可运行的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 val) {
if (s->top == s->capacity - 1) {
int newCap = s->capacity * 2;
int *newBase = (int *)realloc(s->base, sizeof(int) * newCap);
if (newBase == NULL) {
return 0; // 扩容失败
}
s->base = newBase;
s->capacity = newCap;
}
s->base[++s->top] = val;
return 1;
}
int Pop(SeqStack *s, int *out) {
if (s->top == -1) {
return 0; // 空栈
}
*out = s->base[s->top];
s->top--;
return 1;
}
int GetTop(SeqStack *s, int *out) {
if (s->top == -1) {
return 0;
}
*out = s->base[s->top];
return 1;
}
int IsEmpty(SeqStack *s) {
return s->top == -1;
}
这里入栈的 s->base[++s->top] = val 和出栈的 *out = s->base[s->top--] 是这套代码的题眼。注意前置自增和后置自减的顺序不能换,一换就相当于从栈顶的下一个空位开始写,栈顶元素反而没被弹出。
2.3 动态扩容:什么时候扩,扩多少倍,为什么
顺序栈写完后一定会面对一个问题:数组容量是有限的,入栈入满了怎么办?答案是扩容。但扩容不是随便扩的,有两个细节值得展开说。
第一个问题是扩容时机。判断条件写成 s->top == s->capacity - 1,也就是栈顶指针已经顶到数组最后一个位置。每次 Push 进来第一步都查这个条件,满足才扩容。千万不要把判断写成 s->top == s->capacity,那是约定二(top 指向下一个空位)的判满写法,和这里的 top 语义对不上。
第二个问题是扩容倍数。我上面的代码是直接翻倍,newCap = s->capacity * 2。为什么不是加一、加十、或者扩成 1.1 倍?这背后是算法分析里的均摊复杂度问题。
如果每次只多分配一个元素空间,那么连续 n 次入栈,触发扩容的次数是 n 次,第 i 次扩容需要把 i 个已有元素全部搬到新数组里。总移动次数是 0 + 1 + 2 + ... + (n-1),结果是 O(n^2) 的复杂度。也就是说,虽然单次 Push 看起来是 O(1),但连续入栈 n 个元素的总体开销会退化到 O(n^2),这在性能敏感场景下是灾难。
如果每次容量直接翻倍,情况就完全不同。扩容的次数大约只有 log2(n) 次,每次扩容的元素移动量是 1、2、4、8……直到 n。把这么多轮移动次数加起来,总量大约在 1 + 2 + 4 + ... + n = 2n - 1,是 O(n) 的。用均摊的思想去看,n 次入栈的总工作量是 O(n),那么单次入栈的均摊成本就是 O(1)。
这就是为什么包括 C++ vector、Java ArrayList 在内几乎所有动态数组结构,扩容时都选择翻倍。翻倍不是拍脑袋定的,是经过复杂度证明后留下的最优方案之一(Java 里选择 1.5 倍则是为了兼顾内存碎片和复用旧内存,这是另一个话题)。
2.4 顺序栈的局限性和适用边界
顺序栈的好处很直接:底层是一块连续数组,访问时 CPU 缓存友好,入栈出栈只操作一个下标,不会产生节点级别的内存碎片。但它也有两个绕不开的短板。
第一个短板是容量受限。虽然在容量不足时会动态扩容,但扩容本身要 realloc,万一内存碎片严重,realloc 可能要花较多时间把数据搬到新地址,存在一次性的性能抖动,不适合对实时性要求极高的场景。第二个短板是空间浪费。数组是连续分配的,你无法只给“当前使用的元素”分配内存,必须一次性预留整块 capacity 容量。假如初始容量给 1024 但实际只放了 3 个元素,那 1021 个元素的空间就白占了,在嵌入式、内存受限的环境中非常不划算。
所以我在使用顺序栈时,一般遵循两个原则:能估算出最大深度的场景直接用固定数组,干脆不开动态扩容省掉复杂度;估算不了最大深度但确定内存足够时用动态扩容;两种情况都排除不了、又对内存极度敏感时,就换链栈。
3. 链栈:基于单链表的实现与内存管理逻辑
3.1 结构设计与“栈顶即链表头”的关键决策
链栈,简单说就是用链表来当栈。但具体怎么用,有一个关键决策必须想清楚:到底把链表哪一端当栈顶?
答案是链表头。原因非常直白:链表头插、头删的复杂度都是 O(1),只需要改一个头指针;而如果你选择让链表尾作为栈顶,那每次入栈出栈都要从头遍历到尾,复杂度直接变成 O(n),这和栈的 O(1) 要求背道而驰。
明确了这一点之后,链栈的结构其实非常简洁:
c复制typedef struct Node {
int data;
struct Node *next;
} StackNode;
typedef struct {
StackNode *top; // 栈顶指针,指向链表的第一个节点
int length; // 元素个数,方便统计
} LinkStack;
top 指向链表的头节点,头节点就是栈顶元素。入栈就是在链表头部插入新节点,出栈就是删除头部节点。整段逻辑和单链表头插法一模一样,如果你上一篇单链表学得扎实,这里几乎不用费任何力气就能写对。
3.2 入栈、出栈、取栈顶的完整实现
初始化只需要把 top 置为 NULL,length 置为 0,不需要预分配任何内存,这是链栈和顺序栈最大的感官差异。
c复制void InitLinkStack(LinkStack *s) {
s->top = NULL;
s->length = 0;
}
int LinkPush(LinkStack *s, int val) {
StackNode *node = (StackNode *)malloc(sizeof(StackNode));
if (node == NULL) {
return 0;
}
node->data = val;
node->next = s->top;
s->top = node;
s->length++;
return 1;
}
int LinkPop(LinkStack *s, int *out) {
if (s->top == NULL) {
return 0;
}
StackNode *tmp = s->top;
*out = tmp->data;
s->top = tmp->next;
free(tmp);
s->length--;
return 1;
}
int LinkGetTop(LinkStack *s, int *out) {
if (s->top == NULL) {
return 0;
}
*out = s->top->data;
return 1;
}
int LinkIsEmpty(LinkStack *s) {
return s->top == NULL;
}
代码里值得反复看的是 LinkPush 中的三步:node->data = val; node->next = s->top; s->top = node;。这就是单链表头插法的完整过程,和顺序栈里“先移动 top 再写入”的 base[++top] 形成对照。一个操作的是指针链,一个操作的是下标,逻辑模型却完全等价,这正是理解“同一个抽象可以有不同物理实现”的好例子。
3.3 链栈内存管理的风险点
链栈代码本身不难,但内存管理上比顺序栈多了一层风险,这是我在帮同学改代码时反复见到的三个坑。
第一个坑是出栈忘记 free(tmp)。顺序栈弹出元素只是把 top 减一,数据还残留在数组里,不释放也不会泄漏;但链栈弹出之后,被弹节点要是不 free,这个内存就再也找不回来了。一次两次无所谓,循环里漏一次就是一次内存泄漏,写长服务时积累下来直接 OOM。我自己的习惯是每次写完链栈操作,先 grep 一遍 free 是不是跟在每个 Pop 后面。
第二个坑是 free(tmp) 的顺序。一定要先做 *out = tmp->data; s->top = tmp->next;,最后才 free(tmp)。如果你先 free 再取 next,tmp 已经成野指针,访问 tmp->next 就是未定义行为,程序当下可能不崩,但抽查时就是随机崩。第三个坑是入栈后没检查 malloc 返回值。虽然考试代码里经常省略,但实际工程中内存耗尽时 malloc 会返回 NULL,你不判就把 NULL 的 next 字段写进去,等于埋了个无法预料的崩溃点。
4. 顺序栈和链栈怎么选:一张对比表讲清楚
4.1 时间、空间、扩展性三维对比
很多人在面试和考研复习时会纠结一个事情:同一个栈,到底该用数组实现还是链表实现。我的答案是,先把下面这张对比表刻在脑子里,再谈别的。
| 对比维度 | 顺序栈 | 链栈 |
|---|---|---|
| 入栈/出栈单次复杂度 | O(1) | O(1) |
| 连续入栈 n 次总代价 | O(n)(扩容翻倍摊还) | O(n)(每次 malloc 一次) |
| 扩容机制 | 需要 realloc,可能搬运数据 | 天然扩展,每次分配一个节点 |
| 空间利用率 | 连续数组,但有预分配虚高 | 按需分配,但每个节点多一个指针 |
| 内存碎片 | 低 | 高(频繁 malloc/free 容易产生碎片) |
| 缓存友好性 | 高 | 低 |
| 栈满条件 | 容量耗尽才算满 | 内存耗尽才算满 |
| 实现复杂度 | 低 | 中等 |
这里有一个容易被忽略的细节:两者的单次操作复杂度虽然都是 O(1),但 O(1) 的常数不一样。顺序栈只是移动一个数组下标,链栈要 malloc 一个新节点、改指针、再在出栈时 free。malloc/free 本身就是相对昂贵的操作,所以同样规模的入栈出栈,链栈的实际运行时间往往明显高于顺序栈。性能要求高的核心路径上,第一优先考虑顺序栈。
4.2 不同业务场景下的选型建议
我自己的选型原则,按场景拆开说会清楚一些。
场景一:能确定最大深度。比如编译器检查一个源代码文件的括号匹配,栈的最大深度不会超过文件长度,可以直接用固定数组的顺序栈。容量初始化成文件长度或 2 倍文件长度,完全不需要扩容逻辑,速度快,代码也最简单。
场景二:深度不确定但内存充裕。比如写一个在线算法题,需要动态维护一个栈来辅助遍历二叉树,深度无法预估,但内存够大,这时用动态扩容的顺序栈最省心,性能也稳定。
场景三:对内存极其敏感。比如嵌入式环境或低内存设备,这时候链栈见缝插针地分配节点反而更适合,因为你不会为根本用不到的空间提前买单。代价是每个节点都要消耗额外的 4 字节(或 8 字节)指针,这是需要接受的开销。
场景四:频繁创建和销毁栈。比如在一个大循环里反复初始化临时栈,这时候用顺序栈更好,因为不需要反复 malloc/free,只需要在栈内存里重置 top 指针就行,省掉了一次系统调用。
5. 栈的经典应用:括号匹配、表达式求值与函数调用
5.1 括号匹配:一道题带出栈的完整操作序列
栈最经典的入门应用题,就是括号匹配。给你一个字符串,里面只有 ( ) [ ] { },问括号是否成对匹配。比如 ([{}]) 是匹配的,([)] 是不匹配的。
解法很直观:遇到左括号就入栈,遇到右括号就弹出栈顶左括号,检查它们是不是一对。
c复制#include <string.h>
#include <stdbool.h>
bool isValid(char *s) {
int len = strlen(s);
if (len % 2 != 0) return false;
char stack[len];
int top = -1;
for (int i = 0; i < len; i++) {
if (s[i] == '(' || s[i] == '[' || s[i] == '{') {
stack[++top] = s[i];
} else {
if (top == -1) return false; // 右括号多了
char left = stack[top--];
if ((s[i] == ')' && left != '(') ||
(s[i] == ']' && left != '[') ||
(s[i] == '}' && left != '{')) {
return false;
}
}
}
return top == -1; // 左括号多了则栈未空
}
这个题的代码量不大,但它完整地展示了一个栈结构的使用流程:入栈、判空弹栈、匹配失败退出、最终检查栈空。这几个操作正好构成了栈生命周期中的全部要素,所以我很推荐第一次写栈应用就从这题开始。
我见过不少人在这个题上踩过一个很有意思的坑:遇到右括号就用 if 判断栈是否为空,却忘了在匹配左括号配对之前先弹栈。如果忘了弹出,后面对 [()] 这种多层嵌套输入就会判断错误。写代码时记住“右括号一定会消费一个左括号”,每一步都不空转,问题自然就少了。
5.2 表达式求值:中缀转后缀与操作数栈
表达式求值是栈在编译器里的典型应用。人类平时写“1 + 2 * 3”叫中缀表达式,但计算机从左到右扫描时,读到 1 之后不知道后面是不是还有更高优先级的运算符,所以很难直接计算。解决办法是把中缀转成后缀表达式“1 2 3 * +”,再用栈求值。
中缀转后缀的经典算法需要维护一个运算符栈:
- 遇到操作数直接输出;
- 遇到左括号入栈;
- 遇到右括号不断弹出运算符输出,直到碰到左括号,左括号弹栈丢弃;
- 遇到普通运算符时,把栈中所有优先级“大于等于”当前运算符的运算符弹出输出,再把自己压入栈。
转成后缀之后再求值就简单了:遇到数字入栈,遇到运算符弹出两个数字计算结果再压回栈。以 1 2 3 * + 为例,遇到 3 后栈里是 [1,2,3],遇到 * 弹出 2、3 计算 6 入栈成为 [1,6],再遇到 + 弹出 1、6 得到 7,这就是最终结果。
很多初学者容易忽略的一点:为什么后缀求值就要用栈?因为你扫描到运算符时,需要它“最近遇到的”两个操作数,而“最近遇到的还没被消耗的数据”恰恰就是栈顶附近的数据。这和栈的定义完全吻合,所以表达式求值成为了理解栈优先级特性的典型场景。
5.3 函数调用栈:为什么递归可能压爆栈
除了显式写出一个栈结构,你的程序其实每时每刻都在用栈。每次调用函数,操作系统都会在当前线程的调用栈上压入一个栈帧(Stack Frame),里面记录了函数的参数、局部变量、返回地址等;函数 return 时这个栈帧被弹出,程序回到调用点继续执行。
递归之所以需要调用栈来解释,是因为递归就是函数不断调用“自己”。每调用一层,就往系统栈上压一个新栈帧。比如递归计算斐波那契数列:
c复制int fib(int n) {
if (n <= 1) return n;
return fib(n - 1) + fib(n - 2);
}
调用 fib(5) 时,系统栈上会依次压入 fib(5)、fib(4)、fib(3)、fib(2)、fib(1),等 fib(1) 返回,栈帧弹出,fib(2) 再继续调用 fib(0)。每一层没算完之前,所有中间结果都得压在栈里。如果递归深度过大,系统分配的那块栈空间被填满,就是 StackOverflowError。
这个例子给了一个重要的实践启示:递归深度可控时用它,因为它代码简洁、可读性好;但如果你知道数据规模是几百万级,递归大概率爆栈,应该改成显式的栈 + 迭代。当你在 LeetCode 上看到有人用“迭代栈”模拟递归遍历树的写法,本质上就是把系统的调用栈换成了自己控制的栈,从而绕开系统栈 size 的限制。
6. 进阶玩法:单调栈与栈式虚拟机
6.1 单调栈:下一个更大元素问题从 O(n^2) 到 O(n)
栈还有一个非常重要的进阶用法,叫单调栈,也就是让栈内元素保持单调递增或单调递减。这个结构最常用来解决一类问题:寻找数组中每个元素右边(或左边)第一个比它大(或小)的元素。这类问题如果暴力求解,对每个元素都往后扫描,时间复杂度是 O(n^2);单调栈可以把复杂度压到 O(n)。
以“下一个更大元素”为例,给定数组 [2,1,2,4,3],要求每个元素右边第一个比它大的元素,不存在则返回 -1。预期的输出是 [4,2,4,-1,-1]。
解法核心是从右往左扫描,维护一个单调递减的栈:
c复制int* nextGreater(int *nums, int n) {
int *res = (int *)malloc(sizeof(int) * n);
int stack[n];
int top = -1;
for (int i = n - 1; i >= 0; i--) {
while (top >= 0 && stack[top] <= nums[i]) {
top--; // 栈顶比当前元素小,永远不可能是当前元素的答案,直接淘汰
}
res[i] = (top >= 0) ? stack[top] : -1;
stack[++top] = nums[i];
}
return res;
}
这个代码的核心是 while 循环里“淘汰”栈顶元素的操作。为什么可以把栈顶直接 pop 掉?因为你是从右往左扫的,当前这个 nums[i] 会被左边更远的元素“看到”,如果栈顶元素小于等于 nums[i],那么对左边更远的元素来说,nums[i] 比栈顶元素更大、且位置更靠右,栈顶元素永远不会成为任何左边元素的“下一个更大元素”。这就是单调栈效率的由来——每个元素最多入栈一次、出栈一次,总操作次数是 O(n)。
单调栈是面试和竞赛里的高频考点,LeetCode 上《每日温度》《下一个更大元素》等题目考的就是这个思路。学到这里你会发现,栈不是只能做最简单的括号判断,它在处理“区间最值”“相邻更大/更小”这类问题上也极其锋利。
6.2 栈式虚拟机:JVM操作数栈与字节码执行
提起栈,很多做 Java 开发的同学会想起 JVM 的虚拟机栈。JVM 确实是一个典型的栈式虚拟机,它的每个线程都有一个虚拟机栈,栈中每一个元素是一个栈帧(Stack Frame),对应一次方法调用。一个方法开始执行就压入栈帧,方法结束就弹出栈帧。
栈帧内部还包含一个专门用来计算的“操作数栈”(Operand Stack)。字节码指令的执行几乎全靠操作数栈。举个最简单的例子,Java 源码 int c = a + b; 编译后,对应的字节码大致长这样:
text复制iload_1 // 把局部变量表下标为 1 的变量 a 压入操作数栈
iload_2 // 把局部变量表下标为 2 的变量 b 压入操作数栈
iadd // 弹出栈顶两个整数,相加,再把结果压回操作数栈
istore_3 // 弹出操作数栈栈顶,存入局部变量表下标为 3 的变量 c
看到没有,iadd 这条指令不直接操作任何寄存器,它只是从操作数栈里弹出两个数,算完压回,整个过程完全围绕栈展开。这也是“栈式虚拟机”名称的由来。不仅 JVM 是这样,Python 的字节码解释器、Lua 虚拟机等也都是类似的栈式架构。
理解了这一层之后,前面学的顺序栈和链栈就不只是考试题了。当一个调用函数内部再把计算过程用栈组织起来时,它就是你每天都在运行的 Java/Python 程序的底层实现。这也是为什么面试官爱问“讲一下 JVM 虚拟机栈”——它不是孤立的 JVM 概念,而是栈这种数据结构在真实系统里的直接落地。
7. 避坑合集:栈顶指针、扩容、空栈判断这些细节
7.1 栈顶指针初始化与判空判满的混用
前面提到过 top 有 -1 和 0 两种约定,这里再强调一次,因为这是我改代码时遇到频率最高的问题。很多同学教材看的是约定一,网上抄题解用约定二,代码拼在一起后就出现离谱行为。
举个具体例子:某同学教材里学的是 top = -1 表示空栈,入栈用 base[++top] = x;抄题解时看到别人判空用 if (top == 0) return;,他不知道这是约定二的写法,直接抄过来。结果空栈弹栈时 top == -1 根本不等于 0,判空失效,Pop 函数直接访问 base[-1],数组越界,程序随机崩溃。
我的建议是:写代码前在注释里写清楚约定,// top points to the top element, -1 means empty。这个注释不是写给别人看的,是写给你自己防止混用的。万一调试时发现行为诡异,先检查 top 是 -1 约定还是 0 约定,能省掉一大半排查时间。
7.2 扩容、释放、指针失效的三连坑
顺序栈扩容的坑集中在 realloc 上。realloc 失败会返回 NULL,但原来那块内存并不会被释放。如果你直接写 s->base = realloc(s->base, newSize),万一 realloc 失败,s->base 就被覆盖成 NULL,原本的内存指针彻底丢失,造成内存泄漏并且数据全没。正确写法是先放进临时指针,判断非 NULL 后再赋给 s->base,上面的代码就是这么写的。
链栈的坑集中在 free 上。再次强调,LinkPop 必须先把 *out = tmp->data 和 s->top = tmp->next 都做完再 free(tmp)。很多同学习惯先 free 再取 next,虽然偶尔运气好能跑,实质上已经触发未定义行为。别赌,顺序对了,一切安稳。
还有一个和“指针失效”相关的坑:当你在写一个用数组实现的共享栈(两个栈共用一块数组,一个从左边开始、一个从右边开始),扩容或者重置 top 指针时很容易把另一个栈的指针搞乱。这类扩展结构务必画图分析再动代码,不要凭感觉。
7.3 一套能一次跑通的边界测试用例
栈写完之后,我习惯用下面这组用例验证。不要嫌简单,边界条件比主流程更容易出问题。
- 空栈 Pop / GetTop:应当返回错误码,不崩溃。
- 只 Push 一次,再 Pop:取回刚刚那个值,top 回到空栈状态。
- Push 到容量边界:容量为 4 时正好入满 4 个,不触发扩容;第 5 个入栈时触发扩容,且原有 4 个元素顺序不丢。
- 连续往返压弹若干轮:验证重复扩容和缩容后数据一致性。
- 链栈出栈后手动
free:配合内存检测工具,确保没有泄漏。
我在跑边界测试时一般会特意把容量设成很小,比如 INIT_CAPACITY = 2,这样很快就能逼出扩容路径上的问题。跑通了这些用例,栈的代码才算真正站稳了。
最后分享一个小经验。当年我学栈到这一节时,最大的收获不是“我学会了写一个后进先出的数据结构”,而是“操作受限反而让逻辑更清晰”。栈的入栈出栈规则如此简单,以至于后续做表达式求值、做括号匹配、做 DFS 时,我都不用纠结数据结构层面该怎么表达“倒序处理”这个需求,直接拿栈来用就行。而顺序栈和链栈这两种实现,恰恰是理解“同一个抽象接口,两种物理存储”的最好窗口。建议你一定亲手把这两套实现完整跑一遍,不要只看代码。跑完之后再学队列、学树的层序遍历,你会发现思路顺得不是一星半点。
