☰
链栈与代码复用:单链表API实现栈操作及括号匹配
2026/10/14 10:44:54 网站建设 项目流程

简介:面向数据结构初学者的链栈专题学习文档,围绕“头歌数据结构链栈的基本操作及应用”整理,适合正在练习栈与链表交叉知识、需要代码参考的人群。全包仅有1个docx文件,大小约15KB,无需复杂解压,便于快速阅读,目前已有7874人学习使用。文档从链表基本概念入手,说明链栈以链表尾部为栈顶、插入删除高效的特性;随后给出LNode结构与LinkStack类型定义,系统讲解初始化、销毁、清空、判空、求长、取栈顶、入栈、出栈、遍历九类操作,并区分销毁与清空、插入与删除的细节差异。代码示例中还有SElemType、input/output等辅助函数,main函数完整演示了压栈、遍历、弹栈、查空、取顶、求长、清空与销毁的调用流程;括号匹配、表达式求值、递归非递归实现等经典应用场景的说明,也能帮助读者将链栈知识迁移到实际算法中,是一份可读写、可练手的小型学习资料。

1. 头歌链栈这道题:考的不是栈,是代码复用

头歌平台上「数据结构链栈的基本操作及应用」这个实训,表面上是让你实现一个链式栈,实际上是在考一件事:你懂不懂把已经写好的单链表操作,原封不动地挪过来当栈用。代码里那几行GetTop、Push、Pop,每行都只调用了链表的一个既有函数,没有任何新算法。这道题真正的门槛,是你能不能看穿栈和单链表在结构上的同构关系。链栈适合两类人:一类是正在刷数据结构作业、需要快速跑通实训的学生;另一类是工作里要用 C/C++ 写嵌入式或底层逻辑、需要自己维护一套链表式容器的工程师。读完之后你能独立手写链栈全套操作,也能用链栈去解决括号匹配这一类经典应用问题,踩坑点我会在后面的章节里逐个点名。

2. 栈顶就是链表头:链栈的底层结构与选型理由

2.1 结构体定义:为什么链栈结点长成单链表的样子

先看代码里这个定义:

typedef int SElemType; struct LNode { ElemType data; LNode *next; }; typedef LNode *LinkList; typedef LinkList LinkStack;

SElemType是栈元素的类型,这里被定义成int,也就是说这个栈只能存整数。LNode结构体里只有两个成员:data存数据,next存下一个结点的地址,这是标准的单链表结点。关键在于typedef LinkList LinkStack这一行——链栈类型和链表的头指针类型是同一个东西,栈顶结点就是链表的首元结点,栈顶指针就是链表的头指针。

这个设计带来的直接结论是:所有针对「不带头结点的单链表头部」写的操作,都可以直接当成栈操作来用。因为栈的入栈、出栈本来就只发生在栈顶,而栈顶恰好对应链表的头部,无需遍历到尾部。如果当初把栈定义成「带头结点」的链表,Push和Pop就得在头结点之后插入删除,逻辑会绕一层。不带头结点,插入删除都直接在头指针所指位置进行,栈顶操作的时间复杂度稳定在 O(1),这是链栈作为动态栈最舒服的地方。

2.2 为什么不用数组实现栈:动态扩容与内存碎片

很多初学者会问:栈不是用数组也能做吗?顺序栈确实更常见,考试也爱考,但链栈的存在价值在于避开两个数组绕不过去的坑。第一个坑是固定容量:数组栈必须预先指定MAXSIZE,元素多了溢出,少了浪费。链栈的容量只受堆内存大小限制,Push的时候临时malloc一个结点就行,理论上可以无限增长。

第二个坑是扩容代价。数组栈满了之后做扩容,要么一次性重新分配一大块内存并memcpy,要么分段复制,中间一旦失败,旧数据可能丢失。链栈不存在这个问题,它每次只分配一个结点。当然代价也有:每个结点额外吃 4 到 8 字节的指针开销(64 位系统下 8 字节),而且结点散落在堆里,对缓存不友好。如果你的应用场景是元素数量固定、操作频繁且对性能极其敏感,顺序栈更合适;如果元素数量动态变化大、又不想提前预估容量,链栈是更稳妥的选择。

提示:链栈的精华在于「栈顶 = 链表头」这个等式成立后,你不需要重写任何逻辑,只需要把链表操作的函数名换成栈的语义。

3. 复用单链表 API:把 9 个链表操作压缩成 6 个栈操作

3.1 宏映射:#define是如何让栈操作直接继承链表操作的

代码中有一段极易被忽略的宏定义:

#define DestroyList ClearList #define InitStack InitList #define DestroyStack DestroyList #define ClearStack ClearList #define StackEmpty ListEmpty #define StackLength ListLength

这串#define做的事情是:让InitStack(s)在编译时被直接替换成InitList(s),让StackEmpty(s)替换成ListEmpty(s),依此类推。也就是说,栈的初始化、清空、判空、求长,四个操作一行代码都没新写。

这种做法的前提是两种结构的「头部语义完全一致」。InitList(L)做的事是把L置成NULL,对链栈来说,空栈就是栈顶指针为空,完全成立。ClearList(L)用循环释放每个结点,对链栈来说就是清空栈,同样成立。唯一需要留意的是DestroyList被定义成ClearList的别名,意味着「销毁」和「清空」在这里执行的是同一段逻辑——只释放结点内存,不释放栈结构体本身的内存。因为栈本身只是一个指针变量,不存在额外的管理结构,所以这个别名是安全的。如果栈有专门的头结点或管理块,就不能这样偷懒了。

使用这种宏映射,第一要求是参数个数完全一致:InitList(LinkList &L)接收一个引用参数,InitStack(LinkStack &S)也接收一个引用参数,宏替换后才能正常编译。第二要求是返回值类型一致:ListEmpty返回int,StackEmpty也返回int,调用方的判断逻辑才不会出问题。头歌这些题里,宏替换后的代码能直接跑通,就是靠这两个「一致性」兜底。

3.2 三个关键函数:GetTop、Push、Pop 的一行实现与背后逻辑

这是整个实训最核心的三个函数,直接贴代码:

int GetTop(LinkStack S, SElemType &e) { // 若栈不空,则用 e 返回 S 的栈顶元素,并返回 OK;否则返回 ERROR return GetElem(S, 1, e); } int Push(LinkStack &S, SElemType e) { // 插入元素 e 为新的栈顶元素 return ListInsert(S, 1, e); } int Pop(LinkStack &S, SElemType &e) { // 若栈不空,则删除 S 的栈顶元素,用 e 返回其值,并返回 OK;否则返回 ERROR return ListDelete(S, 1, e); }

三个函数的共同点是:都调用链表操作,且位置参数都写的1。GetElem(S, 1, e)取链表第 1 个结点的数据,也就是栈顶数据;取到就返回OK,取不到(空栈或i<1)就返回ERROR。GetTop不修改链表,所以参数S不用引用,传值即可,这也意味着它和出栈有本质区别——它只是「看一眼」栈顶。Push调用ListInsert(S, 1, e),在第 1 个位置之前插入新结点,也就是把新结点放到栈顶。ListInsert内部会先判断i<1返回ERROR,再malloc新结点,如果i==1就执行头插逻辑:s->next=L; L=s;。出栈则调用ListDelete(S, 1, e),删除第 1 个结点,让L指向原第 2 个结点,再把被删结点的数据写入e。

有个细节值得注意:Pop的形参是LinkStack &S,带引用符号,因为删除时会修改链表头指针本身;GetTop的形参是LinkStack S,不带引用,因为只读。初学者最容易犯的错就是把GetTop也写成&S,虽然编译能过,但语义上把只读操作搞成了可能修改指针的操作,后续读代码的人会困惑。

3.3 逆序遍历:StackTraverse 为什么一定要借助临时栈

遍历这个操作在数组栈里很简单,按下标从 0 到 top 循环即可。但在链栈里,遍历方向只能是顺着链表从栈顶向栈底走,而题目要求「从栈底到栈顶依次输出」,方向刚好相反。代码里的解法是:

void StackTraverse(LinkStack S, void(*visit)(SElemType)) { LinkStack temp, p = S; // p 指向栈顶元素 InitStack(temp); // 初始化临时栈 temp while (p) { Push(temp, p->data); // 由 S 栈顶到栈底,依次将栈元素入栈到 temp 栈 p = p->next; } ListTraverse(temp, visit); // 遍历 temp 线性表 }

逻辑分三步:第一步,用指针p从原栈栈顶向下走,依次把每个元素Push进临时栈temp。因为每次Push都是把元素压到temp的栈顶,所以原栈的栈顶元素会成为temp的栈底元素,原栈的栈底元素反而到了temp的栈顶。第二步,原栈遍历结束后,temp栈从栈顶到栈底的顺序,正好等于原栈从栈底到栈顶的顺序。第三步,ListTraverse(temp, visit)按链表顺序从temp的栈顶向栈底输出,最终得到的就是原栈从栈底到栈顶的正确序列。

这里有个性能问题需要说透:这个遍历做了两次 O(n) 的入栈操作,额外分配了一个临时栈的所有结点,空间复杂度 O(n)。如果你只是调试用,可以直接顺着链表输出,顺序是反的但数据是对的;但题目要求正序,就必须付出这个代价。还有一种不额外分配空间的方案——递归输出,利用函数调用栈充当逆序容器,但递归深度等于栈内元素个数,元素多时可能爆栈,而且头歌平台对递归改代码往往不友好,不如临时栈方案稳。

注意:StackTraverse结束后,临时栈temp的所有结点还残留在堆里。如果主程序反复调用这个遍历函数而不清理,内存会持续增长。头歌的测试数据量小,看不出问题,但你在自己的工程代码里用,遍历完必须追加DestroyStack(temp)。

4. 括号匹配实战:从栈运算到表达式校验

4.1 Match 函数的完整流程:左括号入栈、右括号匹配

链栈最经典的应用之一就是括号匹配。代码里Match(char exp[], int n)这个函数接收一个表达式字符串和它的长度,返回值 1 表示匹配,0 表示不匹配。核心逻辑是用栈维护「最近出现的未匹配左括号」:

int Match(char exp[], int n) { LinkStack st; InitStack(st); int flag = 1, i = 0; char ch; while (i < n && flag == 1) { switch (exp[i]) { case '(': case '[': case '{': Push(st, exp[i]); break; case ')': if (!Pop(st, ch) || ch != '(') flag = 0; break; case ']': if (!Pop(st, ch) || ch != '[') flag = 0; break; case '}': if (!Pop(st, ch) || ch != '{') flag = 0; break; } i++; } if (StackEmpty(st) && flag == 1) { DestroyStack(st); return 1; } else { DestroyStack(st); return 0; } }

整个函数是一个状态机:遇到左括号就无条件入栈,遇到右括号就先出栈一个字符与它比对。这里有一个很关键的设计:if (!Pop(st, ch) || ch != '(')这一句把两种情况合并处理。第一种情况,Pop返回ERROR,说明栈是空的,右括号没有对应的左括号,直接判定不匹配;第二种情况,Pop成功,但弹出来的字符不是匹配的左括号,说明括号类型错位,比如(],同样判定不匹配。跳出循环后还需检查StackEmpty(st),这是为了处理(()这类左括号多于右括号的情况——遍历完了栈里还剩左括号,说明有括号没闭合,返回 0。两个条件同时满足,即循环中没有发现错误且栈为空,才返回 1。

这段代码还有一个容易被忽略的优点:它在两个 return 之前都调用了DestroyStack(st)。原因是栈里可能残留着未匹配的左括号结点,如果不释放就直接返回,会内存泄漏。头歌的内存检查虽然宽,但你自己写代码时养成「函数出口必清理」的习惯,能省掉很多调试时间。

4.2 display 函数与 main 的整合:从输入到判定的最短路径

display和main的代码很短,但承担着承上启下的作用:

void display(char exp[]) { int n = strlen(exp); if (Match(exp, n)) printf("%s 是匹配的表达式\n", exp); else printf("%s 不是匹配的表达式\n", exp); } int main() { char str[80]; scanf("%s", str); display(str); return 0; }

输入用scanf("%s", str),意味着表达式里不能有空格,否则%s会在空格处截断。比如输入( [ ] )这种带空格的表达式,实际进到Match里的只有(,结果必然误判。头歌的测试数据通常不带空格,所以能过,但你在自己的分词器里如果这么写,就会踩坑。正确做法是用gets或fgets读取整行,然后手动过滤空白字符。

另一个细节:main里数组长度是 80,表达式的长度超过 79 就会溢出,这在平台测试里通常不会出现,但真实场景中,用户输入一行很长的逻辑表达式是常有的事。稳妥的做法是fgets(str, sizeof(str), stdin),并检查最后一个字符是否为换行符来判断是否被截断。这个小问题正好说明了实训代码和工程代码的差距——平台关注的是算法正确性,工程关注的是输入边界。

4.3 顺手验证:把 Match 函数和手推用例对上

写完括号匹配,我建议你立刻拿三个用例心算一遍,验证逻辑和自己的理解一致。第一个用例():遍历到(入栈,遍历到)出栈比对成功,循环结束,StackEmpty(st)为真,返回 1。第二个用例([)]:(入栈,[入栈,遇到)出栈得到[,与)不匹配,flag 置 0,循环提前结束,返回 0。第三个用例(():两个(入栈,遇到)出栈一个(匹配成功,循环结束但栈里还剩一个(,StackEmpty为假,返回 0。手推一遍之后,你对Pop的「取出即删除」语义、StackEmpty的「最后防线」作用,都会有更直观的感觉。

5. 链栈常见问题排查:三个翻车现场与修复方案

5.1 现场一:Push 之后遍历栈,发现栈还是空的

现象:手动测试时,Push(s, 1)后紧接着调用StackTraverse(s, output),终端什么也不输出,或者输出为空。

原因:最常见的是Push的形参写成了LinkStack S而不是LinkStack &S。Push内部调用了ListInsert,头插成功后修改了链表头指针L,但如果S是传值,函数结束后对指针的修改不会带回到调用方,主程序的s还是NULL,栈自然还是空的。另一种可能:ListInsert返回了ERROR,而代码里忽略了返回值。比如i传成了 2 而不是 1,插入位置指向了空结点的后面,插入失败。

解决:检查Push的形参表,确认是LinkStack &S。检查调用时位置参数恒为 1。加入返回值检查,如果Push返回ERROR,打印错误信息后立即终止程序,避免带着未入栈的数据继续往下走。例如:

if (!Push(s, e)) { printf("Push failed\n"); exit(1); }

5.2 现场二:Pop 之后 e 的值是乱码或旧值

现象:对一个空栈调用Pop(s, e),程序没有崩溃,但e输出的值不确定,有时是 0,有时是垃圾值。

原因:ListDelete在删除位置不合理时返回ERROR,根本不会把数据写入e。此时e保留的是上一次调用遗留下来的值。如果e是局部变量且从未初始化,它的值就是栈上的随机垃圾数据。问题不在ListDelete,而在调用方没有检查Pop的返回值就使用e。

解决:出栈之前先判空,或检查返回值。标准写法:

if (Pop(s, e)) { printf("弹出的栈顶元素 e=%d\n", e); } else { printf("栈为空,无法出栈\n"); }

这个问题的本质是 C/C++ 函数没有「未定义值」的运行时检查,必须靠调用方主动防御。链栈代码本身没有错,错在默认Pop一定会成功。

5.3 现场三:括号匹配把[}判成了匹配成功

现象:输入[},Match返回 1,明显错误。

原因:一个是case没有正确配对。如果代码里case '['的分支中不小心写成了if (!Pop(st, ch) || ch != '('),比对的是(而不是[,就会出错。另一种可能是switch里case '['和case '{'之间漏了break,导致[走完自己的逻辑后又穿透到{的处理里。

解决:逐个检查case内的比对字符是否与case的括号类型一致。检查每个case末尾是否有break。第 4 章那段代码里,三个右括号各自独立break,不存在穿透问题,但你在头歌平台自己改代码时,很容易在复制粘贴过程中漏掉一行。遇到这类问题,最快的排查方式是写一段三行测试代码,分别测()、[]、{},再测(]、[}、{(,看输出是否符合预期。

5.4 现场四:StackTraverse 输出的顺序反了

现象:依次入栈 1、2、3,遍历输出得到的是 3 2 1,而题目要求从栈底到栈顶输出为 1 2 3。

原因:StackTraverse没有使用临时栈,直接顺着链表从头到尾遍历了。链表的首元结点是栈顶,遍历出来的顺序天然是栈顶到栈底。题目要求从栈底到栈顶,所以方向相反。

解决:恢复第 3.3 节的方案,用临时栈做一次倒序。注意在函数末尾要把临时栈销毁。如果你在头歌平台看到输出是3 2 1而不是1 2 3,十有八九是这里写成了直接遍历,而不是借助临时栈倒序。

5.5 现场五:GetTop 调用一次之后,栈顶元素消失了

现象:GetTop(s, e)之后,再调用StackTraverse(s, output),发现原来的栈顶元素不见了。

原因:把GetTop的实现写成了return Pop(S, e)。Pop会删除栈顶结点,GetTop的本意是「只看不删」。两者行为完全不同。

解决:确认GetTop内部调用的必须是GetElem(S, 1, e),不是ListDelete。这是概念题,考的就是你是否能区分「读取栈顶」和「弹出栈顶」两个操作。读完本章第 3.2 节后,你应该能立刻发现这行代码的问题所在。

6. 复现代码的验证方法:三个维度确定链栈真的写对了

头歌这类平台判题是「输入一组测试数据,比对输出」,但你自己复现代码时,不能只依赖平台的测试用例。我习惯用三个维度的测试来验证链栈实现是否正确。

第一个维度是边界测试:空栈上执行所有操作。初始化后立刻调用StackEmpty应返回 1;调用GetTop应返回ERROR;调用Pop应返回ERROR;调用StackTraverse应输出空行或什么都不输出。这些用例能验证ListEmpty、GetElem、ListDelete在表空时的行为是否符合预期。第二个维度是单元素测试:压入一个元素,遍历,出栈,再遍历。重点观察第一次进出栈后链表指针是否正确复位,L是否回到了NULL状态。第三个维度是混合压力测试:连续压入 10 个元素,再间断式出栈 3 个,再压入 2 个,最后清空。这个过程中每一步都打印栈的长度和栈顶元素,与手推结果比对。运行完这三组测试,链栈的每个函数基本都被覆盖到了。

主程序本身还有个工程细节值得注意:头歌的main在DestroyStack(s)后就结束了,没有暂停窗口。你在本地跑的时候,控制台会一闪而过,看不到输出结果。我一般会在return 0前加一行system("pause")或getchar(),这是在实训代码基础上做最小改动就能提升调试体验的习惯。如果是 Linux 或 macOS 环境,用getchar()更稳妥,因为system("pause")是 Windows 特有的命令。

括号匹配的测试用例也建议按梯度准备:()、[]、{}三种独立成对的情况;([]){}这种嵌套且合法的情况;([)]、(()、())三种典型非法情况;还有空字符串的情况——空字符串应返回 1,因为空表达式天然匹配。把这七个用例跑完,Match函数的逻辑漏洞基本无处遁形。记得所有测试结束后,用DestroyStack释放残留结点,再借助 Valgrind(Linux)或 CRT 库的内存泄漏检测(Windows)确认没有泄漏。从那以后,我每次拿到这类「复用既有数据结构实现新结构」的代码,都会先做一层「语义映射」检查——确认每个新函数背后复用的是哪个旧函数、参数是否一致、修改指针时是否传了引用,然后才敢把代码提交到平台。希望帮到你。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询