顺序栈和链式栈,很多同学在数据结构期末复习的时候容易栽在这两块。别问我是怎么知道的,每年批改试卷的时候,栈顶指针减一还是先减一再用、链式栈入栈要不要带头结点,这些细节总能让一批人集体翻车。这篇我把自己带学生复习时总结的思路、代码和踩坑记录整理出来,争取让你一个晚上把这两种栈彻底拿捏。
先说清楚这篇能帮你解决什么:考试中的选择题、填空题、算法设计题,以及面试里常考的栈实现细节。无论是准备期末考、考研,还是单纯想夯实基础,这份复习材料都值得你从头到尾过一遍。我尽量用大白话把指针怎么动、内存怎么分配、边界条件怎么处理讲透。
1. 顺序栈与链式栈的整体设计思路
1.1 为什么先要理解栈的本质
栈是操作受限的线性表,这个“受限”两个字很关键。它只允许在一端进行插入和删除操作,这一端叫作栈顶,另一端叫作栈底。数据在栈里的进出规则就是后进先出,也就是LIFO。你把它想象成坐电梯,先进去的人站在最里面,最后进去的人反而最先出来。
这个LIFO特性是理解一切栈相关操作的底层逻辑。顺序栈和链式栈只是用两种不同的存储方式去实现同一个逻辑结构,题目再怎么变,归根到底都在考你“入栈时元素存哪里、出栈时从哪儿取”。只要抓住这个本质,很多看似复杂的代码就不会让你头晕。
在数据结构学习路线里,栈是线性表向递归、表达式求值、括号匹配、函数调用过渡的桥梁。很多同学学完链表就直接跳到树和图,结果后面遇上深度优先搜索的回溯逻辑就懵了,其实就是栈的理解不够扎实。顺序栈考察的是你对数组空间和下标边界的控制力,链式栈考察的是你对指针操作和内存管理的熟练度,两者侧重点不同,但是互相验证。
1.2 两种存储方案的设计取舍
顺序栈用的是连续的一片内存空间来存放元素,底层就是数组。它的特点是物理上相邻,访问速度快,但我们必须在初始化的时候指定一个最大容量。容量设小了会溢出,设大了浪费内存,这就是典型的“空间换操作的便利性和可控性”。
链式栈则不同,每个节点是单独通过指针串起来的,物理上不需要连续空间。它的容量理论上只受限于系统内存,每次入栈动态创建一个新节点,不需要提前预估大小。但这种灵活性的代价是,每个节点都要额外的指针域开销,而且在高频操作下,频繁的malloc和free会带来一定的性能损耗。
说人话就是,顺序栈是“一次租个整层办公楼,工位固定,走两步就到”;链式栈是“一个格子一个格子地租,随用随租,但是每个格子门口都要挂个牌子指路到下个格子”。实际项目中两种都有应用,但你在复习阶段不需要纠结哪个更好,重要的是把各自的实现原理和优缺点背熟、写熟,考试才能游刃有余。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 核心细节解析与实操要点
2.1 顺序栈的存储结构与指针逻辑细节
顺序栈的定义在严蔚敏的《数据结构(C语言版)》里写得很清楚,这里我用自己的方式给你拆一下:
c复制#define MAXSIZE 100
typedef struct {
int data[MAXSIZE];
int top;
} SqStack;
关键就一个整型变量top,它是栈顶指针。注意这里的“指针”不是指C语言里的指针变量,而是指示数组下标的整型索引,这是新手最容易犯迷糊的地方。top的值表示栈顶元素在数组中的位置。
关于top的初始化,市面上有两种约定,我给你对比一下:
- 第一种:top = -1,表示栈空。此时入栈操作是
data[++top] = x,即先移动指针再赋值;出栈操作是x = data[top--],即先取出元素再回退指针。栈满条件为top == MAXSIZE - 1。 - 第二种:top = 0,表示栈空。此时入栈操作是
data[top++] = x,即先赋值再移动指针;出栈操作是x = data[--top],即先回退指针再取元素。栈满条件为top == MAXSIZE。
我强烈建议你只记第一种,因为top = -1时,栈顶指针指向的就是栈顶元素本身,思考起来更直观,做算法题时用++top和top--也不容易搞混。很多教材默认用的就是这种,考试答案通常也按这个写。
这里有个细节值得注意:判断栈空和栈满的时候,千万不要把两个条件记反了。栈空是top等于初始值,栈满是top跑到最大容量边界。你要是把top == MAXSIZE当成栈空,任何入栈操作都会报溢出,问题排查的时候会特别抓狂。
2.2 链式栈的存储结构,为什么带头结点更省心
链式栈本质上是单链表,但只能从头部插入和删除。也就是入栈用头插法,出栈用头删法,这正好利用了单链表头部操作O(1)的优势。它的节点定义如下:
c复制typedef struct StackNode {
int data;
struct StackNode *next;
} StackNode;
链式栈需要一个栈顶指针top,它始终指向栈顶节点。初始化时让top = NULL,这就是空栈。入栈时申请一个新节点,把新节点的next指向原来的栈顶,然后让top更新为新节点,相当于新节点“压”在了最上面。
很多人写链式栈的时候会有个疑问:要不要加头结点?我的建议是,链式栈完全不需要头结点,因为所有的插入和删除都发生在栈顶,头结点反而会让操作变得别扭。你看如果加了头结点,入栈就得找头结点后面插,出栈还得判断“删除的是不是最后一个有效节点”,逻辑平白多出好多分支。
带头结点更适合的场景是单链表的通用操作,那里有大量中间位置插入删除的情况。而栈这种只能在端点操作的结构,无头结点、直接用top指针管理才是正道。面试的时候如果你能把“为什么不需要头结点”讲清楚,反而比死记代码更能加分。
3. 实操过程与核心环节实现
3.1 顺序栈的完整C语言实现与扩容策略
先从顺序栈的完整实现开始。这个代码我按“可运行、可测试”的标准给你写,不是只有函数骨架:
c复制#include <stdio.h>
#include <stdlib.h>
#define MAXSIZE 100
typedef struct {
int *base;
int top;
int stackSize;
} SqStack;
// 初始化
int InitStack(SqStack *S) {
S->base = (int *)malloc(MAXSIZE * sizeof(int));
if (!S->base) return 0;
S->top = -1;
S->stackSize = MAXSIZE;
return 1;
}
// 入栈
int Push(SqStack *S, int e) {
if (S->top == S->stackSize - 1) {
// 栈满,尝试扩容
S->base = (int *)realloc(S->base, (S->stackSize + 10) * sizeof(int));
if (!S->base) return 0;
S->stackSize += 10;
}
S->base[++S->top] = e;
return 1;
}
// 出栈
int Pop(SqStack *S, int *e) {
if (S->top == -1) return 0;
*e = S->base[S->top--];
return 1;
}
// 获取栈顶元素
int GetTop(SqStack *S, int *e) {
if (S->top == -1) return 0;
*e = S->base[S->top];
return 1;
}
// 判空
int IsEmpty(SqStack *S) {
return S->top == -1;
}
这里我做了个改动:把原来固定的data数组改成了动态分配的base指针,并且在Push里加了扩容逻辑。为什么要这样做?因为考试只要求写出基础操作,但真实工程里栈空间不可能是永久固定的。用realloc扩容后,顺序栈就具备了动态增长的弹性,这样你在回答“顺序栈的缺点”时,可以更辩证地说“固定容量的顺序栈会溢出,但可以通过动态扩容缓解”,这个回答比单纯背缺点是加分项。
关于扩容的时机有个细节:不要在top接近栈满的前一刻才扩容,而是每次栈满时一次性扩容固定大小。我这个例子是每次增加10个元素的空间,实际项目中可以根据预估增长率设大一些,比如翻倍扩容。realloc是C语言重新分配内存的函数,它会在原内存块后面找新的连续空间,如果后面不够,则会在其他地方重新分配并把旧数据复制过去。这个操作有时间开销,频繁调用会影响性能,所以不要一次扩太小。
还有一点,出栈的时候我没有真正把元素清除,只是把top减一。这意味着数组中仍然残留着旧值,但逻辑上它已经不在栈里了。下次入栈时,新的元素会直接覆盖这个位置。这个特性在排查一些诡异的Bug时值得注意,尤其是你用调试器查看内存内容时,出栈位置的老数据还在,别被它误导。
3.2 链式栈的C语言实现与内存管理注意事项
接下来是链式栈的完整实现。我先把代码放出来,然后逐段解释容易踩坑的地方:
c复制#include <stdio.h>
#include <stdlib.h>
typedef struct StackNode {
int data;
struct StackNode *next;
} StackNode;
// 初始化空栈
StackNode* InitStack() {
return NULL;
}
// 入栈(头插法)
StackNode* Push(StackNode *top, int e) {
StackNode *p = (StackNode *)malloc(sizeof(StackNode));
if (!p) {
printf("内存分配失败\n");
return top;
}
p->data = e;
p->next = top;
top = p;
return top;
}
// 出栈(头删法)
StackNode* Pop(StackNode *top, int *e) {
if (top == NULL) {
printf("栈已空,无法出栈\n");
return top;
}
StackNode *p = top;
*e = p->data;
top = top->next;
free(p);
return top;
}
// 获取栈顶元素
int GetTop(StackNode *top, int *e) {
if (top == NULL) return 0;
*e = top->data;
return 1;
}
// 判空
int IsEmpty(StackNode *top) {
return top == NULL;
}
链式栈的核心操作就四个字:头插头删。入栈时p->next = top把新节点链到旧栈顶前面,出栈时先用临时指针p保存当前栈顶,然后top后移,最后释放p。很多人第一次写会忘了free(p),这个错误很隐蔽,因为程序看着能正常运行,但每次出栈都泄漏一块内存,跑久了内存占用肉眼可见地涨。
这里必须重点提醒一下:出栈时用临时指针保存旧栈顶这一操作,绝对不是可有可无的。如果你直接写top = top->next,那旧栈顶节点就从链表上断开了,但它的内存没有被释放,成了一个野地址,你再也找不到它。这个操作必须配合free,缺一不可,否则不是内存泄漏就是逻辑混乱。
内存管理在链式栈中特别重要。每次malloc都要对应一个free,每次入栈申请的内存必须通过出栈释放掉。笔试中如果让你写链式栈的销毁函数,本质就是循环Pop到栈空,顺便把所有节点释放干净,不要只把top置成NULL了事,那样所有节点全泄漏了。
我还有个写链式栈的个人习惯:函数返回值直接返回新的栈顶指针。这样可以避免使用二级指针,代码逻辑清晰。但是如果你在写课堂作业,老师要求用二级指针传参,比如void Push(StackNode * *top, int e),也别慌,本质上是把上面代码里的top换成了*top,思路不变。
3.3 顺带讲透共享栈这个加分考点
顺序栈有两个经典变形:双栈共享空间和共享栈。这个考点在期末和考研中经常以小题形式出现,但很多同学理解不到位。所谓共享栈,就是用一片连续内存同时存放两个栈,一个栈底位于数组左端,另一个栈底位于数组右端,两个栈各自往中间增长。
共享栈的结构定义:
c复制typedef struct {
int data[MAXSIZE];
int top1;
int top2;
} SharedStack;
top1初始化为-1,top2初始化为MAXSIZE。栈1入栈时执行data[++top1] = e,栈2入栈时执行data[--top2] = e。栈满的条件是top1 + 1 == top2,也就是两个栈顶指针碰到一起了。
共享栈最大的优点就是空间利用率高。当两个栈的数据类型相同、此消彼长的时候,固定给每个栈分配MAXSIZE/2会造成巨大浪费,而共享栈让两个栈互相“借用”对方的多余空间。这种思想在很多算法题里也有体现,比如双端队列、双指针扫描,本质上都是共享存储空间的思路。
考试里共享栈常考的点就三个:初始化时两个top的取值、栈满的判断条件、入栈时谁往右走谁往左走。你只要在草稿纸上画一个横着的数组,左边标top1,右边标top2,让它们相向而行,几秒钟就能推出来,比死记公式靠谱得多。
4. 顺序栈与链式栈的深度对比与选型分析
4.1 六个维度对比,一张表说清差异
很多同学学完这两种栈之后,能默写代码,但是问“哪种更好”就卡住了。我整理了一张对比表,你直接拿去背:
| 对比维度 | 顺序栈 | 链式栈 |
|---|---|---|
| 存储空间 | 连续数组,需预先分配容量 | 节点分散,按需动态分配 |
| 栈空判断 | top == -1 | top == NULL |
| 栈满判断 | top == MAXSIZE - 1 | 无(取决于系统内存) |
| 入栈出栈时间复杂度 | O(1),无额外开销 | O(1),但有malloc/free开销 |
| 空间利用 | 可能浪费或溢出 | 无浪费,但每个节点多存一个指针 |
| 缓存友好性 | 高(连续内存访问) | 低(节点可能离散分布) |
注意,两者入栈出栈的时间复杂度都是O(1),这一点几乎所有教材都强调。但做题的时候要注意,链式栈的O(1)是“渐近意义”上的,实际运行中每次入栈都要malloc,如果插入一千万个元素,这些malloc的系统调用开销会非常可观。
还有个面试题经常问的考点:斐波那契数列计算、十进制转二进制、表达式求值等场景下,选哪种栈实现更好?候选人往往只从时间复杂度角度回答,忽略了局部性原理。顺序栈因为是一段连续内存,访问时cache命中率高;链式栈节点分布离散,每个节点可能跨越不同的内存页,cache miss率高。所以理论上同等规模的数据,顺序栈操作速度往往更快。
4.2 实际应用场景:从函数调用到表达式求值
栈的应用在实际工程中无处不在,这里给你列举几个高频场景,理解了这些你才知道“复习栈”不只是为了应付考试。
最典型的场景就是函数调用。每调用一个函数,系统就把当前的返回地址、局部变量、参数等信息压入系统栈中,函数执行完毕再弹栈恢复现场。递归函数之所以能层层深入再层层返回,本质上就是系统栈在背后撑腰。如果你能理解系统栈的工作方式,递归转非递归、二叉树的非递归遍历就不会再是难题。
第二个高频场景是表达式求值。中缀表达式转后缀表达式、后缀表达式求值,是两个经典的栈应用。运算数入栈,运算符弹出两个数计算再压回结果,这个过程很机械但极其高效。计算机数学表达式求值题里,栈是标准解法,你不把栈学好,这道大题基本就白给了。
第三个场景是括号匹配。编译器检查代码中的括号嵌套是否正确,用的就是栈。遇到左括号入栈,遇到右括号弹出栈顶匹配,如果匹配上了继续走,匹配不上或者栈提前空了,就报语法错误。这道题在很多面试中会出现,实现起来其实不到三十行代码,但考的是你栈操作的边边角角是否熟练。
顺带提一句,很多新兴的中间件也大量使用栈结构的思想。比如Redis中list数据结构的底层设计,在很多场景下可以通过LPUSH和RPOP实现栈式操作,它的逻辑其实和链式栈同源。考研复试或面试时,如果能把数据结构的栈模型和中间件的实际应用结合起来谈,会给考官留下很深的印象。
4.3 学习路线建议:先通吃原理,再刷题验证
我给准备期末或考研的同学一条很实在的建议:第一遍复习不要直接扎进题海,先把顺序栈和链式栈的代码各自手写十遍以上,写到手不抖、闭着眼也能写对为止。有些同学看着代码觉得“我看懂了”,一合上书就卡在某个判断条件上,这就是典型的眼高手低。
手写代码时,要特别关注四个关键操作:初始化、判空、入栈、出栈。这四个操作能形成肌肉记忆后,再去练习应用层题目,比如括号匹配、进制转换、回文判断。栈的底层操作翻来覆去就是那么几句,但应用层题目千变万化,就像你学会了发动机原理,才能去改车调车。
刷题方面,我建议按题型分类练习:链表相关题、括号匹配题、表达式求值题、单调栈题。单调栈是栈的进阶应用,用来解决“下一个更大元素”这类问题,在很多面试中以中高难度出现。先掌握基础再挑战进阶,进度虽然慢一点,但基础越牢靠,后面提速越明显。
5. 常见问题与排查技巧实录
5.1 经典报错与运行时错误速查表
我在带学生过程中,发现新手写栈代码容易犯的错高度集中。我把这些高频问题整理成了速查表,你做实验或者复习对照着看:
| 报错现象 | 可能原因 | 排查方法 |
|---|---|---|
| 入栈后读取栈顶数据不对 | top初始化或更新时机错误 | 检查top初值,确认是先赋值还是先移动指针 |
| 出栈时程序崩溃 | 对空栈执行Pop,或者top越界 | Pop前加入IsEmpty判断 |
| 循环入栈后内存越界 | 顺序栈未判满直接入栈 | 入栈前检查top是否等于MAXSIZE-1 |
| 链式栈出栈后内存泄漏 | 未free被删除节点 | 用临时指针保存旧节点,pop后free |
| 链式栈遍历时死循环 | 链表成环或next指错 | 检查入栈时的指针顺序,确认p->next = top |
| 从栈中取到的元素顺序反了 | 栈顶指向栈底,逻辑相反 | 检查top是否始终指向最后入栈的节点 |
括号匹配调试小技巧:直接用纸笔手动模拟一遍,拿一个较长的表达式,比如[()]{}{[()()]()},自己用笔画栈的变化过程,哪里配不上,错误立刻现形。代码里的断点调试当然也行,但对于这种逻辑题,手动模拟其实更快。
如果你是在DevC++或者VS里跑,遇到程序卡死,多半是循环条件写错了,或者是忘记在某个分支return。我在调试链式栈时,习惯在每次入栈和出栈后随手打印top->data看一眼,这样一旦出错,马上就能定位到是哪一步改变了栈顶数据。
5.2 我在实际复习和教学中踩过的坑
这里分享几个我个人的真实教训,你们可能也会遇到。
第一个坑是顺序栈的“栈空”和“栈满”混用。我之前带过一个学生,用top = 0表示栈空,但是入栈用了data[top++] = x,出栈用了x = data[--top],这套逻辑本身没问题,可他在判空时写成了top == MAXSIZE - 1,结果栈明明是空的,程序却认为满了,怎么调试都找不到原因。后来我让他把初始化、判空、入栈、出栈四个函数的top变动画在纸上,一画出来错误立刻清楚了。所以遇到类似的逻辑错误,画状态图是最快的排查手段。
第二个坑是链式栈忘记free。看起来没报错,程序也能运行,但跑一个长循环后内存暴涨。我早期自己写代码也犯过,当时以为栈操作没问题,结果用工具检测内存泄漏才恍然大悟。从那之后我养成了一个习惯:写链式栈代码时先写销毁或者出栈,把所有节点都free干净,再写别的部分。就像写完一篇文章先检查标点符号一样,把内存管理这个底线守住。
第三个坑是手写代码时函数的返回值问题。很多同学喜欢在Push函数里直接操作全局指针变量,局部变量和全局变量混着用,程序能跑但看不出来是哪个地方出了问题。我建议在练习阶段就养成模块化封装的好习惯,不要用全局变量,而是像上面示例代码那样通过返回值或二级指针传递栈的状态。这个习惯在考研手写算法的时候尤其重要,阅卷老师看到清晰的参数传递设计,也会给更高的印象分。
5.3 复习策略:如何用5~7天把两种栈彻底拿下
如果你离期末还有一周左右,我给一个可执行的时间安排,照着做基本够用:
- 第1天:看书理解栈的基本概念和两种存储结构,抄写顺序栈和链式栈的全部代码各三遍,不要跑,只抄写。
- 第2天:合上书手写两个实现,对照书上的标准答案检查细节,重点检查top的初始化和指针赋值。
- 第3天:手写基于栈的括号匹配和进制转换,这两道题足以暴露你80%的操作盲区。
- 第4天:练习表达式求值,不要求一次写对,但要把算法流程在纸上画清楚。
- 第5天:刷选择题和判断题,重点复习顺序栈/链式栈的优缺点对比、栈满判断条件、共享栈初始条件。
- 第6天:做一套完整的期末真题或考研真题,时间限制内完成栈相关题目。
- 第7天:复盘错题,把记错的条件、指针逻辑、边界情况全部整理到一页纸上,考前只看这一页。
这套节奏我用了很多年,对大部分学生是行之有效的。核心逻辑是“理解原理 -> 手写代码 -> 应用练习 -> 真题检验”,每一步都踏实地走,而不是考前突击只看不动手。前三天你可能觉得手写很浪费时间,但后面做真题的时候你会发现,写栈相关的代码几乎不用思考,手指自己就动起来了。
最后再分享一个小技巧:复习到后期,可以在草稿纸上画一张大图,左边画顺序栈的数组结构,右边画链式栈的节点结构,中间写两行对应关系。这张图就是你考前的最后一眼记忆锚点。栈这个知识点并不难,难的是很多细节一旦混在一起就乱了,学会画图梳理,比什么都管用。
