简介:这份《数据结构》课程设计实验报告以“表达式求值”为题,面向计算机相关专业学生及正在学习栈应用的初学者,帮助读者理解如何借助栈结构解决算术表达式求值中运算符优先级与括号嵌套的难题。压缩包内仅含1个doc文档,约122KB,内容为完整的课程设计报告书,结构规范、层次清晰。报告从前言、概要设计、详细设计、软件测试到总结与附录逐层展开,重点讲解运算符栈OPTR与操作数栈OPND的协同工作机制,涵盖顺序栈的存储结构设计、算符优先关系表的构建、ADT Stack的接口定义以及Precede、Operate、EvalExpr等核心函数的实现思路,并配有算法流程图与测试分析。目前已有461人学习下载,适合需要撰写同类实验报告、准备课程设计答辩或巩固栈与表达式求值算法的读者参考借鉴,可据此快速梳理设计脉络、理解算符优先算法的完整实现过程。
1. 表达式求值实验为什么总在“栈溢出”和“优先级”上翻车
很多人第一次拿到“数据结构表达式求值实验报告”这个题目,以为就是写个计算器,把3+5*2算成 13 就交差。真动手才发现,输入(3+5)*2-10/5时结果对不上,输入2^3^2时结果从 512 变成 64,输入-3+2时程序直接崩掉。表达式求值在数据结构课程里属于栈结构的经典应用,也是王道408、严蔚敏C语言版教材里反复强调的考点,但教材给的是骨架,实验报告要的是能跑、能测、能解释的完整方案。这个方向适合正在做课程设计的学生、准备408数据结构考研的复习者,以及想用Java或C++把中缀转后缀真正落地的一线开发者。核心就三件事:栈怎么设计、优先级怎么定、边界怎么兜。把这三件事拆开揉碎,实验报告自然有内容可写,代码也能扛住老师的花式测试用例。
2. 中缀转后缀:两个栈的配合逻辑与手算验证
2.1 为什么必须先把中缀转成后缀
人习惯看中缀表达式,比如1+2*3,但计算机从左到右扫描时无法直接判断+和*谁先算。后缀表达式(逆波兰式)把运算符放到操作数后面,变成1 2 3 * +,扫描时遇到运算符就弹出栈顶两个操作数计算,不需要考虑括号和优先级。这个转换过程是表达式求值实验的核心,也是数据结构课程里栈的典型应用场景。常见做法是用一个运算符栈暂存还没轮到计算的符号,遇到数字直接输出到后缀序列,遇到运算符则根据优先级决定是压栈还是弹出。严蔚敏版教材里给出的算符优先法就是这套逻辑,王道408的代码题也反复考这个转换过程。
转换规则可以拆成四条:遇到操作数直接加入后缀序列;遇到左括号直接压栈;遇到右括号则不断弹出栈顶运算符加入后缀序列,直到遇到左括号并将其弹出丢弃;遇到普通运算符时,如果栈顶运算符优先级不低于当前运算符,就弹出栈顶加入后缀序列,重复此过程直到栈顶优先级更低或栈为空,然后把当前运算符压栈。扫描结束后把栈里剩余运算符依次弹出加入后缀序列。
手算验证是实验报告里必须体现的一步。以3+5*(2-8)/4为例,扫描过程如下:3输出,+压栈,5输出,*因为栈顶+优先级更低所以直接压栈,(压栈,2输出,-压栈,8输出,)触发弹出-输出并丢弃(,此时后缀序列为3 5 2 8 -,栈内为+ *。接着/到来,栈顶*优先级不低于/,弹出*输出,此时栈顶变为+,优先级低于/,停止弹出,将/压栈。然后4输出。扫描结束,依次弹出/、*、+,最终后缀表达式为3 5 2 8 - * 4 / +。这个手算过程写进实验报告,比只贴代码更有说服力。
2.2 用C语言实现转换:结构体栈与优先级表
下面是一段可以直接编译运行的C语言代码,实现了中缀转后缀的核心逻辑。代码里用数组模拟栈,定义了运算符优先级表,处理了多位数和小数点的情况。
#include <stdio.h> #include <stdlib.h> #include <string.h> #include <ctype.h> #define MAX 100 // 运算符栈 typedef struct { char data[MAX]; int top; } OpStack; void initOpStack(OpStack *s) { s->top = -1; } int isOpStackEmpty(OpStack *s) { return s->top == -1; } void pushOp(OpStack *s, char op) { if (s->top < MAX - 1) { s->data[++(s->top)] = op; } } char popOp(OpStack *s) { if (!isOpStackEmpty(s)) { return s->data[(s->top)--]; } return '\0'; } char peekOp(OpStack *s) { if (!isOpStackEmpty(s)) { return s->data[s->top]; } return '\0'; } // 返回运算符优先级,数字越大优先级越高 int priority(char op) { switch (op) { case '+': case '-': return 1; case '*': case '/': return 2; case '^': return 3; case '(': return 0; // 左括号在栈内优先级最低,保证不弹出 default: return -1; } } // 中缀转后缀 void infixToPostfix(const char *infix, char *postfix) { OpStack s; initOpStack(&s); int j = 0; int i = 0; while (infix[i] != '\0') { char c = infix[i]; if (isdigit(c) || c == '.') { // 处理多位数和小数点 while (isdigit(infix[i]) || infix[i] == '.') { postfix[j++] = infix[i++]; } postfix[j++] = ' '; // 用空格分隔操作数 } else if (c == '(') { pushOp(&s, c); i++; } else if (c == ')') { while (!isOpStackEmpty(&s) && peekOp(&s) != '(') { postfix[j++] = popOp(&s); postfix[j++] = ' '; } if (!isOpStackEmpty(&s)) { popOp(&s); // 弹出左括号 } i++; } else if (c == '+' || c == '-' || c == '*' || c == '/' || c == '^') { // 注意:^ 是右结合,栈顶优先级大于当前才弹出 while (!isOpStackEmpty(&s) && priority(peekOp(&s)) > priority(c)) { postfix[j++] = popOp(&s); postfix[j++] = ' '; } // 对于左结合运算符,栈顶优先级等于当前也要弹出 if (c != '^') { while (!isOpStackEmpty(&s) && priority(peekOp(&s)) == priority(c)) { postfix[j++] = popOp(&s); postfix[j++] = ' '; } } pushOp(&s, c); i++; } else { i++; // 跳过空格等无关字符 } } while (!isOpStackEmpty(&s)) { postfix[j++] = popOp(&s); postfix[j++] = ' '; } postfix[j] = '\0'; } int main() { char infix[] = "3+5*(2-8)/4"; char postfix[MAX * 2]; infixToPostfix(infix, postfix); printf("中缀: %s\n", infix); printf("后缀: %s\n", postfix); return 0; }这段代码的关键点在于优先级表的定义和右结合运算符的处理。priority函数里左括号返回 0,保证它不会被普通运算符弹出,只有遇到右括号时才主动弹出。^运算符在数学上是右结合的,2^3^2应该算成2^(3^2)=512,所以代码里对^单独处理,栈顶优先级等于当前优先级时不弹出。其他运算符都是左结合,栈顶优先级等于当前时也要弹出,保证1-2-3算成(1-2)-3=-4而不是1-(2-3)=2。多位数处理用while循环连续读取数字和小数点,并在每个操作数后加空格分隔,方便后续后缀求值时切分。
参数方面,MAX定义了栈的最大容量,实际实验里可以改成动态分配或者根据输入长度计算。postfix数组大小设为MAX*2是为了容纳空格分隔符,如果输入表达式很长需要相应调整。priority函数里没有处理一元负号,比如-3+2里的负号会被当成减号,这是后面避坑章节要专门解决的问题。
2.3 后缀求值:操作数栈的压弹节奏
拿到后缀表达式后,求值过程比转换更直接。扫描后缀序列,遇到数字就压入操作数栈,遇到运算符就弹出两个操作数,先弹出的是右操作数,后弹出的是左操作数,计算完把结果压回栈。扫描结束后栈里剩下的唯一元素就是最终结果。
// 操作数栈 typedef struct { double data[MAX]; int top; } NumStack; void initNumStack(NumStack *s) { s->top = -1; } void pushNum(NumStack *s, double val) { if (s->top < MAX - 1) { s->data[++(s->top)] = val; } } double popNum(NumStack *s) { if (s->top >= 0) { return s->data[(s->top)--]; } return 0.0; } // 后缀表达式求值 double evalPostfix(const char *postfix) { NumStack s; initNumStack(&s); int i = 0; while (postfix[i] != '\0') { if (isdigit(postfix[i]) || postfix[i] == '.') { double num = 0; int decimal = 0; double fraction = 0.1; while (isdigit(postfix[i]) || postfix[i] == '.') { if (postfix[i] == '.') { decimal = 1; } else if (!decimal) { num = num * 10 + (postfix[i] - '0'); } else { num += (postfix[i] - '0') * fraction; fraction *= 0.1; } i++; } pushNum(&s, num); } else if (postfix[i] == '+' || postfix[i] == '-' || postfix[i] == '*' || postfix[i] == '/' || postfix[i] == '^') { double right = popNum(&s); double left = popNum(&s); double result = 0; switch (postfix[i]) { case '+': result = left + right; break; case '-': result = left - right; break; case '*': result = left * right; break; case '/': result = left / right; break; case '^': { result = 1; for (int k = 0; k < (int)right; k++) { result *= left; } break; } } pushNum(&s, result); i++; } else { i++; // 跳过空格 } } return popNum(&s); }求值部分最容易翻车的地方是操作数弹出顺序。后缀表达式3 5 -对应中缀3-5,扫描到-时先弹出的是5,后弹出的是3,所以left=3, right=5,计算left-right得到-2。如果顺序写反,结果就变成5-3=2,这种错误在实验报告里如果没写清楚,老师一眼就能看出来。^运算符这里用循环实现整数次幂,实际实验里如果要求支持小数次幂,需要引入math.h的pow函数,但要注意链接时加-lm参数。
3. 优先级与结合性:三个必须写进实验报告的参数表
3.1 运算符优先级表的设计与验证
优先级表是表达式求值的“宪法”,所有弹出和压栈决策都依赖它。下面这张表可以直接放进实验报告,覆盖了常见运算符和括号。
| 运算符 | 栈内优先级 | 栈外优先级 | 结合性 | 说明 |
|---|---|---|---|---|
+ | 1 | 1 | 左结合 | 加减同级 |
- | 1 | 1 | 左结合 | 加减同级 |
* | 2 | 2 | 左结合 | 乘除同级 |
/ | 2 | 2 | 左结合 | 乘除同级 |
^ | 3 | 4 | 右结合 | 幂运算,栈外优先级高于栈内 |
( | 0 | 5 | — | 左括号栈内最低,保证不被弹出 |
) | — | 0 | — | 右括号不压栈,触发弹出 |
这张表里^的栈内优先级是 3,栈外优先级是 4,这个差异是右结合的关键。当扫描到第二个^时,栈顶也是^,栈内优先级 3 小于栈外优先级 4,所以不弹出,直接压栈,最终计算顺序从右往左。而+的栈内和栈外都是 1,扫描到第二个+时栈顶也是+,栈内优先级等于栈外优先级,左结合要求弹出栈顶,所以先算左边的加法。这个细节在王道408的代码题里经常考,实验报告里把这张表列出来并解释清楚,能直接体现对栈结构的理解深度。
3.2 结合性对结果的影响:用2^3^2和1-2-3做对比
结合性不是理论概念,它直接改变计算结果。2^3^2如果按左结合算,先算2^3=8,再算8^2=64;如果按右结合算,先算3^2=9,再算2^9=512。数学上幂运算规定为右结合,所以正确答案是 512。1-2-3如果按右结合算,先算2-3=-1,再算1-(-1)=2;按左结合算,先算1-2=-1,再算-1-3=-4。减法是左结合,正确答案是 -4。
在代码里体现这个差异,就是在弹出条件上加一个判断:对于右结合运算符,只有栈顶优先级严格大于当前优先级才弹出;对于左结合运算符,栈顶优先级大于等于当前优先级就弹出。这个逻辑在 2.2 节的代码里已经实现,实验报告里可以单独列一小节,用这两个表达式做测试用例,把中间过程打印出来,证明代码正确处理了结合性。
3.3 括号匹配与非法表达式拦截
括号处理是表达式求值里另一个高频翻车点。左括号在栈内优先级设为 0,保证任何运算符都不会把它弹出去,只有遇到右括号时才主动弹出直到左括号。如果扫描完整个表达式后栈里还有左括号,说明括号不匹配,需要报错。同样,如果遇到右括号时栈已经空了,或者栈顶不是左括号,也说明括号不匹配。
非法表达式拦截还包括:运算符连续出现(如3++2)、操作数缺失(如3+)、除数为零、小数点位置错误(如3..5)。这些检查不需要全部在转换阶段做,可以在求值阶段捕获异常。实验报告里建议单独写一个校验函数,在转换前先扫描一遍,把明显非法的输入拦下来,给出具体错误位置。这样比程序崩溃或者输出一个莫名其妙的结果要好得多,也是实验报告里“测试与分析”部分的重要素材。
4. 避坑与排查:表达式求值实验里最常见的五个翻车现场
4.1 现象:-3+2算成-1而不是-1,但3*-2直接崩溃
原因:一元负号没有被识别。在3*-2里,*后面的-是一元负号,但代码把它当成二元减号处理,弹出操作数时栈里只有一个3,另一个操作数不存在,导致栈下溢。-3+2里开头的-也是一元负号,但代码把它当成二元减号,压栈后没有左操作数,最终结果虽然碰巧对,但逻辑是错的。
解决:在转换阶段判断-是一元还是二元。如果-出现在表达式开头,或者出现在(后面,或者出现在另一个运算符后面,就是一元负号。一元负号可以特殊标记为#或者~,优先级设为最高,求值时取相反数。实验报告里可以把一元负号作为扩展功能写进去,体现对边界情况的考虑。
4.2 现象:多位数123+456算成1+2+3+4+5+6=21
原因:扫描时逐个字符处理,没有把连续数字合并成一个操作数。123被拆成1、2、3三个操作数压栈,求值时自然出错。
解决:在扫描到数字时,用while循环连续读取后续所有数字和小数点,拼成一个完整的数字字符串,再用atof或手动转换。2.2 节的代码里已经用while (isdigit(infix[i]) || infix[i] == '.')处理了这个问题,并在操作数后加空格分隔。如果实验要求支持科学计数法(如1.5e3),还需要额外处理e和符号。
4.3 现象:2^3^2输出 64 而不是 512
原因:^被当成左结合运算符处理,栈顶优先级等于当前优先级时弹出了栈顶,导致先算左边的2^3。
解决:在弹出条件里对^单独判断,只有栈顶优先级严格大于当前优先级才弹出。2.2 节代码里if (c != '^')那段就是处理这个问题的。实验报告里可以把2^3^2作为测试用例,打印转换后的后缀表达式应该是2 3 2 ^ ^,求值结果 512。
4.4 现象:输入(3+5)*2时程序输出3 5 + 2 *但求值结果不对
原因:后缀表达式转换正确,但求值时操作数弹出顺序写反了。3 5 +应该弹出5和3,计算3+5=8,如果写成5+3结果虽然一样,但遇到3 5 -时就会算成5-3=2而不是3-5=-2。
解决:求值时先弹出的赋值给right,后弹出的赋值给left,计算left op right。这个顺序在 2.3 节代码里已经体现。实验报告里建议用10-3-2做测试,正确结果是 5,如果顺序写反会得到 9 或别的值。
4.5 现象:除数为零时程序输出inf或直接崩溃
原因:浮点数除以零在C语言里不会报错,但结果是inf或nan,如果后续还有运算会传播。整数除以零会直接触发硬件异常导致程序崩溃。
解决:在求值阶段遇到除法时,先判断除数是否为零。如果为零,输出错误信息并终止求值,或者返回一个特殊值。实验报告里可以把除零检查作为健壮性的一部分写进去,同时测试1/0和0/0两种情况,说明处理策略。
5. 从实验报告到可复用代码:把表达式求值封装成独立模块
5.1 接口设计与错误码约定
实验报告交完之后,这套代码其实可以继续用。我一般会把表达式求值封装成一个独立模块,对外只暴露两个函数:一个负责校验和转换,一个负责求值。接口设计如下:
// 错误码定义 typedef enum { EVAL_OK = 0, EVAL_ERR_BRACKET = 1, // 括号不匹配 EVAL_ERR_DIV_ZERO = 2, // 除数为零 EVAL_ERR_INVALID_EXPR = 3, // 非法表达式 EVAL_ERR_STACK_OVERFLOW = 4 // 栈溢出 } EvalError; // 对外接口 EvalError evaluateExpression(const char *infix, double *result);这个接口把错误码和结果分开,调用方先检查错误码再使用结果,避免拿到一个无效值继续计算。错误码用枚举而不是数字,可读性更好。实验报告里如果要求写“模块设计”章节,这个接口定义可以直接放进去。
5.2 用测试用例驱动验证:从1+1到((2+3)*4-5)/6
验证表达式求值模块最有效的方法是用测试用例驱动。下面这组用例覆盖了基本运算、优先级、括号、结合性、多位数、小数和错误处理,可以直接写进实验报告的测试章节。
| 用例编号 | 输入表达式 | 期望结果 | 覆盖点 |
|---|---|---|---|
| 1 | 1+1 | 2 | 最基本加法 |
| 2 | 3+5*2 | 13 | 乘法优先级高于加法 |
| 3 | (3+5)*2 | 16 | 括号改变优先级 |
| 4 | 10-3-2 | 5 | 减法左结合 |
| 5 | 2^3^2 | 512 | 幂运算右结合 |
| 6 | 123+456 | 579 | 多位数 |
| 7 | 3.14*2 | 6.28 | 小数 |
| 8 | ((2+3)*4-5)/6 | 2.5 | 嵌套括号 |
| 9 | 1/0 | 错误码 2 | 除零 |
| 10 | (1+2 | 错误码 1 | 括号不匹配 |
把这组用例跑通,实验报告的“测试结果与分析”部分就有扎实的数据支撑。每个用例可以打印输入、输出和中间的后缀表达式,方便定位问题。如果某个用例失败,对照前面的避坑章节排查,基本能覆盖90%以上的常见错误。
5.3 性能边界与栈容量估算
表达式求值的性能瓶颈在栈操作,时间复杂度是 O(n),空间复杂度也是 O(n),n 是表达式长度。实际实验里输入表达式通常不会超过几百个字符,用固定大小的数组栈完全够用。但如果要做成一个通用模块,栈容量需要动态估算:运算符栈的最大深度不会超过表达式长度,操作数栈的最大深度也不会超过操作数个数。保守做法是把栈容量设为输入长度的两倍,或者用动态数组在压栈时自动扩容。
我自己的习惯是在实验报告里加一段“复杂度分析”,把时间复杂度和空间复杂度写清楚,再说明栈容量估算依据。这样老师能看到你不只是会写代码,还理解背后的资源消耗。表达式求值这个方向,从课程实验到408考研再到实际项目里的计算引擎,核心逻辑都是一样的,把栈、优先级、结合性这三块吃透,后面遇到更复杂的语法分析也能触类旁通。希望帮到你。
本文还有配套的精品资源,点击获取