栈的两个经典应用:括号匹配与表达式求值
栈这名字听起来挺简单——一摞盘子后放先取。但真把它用在问题里最经典的莫过于两个场景括号匹配和表达式求值。这两个问题几乎出现在每一本数据结构的教材里也出现在每一场笔试和面试中。这篇我打算把这两块掰开揉碎从思路到代码到数据结构的细节选择到调试时踩过的坑一次性讲清楚。适合正在学数据结构的学生也适合想补一补算法基础的开发者。栈不是只有“先进后出”这一个概念它是一整套思考问题的方式。1. 整体设计思路为什么匹配类问题天生适合用栈1.1 栈的核心特征解决“最近相关性”问题栈的本质是一种受限的线性表只允许在一端插入和删除。这个限制看起来是缺点却恰好造就了它的强项处理数据中具有“最近相关性”的结构。什么叫最近相关性举个例子。你在看一段HTML代码看到一个div接着又有嵌套的span最后是/span和/div。哪个闭合标签应该先和哪个开始标签配对显然是最近的那个。这和你把文件放进文件夹再取出是一个道理后放进去的先处理。括号匹配就是最近相关性的典型场景。一个左括号出现后不一定立刻匹配——它可能在等后面中间夹着的一堆括号先配对完最后才轮到它。这种“先出现的最后配对”关系恰好就是栈的后进先出特性。所以当你分析一个问题时只要发现其中隐藏着“后到的先处理、先到的后收尾”的规律基本上栈就是合适的结构。这种识别能力比记住几个API更重要。1.2 顺序栈与链栈的选择栈的实现方式主要有两种顺序栈数组实现和链栈链表实现。很多初学者觉得这东西随便选一个就行其实差别不小。顺序栈在内存中连续存储通过一个栈顶指针标记当前位置。它的优点是访问快、空间利用率高缺点是栈的大小在创建时就要确定如果预估不准可能出现溢出。不过实际开发中栈的深度通常有明确上限所以这点可以接受。链栈用链表节点存储数据每个节点包含数据和指向下一个节点的指针。它的优点是不用担心容量缺点是每个节点额外占用指针空间并且节点分散在内存各个位置缓存不友好。我在实际学习中更推荐先用顺序栈原因很简单代码简洁、便于调试、性能直观。教材里的大部分题目也是基于顺序栈讲解的。等你把顺序栈的原理吃透了再去看链栈的实现就是顺手的事。下面给出两种结构的定义方便对比// 顺序栈定义 #define MAX_SIZE 100 typedef struct { int data[MAX_SIZE]; // 这里以int为例 int top; // 栈顶下标空栈为-1 } SeqStack; // 链栈节点定义 typedef struct Node { int data; struct Node* next; } LinkStackNode; typedef struct { LinkStackNode* top; // 栈顶指针 int count; // 节点数量 } LinkStack;1.3 栈的基本操作的实现细节不管哪种实现栈的基础操作都逃不过这五个初始化、判断是否为空、判断是否已满、压栈、出栈。还有两个常用变体取栈顶元素不弹出和清空栈。我先写一套顺序栈的核心操作。这套代码我建议你抄下来反复读因为它虽然简单但包含了大量后续代码的基础逻辑。#include stdio.h #include stdlib.h #include stdbool.h #define MAX_SIZE 100 typedef struct { char data[MAX_SIZE]; int top; } CharStack; // 初始化栈顶指针置为-1 void initStack(CharStack* s) { s-top -1; } // 判空栈顶为-1即为空 bool isEmpty(CharStack* s) { return s-top -1; } // 判满栈顶到达最大下标 bool isFull(CharStack* s) { return s-top MAX_SIZE - 1; } // 压栈先移动指针再写入数据 bool push(CharStack* s, char c) { if (isFull(s)) { printf(栈满无法入栈\n); return false; } s-top 1; s-data[s-top] c; return true; } // 出栈先取出数据再移动指针 bool pop(CharStack* s, char* c) { if (isEmpty(s)) { printf(栈空无法出栈\n); return false; } *c s-data[s-top]; s-top - 1; return true; } // 取栈顶元素只读不弹 bool peek(CharStack* s, char* c) { if (isEmpty(s)) { return false; } *c s-data[s-top]; return true; }这里有一个细节值得注意push和pop的参数设计。我在初学的时候习惯写void push(CharStack* s, char c)不返回任何值。后来在实战中发现如果栈满了或者栈空了静默失败会留下隐患。更好的做法是让push和pop返回bool值调用方可以根据返回值判断操作是否成功。这种设计思路在高阶的数据结构实现中会反复用到。还有一个细节是top指针的处理。很多人容易搞混有的写法是top初始化为0数据从data[1]开始放有的写法是top初始化为-1数据从data[0]开始放。这两种都是对的但我更推荐-1这种写法因为它在逻辑上更贴近“空栈”的概念且在使用数组下标时不容易出现越界。2. 括号匹配问题的完整实现2.1 问题描述与匹配算法括号匹配的问题是给定一个字符串里面包含( )、[ ]、{ }三种括号判断括号的使用是否正确。正确的定义有两个层面一是左右括号数量一样多二是括号的嵌套顺序正确。数量相等是必要不充分条件。字符串([)]中四种括号数量都对但顺序是错的——(应该等]后面的)吗不是。正确的匹配应该是([ ])这样嵌套的而不是交叉的。用栈解决这个问题的算法很经典只有三步遍历字符串遇到左括号就压入栈。遇到右括号先检查栈是否为空。如果为空说明当前右括号没有对应的左括号匹配失败。如果不为空弹出栈顶元素检查是否和当前的右括号配对。遍历结束后如果栈为空说明所有左括号都得到了匹配否则说明还有未闭合的左括号。为什么遇到右括号只需要检查栈顶因为栈顶是最近一个未匹配的左括号。如果它都不能配对那更早的左括号就更不可能和当前右括号配对了。配对检查的逻辑可以用一个辅助函数处理bool isMatch(char left, char right) { if (left ( right )) return true; if (left [ right ]) return true; if (left { right }) return true; return false; }2.2 完整代码与逐步演算下面给出一个单函数的版本把所有逻辑都在一个函数里做完。这样方便初学者一次看清流程#include stdio.h #include stdbool.h #include string.h #define MAX_SIZE 100 // 省略上一节中的栈操作函数 bool isMatch(char left, char right) { if (left ( right )) return true; if (left [ right ]) return true; if (left { right }) return true; return false; } bool checkBrackets(const char* str) { CharStack stack; initStack(stack); int len strlen(str); for (int i 0; i len; i) { char c str[i]; if (c ( || c [ || c {) { if (!push(stack, c)) { return false; // 栈满了直接判定失败 } } else if (c ) || c ] || c }) { if (isEmpty(stack)) { printf(右括号多余: %c at index %d\n, c, i); return false; } char top; pop(stack, top); if (!isMatch(top, c)) { printf(括号不匹配: %c 与 %c\n, top, c); return false; } } // 其他字符直接忽略 } if (!isEmpty(stack)) { char top; peek(stack, top); printf(左括号多余: %c\n, top); return false; } return true; } int main() { const char* test1 {[()]}; const char* test2 ([)]; const char* test3 (((); printf(%s - %s\n, test1, checkBrackets(test1) ? 匹配 : 不匹配); printf(%s - %s\n, test2, checkBrackets(test2) ? 匹配 : 不匹配); printf(%s - %s\n, test3, checkBrackets(test3) ? 匹配 : 不匹配); return 0; }我们手动走上一个例子{[()]}第1个字符{左括号入栈。栈{第2个字符[左括号入栈。栈{ [第3个字符(左括号入栈。栈{ [ (第4个字符)右括号弹出栈顶(匹配栈{ [第5个字符]右括号弹出栈顶[匹配栈{第6个字符}右括号弹出栈顶{匹配栈为空。全部通过匹配成功。再看([)]第1个字符(入栈。栈(第2个字符[入栈。栈( [第3个字符)弹出栈顶[和)比对不匹配立即返回false。问题就出在这里——[还没闭合)却提前出现了这属于交叉嵌套。用栈来识别这个问题非常高效一个栈顶元素的对比就发现了。2.3 复杂度的分析这个算法的时间复杂度是O(n)空间复杂度是O(n)。n是字符串长度。时间上每个字符最多被处理一次左括号入栈一次右括号出栈一次其余的比对都是O(1)操作因此总操作次数和字符串长度成正比。空间上最极端的情况是字符串全是左括号栈中需要存储n个元素因此空间消耗也是线性的。这两个复杂度对这个算法来说几乎是最优的——你不可能少于O(n)的时间因为每个字符至少要读一遍才能判断你也不可能少于O(n)的最坏空间因为如果右括号从不出现你不保存所有左括号就无法匹配。所以栈结构在这个问题上的时间空间效率都是天花板。2.4 边界情况的讨论判断一个算法是否严谨不是看它在正常输入下多顺利而是看它在边界情况下会不会出bug。我经常在初学者的代码里发现这么几类遗漏空串。空串应该返回true因为没有什么需要匹配的。上面的代码在len为0时直接跳过循环栈为空返回true正确。字符串中只有左括号比如(((。遍历结束后栈不为空判定失败正确。而且还能告诉你具体是哪个左括号没匹配上。字符串中只有右括号比如)))。遍历时遇到第一个右括号时栈为空直接返回false正确显示右括号多余。字符串中包含无关字符比如字母、数字、符号。处理方式取决于需求。如果你是在校验一段代码那么可以把非括号字符跳过如果是要严格校验也可以直接返回false。上面的代码默认跳过。有一种情况需要特别小心字符串非常长比如几百万个括号。这时如果栈的容量不够就会溢出。我上面的代码用了固定大小的数组在栈满时返回false。这种方式能防止越界但逻辑上等于把所有长输入的括号匹配都判定为失败严格来说不算正确匹配。如果你要处理长字符串建议改用动态扩容的顺序栈数据结构课程中通常称为动态顺序表也就是数组不够用时就重新分配一块更大的内存。实际工程中还可以直接把栈的类型换成链式结构没有容量上限。3. 表达式求值问题的完整实现3.1 中缀表达式与后缀表达式的关系表达式求值比括号匹配高了一个台阶。先说清楚这里的术语。我们平时写的3 4 * 5是中缀表达式运算符写在两个操作数的中间。这种写法对人很友好但对计算机不友好因为必须考虑运算符优先级和括号。为了处理优先级你得维护很多规则。后世的解决方案是借助后缀表达式也叫逆波兰表达式。后缀表达式的特点是运算符写在操作数后面比如中缀的3 4写成后缀是3 4 。更华丽一点的例子里名题(3 4) * 5 - 6对应的后缀表达式是3 4 5 * 6 -。后缀表达式的一大好处是它不需要括号也不需要优先级规则只要按顺序扫描见到操作数就压栈见到运算符就弹出两个操作数计算再把结果压回去一遍就做完。整个求值过程完全线性。因此表达式求值问题被拆成两个阶段阶段一把中缀表达式转换为后缀表达式。阶段二计算后缀表达式的值。两个阶段都依赖栈。这也是为什么这个题目能成为数据结构的经典应用题——它把一个复杂问题分解为两个栈的巧妙使用。3.2 中缀转后缀的规则与实现中缀转后缀的算法一般称为调度场算法。核心思想是运算符不是一读到就输出而是要先和栈顶的运算符比较优先级。优先级高或相等的先输出优先级低的先压栈等待。括号则完全改变这一过程。具体规则如下读到数字直接输出到后缀表达式。读到左括号无条件压入运算符栈。读到右括号把运算符栈顶的运算符依次弹出并输出直到碰到左括号为止然后把左括号弹出丢弃。读到普通运算符如 - * /只要运算符栈不空且栈顶不是左括号且栈顶运算符的优先级大于等于当前运算符的优先级就弹出栈顶并输出然后当前运算符入栈。这里有一个初学者容易混淆的细节什么时候用“大于”什么时候用“大于等于”我的建议是采用“大于等于”弹出。原因是一个表达式如果是左结合的比如a - b - c实际含义是(a - b) - c。处理到第二个减号时栈顶的减号和当前减号优先级相同先弹出栈顶减号再压入当前减号这样在计算时能保证从左到右的顺序。如果用单纯“大于”那么a - b - c可能会被转换为a b c - -计算顺序就变了结果会出问题。如果设计的语言是右结合性的则要用“大于”。不过C语言中的大多数运算符都是左结合所以用“大于等于”更符合通常的实现。下面给出转换的核心代码#include stdio.h #include stdlib.h #include string.h #include ctype.h #define MAX_EXPR 100 int precedence(char op) { if (op || op -) return 1; if (op * || op /) return 2; return 0; // 括号 } // 这个函数把后缀表达式放到out数组中 void infixToPostfix(const char* infix, char* out) { CharStack stack; initStack(stack); int len strlen(infix); int outIdx 0; for (int i 0; i len; i) { char c infix[i]; if (isspace(c)) { continue; } // 如果是数字或小数点读取整个数字并输出 if (isdigit(c) || c .) { while (isdigit(infix[i]) || infix[i] .) { out[outIdx] infix[i]; } out[outIdx] ; // 用空格分隔操作数 i--; // 因为for循环还会自增 } else if (c () { push(stack, c); } else if (c )) { char op; while (pop(stack, op) op ! () { out[outIdx] op; out[outIdx] ; } // 理论上循环结束时op是(已经被丢弃 } else if (c || c - || c * || c /) { char top; while (!isEmpty(stack) peek(stack, top) top ! ( precedence(top) precedence(c)) { pop(stack, top); out[outIdx] top; out[outIdx] ; } push(stack, c); } } // 表达式扫描结束把栈里剩余的运算符全部输出 char top; while (pop(stack, top)) { if (top ! () { out[outIdx] top; out[outIdx] ; } } out[outIdx] \0; }这个版本的代码我写得很刻意——输出操作数时在末尾加了空格。为什么要加空格因为如果操作数是多位数或者带小数点不加分隔符会导致后缀表达式无法区分12 3和123。空格在这里就是操作数之间的分隔符是我为后续求值环节故意留的接口。注意一个细节内层循环读取数字时用了while (isdigit(infix[i]) || infix[i] .)然后在出循环后i--。为什么需要这步因为在循环内部已经把i推进到了字符后但外面还有一个for循环的i如果不做这步补偿会跳过表达式里的一个字符。这种细小的下标问题在实际写代码时经常出现建议多打日志观察。转换过程在中缀表达式3 4 * 5上的行为是这样的读到3输出。后缀3读到栈空压栈。栈读到4输出。后缀3 4读到*栈顶是优先级1小于2不弹出直接压栈。栈 *读到5输出。后缀3 4 5表达式结束弹出所有运算符。输出*和。后缀3 4 5 * 完全没有问题。再看带上括号的(3 4) * 5 - 6读到(压栈。读到3输出。读到栈顶是(不弹出压栈。读到4输出。读到)弹出直到(输出。读到*此时栈空压栈。读到5输出。读到-栈顶*优先级2大于-的1弹出*然后-压栈。读到6输出。表达式结束弹出-。最终后缀表达式3 4 5 * 6 -正是前面提到的标准形式。3.3 后缀表达式求值的逐位演算有了后缀表达式计算就简单多了不需要再回头看优先级。操作流程是从左到右扫描后缀表达式。操作数压入操作数栈。遇到运算符弹出栈顶两个操作数先弹出的数作为右操作数后弹出的数作为左操作数计算后将结果压回栈。扫描结束后栈中剩下的唯一元素就是表达式的结果。这里面最容易踩的坑是弹出顺序。假设栈里从栈底到栈顶是a, b先弹出b再弹出a。如果运算符是减法或除法错误的顺序会导致结果错误。我见过不少同学在这一步把a - b写成了b - a整个结果就不对了。原因很简单后缀表达式中操作数的出现顺序是“左操作数在前右操作数在后”。栈的特性是先弹出的反而是后进入的所以必须先弹右操作数再弹左操作数。这在代码中通常写成double right popValue(stack); double left popValue(stack); switch (op) { case : pushValue(stack, left right); break; case -: pushValue(stack, left - right); break; case *: pushValue(stack, left * right); break; case /: pushValue(stack, left / right); break; }如果写反了3 4 -会算成1而不是-1。这个问题在考场上很常见我特别喜欢在代码审查时提醒别人。下面是一段可以直接运行的后缀求值代码typedef struct { double data[MAX_EXPR]; int top; } ValueStack; void initValueStack(ValueStack* s) { s-top -1; } bool pushValue(ValueStack* s, double val) { if (s-top MAX_EXPR - 1) return false; s-data[s-top] val; return true; } bool popValue(ValueStack* s, double* val) { if (s-top 0) return false; *val s-data[s-top--]; return true; } double evaluatePostfix(const char* postfix) { ValueStack stack; initValueStack(stack); int len strlen(postfix); for (int i 0; i len; i) { char c postfix[i]; if (isspace(c)) { continue; } if (isdigit(c) || c .) { char buffer[32]; int idx 0; while (isdigit(postfix[i]) || postfix[i] .) { buffer[idx] postfix[i]; } buffer[idx] \0; pushValue(stack, atof(buffer)); i--; } else if (c || c - || c * || c /) { double right, left; popValue(stack, right); popValue(stack, left); double result; switch (c) { case : result left right; break; case -: result left - right; break; case *: result left * right; break; case /: result left / right; break; } pushValue(stack, result); } } double final; popValue(stack, final); return final; }来看3 4 5 * 6 -的演算过程读3入栈。栈3读4入栈。栈3 4读弹出4和3计算347入栈。栈7读5入栈。栈7 5读*弹出5和7计算7*535入栈。栈35读6入栈。栈35 6读-弹出6和35计算35-629入栈。栈29最终结果是29。手动算一下原中缀表达式(3 4) * 5 - 6结果正是29算法成立。3.4 完整工程代码把两个阶段拼起来这里把中缀转后缀和后缀求值封装到一个演示程序里方便直接复现。#include stdio.h #include stdlib.h #include string.h #include ctype.h #define MAX_EXPR 200 // 这里放前面所有提到的栈操作函数、precedence函数、infixToPostfix函数、evaluatePostfix函数 // 由于篇幅原因省略重复声明实际运行时请按顺序粘贴 int main() { char infix[MAX_EXPR]; char postfix[MAX_EXPR]; printf(请输入中缀表达式支持 - * / 和括号: ); fgets(infix, MAX_EXPR, stdin); // 去掉fgets带来的换行符 infix[strcspn(infix, \n)] \0; infixToPostfix(infix, postfix); printf(后缀表达式: %s\n, postfix); double result evaluatePostfix(postfix); printf(计算结果: %f\n, result); return 0; }在我的实际测试中输入3 4 * ( 2 - 1 )时输出的后缀是3 4 2 1 - * 计算结果为7和预期一致。输入( 15 / 3 2 ) * 4时后缀是15 3 / 2 4 *计算结果为28也一致。需要提醒的是这个版本的代码没有做输入校验。如果用户输入包含非法字符比如字母或连续运算符程序可能崩溃或给出错误结果。一个更严谨的工程版本应该在转换阶段就检查语法但这会显著增加代码量。作为数据结构实验这个简化版本足以展示栈的核心应用但如果你要交付一个真正能用的计算器还需要补充语法检查与错误恢复机制。3.5 两种求值方案的真实对比除了转后缀再求值表达式求值还有一种思路使用两个栈同时进行一个栈存操作数一个栈存运算符在扫描过程中根据优先级决定是立即计算还是继续压栈。这种双栈法的好处是不需要额外生成后缀表达式字符串省了一点空间并且转换与求值同步完成在处理交互式输入时更自然。但从可读性角度看我更喜欢先转后缀再求值。因为两步走每一步只用解决一个相对独立的问题代码结构清晰出了故障也容易定位。双栈法把所有逻辑揉在每个字符的处理分支里代码往往更难看懂。实际项目中如果你要做一个计算器我建议用一个现成的表达式解析库比如在成熟语言里可以直接用自带的eval或表达式求值库不必自己从零写调度场。但学习阶段我强烈建议手写一遍因为这里面包含的栈、优先级处理、边界判断等技巧会出现在大量更高阶算法中比如编译原理中的语法分析器就和这个思路一脉相承。4. 调试经验与常见问题排查技巧4.1 栈操作中的空指针与越界问题在我初学的时候最痛苦的不是理解算法而是写出来的代码时不时崩溃。仔细分析后绝大多数崩溃集中在这几个位置一是出栈时没有检查栈是否为空。如果对空栈调用pop数组下标会变成-2、-3轻则乱读内存重则段错误。我常用的排查方式是在pop和peek里加打印每次操作后输出栈顶下标和当前栈内容。虽然效率不高但用在调试阶段非常有效。二是数组容量设置过小。如果你把MAX_SIZE设为50而表达式是100个字符那么压栈时就会误判为栈满。我建议在实验阶段直接把MAX_SIZE设得大一些比如200或1000免得容量问题干扰算法调试。等算法逻辑全部验证通过后再回头优化容量也不迟。三是忘记初始化栈。很多人定义了CharStack stack但没有调用initStack导致stack.top是一个随机值。第一次push的时候程序可能往data数组的随机位置写数据最后读到完全不可预期的结果。这类bug非常隐蔽因为运行时不一定立刻崩溃而是表现为输出偶尔正确、偶尔错误。排查方法是在main入口打个断点看看栈初始化是否执行了。4.2 运算符优先级与括号处理的经典错误表达式求值中最容易出错的细节有三个。第一个是优先级比较的方向写反了。如果用top c的优先级作为弹出条件会导致低优先级的运算符被先弹出最终后缀表达式顺序错误。比如输入1 2 * 3如果优先级判断出错转换出的后缀可能是1 2 3 * 或1 2 * 3 前者是正确的一倍后者是错误。排查时直接打印转换后的后缀表达式再人工验证一遍是否符合预期。第二个是右括号处理时没有弹出左括号。有些人的循环条件是while (!isEmpty top ! ()但循环结束后没有主动把左括号pop掉导致左括号残留在栈里后续的处理全乱。正确的做法是在while循环弹出元素后额外的pop操作把栈顶的左括号丢掉或者在while循环的条件里加一个约定——凡是右括号触发时循环里“弹出到左括号为止且包含左括号的弹出”。我的建议是单独一行代码明确pop掉左括号这样代码可读性最好。第三个是负数和一元运算符的问题。比如表达式-3 5中的-并不是减法运算符而是负号。上面的算法会对这个-按普通运算符处理导致出栈时操作数不足。如果要支持负数和一元运算符通常的做法是判断-是否出现在表达式开头或其前一个字符是运算符或左括号如果是就将-与紧跟的数字合并为一个负数操作数来处理。这个处理方式我在换行表达式求值工具时经常用到。4.3 性能问题与优化思路虽然教材题目一般不苛求性能但在真实应用中需要考虑。假如你要对一个百万字符的超长表达式进行求值栈的使用会相当频繁。可以考虑几个方向优化第一个方向是避免使用函数调用的开销。每次push和pop都调用一次函数在大量循环中会带来不小的时间损耗。如果你非常追求性能可以把栈操作用宏定义或内联函数实现或者在循环体内直接操作栈结构体。第二个方向是预分配空间。如果知道表达式的最大长度可以在初始化时直接把操作数栈和运算符栈的容量都配好避免动态扩容带来的额外复制开销。第三个方向是减少后缀表达式的内存占用。在后缀求值阶段一个常见的优化是把“输出后缀表达式到字符串”和“直接在后缀表达式生成过程中同步求值”合并。但这样做代码会变得更难读所以我的建议是在学习阶段不做此类优化等理解了原理后再说。第四个方向是使用更合适的数据类型。如果表达式中的数字是整数可以使用整型栈而不是浮点型栈。浮点运算比整型慢而且在精度敏感场景下可能引入误差。对大多数数据结构实验而言整型操作数和浮点操作数差别不大但长期习惯用最恰当的类型会让代码更专业。4.4 测试用例的设计原则很多同学写完代码后只会用一两个简单表达式测试这是远远不够的。我建议至少准备下面这一组测试用例按顺序跑一遍空表达式应当报错或返回0。单个数字如5应当返回5。单运算符如12应当返回3。基本优先级如12*3应当返回7。括号改变优先级如(12)*3应当返回9。连续相同优先级如10-3-2应当返回5而不是9。除法与浮点数如9/2应当返回4.5。复杂嵌套如((12)*(34))/(5-1)应当返回2.25这题的结果是21/45.25我算错了改成((12)*(34))/(5-1)21/45.25——注意这里表达式是14/43.5不对让我重新口算(12)*(34)3*7215-1421/45.25。对结果是5.25。括号匹配的测试用例则应该包括空串匹配。()匹配。[]匹配。{}匹配。{[()]}匹配。([)]不匹配。((())不匹配。())不匹配。((()))匹配。这些用例覆盖了正常、交叉、多余左括号、多余右括号、嵌套等全部情况。任何一套通过全部用例的代码基本可以认为是健壮的。4.5 如何在实验报告中清晰表达如果你是在做课程实验报告里除了代码还应该写清楚四部分第一部分是需求分析。明确输入是什么、输出是什么、有哪些约束条件。比如“输入一个可能包含空格和括号的中缀表达式输出计算结果支持 - * / 和括号优先”。第二部分是设计思路。用文字描述数据结构和核心算法的思想不需要写太多代码。让老师看出你理解栈的先进后出如何匹配括号的嵌套关系以及运算符栈如何保存优先级。第三部分是实现细节。把你认为最难的几个点单独拿出来说明比如左括号在运算符栈中的处理、后缀表达式中操作数的分隔方法、栈顶指针的初始化和判断条件。第四部分是测试分析。把上面的测试用例整理成表格记录每条用例的输入输出和实际运行结果最好再包括一个演算过程。写实验报告时最大的坑是只贴代码不写说明。很多学生以为代码能跑就万事大吉但实际上数据结构这门课考察的核心就是“你会不会用数据结构解决问题”而不是“你会不会背代码”。把思路说清楚比贴几百行代码更有价值。5. 从题目到工程应用的扩展路线5.1 括号匹配在真实系统中的位置括号匹配这个看似简单的题目在真实系统中无处不在。代码编辑器里的语法高亮、括号补全和自动缩进都要靠括号匹配来定位配对的括号。如果你曾经用过一个编辑器按下热键就能把光标从当前位置跳到配对的括号上那就是括号匹配算法在发挥作用。JSON和XML这类数据格式的校验也依赖栈思路。JSON中{}和[]的嵌套本质和括号匹配一模一样XML标签的配对要求严格嵌套也是同一个模型。只不过真实系统里栈元素不再是单个字符而是一个个结构体。思考方式不变变的只是栈里存的“东西”。5.2 表达式求值的工业级扩展表达式求值应用的范围远超教科书里的简单四则运算。数据库查询优化器在处理SQL条件时需要把中缀形式的查询条件转换为更适合执行的形式本质上是一棵表达式树的构建与求值。电子表格软件中的公式引擎本质上就是一个表达式求值系统只不过运算符的种类更多还包含单元格引用和函数调用。如果你把中缀转后缀的运算符扩展为自定义函数、比较运算符、逻辑运算符并增加函数调用的栈帧处理那你大致已经写出了一个小型脚本解释器的运算部分。编译原理课程中讲到的递归下降分析、抽象语法树构建都和这里所说的栈应用一脉相承。表达式求值问题从来不是孤立的知识点它像一把钥匙打开的是整个语法分析的大门。5.3 进一步挑战自建表达式树如果想在基础题目之上进阶一步我建议你自己动手体会一个有趣的任务不要只计算后缀表达式的值而是把后缀表达式构建成表达式树。操作数作为叶子节点运算符作为内部节点然后通过树的遍历输出中缀表达式或前缀表达式。这个任务有几个好处它让你理解表达式树的建立过程和栈的顺序打印是互逆操作。它可以让你发现中缀转后缀时丢失的括号信息如何恢复以及优先级如何体现在树结构上。它为后续学习语法分析中的抽象语法树打基础。我在做这个任务时最大的体会是栈和树不是两个孤立的结构它们经常在同一份逻辑里协同工作。这也是数据结构课程后期一个重要主题——各种结构不是彼此绝缘的而是互相嵌套、互相转化的。6. 写在最后的实操心得这次把栈的两个经典应用完整梳理了一遍我自己在复习的过程里也有一些新的感悟。第一点栈的核心价值不在于它存储了什么而在于它施加了什么样的存取约束。正是因为只能从一端进出它才能精确地模拟“最近相关”这种语义。如果你想存储一批数据用数组就行但如果你要处理的数据天然具有嵌套结构栈就是不二之选。第二点结构化的分解永远好过一锅端的聪明代码。把表达式求值拆成中缀转后缀、后缀求值两个阶段每一步都简单清晰。但如果你一上来就想一步到位用一个复杂的循环结构完成全部工作不仅容易写出bug还很难调试。先把它写对再考虑是不是可以合并优化这是我在多个项目里反复验证过的原则。第三点调试栈相关代码时最有力的工具就是打印。在入栈、出栈的每个关键节点打印当前栈的内容和后缀表达式的当前产出几乎能立刻定位逻辑错误。等一切正常后再把调试输出关掉换成简洁的日志接口。第四点如果你觉得这个题目做完了就结束了那就太可惜了。试着改造上面的代码让它支持函数调用、变量赋值、布尔表达式甚至把它扩展成一个简单的解释器前端。你会发现数据结构课程里每一个小小的实验题铺展开来都能通向一个宏大的工程领域。从栈到括号匹配再到表达式求值看似是两条孤立的路它们最终在一棵树的根节点汇集进入语言的殿堂。

相关新闻

死锁的四个必要条件:从多线程事故到系统化排查

死锁的四个必要条件:从多线程事故到系统化排查

1. 死锁不是玄学:从一次真实的多线程事故说起三年前我在某团队维护一个订单处理服务,有一阵子线上频繁报警,线程池被占满,请求大量超时。翻日志发现一段很诡异的循环:A任务在等B任务释放锁,B任务又在等A任务…

2026/10/15 20:56:13 阅读更多 →
栈与队列经典应用:括号匹配与表达式求值详解

栈与队列经典应用:括号匹配与表达式求值详解

1. 栈与队列的底层逻辑:为什么这两个问题是经典入门题先聊点背景。刚接触数据结构时,很多人会有一个困惑:栈和队列不就是两个很简单的线性结构吗?一个后进先出,一个先进先出,规则几句话就讲完了&#xff0c…

2026/10/15 18:36:44 阅读更多 →
基于麻雀搜索算法优化LSSVM的回归预测

基于麻雀搜索算法优化LSSVM的回归预测

我注意到你还没有提供具体的项目信息。为了让我为你生成一篇贴合实际、高质量的博文,请按以下格式补充内容:项目标题:(必填,例如“基于麻雀搜索算法优化LSSVM的回归预测”这类主题)项目正文:&am…

2026/10/15 18:38:51 阅读更多 →

最新新闻

算法导论英文原版第三版PDF:从伪代码到红黑树实现与复杂度分析

算法导论英文原版第三版PDF:从伪代码到红黑树实现与复杂度分析

简介:《算法导论》英文原版教材(第三版)PDF,面向计算机专业学生、算法学习者与求职备考人群,用于系统建立算法设计与分析的知识体系。全书共34章,从算法在计算中的角色讲起,依次覆盖插入排序、分…

2026/10/15 20:56:57 阅读更多 →
Win7 64位下USB转串口驱动安装指南:CH340/CP210x/FTDI/PL2303排错全收录

Win7 64位下USB转串口驱动安装指南:CH340/CP210x/FTDI/PL2303排错全收录

简介:这是一份专为 Windows 7 64 位系统准备的 USB 转串口驱动资源,适用于需要通过 USB 接口连接老式串行设备(如工业控制、GPS、调制解调器等)的用户,解决现代电脑缺少物理串口导致设备无法识别的问题。压缩包共含 25…

2026/10/15 20:56:57 阅读更多 →
光伏功率预测系统用户手册V1.0.1实战指南:数据链路、模型调优与考核避坑

光伏功率预测系统用户手册V1.0.1实战指南:数据链路、模型调优与考核避坑

简介:这份《国能日新光伏功率预测系统用户使用手册V1.0.1》面向光伏电站运维人员、电力调度从业者及新能源相关专业学习者,系统讲解北京国能日新光伏电场功率预报管理系统的操作与配置方法。手册围绕人机界面、接口与数据库三部分展开,涵盖用…

2026/10/15 20:56:57 阅读更多 →
Erupt 对接 Elasticsearch 数据源全解析:注解驱动索引绑定、查询下推与完整 CRUD 实现

Erupt 对接 Elasticsearch 数据源全解析:注解驱动索引绑定、查询下推与完整 CRUD 实现

后端低代码AI 应用人工智能AI Agent认证鉴权RAG 【免费下载链接】erupt 🚀 通用数据管理框架,VORM 对象视图模型,注解驱动低代码开发 项目地址: https://gitcode.com/erupts/erupt 点击查看 免费下载 erupt-data-es 是 Erupt 通用…

2026/10/15 20:56:57 阅读更多 →
从零构建VST 3插件:CMake工程、参数管理与避坑实战

从零构建VST 3插件:CMake工程、参数管理与避坑实战

简介:这是一份VST 3插件开发SDK资源包,面向音频插件开发者、音乐软件工程师及具备一定C基础的学习者,帮助其在Windows、macOS、Linux和iOS平台上构建音效、乐器和音频处理工具。SDK遵循VST 3模块化架构,插件与宿主经由音频处理器、…

2026/10/15 20:56:57 阅读更多 →
NDIS小端口驱动开发实战:从Miniport注册到NBL收发路径详解

NDIS小端口驱动开发实战:从Miniport注册到NBL收发路径详解

简介:这是一份面向Windows内核网络驱动开发者的NDIS 6.0小端口驱动实例源码,针对Realtek 8111/8168/8169/8110等PCI千兆以太网控制器编写。由于DDK中可参考的miniport示例仅有E100BEX,学习NDIS 6.0网卡驱动的资料相当稀缺,本资源正…

2026/10/15 20:55:56 阅读更多 →

日新闻

Docker Compose 容器化部署 ChirpStack LoRaWAN 网络服务器实战

Docker Compose 容器化部署 ChirpStack LoRaWAN 网络服务器实战

1. 为什么选择容器化方式部署 LoRa 网络服务LoRa 物联网项目落地时,网络服务器是整个链路的核心枢纽。它负责接收网关上报的数据包、完成去重与解密、再把有效载荷分发给上层应用。早期大家习惯把网络服务器直接装在物理机上,依赖一堆系统库和特定版本的…

2026/10/15 0:00:46 阅读更多 →
共享LLM服务下的跨模型自动扩缩容:Kubernetes实战与避坑指南

共享LLM服务下的跨模型自动扩缩容:Kubernetes实战与避坑指南

1. 共享推理服务里,扩缩容到底难在哪做过大模型推理服务部署的人,大概都经历过这样的场景:一个团队共用一套推理集群,A 业务跑的是 7B 的小模型,B 业务跑的是 72B 的大模型,C 业务白天流量高、晚上几乎归零…

2026/10/15 0:00:46 阅读更多 →
Systemd 启动阶段根文件系统只读之谜:fstab 与 remount-fs 服务深度解析

Systemd 启动阶段根文件系统只读之谜:fstab 与 remount-fs 服务深度解析

1. 引子:一次内核恐慌引发的追查先从一个真实场景说起。某次我给一台老旧的嵌入式设备折腾系统裁减,为了把启动时间压进两秒以内,我手贱地把一串自认为“没用”的 systemd 服务给 disable 了,其中就包括systemd-remount-fs.servic…

2026/10/15 0:00:46 阅读更多 →

周新闻

流感时间序列预测实战:ARIMA/LSTM全流程拆解与避坑指南

流感时间序列预测实战:ARIMA/LSTM全流程拆解与避坑指南

简介:基于 ARIMA、LSTM、Transformer 等模型的流感时间序列预测 Python 源码,面向计算机相关专业课程设计与期末大作业学生,以及项目实战学习者。内容覆盖预处理、平稳性检验、定阶、残差分析、多模型对比预测的完整时序建模流程,…

2026/10/15 16:18:54 阅读更多 →
影刀RPA新手教程:键盘模拟输入实战——输入文本与模拟按键的区别

影刀RPA新手教程:键盘模拟输入实战——输入文本与模拟按键的区别

影刀RPA新手教程:键盘模拟输入实战——输入文本与模拟按键的区别 做影刀RPA自动化,十个新手有八个栽在"往输入框里填东西"这件事上:要么填不进去,要么填了一半,要么直接把原来内容追加在后面。这背后的根因&…

2026/10/15 19:41:51 阅读更多 →
影刀RPA新手教程:阅文起点小说数据采集实战——书籍信息与章节内容

影刀RPA新手教程:阅文起点小说数据采集实战——书籍信息与章节内容

影刀RPA新手教程:阅文起点小说数据采集实战——书籍信息与章节内容 1. 认识影刀:什么场景该用RPA采小说数据 起点中文网的页面结构相对稳定——分类榜单、书籍详情、章节内容三块独立页面,跳转链路清晰。这种场景非常适合影刀自动化&#x…

2026/10/15 13:48:15 阅读更多 →

月新闻

我发现了一个新思路:用 Remotion + Claude Code 像写代码一样自动化生成短视频

我发现了一个新思路:用 Remotion + Claude Code 像写代码一样自动化生成短视频

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

2026/10/15 0:14:36 阅读更多 →
Windows下 Codex 中 Chrome 和 Computer Use 插件不可用问题排查及解决参考方式:TaoToken 统一 Key 配置与验证

Windows下 Codex 中 Chrome 和 Computer Use 插件不可用问题排查及解决参考方式:TaoToken 统一 Key 配置与验证

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

2026/10/14 22:45:47 阅读更多 →
黑夜航拍船只数据集训练YOLOV5模型全流程解析

黑夜航拍船只数据集训练YOLOV5模型全流程解析

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

2026/10/15 9:49:14 阅读更多 →