ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

顺序栈与链式栈:C语言实现原理与工程实践全解析

顺序栈与链式栈:C语言实现原理与工程实践全解析 栈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 括号匹配、表达式求值与浏览器的后退按钮除了底层系统栈的经典应用还有三个括号匹配、逆波兰表达式求值、浏览器后退功能。括号匹配是栈最直观的应用。扫描字符串遇到左括号就入栈遇到右括号就弹出栈顶并检查是否匹配。如果扫描过程中栈提前为空或者扫描结束时栈还有剩余说明括号不匹配。这个算法在编译器的语法分析阶段大量使用。表达式求值有两个方向中缀表达式转后缀后缀表达式求值。转换过程用栈保存运算符求值过程用栈保存操作数。比如(12)*3转换成后缀123*遇到数字入栈遇到运算符弹出两个数运算后把结果入栈最终栈顶就是答案。浏览器的后退按钮也是一个栈。你每访问一个新页面就压栈点击后退就是弹出栈顶再点前进就需要另一个栈来保存被弹出的页面。两个栈配合就还原出一个完整的浏览记录。这种场景特别适合用链式栈因为页面数量不确定而且内存动态分配比扩容数组更自然。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-checkfull ./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 语言工程里更稳妥的选择。
返回列表