ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

链栈C语言实现详解:从结构设计到完整代码与避坑指南

链栈C语言实现详解:从结构设计到完整代码与避坑指南

1. 链栈到底解决了什么问题

栈这个结构,学过数据结构的人都不陌生,先进后出,像一摞盘子,你只能从最上面拿。但真到写代码的时候,很多人第一反应是用数组去模拟栈,也就是顺序栈。顺序栈确实简单,一个数组加一个栈顶指针就完事了,但它有个绕不开的硬伤:容量是固定的。你开100个元素的数组,存到101个就溢出;你开10000个,大部分时候又浪费内存。这个矛盾在嵌入式或者资源受限的场景里特别扎眼。

链栈就是冲着这个痛点来的。它用链表来组织栈元素,栈顶就是链表的头节点,每次入栈相当于在头部插入一个节点,出栈就是把头部节点摘掉。这样一来,栈的容量只受限于系统可用内存,理论上可以一直增长,不需要预先估计规模。而且链栈的入栈和出栈都只操作头节点,时间复杂度稳定在O(1),不存在顺序栈扩容时整体搬移数据的开销。

这篇文章我打算把链栈的完整C语言实现从头到尾捋一遍,包括初始化、判栈空、入栈、出栈、取栈顶元素、求栈长这六个基本操作。代码是可直接编译运行的完整版本,不是伪代码。适合正在学数据结构的学生、准备面试的开发者,以及需要手写一个轻量级栈结构的嵌入式工程师。我会把每个操作背后的指针变化画清楚,把容易踩的坑标出来,最后给一份可以直接抄的完整代码。

2. 链栈的结构设计与核心思路拆解

2.1 为什么选带头节点的单链表

链栈的实现方式其实有好几种,可以用带头节点的单链表,也可以不用头节点,甚至可以用双向链表。我选的是带头节点的单链表,栈顶固定在头节点之后。这个选择不是随便定的,背后有几个实际考量。

第一,带头节点之后,入栈和出栈的操作逻辑变得统一。不管栈里有没有元素,入栈永远是“在头节点后面插入新节点”,出栈永远是“删除头节点后面的节点”。不需要单独判断“栈为空时入栈”和“栈非空时入栈”两种情况,代码分支少了一半。

第二,头节点本身不存数据,它只是一个锚点。这样栈顶指针永远指向头节点,不会因为出栈到空栈就变成野指针。你想想,如果不带头节点,出栈最后一个元素之后,栈顶指针就变成NULL了,下次入栈又得重新处理NULL的情况,代码里到处是if判断,容易出错。

第三,求栈长的时候,从头节点开始遍历,逻辑清晰。虽然求栈长是O(n)的操作,但链栈本来就不适合频繁求长度,这个后面会讲优化方案。

typedef int ElemType; // 元素类型,按需修改 typedef struct StackNode { ElemType data; // 数据域 struct StackNode *next; // 指针域 } StackNode, *LinkStackPtr; typedef struct { LinkStackPtr top; // 栈顶指针,始终指向头节点 int count; // 栈元素个数,可选,用于O(1)求栈长 } LinkStack;

这里我多加了一个count字段。严格来说,链栈求栈长可以遍历链表来数,但那是O(n)。加一个计数器,入栈加一,出栈减一,求栈长就是O(1)。多占4个字节,换一个高频操作的性能提升,这笔账划算。当然,如果你在极端省内存的场景,可以把count去掉,用遍历的方式求长度。

2.2 栈顶到底放在链表的哪一头

这个问题初学者容易搞混。链栈的栈顶应该放在链表的头部,不是尾部。原因很简单:单链表在头部插入和删除都是O(1),在尾部插入虽然是O(1)(如果有尾指针),但删除尾部节点需要找到前驱,是O(n)。栈的核心操作是入栈和出栈,都发生在栈顶,所以栈顶必须放在操作最快的那一端,也就是头部。

用生活化的类比:链表就像一列火车,头部是车头。栈顶放在车头位置,每次加车厢或者摘车厢都在车头操作,你一眼就能看到,动手也快。如果栈顶放在车尾,每次摘最后一节车厢,你还得从头走到尾找到倒数第二节,费时费力。

2.3 内存管理策略:谁分配,谁释放

链栈的每个节点都是动态分配的,用malloc申请,用free释放。这里有个原则:入栈时分配节点,出栈时释放节点。初始化的时候只分配头节点,头节点在销毁栈的时候统一释放。

我见过不少人在出栈时只把栈顶指针往下移,不free被摘掉的节点,结果内存泄漏。也见过有人在入栈时复用已释放的节点,导致野指针。这两个极端都要避免。正确的做法是:出栈时先用一个临时指针保存要删除的节点,把栈顶指针移到下一个节点,然后free临时指针指向的节点。

注意:free之后要把指针置NULL,虽然在这个场景里临时指针马上就不用了,但养成习惯能避免很多悬空指针的问题。

3. 六个基本操作的完整实现与细节剖析

3.1 初始化:头节点从哪来

初始化链栈就做一件事:创建一个头节点,让栈顶指针指向它,并把next置为NULL,count置为0。

int InitStack(LinkStack *S) { S->top = (LinkStackPtr)malloc(sizeof(StackNode)); if (S->top == NULL) { return 0; // 内存分配失败 } S->top->next = NULL; S->count = 0; return 1; // 初始化成功 }

这里返回int而不是void,是为了让调用者知道初始化是否成功。malloc在内存不足时会返回NULL,虽然现代系统上很少发生,但在嵌入式环境里这是必须处理的。我试过在一个内存只有几十KB的板子上跑,malloc失败是家常便饭,不检查返回值程序直接就崩了。

头节点的data域我们不关心,不初始化也没关系,但有些静态分析工具会报“未初始化变量”的警告,所以稳妥起见可以给data赋个0。不过这不是必须的,看你的编译选项严格程度。

3.2 判栈空:为什么不能只看top是否为NULL

判栈空的逻辑很简单:头节点的next是否为NULL。如果next是NULL,说明头节点后面没有元素,栈为空。

int IsEmpty(LinkStack *S) { return (S->top->next == NULL); }

这里要特别强调:不能判断S->top是否为NULL。因为我们的设计是带头节点的,S->top永远指向头节点,初始化之后就不是NULL了。如果你判断S->top == NULL,那永远返回假,栈空判断就失效了。这是带头节点链表最容易犯的错误,没有之一。

那什么时候S->top会是NULL呢?只有一种情况:初始化失败,malloc返回了NULL。这种情况下整个栈都是无效的,判栈空也没有意义。所以判栈空的前提是栈已经成功初始化。

3.3 入栈:头插法的标准姿势

入栈就是在头节点后面插入一个新节点。步骤分三步:申请新节点、填入数据、调整指针。

int Push(LinkStack *S, ElemType e) { LinkStackPtr newNode = (LinkStackPtr)malloc(sizeof(StackNode)); if (newNode == NULL) { return 0; // 内存分配失败,入栈失败 } newNode->data = e; newNode->next = S->top->next; // 新节点指向原来的栈顶元素 S->top->next = newNode; // 头节点指向新节点 S->count++; return 1; }

指针调整的顺序很关键。必须先把新节点的next指向原来的栈顶元素,再让头节点的next指向新节点。如果顺序反了,先执行S->top->next = newNode,那原来的栈顶元素就丢了,链断了。这个顺序问题在纸上画一下指针图就清楚了,但写代码的时候一着急就容易写反。

我用一个具体的例子走一遍。假设栈里已经有元素3和7,栈顶是3。头节点->3->7->NULL。现在入栈5:

  1. 申请新节点,data=5。
  2. newNode->next = S->top->next,newNode->next指向3。此时链表是:头节点->3->7->NULL,newNode->5->3->7->NULL(newNode还没接入)。
  3. S->top->next = newNode,头节点->5->3->7->NULL。入栈完成。

实操心得:入栈失败时要不要返回错误码?我的建议是要。虽然大多数时候malloc不会失败,但一旦失败,调用者需要知道这次入栈没成功。如果Push返回void,调用者以为入栈成功了,后续逻辑全乱。返回int,0表示失败,1表示成功,调用者可以决定是重试还是报错。

3.4 出栈:别忘了free

出栈是把头节点后面的第一个节点摘掉,取出它的数据,然后释放内存。

int Pop(LinkStack *S, ElemType *e) { if (IsEmpty(S)) { return 0; // 栈空,出栈失败 } LinkStackPtr temp = S->top->next; // 临时保存要删除的节点 *e = temp->data; // 取出数据 S->top->next = temp->next; // 头节点跳过temp free(temp); // 释放节点内存 temp = NULL; // 避免悬空指针 S->count--; return 1; }

出栈有三个关键点。第一,出栈前必须判空,空栈出栈是非法操作,直接返回失败。第二,用temp保存要删除的节点,因为调整指针之后你就找不到它了,没法free。第三,free之后把temp置NULL,虽然temp是局部变量马上销毁,但这是一个好习惯,能防止后续误用。

出栈的参数用ElemType *e而不是返回值,是因为返回值要用来表示成功或失败。C语言没有异常机制,只能通过返回码加输出参数的方式来传递数据和状态。这是C语言接口设计的常见模式,你会在很多标准库和开源项目里看到。

3.5 取栈顶元素:只读不删

取栈顶元素和出栈的区别在于:取栈顶元素只读取数据,不删除节点,栈的结构不变。

int GetTop(LinkStack *S, ElemType *e) { if (IsEmpty(S)) { return 0; // 栈空,取栈顶失败 } *e = S->top->next->data; return 1; }

这个操作很简单,但要注意判空。空栈取栈顶元素是没有意义的,必须返回失败。有些实现会返回一个默认值,比如0或者-1,但这样调用者无法区分“栈顶元素就是0”和“栈空返回的0”。用返回码加输出参数的方式,语义最清晰。

3.6 求栈长:遍历还是计数器

求栈长有两种实现方式。第一种是遍历链表数节点:

int StackLength(LinkStack *S) { int len = 0; LinkStackPtr p = S->top->next; while (p != NULL) { len++; p = p->next; } return len; }

第二种是直接返回count字段:

int StackLength(LinkStack *S) { return S->count; }

两种方式结果一样,但性能差很多。遍历是O(n),计数器是O(1)。如果你的程序频繁求栈长,比如在循环里判断栈是否达到某个阈值,那必须用计数器。如果只是偶尔求一次,遍历也够用,还能省4个字节。

我个人的选择是保留count字段。原因很简单:链栈的入栈和出栈本来就是O(1),加一个计数器不增加时间复杂度,只是常数级开销。而求栈长从O(n)降到O(1),在需要频繁判断栈大小的场景里提升明显。这4个字节花得值。

注意:如果你选择用count字段,那入栈和出栈时必须同步更新count,不能漏。漏更新一次,后续所有求栈长的结果都是错的,而且这种bug很难查,因为栈的入栈出栈逻辑本身是对的,只是长度不对。

4. 完整代码与编译运行实录

4.1 完整代码清单

把上面的片段拼起来,加上测试用的main函数,就是一份可以直接编译运行的完整代码。

#include <stdio.h> #include <stdlib.h> typedef int ElemType; typedef struct StackNode { ElemType data; struct StackNode *next; } StackNode, *LinkStackPtr; typedef struct { LinkStackPtr top; int count; } LinkStack; /* 初始化 */ int InitStack(LinkStack *S) { S->top = (LinkStackPtr)malloc(sizeof(StackNode)); if (S->top == NULL) { return 0; } S->top->next = NULL; S->count = 0; return 1; } /* 判栈空 */ int IsEmpty(LinkStack *S) { return (S->top->next == NULL); } /* 入栈 */ int Push(LinkStack *S, ElemType e) { LinkStackPtr newNode = (LinkStackPtr)malloc(sizeof(StackNode)); if (newNode == NULL) { return 0; } newNode->data = e; newNode->next = S->top->next; S->top->next = newNode; S->count++; return 1; } /* 出栈 */ int Pop(LinkStack *S, ElemType *e) { if (IsEmpty(S)) { return 0; } LinkStackPtr temp = S->top->next; *e = temp->data; S->top->next = temp->next; free(temp); temp = NULL; S->count--; return 1; } /* 取栈顶元素 */ int GetTop(LinkStack *S, ElemType *e) { if (IsEmpty(S)) { return 0; } *e = S->top->next->data; return 1; } /* 求栈长 */ int StackLength(LinkStack *S) { return S->count; } /* 销毁栈 */ void DestroyStack(LinkStack *S) { LinkStackPtr p = S->top; LinkStackPtr temp; while (p != NULL) { temp = p; p = p->next; free(temp); } S->top = NULL; S->count = 0; } /* 测试 */ int main() { LinkStack S; ElemType e; int i; if (!InitStack(&S)) { printf("初始化失败\n"); return 1; } printf("初始化后栈长: %d\n", StackLength(&S)); printf("栈是否为空: %s\n", IsEmpty(&S) ? "是" : "否"); for (i = 1; i <= 5; i++) { Push(&S, i * 10); printf("入栈 %d, 当前栈长: %d\n", i * 10, StackLength(&S)); } if (GetTop(&S, &e)) { printf("当前栈顶元素: %d\n", e); } while (!IsEmpty(&S)) { Pop(&S, &e); printf("出栈 %d, 剩余栈长: %d\n", e, StackLength(&S)); } printf("全部出栈后是否为空: %s\n", IsEmpty(&S) ? "是" : "否"); DestroyStack(&S); return 0; }

4.2 编译与运行结果

用gcc编译,命令如下:

gcc -o linkstack linkstack.c -Wall -g

-Wall打开所有警告,-g保留调试信息。编译如果没有警告,说明代码质量基本过关。运行结果:

初始化后栈长: 0 栈是否为空: 是 入栈 10, 当前栈长: 1 入栈 20, 当前栈长: 2 入栈 30, 当前栈长: 3 入栈 40, 当前栈长: 4 入栈 50, 当前栈长: 5 当前栈顶元素: 50 出栈 50, 剩余栈长: 4 出栈 40, 剩余栈长: 3 出栈 30, 剩余栈长: 2 出栈 20, 剩余栈长: 1 出栈 10, 剩余栈长: 0 全部出栈后是否为空: 是

结果符合预期:入栈顺序是10、20、30、40、50,出栈顺序是50、40、30、20、10,典型的后进先出。栈长在入栈时递增,出栈时递减,取栈顶元素返回的是最后入栈的50。

4.3 内存泄漏检测

在Linux下可以用valgrind检测内存泄漏:

valgrind --leak-check=full ./linkstack

如果代码正确,valgrind会报告“All heap blocks were freed -- no leaks are possible”。这说明所有malloc的节点都被free了,没有内存泄漏。我强烈建议每次写完涉及动态内存的代码都跑一遍valgrind,很多隐藏的内存问题只有它能发现。

实操心得:valgrind在嵌入式环境里跑不了,但你可以用计数器法。在全局维护一个malloc_count,每次malloc加一,每次free减一,程序结束时如果malloc_count不为0,说明有泄漏。这个方法简单粗暴,但很有效。

5. 常见问题与排查技巧实录

5.1 段错误:最常见也最好查

段错误是链栈代码最常见的崩溃原因,没有之一。我总结了几种典型场景和排查方法。

场景一:初始化失败后继续操作。如果InitStack返回0,说明malloc失败,S->top是NULL。这时候你调用Push,执行S->top->next,直接访问NULL指针,段错误。解决办法是检查InitStack的返回值,失败就退出。

场景二:出栈时没判空。空栈时S->top->next是NULL,你执行temp->data,temp是NULL,段错误。解决办法是出栈前必须调用IsEmpty判断。

场景三:free之后继续使用。出栈时free了temp指向的节点,但后面又通过其他指针访问这个节点,段错误。解决办法是free之后把指针置NULL,并且确保没有其他指针指向这个节点。

排查段错误最有效的工具是gdb。编译时加-g,运行gdb ./linkstack,然后run,崩溃后输入bt查看调用栈,能直接定位到出错的行号。如果是在嵌入式环境,可以用printf大法,在关键位置打印指针地址,看哪个指针是NULL或者非法值。

5.2 栈空判断失效:带头节点的陷阱

前面提过,带头节点的链栈,判栈空必须判断S->top->next == NULL,不能判断S->top == NULL。我见过有人写成:

int IsEmpty(LinkStack *S) { return (S->top == NULL); // 错误! }

这样写的话,初始化之后S->top指向头节点,不是NULL,所以IsEmpty永远返回0(假),栈空判断完全失效。后果是出栈时不判空,直接操作空栈,段错误。

这个bug的隐蔽性在于:初始化之后程序运行正常,入栈出栈都没问题,只有出栈到空栈再出栈时才崩溃。如果你测试的时候没有测“空栈出栈”这个边界情况,就发现不了。

5.3 内存泄漏:出栈忘记free

出栈时只调整指针不free,是内存泄漏的经典原因。代码长这样:

int Pop(LinkStack *S, ElemType *e) { if (IsEmpty(S)) return 0; *e = S->top->next->data; S->top->next = S->top->next->next; // 只调整指针,没free S->count--; return 1; }

这样写逻辑上没问题,出栈的数据也对,但被摘掉的节点内存永远回收不了。如果程序长时间运行,反复入栈出栈,内存会持续增长,最终耗尽。用valgrind一跑就能发现,报告里会显示“definitely lost”的字节数等于节点大小乘以出栈次数。

5.4 常见问题速查表

问题现象可能原因排查方法解决方案
段错误,崩溃在Push栈未初始化或初始化失败检查InitStack返回值初始化失败时不要继续操作
段错误,崩溃在Pop空栈出栈在Pop开头加IsEmpty判断出栈前必须判空
栈空判断永远为假判断了S->top == NULL打印S->top和S->top->next改为判断S->top->next == NULL
内存持续增长出栈未free节点用valgrind检测出栈时free被摘节点
栈长不正确count未同步更新检查Push和Pop中的count操作入栈count++,出栈count--
取栈顶元素返回错误值空栈取栈顶检查GetTop是否判空空栈时返回失败码

5.5 独家避坑技巧

第一个技巧:画图再写代码。链栈的指针操作,在纸上画三个节点,标出头节点、栈顶节点、新节点,把指针箭头画清楚,再写代码。我刚开始学的时候觉得自己脑子能记住,结果指针顺序写反了好几次,后来老老实实画图,一次就对了。

第二个技巧:边界测试必做。空栈出栈、空栈取栈顶、连续入栈出栈、入栈到很大数量再全部出栈,这四个场景必须测。很多bug只在边界情况出现,正常流程测不出来。

第三个技巧:用assert辅助调试。在Push和Pop里加assert(S != NULL && S->top != NULL),能提前发现非法调用。发布版本可以用#define NDEBUG关掉assert,不影响性能。

第四个技巧:count字段用unsigned int。栈长不可能是负数,用unsigned int更语义化。但要注意,如果出栈时count已经是0还减,会变成很大的正数。所以出栈前判空是必须的,判空之后count一定大于0,减一是安全的。

6. 链栈与顺序栈的选型对比

6.1 性能与内存的权衡

链栈和顺序栈没有绝对的优劣,关键看场景。我把两者的核心差异整理成表格:

对比维度链栈顺序栈
容量限制仅受内存限制固定容量,需预分配
入栈时间复杂度O(1)平均O(1),扩容时O(n)
出栈时间复杂度O(1)O(1)
取栈顶O(1)O(1)
求栈长O(1)(带count)或O(n)O(1)
内存开销每节点多一个指针域无额外开销,但可能浪费预分配空间
缓存友好性差,节点分散在堆上好,连续内存
实现复杂度稍复杂,涉及动态内存简单,数组加指针

顺序栈的缓存友好性是它最大的优势。数组是连续内存,CPU缓存命中率高,实际运行速度往往比链栈快,尽管时间复杂度一样。链栈的节点是malloc出来的,散落在堆的各个位置,缓存命中率低,每次访问都可能触发缓存未命中。

但链栈的容量弹性是顺序栈比不了的。如果你不确定栈的最大深度,或者栈的深度波动很大,链栈更合适。顺序栈要么开小了溢出,要么开大了浪费。

6.2 什么场景选链栈

根据我的经验,以下几种场景优先选链栈:

场景一:栈深度不可预测。比如解析表达式时,括号嵌套深度取决于输入,可能很深也可能很浅。用链栈不用担心溢出。

场景二:内存碎片化的环境。嵌入式系统运行久了,内存碎片化严重,大块连续内存很难申请。链栈每次只申请一个节点,对连续内存要求低,更容易分配成功。

场景三:需要频繁入栈出栈且深度变化大。链栈的入栈出栈不涉及数据搬移,深度变化时没有额外开销。顺序栈在扩容时要搬移所有元素,深度频繁变化时开销明显。

反过来,如果栈深度已知且固定,比如函数调用栈,那顺序栈更合适,简单高效。如果对性能要求极高且深度可控,顺序栈的缓存优势能带来实际的速度提升。

6.3 一个折中方案:动态数组栈

如果你既想要顺序栈的缓存友好性,又想要链栈的弹性容量,可以考虑动态数组栈。底层还是数组,但容量不够时用realloc扩容,通常是翻倍扩容。这样入栈平均O(1),缓存友好性也保留了大半。

typedef struct { ElemType *data; int top; int capacity; } DynArrayStack; int Push(DynArrayStack *S, ElemType e) { if (S->top == S->capacity) { int newCap = S->capacity * 2; ElemType *newData = (ElemType *)realloc(S->data, newCap * sizeof(ElemType)); if (newData == NULL) return 0; S->data = newData; S->capacity = newCap; } S->data[S->top++] = e; return 1; }

这个方案在大多数场景下比纯链栈更实用。但它的缺点是扩容时需要搬移数据,而且realloc可能失败。链栈的每个节点独立分配,不存在整体搬移的问题。选哪个,看你的具体需求。

7. 从链栈延伸出去的知识点

7.1 链栈与递归的关系

栈和递归是天然绑定的。函数调用本身就是用栈来实现的,每次调用压入一个栈帧,返回时弹出。你写递归函数的时候,系统在背后维护了一个调用栈。如果递归深度太大,系统栈溢出,程序崩溃。

链栈可以用来模拟递归。比如二叉树的非递归遍历,就是用显式的栈来替代系统的调用栈。你把原本递归函数里的局部变量和返回地址打包成一个结构体,压入链栈,循环处理。这样递归深度不受系统栈限制,只受堆内存限制。

我试过把一个深度上万层的递归改成链栈模拟,系统栈版本直接段错误,链栈版本跑得好好的。当然,改成非递归之后代码复杂度上升不少,可读性下降,这是代价。

7.2 链栈在表达式求值中的应用

表达式求值是栈的经典应用。中缀表达式转后缀表达式,以及后缀表达式的计算,都需要栈。链栈在这里的优势是容量弹性,因为表达式的嵌套深度取决于输入,可能很深。

以中缀转后缀为例,算法需要两个栈:一个存操作数,一个存运算符。遇到操作数直接输出,遇到运算符根据优先级决定是压栈还是弹出。括号的处理也需要栈。用链栈实现,不用担心表达式太长导致栈溢出。

7.3 链栈的线程安全问题

标准链栈不是线程安全的。多个线程同时入栈或出栈,指针操作会竞争,导致链表断裂或数据丢失。如果需要在多线程环境使用链栈,必须加锁。

最简单的方案是用互斥锁保护整个栈:

pthread_mutex_t lock = PTHREAD_MUTEX_INITIALIZER; int Push(LinkStack *S, ElemType e) { pthread_mutex_lock(&lock); // ... 入栈逻辑 ... pthread_mutex_unlock(&lock); return 1; }

加锁的代价是性能下降,每次入栈出栈都要加锁解锁。如果并发量不大,这个开销可以接受。如果并发量很大,可以考虑无锁栈,用CAS原子操作实现。但无锁编程复杂度高,容易出bug,除非性能要求极高,否则不建议自己写。

注意:加锁的粒度要控制好。锁的范围太大,并发度低;锁的范围太小,可能保护不全。对于链栈,整个入栈或出栈操作应该是一个原子操作,锁的范围要覆盖指针调整的全过程。

7.4 链栈的持久化

有时候你需要把栈的内容保存到文件,下次启动时恢复。链栈的持久化很简单:遍历链表,把每个节点的data写入文件。恢复时按顺序读取,依次入栈。

但要注意顺序。写入时是从栈顶到栈底,恢复时如果按写入顺序入栈,栈顶就变成栈底了。正确的做法是写入时从栈底到栈顶,或者恢复时反向读取。我建议写入时从栈底到栈顶,这样恢复时顺序入栈,栈顶还是原来的栈顶。

void SaveStack(LinkStack *S, const char *filename) { FILE *fp = fopen(filename, "wb"); if (fp == NULL) return; // 先写入栈长 fwrite(&S->count, sizeof(int), 1, fp); // 从栈底到栈顶写入,需要先反转或者用递归 // 简单做法:用临时数组保存,反向写入 ElemType *temp = (ElemType *)malloc(S->count * sizeof(ElemType)); LinkStackPtr p = S->top->next; int i = S->count - 1; while (p != NULL) { temp[i--] = p->data; p = p->next; } fwrite(temp, sizeof(ElemType), S->count, fp); free(temp); fclose(fp); }

这段代码先把栈元素倒序存入临时数组,再写入文件。恢复时顺序读取,依次入栈,栈顶就是原来的栈顶。临时数组的额外内存开销是O(n),对于大栈可能是个问题,但持久化本来就是低频操作,可以接受。

8. 我个人的实操体会

链栈这个结构,代码量不大,但指针操作密集,是练习指针和动态内存的好素材。我刚开始学的时候,觉得带头节点的设计多此一举,直接用一个top指针指向栈顶元素不就行了。后来写多了才发现,带头节点省去了大量边界判断,代码更简洁,出错概率更低。那一个头节点的内存开销,换来的是代码的健壮性,非常划算。

另一个体会是count字段的价值。教科书上的链栈求栈长通常是遍历,因为教科书关注的是算法本身,不考虑工程优化。但实际项目中,求栈长往往是高频操作,O(n)的遍历会成为性能瓶颈。加一个count字段,O(1)求长度,这是工程思维和学术思维的区别。

最后说说出栈时的free。我见过太多人忘记free,包括我自己早期也犯过这个错。内存泄漏在短时间运行的程序里看不出来,但长时间运行的服务里,内存会一点点被吃掉,最终OOM。养成“谁分配谁释放”的习惯,每个malloc都要有对应的free,这是C语言程序员的基本功。

如果你把这份代码敲一遍,编译运行,再用gdb和valgrind过一遍,链栈的指针操作和内存管理就算入门了。接下来可以试试用链栈实现表达式求值,或者把链栈改成线程安全版本,这些都是很好的进阶练习。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进