栈(stack)这名字听起来简单,但凡是真刀真枪写过C语言的同学都知道,顺序栈和链式栈不是背背定义就完事的东西。面试会问、课程设计会考、写编译器时要手撸、看崩溃日志时要理解栈回溯,甚至连gdb调试时看到的栈帧信息,本质都是栈在底层运转。这篇就把顺序栈和链式栈彻底掰开,从结构体定义到入栈出栈,从扩容策略到内存释放,再到函数调用栈和栈回溯的真实场景,直接按C语言工程标准来一遍。适合正在啃数据结构、准备复试机试、或者项目中需要手写栈的读者。
1. 顺序栈:用数组模拟“后进先出”
1.1 栈的底层逻辑与结构体定义
栈的核心约束只有一句话:只能在栈顶插入和删除。这句话翻译成数组操作,就是用一个连续的存储空间加上一个指示栈顶位置的变量。数组的物理下标天然有序,栈顶指针指向当前栈顶元素的位置,入栈就是先把指针上移再把数据写入,出栈就是先把数据取走再把指针下移。
C语言里最常用的是如下结构体:
typedef struct { int *data; // 栈底指针,指向动态分配的数组 int top; // 栈顶下标,初始为 -1 表示空栈 int capacity; // 当前数组容量 } SeqStack;很多初学教材会直接用固定大小数组int data[MAXSIZE],但在工程实践里,我更推荐动态数组。原因很简单:你很难提前预知任务到底会压入多少数据。固定数组一旦写满就报错,而动态扩容只是多写几行代码,却能让栈真正“用起来”。
top初始化为-1还是0,这一点必须前后一致。用-1表示空栈,入栈时先top++,再赋值data[top] = val;用0表示空栈则相反,先赋值再top++。我个人习惯用-1,因为逻辑上更直观:空栈时栈顶下标不存在。别小看这个约定,后面所有判断函数全都依赖它。
1.2 初始化、入栈、出栈的完整实现
初始化函数负责给数据指针分配内存,同时设置初始容量。这里有个小陷阱:realloc扩容失败时会返回NULL,如果直接赋值给原指针,原来的内存块就丢了。所以扩容时一定要用临时指针接收返回值。
void initStack(SeqStack *s, int initCap) { s->data = (int *)malloc(sizeof(int) * initCap); if (s->data == NULL) { printf("内存分配失败\n"); exit(1); } s->top = -1; s->capacity = initCap; }入栈操作最关键的是检查容量。如果栈已满,需要扩容。扩容策略常见的有两种:翻倍扩容和固定增量扩容。翻倍扩容的空间复杂度是 O(log n),总代价低,适合大多数场景;固定增量扩容适合你知道数据量增长趋势的场景,但频繁realloc会带来内存碎片。
void push(SeqStack *s, int val) { if (s->top + 1 == s->capacity) { int newCap = s->capacity * 2; int *tmp = (int *)realloc(s->data, sizeof(int) * newCap); if (tmp == NULL) { printf("扩容失败\n"); return; } s->data = tmp; s->capacity = newCap; } s->top++; s->data[s->top] = val; }出栈和取栈顶不一样:出栈要删除元素,取栈顶只是读值。出栈时可以将top直接下移,不用立刻清空那个位置的数据,因为下次入栈会覆盖。但如果data里存的是指针,必须先把指针指向的内存释放掉,再让top下移,否则会内存泄漏。
1.3 动态扩容的边界与栈满判断
实现一个顺序栈,永远要回答两个问题:“栈满了吗?”和“栈空了吗?”。
用top == capacity - 1判断满栈,用top == -1判断空栈。但在并发或更高阶的场景下,这两个判断会变得隐晦。比如你写一个支持多线程的程序,栈顶指针的修改不是原子操作,就需要加锁或者用原子变量。如果你只是在学习阶段,先把这个最基本的判断写对就够了。
动态扩容时,原来的数组元素要整体搬迁,realloc可能直接移动内存块,也可能原地扩容。原地扩容意味着原来的指针地址不变,但对用户透明。你需要注意扩容后data指针可能变化,所有保存过指向栈内元素地址的变量都会失效。这是顺序栈一个隐藏的坑:如果你在入栈前取了一个&s->data[top]指针,扩容后再用这个指针,那已经指向了被释放的内存。
2. 链式栈:让节点在堆上“叠罗汉”
2.1 链式栈的结构设计与内存模型
顺序栈用连续内存模拟栈,链式栈则完全放弃连续空间,用节点在堆上“叠罗汉”。每个节点包含数据域和指向下一个节点的指针。栈顶就是链表的头节点,入栈相当于头插法,出栈相当于删除头节点。
typedef struct Node { int data; struct Node *next; } StackNode; typedef struct { StackNode *top; // 栈顶指针 int size; // 栈中元素个数,方便判断空栈 } LinkStack;为什么链式栈的入栈出栈都能达到 O(1)?因为只要我们把栈顶固定在链表头部,插入和删除都只需要修改top指针,不需要遍历。很多初学者会把栈顶放在链表尾部,结果入栈要遍历到末尾,复杂度直接变成 O(n),那就失去链式栈的意义了。
链表节点的内存是每次入栈时单独malloc的,这决定了链式栈的空间分布是离散的,不会有顺序栈那种连续内存块耗尽的问题。但代价是每个节点多了一个next指针的内存开销。对于 int 类型的栈,顺序栈只需要8字节(4字节数据加4字节空白),链式栈可能要16字节甚至更多。
2.2 链式栈的入栈与出栈实现
链式栈入栈的核心:新节点先指向当前栈顶,再更新栈顶指针为新节点。顺序不能反,如果先更新了top,就找不到原来的栈顶了。
void pushLink(LinkStack *s, int val) { StackNode *node = (StackNode *)malloc(sizeof(StackNode)); if (node == NULL) { printf("节点分配失败\n"); return; } node->data = val; node->next = s->top; s->top = node; s->size++; }出栈则是保存当前栈顶节点,取出数据,将top指向下一个节点,最后释放保存的节点。注意出栈前必须检查栈是否为空,否则s->top就是NULL,访问top->data会直接段错误。
int popLink(LinkStack *s) { if (s->top == NULL) { printf("栈为空,无法出栈\n"); return -1; } StackNode *tmp = s->top; int val = tmp->data; s->top = tmp->next; free(tmp); s->size--; return val; }这块代码看似简单,最容易出错的地方是free(tmp)之后,tmp->next已经无法访问。所以你必须在free之前先把top指针更新好。我见过很多回同学把s->top = tmp->next;和free(tmp);写反,结果每次出栈都访问野指针,程序时好时坏。
2.3 内存释放与栈销毁的细微之处
链式栈的销毁与顺序栈全然不同。顺序栈销毁时只释放data数组和结构体本身,链式栈却要把每个节点逐个释放。如果只释放top,整个链表都泄漏了,而且程序退出后,操作系统虽然会回收进程的内存,但长时间运行的程序如果反复创建销毁栈,内存会一点点涨上去,最后被系统杀掉。
void destroyLink(LinkStack *s) { StackNode *cur = s->top; while (cur != NULL) { StackNode *next = cur->next; free(cur); cur = next; } s->top = NULL; s->size = 0; }这里有一个容易被忽略的经验:先用next保存后继节点,再释放当前节点。如果你写完free(cur); cur = cur->next;,就犯了一个教科书级错误:cur已经被释放,cur->next是野指针访问。这个错误在 Debug 版本可能会侥幸运行,Release 版本却可能直接崩溃,是最难排查的一类问题。
3. 栈的经典应用场景:从函数调用到回溯
3.1 函数调用栈与栈帧形成过程
栈不只是你主动创建的数据结构,程序运行时每一个函数调用都在底层使用调用栈(call stack)。调用一个函数时,系统会分配一块栈帧(stack frame),保存函数的局部变量、参数、返回地址,以及上一层函数的栈底指针。函数返回时,对应栈帧被销毁。整个过程完全符合后进先出:最后被调用的函数最先返回。
这也是为什么递归调用过深会栈溢出(stack overflow)。每一层递归都向栈里压入一个栈帧,栈空间耗尽就崩了。我看到有人做转录组 t-SNE 分析时遇到protect(): protection stack overflow错误,本质就是 R 的某层保护机制使用了类似栈的结构,递归或迭代中压入保护元素的次数超过了上限。虽然在 R 里和 C 语言的栈溢出触发机制不同,但思想一致:无限制地向栈中压数据,终会溢出。
3.2 使用栈回溯(backtrace)排查崩溃问题
调试程序时,崩溃日志里的 backtrace(栈回溯)就是把你当前所在函数的栈帧一层层向上展开,还原出一个函数调用链。很多同学在 gdb 调试 C 程序时,会用bt命令查看调用栈。这背后依赖的就是运行时栈的栈帧信息:每个栈帧里保存着返回地址和上一帧指针,回溯过程就是从当前帧沿链走回 main 函数。
arm 平台上的调用栈回溯略有特殊,因为 arm 架构的寄存器布局和 x86 不一样,某些优化选项下栈帧指针可能被省略,导致回溯信息不完整。学习栈帧形成过程的最好方法,是写两个简单的 C 函数互相调用,然后编译成汇编,观察push、pop、mov指令如何在栈上安排变量。这个实验做完,你对栈的理解会立刻上一个台阶。
3.3 括号匹配、表达式求值与浏览器的后退按钮
除了底层系统,栈的经典应用还有三个:括号匹配、逆波兰表达式求值、浏览器后退功能。
括号匹配是栈最直观的应用。扫描字符串,遇到左括号就入栈,遇到右括号就弹出栈顶并检查是否匹配。如果扫描过程中栈提前为空,或者扫描结束时栈还有剩余,说明括号不匹配。这个算法在编译器的语法分析阶段大量使用。
表达式求值有两个方向:中缀表达式转后缀,后缀表达式求值。转换过程用栈保存运算符,求值过程用栈保存操作数。比如(1+2)*3,转换成后缀12+3*,遇到数字入栈,遇到运算符弹出两个数运算后把结果入栈,最终栈顶就是答案。
浏览器的后退按钮也是一个栈。你每访问一个新页面就压栈,点击后退就是弹出栈顶,再点前进就需要另一个栈来保存被弹出的页面。两个栈配合,就还原出一个完整的浏览记录。这种场景特别适合用链式栈,因为页面数量不确定,而且内存动态分配比扩容数组更自然。
4. 顺序栈 vs 链式栈:到底该怎么选
4.1 三维度对比:性能、空间与代码复杂度
用一张表把二者的核心差异列出来,接下来说说怎么选。
| 对比项 | 顺序栈 | 链式栈 |
|---|---|---|
| 存储空间 | 连续内存,扩容时整体搬迁 | 离散内存,按需分配 |
| 空间利用率 | 有扩容预留,可能浪费 | 每个节点带指针,额外开销大 |
| 入栈/出栈时间复杂度 | O(1),但扩容偶尔 O(n) | O(1),无扩容问题 |
| 栈空/栈满判断 | 需要手动维护容量 | 只看栈顶是否为空 |
| 内存释放 | 一次释放 | 逐节点释放 |
| 适合规模 | 数据量可预估、要求缓存友好 | 数据量动态变化、生命周期不同 |
性能上,顺序栈的连续内存对 CPU 缓存非常友好,遍历或连续压栈时命中率高。链式栈每个节点都通过指针连接,在堆上随机分布,访问时缓存命中率较低,节点多了会有明显性能差距。但链式栈不会因为扩容而意外停顿,顺序栈扩容时如果数据量大,realloc可能耗时较长。
安全角度,顺序栈扩容失败可能出现一系列问题,链式栈则要面对每个节点的malloc失败。两者各有风险,但链式栈的操作步骤更多,初学者更容易写出带内存泄漏的代码。
4.2 在真实项目里怎么选
如果让我给建议,一般遵循三条经验。
第一,数据规模可预估并且追求速度时,选顺序栈。比如实现一个计算器核心算法,操作数的量级在几百以内,用固定容量数组完全够。此时扩容代码都不需要写,性能又极稳。第二,栈会不断创建销毁、元素数量变化剧烈时,选链式栈。比如用栈做深度优先搜索,每个分支都要压栈若干节点,深度和分支规模难预估,链式栈更稳。第三,如果栈元素是结构体或者占内存很大的对象,尽量存指针而不是存对象本身。顺序栈和链式栈都可以存void *或特定对象指针,这样栈只管理指针,真正的数据留在堆上。这样既避免了对象拷贝开销,又方便管理。
很多大学教材会默认先讲顺序栈,因为它的代码更短、更贴近数组基础。但到了面试环节,面试官往往更关注你能否讲清楚链式栈的节点释放顺序,以及两种栈在极端情况下各自会踩什么坑。两个都实现一遍,并且自己对比备份,才是真掌握。
5. 避坑手册:C语言栈实现的常见问题排查
5.1 栈顶指针与下标概念混乱
顺序栈最常见的 bug,是初始化时top的取值和入栈顺序不匹配。如果你把top初始化为0,同时又按-1那套流程写top++再赋值,第一个元素会存到data[1],栈底就空出来了,而且空栈判断也会出错。
排查这种问题的一个好方法是:在 push 和 pop 函数入口打印top的值,手动模拟一遍。我建议所有初学者都在代码里加一个printStack函数,当栈操作逻辑出现问题时,肉眼观察数据是不是按预期排列。调试过后再删掉也不迟。
5.2 动态扩容时丢失原指针
前面提到的realloc失败问题,是很多老手都会掉进去的坑。正确写法如下:
int *tmp = (int *)realloc(s->data, sizeof(int) * newCap); if (tmp == NULL) { // 原指针仍有效,可以选择报错或继续用旧容量 return; } s->data = tmp; s->capacity = newCap;这里的关键是:realloc失败时,原内存块不被释放,s->data依然有效。如果你写s->data = realloc(...),一旦失败,s->data变成NULL,原来的数据全部丢失。内存分配失败虽然少见,但在嵌入式环境或者内存消耗大的分析任务中很常见,必须防御。
5.3 链式栈的野指针与内存泄漏
链式栈的野指针大多来自节点释放顺序错误。另外一个常见错误是在 pop 结束后忘记了s->size--,然后基于size判断空栈时,逻辑全都乱了。如果使用size字段,就要确保 push、pop、destroy 都更新它,保持一致性。
内存泄漏的检测办法,在 Linux 下可以用valgrind --leak-check=full ./prog,它会报告每一块未释放的内存和分配位置。Windows 下可以用 CRT 调试库,或者直接用 Visual Studio 的诊断工具。做课程设计时如果不检查内存泄漏,可能感觉程序没问题,但长时间运行后内存飙升,就是节点没释放干净。
5.4 递归栈溢出与保护栈溢出
当你在 C 语言中递归调用过深,会触发栈溢出。默认栈大小在 Linux 上通常是8MB,每一步递归栈帧可能占几十到几百字节,所以递归深度大概几万层就会崩。排查这类问题,可以用 gdb 看崩溃时的调用栈,找到递归链中哪一步没有退出条件。
R 语言里的protection stack overflow也是类似思想。R 的 GC 保护栈是一种后进先出结构,用来防止临时对象被垃圾回收器回收。当你在循环里反复调用Rf_protect而没执行Rf_unprotect时,保护栈就会膨胀直至溢出。这类问题的通用解法是:检查压栈和弹栈是否配平,递归函数是否真的在向基线条件收敛。
5.5 一个完整的顺序栈测试用例模板
写栈实现的时候,我建议你保留一套最基础的测试用例,每次改代码都跑一遍,避免回归。下面是一个极简测试思路。
SeqStack s; initStack(&s, 4); assert(emptyStack(&s)); push(&s, 1); push(&s, 2); assert(topStack(&s) == 2); pop(&s); assert(topStack(&s) == 1); destroyStack(&s);这套模板的价值在于,能最快地暴露“扩容后数据丢失”“top指针错位”“空栈判断错误”这几类问题。等你把测试跑通,再去刷题或写应用就踏实多了。
我个人在实际操作中最大的体会是:栈这种结构,代码写起来几十行,但几乎所有危险都藏在“指针指向哪里”和“内存何时释放”这两件事上。顺序栈的扩容、链式栈的节点释放,每写错一行,都会在很晚才会暴露。所以动手写之前,先把你选择的“栈顶语义”固定下来,然后所有函数都统一用它。这里再分享一个小技巧:如果你实现的栈要在多个函数间共享,最好把栈的指针传入函数,而不是当作全局变量。全局变量在中小项目里看似方便,但一旦多线程运行,就成了无穷尽的纠缠源头。把栈的生命周期交给调用方管理,才是 C 语言工程里更稳妥的选择。