在线咨询 400-826-1668
回到顶部
ARTICLE DETAIL

资讯详情

深耕国风建站与运营引流的一线实战洞察。

链栈实现原理与C语言实战:从数据结构到表达式求值

链栈实现原理与C语言实战:从数据结构到表达式求值 1. 从“栈”到“链栈”为什么我们需要另一种实现在C语言里学数据结构数组实现的顺序栈通常是第一个接触的栈结构。它直观、简单用一块连续的内存空间配合一个栈顶指针或索引就能搞定。但写过几个项目后你大概率会遇到一个尴尬的场景程序跑得好好的突然就“栈溢出”了。这往往不是因为递归太深而是你声明的那个固定大小的数组栈在某个未曾预料到的业务高峰下被撑爆了。你可能会想那我一开始就把数组栈的容量MAXSIZE定义得巨大无比比如10000不就行了这确实能解决一部分问题但带来了新的浪费在绝大多数平静的业务时段这块巨大的内存就被闲置了对于内存资源紧张的嵌入式环境或追求极致的性能场景这是不可接受的。这就是链栈登场的核心动机。链栈顾名思义就是用链表来实现栈。它不再依赖一块预先分配好的、固定大小的连续内存而是像珍珠项链一样将一个个节点每个节点存储数据和指向下一个节点的指针动态地串联起来。栈顶就是链表的头节点。入栈就是在链表头部插入一个新节点出栈就是删除并返回头节点。内存是随用随申请malloc不用就释放free。理论上只要系统内存足够链栈的容量就是“无限”的。听起来很美对吧但天下没有免费的午餐。链栈的每个节点都需要额外的指针域来存储地址这带来了空间开销。同时动态内存管理申请和释放本身也有时间成本。所以链栈和顺序栈的选择从来不是谁替代谁而是典型的“空间换时间”和“时间换空间”思想的又一次具体交锋。当你无法预估数据量的上限或者对内存的使用效率要求极高即希望内存用量严格贴合当前数据量时链栈就是更优解。反之如果数据规模明确且可控顺序栈的连续内存访问带来的缓存友好性和操作速度则更具优势。2. 链栈的蓝图结构定义与核心操作接口在动手写代码之前我们必须把链栈的“图纸”画清楚。一个链栈节点需要包含两部分数据域和指针域。数据域存放我们真正关心的信息可以是整型、字符、结构体甚至是指向复杂数据的指针。指针域则存储下一个节点的内存地址将节点链接起来。在C语言中我们这样定义节点typedef int SElemType; // 为方便起见假设栈元素为整型实际可替换为任意类型 typedef struct StackNode { SElemType data; // 数据域 struct StackNode *next; // 指针域指向下一个节点 } StackNode;有了节点链栈本身怎么表示链栈只需要一个“栈顶指针”就够了。这个指针指向链表也就是我们的栈的第一个节点。当栈为空时这个指针指向NULL。typedef struct LinkStack { StackNode *top; // 栈顶指针 int count; // 栈中元素个数非必须但强烈建议添加 } LinkStack;这里我强烈建议加入一个count成员来记录栈的长度。虽然遍历链表也能得到长度但时间复杂度是O(n)。维护一个count变量我们就能在O(1)时间内获取栈的大小这在很多算法判断中非常有用代价仅仅是每次入栈出栈时多一个加减操作非常划算。接下来我们定义链栈必须实现的几个核心操作接口这构成了我们后续所有工作的基础初始化(InitStack): 创建一个空的链栈即让栈顶指针top指向NULL计数器count置为0。入栈(Push): 在栈顶插入一个新元素。这对应着在链表头部插入一个新节点。出栈(Pop): 删除栈顶元素并返回其值。这对应着删除链表头节点。取栈顶元素(GetTop): 仅获取栈顶元素的值不删除它。这对应着访问链表头节点的数据域。判空(StackEmpty): 判断栈是否为空。只需检查top是否为NULL或count是否为0。求长度(StackLength): 返回栈中元素个数。直接返回count成员。销毁栈(DestroyStack): 释放链栈占用的所有内存。这需要遍历整个链表逐个释放节点最后将栈顶指针置NULL计数器归零。3. 从零构建链栈的初始化、入栈与出栈实现理论清晰了我们开始动手实现最关键的几个操作。我会在代码中穿插大量注释解释每一步的意图和注意事项。3.1 初始化操作为链栈“奠基”初始化操作的目标是创建一个逻辑上为空的链栈。在顺序栈中我们可能是分配一个数组。在链栈中我们只需要准备好那个指向栈顶的指针。// 初始化链栈 Status InitStack(LinkStack *S) { if (S NULL) { return ERROR; // 传入的栈指针无效 } S-top NULL; // 栈顶指针置空表示空栈 S-count 0; // 元素个数初始化为0 return OK; }注意这里的Status、OK、ERROR通常是自定义的返回值类型和状态码例如typedef int Status;和#define OK 1、#define ERROR 0。这能让函数返回值语义更清晰。你也可以直接用int返回0/1或者用bool。3.2 入栈操作在链表头部“加盖”入栈是链栈最核心的操作之一其本质是在链表头部插入一个新节点。这个过程可以分解为三步1. 造新节点2. 新节点指向原栈顶3. 栈顶指针指向新节点。// 元素入栈 Status Push(LinkStack *S, SElemType e) { // 1. 参数检查 if (S NULL) { return ERROR; } // 2. 为新节点申请内存 StackNode *new_node (StackNode *)malloc(sizeof(StackNode)); if (new_node NULL) { // 内存申请失败通常是系统内存不足 printf(Memory allocation failed!\n); return ERROR; } // 3. 填充新节点数据 new_node-data e; // 4. 将新节点插入链表头部 new_node-next S-top; // 新节点的next指向原来的栈顶 // 5. 更新栈顶指针和计数器 S-top new_node; // 栈顶指针现在指向新节点 S-count; // 栈内元素数量加1 return OK; }为什么要在头部插入这是由栈“后进先出”的特性决定的。栈顶是唯一允许操作的位置。如果我们像普通链表那样在尾部插入那么每次入栈都需要遍历整个链表找到尾部时间复杂度是O(n)。而在头部插入我们只需要操作S-top这个指针时间复杂度是O(1)效率极高。这也是链栈相比某些实现方式的优势所在。3.3 出栈操作从链表头部“拆除”出栈是入栈的逆过程即删除链表头节点并返回其存储的数据。关键点在于必须先保存要返回的数据和下一个节点的地址再释放当前节点。// 元素出栈 Status Pop(LinkStack *S, SElemType *e) { // 1. 参数检查和栈空判断 if (S NULL || e NULL) { return ERROR; } if (StackEmpty(S)) { // 假设StackEmpty函数已实现 printf(Stack is empty, cannot pop!\n); return ERROR; } // 2. 保存待出栈节点的数据和其后继节点地址 StackNode *p S-top; // p指向当前栈顶节点 *e p-data; // 通过指针e将栈顶数据返回给调用者 // 3. 更新栈顶指针 S-top p-next; // 栈顶指针指向原栈顶的下一个节点 // 4. 释放原栈顶节点内存 free(p); p NULL; // 良好习惯释放后指针置NULL防止“野指针” // 5. 更新计数器 S-count--; return OK; }这里有一个非常经典的坑很多初学者会先S-top S-top-next;然后再用*e S-top-data;。仔细想想这时候S-top已经指向了新的栈顶原第二个节点你取到的数据是第二个节点的数据而不是被删除的第一个节点的数据这就造成了逻辑错误。所以一定要在修改栈顶指针之前把待删除节点的数据保存下来。4. 进阶操作、内存管理与经典应用场景实现了核心的增删之后链栈的其他操作就相对简单了。4.1 取栈顶、判空与销毁// 获取栈顶元素不删除 Status GetTop(LinkStack *S, SElemType *e) { if (S NULL || e NULL || StackEmpty(S)) { return ERROR; } *e S-top-data; // 直接读取头节点数据 return OK; } // 判断栈是否为空 Status StackEmpty(LinkStack *S) { // 两种判断方式等价任选其一。使用count判断可能更快。 // return (S NULL) ? ERROR : (S-top NULL); return (S NULL) ? ERROR : (S-count 0); } // 获取栈长度 int StackLength(LinkStack *S) { if (S NULL) { return 0; } return S-count; // O(1)时间复杂度维护count的优势体现 }4.2 销毁操作避免内存泄漏的关键链栈的内存是动态申请的使用完毕后必须手动销毁这是C语言程序员的基本素养。销毁操作需要遍历整个链表。// 销毁链栈 Status DestroyStack(LinkStack *S) { if (S NULL) { return ERROR; } StackNode *p S-top; StackNode *q NULL; // 用于临时保存下一个节点地址 // 遍历链表逐个释放节点 while (p ! NULL) { q p-next; // 在释放p之前先记住它的下一个节点 free(p); // 释放当前节点 p q; // p移动到下一个节点 } // 重置栈结构体状态 S-top NULL; S-count 0; // 注意这里没有 free(S); 因为栈结构体本身可能不是动态申请的。 // 如果S也是malloc来的调用者需要在DestroyStack后free(S)。 return OK; }重要提示销毁函数的实现揭示了链式结构内存管理的一个通用模式你需要一个临时指针q来保存下一个节点的地址。因为一旦free(p)执行p所指向的内存就被系统回收p-next就变成了非法访问。这个错误非常隐蔽会导致程序崩溃。4.3 链栈的经典应用场景理解了链栈的实现我们来看看它在哪里能大显身手。任何具有“后进先出”特性的场景栈都是天然的数据结构。函数调用栈这是栈最广为人知的应用。每次调用函数系统都会将返回地址、局部变量、参数等压入一个栈中。函数返回时再从栈顶弹出这些信息恢复到调用者的上下文。链栈的思想在这里以系统栈的形式体现。表达式求值如中缀转后缀、计算后缀表达式栈用于处理运算符的优先级。例如计算3 5 * 2你需要先将5和2压栈遇到乘法运算符时弹出计算再将结果压栈最后处理加法。括号匹配检查编译器检查代码中的括号(),[],{}是否成对出现。遍历字符串遇到左括号就入栈遇到右括号就出栈并检查是否匹配。最后栈应为空。浏览器的前进/后退功能可以看作是两个栈后退栈和前进栈的协同工作。点击新页面将当前页压入后退栈清空前进栈。点击后退从后退栈弹出并压入前进栈显示弹出的页面。深度优先搜索在图和树的遍历中DFS通常使用递归或显式的栈来实现。链栈的动态特性在这里非常有用因为你无法预知搜索的深度。撤销操作许多编辑软件如文本编辑器、绘图软件的撤销功能就是将用户的操作历史压入一个栈中。执行撤销时就从栈顶弹出最近的操作并反向执行。5. 实战用链栈实现一个简单的表达式求值器为了将理论付诸实践我们来实现一个能计算后缀表达式逆波兰表达式的求值器。后缀表达式没有括号运算符在操作数之后非常适合用栈来求值。例如中缀表达式(3 4) * 5对应的后缀表达式是3 4 5 *。算法思路从左到右扫描后缀表达式字符串。遇到操作数数字将其转换为整数后压入链栈。遇到运算符则从栈中弹出两个操作数注意顺序先弹出的是右操作数后弹出的是左操作数进行运算。将运算结果压回栈中。重复步骤1-4直到表达式结束。最后栈中应只剩下一个元素即为最终结果。#include stdio.h #include stdlib.h #include ctype.h // 用于isdigit函数 #include string.h // ... 此处插入之前定义的链栈所有代码StackNode, LinkStack, InitStack, Push, Pop等... // 假设 SElemType 为 int // 辅助函数判断字符是否为运算符 int isOperator(char c) { return (c || c - || c * || c /); } // 辅助函数执行运算 int calculate(int a, int b, char op) { switch (op) { case : return a b; case -: return a - b; // a是左操作数b是右操作数 case *: return a * b; case /: if (b 0) { printf(Error: Division by zero!\n); exit(EXIT_FAILURE); } return a / b; default: printf(Error: Unknown operator %c\n, op); exit(EXIT_FAILURE); } } // 核心函数计算后缀表达式 int evaluatePostfix(const char* expression) { LinkStack S; InitStack(S); // 初始化一个链栈 int i 0; int num 0; int operand1, operand2, result; while (expression[i] ! \0) { // 跳过空格 if (expression[i] ) { i; continue; } // 情况1处理多位数字 if (isdigit(expression[i])) { num 0; while (isdigit(expression[i])) { num num * 10 (expression[i] - 0); // 将字符转换为整数 i; } Push(S, num); // 数字入栈 } // 情况2处理运算符 else if (isOperator(expression[i])) { // 检查栈中是否有至少两个操作数 if (StackLength(S) 2) { printf(Error: Invalid postfix expression.\n); DestroyStack(S); exit(EXIT_FAILURE); } Pop(S, operand2); // 先弹出的是右操作数 Pop(S, operand1); // 后弹出的是左操作数 result calculate(operand1, operand2, expression[i]); Push(S, result); // 计算结果入栈 i; } else { printf(Error: Invalid character %c in expression.\n, expression[i]); DestroyStack(S); exit(EXIT_FAILURE); } } // 表达式处理完后栈中应恰好剩下一个元素结果 if (StackLength(S) ! 1) { printf(Error: Invalid postfix expression.\n); DestroyStack(S); exit(EXIT_FAILURE); } Pop(S, result); // 弹出最终结果 DestroyStack(S); // 销毁栈释放内存 return result; } int main() { // 测试用例后缀表达式 3 4 5 * 等价于 (34)*5 35 // 测试用例后缀表达式 10 2 8 * 3 - 等价于 10 (2*8) - 3 23 const char* expr1 3 4 5 *; const char* expr2 10 2 8 * 3 -; printf(Postfix Expression: %s\n, expr1); printf(Result: %d\n\n, evaluatePostfix(expr1)); printf(Postfix Expression: %s\n, expr2); printf(Result: %d\n, evaluatePostfix(expr2)); return 0; }这个例子完整地展示了链栈从定义、实现到应用的全过程。你将看到Push和Pop如何动态地管理操作数栈以及链栈如何优雅地处理未知长度的计算过程。你可以尝试输入更复杂的后缀表达式来测试它。6. 避坑指南链栈开发中的常见问题与调试技巧即便理解了原理亲手实现链栈时还是会遇到各种问题。下面是我在多年实践中总结的几个典型“坑”和应对策略。6.1 内存泄漏动态分配的“隐形杀手”这是链式结构最常犯也最难查的错误。症状是程序运行时间长了内存占用越来越大最终可能被系统终止。根源malloc和free没有成对出现。常见于只写了Push里的malloc忘了在Pop或DestroyStack里写free。在某个错误处理的分支return了但之前malloc的内存没有释放。栈使用完后没有调用DestroyStack。排查与预防成对编程写下每一个malloc时立刻思考它应该在何处被free。使用工具在Linux/macOS下可以使用valgrind工具检测内存泄漏。在Windows下Visual Studio的调试器也有内存诊断功能。防御性销毁在可能提前退出的函数中如Pop遇到空栈确保已分配的资源在返回前被正确释放或状态被重置。6.2 野指针与悬垂指针指向“虚无”的灾难free(p)之后指针p本身并不会变成NULL它仍然保存着那个已经释放的内存地址。这就是“野指针”。如果后续不小心又通过p去访问或修改内存行为是未定义的极可能导致程序崩溃或数据损坏。解决方案free(p); p NULL; // 释放后立即置空这是一个必须养成的好习惯。这样即使后续误用了p因为对NULL指针的解引用通常会立刻导致段错误能让你快速定位问题而不是让错误潜伏。6.3 栈顶指针更新的顺序错误正如在Pop操作中强调的必须先保存数据再修改栈顶指针。这个顺序一旦颠倒就会取错数据或丢失对节点的引用导致内存泄漏。在编写Push时也要注意new_node-next S-top;必须在S-top new_node;之前执行。6.4 空栈判断遗漏在任何尝试访问栈顶元素的操作前Pop,GetTop都必须先检查栈是否为空。直接访问S-top-data或S-top-next会导致对NULL指针的解引用程序崩溃。最佳实践将空栈检查封装成一个独立的、健壮的StackEmpty函数并在所有相关操作中调用它。6.5 多线程环境下的竞争条件如果你的链栈需要在多线程程序中被共享访问那么简单的Push和Pop操作不是线程安全的。两个线程可能同时修改S-top造成数据错乱或丢失。解决方案需要使用互斥锁mutex或信号量semaphore等同步机制来保护对栈的访问确保同一时间只有一个线程能执行修改栈结构的操作。这是一个更高级的话题但在设计可复用库时必须考虑。调试链栈一个非常有效的方法是可视化。在关键操作入栈、出栈前后打印出栈的当前状态。你可以写一个简单的PrintStack函数从S-top开始遍历链表打印每个节点的数据和next指针的值可以用%p格式打印地址。亲眼看到指针是如何被修改、节点是如何链接的比在脑子里空想要清晰得多。
返回列表