ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

C++线性栈实现计算器:表达式求值原理与代码详解

C++线性栈实现计算器:表达式求值原理与代码详解 简介面向数据结构初学者及C编程爱好者下载包内含一套基于线性栈模板实现的简易计算器完整源码与讲解材料重点展示如何运用栈结构完成表达式读取、运算优先级处理与结果计算支持加减乘除、乘方、开方、求余等常见运算并允许连续输入多个表达式反复使用。资源包共4个文件包含2个C/C头文件、1个计算器源程序文件以及1份代码思路介绍PPT整体大小约480KB源码与演示文稿分开存放便于按需查阅、课堂讲解或期末复习。目前已有1083人浏览下载适合作为数据结构课程设计、课后实验或自学栈应用的参考项目。通过这份材料读者既能获得可直接编译运行的计算器程序也能借助PPT沿着代码思路梳理线性栈的模板封装、运算符比较与求值流程理解栈在表达式求值中的核心作用计算器用户界面直观支持连续多次计算可在此基础上继续扩展函数、括号等更丰富的功能实践价值较高。 做C课程设计或者刷数据结构题的时候计算器大概是“线性栈”这个知识点最经典也最能用上的练习了。我这次用C线性栈写了一个支持加减乘除和括号的简易计算器能处理小数、负数还能识别除零和括号不匹配这些错误。整个项目没用什么花哨的语法就是把栈的入栈、出栈、取栈顶这些操作老老实实用在了表达式求值上做完以后对栈的理解会比看书深刻得多。这篇文章就把完整的实现思路、代码和调试过程都贴出来适合正在学数据结构的同学也适合准备C面试想找个经典例子梳理一遍的人。1. 为什么计算器必须用栈1.1 “栈”到底解决什么问题先想一个问题如果让你算1 2 * 3你不会从左往右算成9而是知道乘法优先级高先算2 * 3 6再加1得7。这个“先算后面再算前面”的操作本质上就是一种后进先出的规律也就是栈的典型特性。更明显的场景是括号比如(1 2) * (3 4)。我们人工计算时会先找最内层的括号算完再把结果往外一层一层带出来。这个过程把暂时算不了的中间结果和后缀运算符“压住”等条件成熟再“弹出”就是典型的栈行为。用人话讲栈能把“暂时还用不到、但一会儿必须用”的数据暂时寄存起来。表达式求值里数字要寄存运算符也要寄存而且必须保证后寄存的先被处理这正好和运算符优先级、括号嵌套的顺序完全吻合。1.2 两种主流的求值思路计算器实现一般有两种路线中缀转后缀再对后缀表达式求值先把人容易读懂的1 2 * 3转成机器好处理的1 2 3 * 然后遇到数字就压栈遇到运算符就弹出两个数计算结果再压栈。这个方案逻辑清晰但需要写两个阶段的逻辑。双栈直接求值同时开一个操作数栈和一个运算符栈边扫描表达式边处理。优先级够就把运算符压栈优先级不够就弹出栈顶运算符先算。这个方案逻辑更紧凑代码也不难理解。我这次选的是后者——双栈直接求值。原因是它的代码路径更短调试时看两个栈的变化计算过程一目了然。中缀转后缀那种方案对初学者来说更容易在某一步忘记处理括号双栈方案里括号的处理比较统一容错率更高。1.3 难点清单真正开始写之前先盘一下会遇到哪些坑运算符优先级比较、-一级*、/二级括号特殊处理。多位数字与小数不能cin num偷懒要把连续的数字字符拼成一个完整的数。括号匹配遇到右括号要一直出栈到左括号如果栈空了还没找到左括号说明表达式不合法。负数处理-5 3里的-是一元负号不是减号需要特殊判断。异常输入除零、连续两个运算符、括号不配对这些都要在程序里明确报错而不是让代码默默崩溃。做工程和做练习的最大区别就是把“正常情况跑通”变成“异常情况也得有反馈”。这也是这个项目最有价值的地方。2. 线性栈的设计与实现2.1 顺序栈还是链栈题目里说的“线性栈”其实就是指用线性结构来存储栈元素。这个线性结构可以是数组也可以是链表于是就有我们熟悉的顺序栈和链栈。具体到计算器这个场景用户在一条表达式里遇到的操作数和运算符数量是有限的而且最大值也能估到——就是表达式字符串的长度。所以直接用顺序栈就够了不用考虑运行时长运行时扩充的问题。顺序栈的三个基本操作入栈把栈顶指针加一后放数据出栈取数据后减一取栈顶只读不改。我在实现时没有直接用标准库的std::stack而是自己写了一个模板类。为什么因为这是练习线性栈的绝佳机会手写一遍Push、Pop、Top、IsEmpty之后你才能真正体会到“栈就是一种受限的线性表”这句话是什么意思。2.2 手写模板Stack类底层我用std::vector来存数据因为它的动态扩容已经帮我们处理好了“栈满”的问题代码更安全也不影响我们理解栈的核心逻辑。#include iostream #include vector #include string #include cctype #include sstream #include iomanip using namespace std; template typename T class Stack { private: vectorT data; public: bool IsEmpty() const { return data.empty(); } void Push(const T val) { data.push_back(val); } T Pop() { T top data.back(); data.pop_back(); return top; } T Top() const { return data.back(); } };如果你在考试或课程设计里被要求“手写数组栈”那也不难思路其实一模一样用一个动态数组或固定数组再加一个top游标变量入栈前检查是否越界出栈时返回并回退游标。核心逻辑和我上面这份代码是等价的只是把vector换成了裸数组。2.3 运算符优先级表的含义双栈算法里最关键的就是优先级判断。我定义了一个函数来返回运算符的优先级数值int Precedence(char op) { if (op || op -) return 1; if (op * || op /) return 2; return 0; // 左括号特殊处理 }为什么要让括号返回0因为在比较优先级的时候左括号只有在遇到右括号时才需要被弹出来平时它应该一直在栈底待着不能随便参与计算。给它的优先级设为最低这样任何运算符到来都不会把左括号挤出来逻辑就封闭了。这个优先级表看起来简单但它正是整个算法的核心。栈顶运算符要不要拿出来算全靠它决定。如果你以后要做带幂运算的计算器还得额外处理右结合的问题——幂运算符^的优先级不仅高而且是从右往左结合。这个扩展留给读者当作业。3. 表达式求值的完整实现3.1 从字符串到数字解析token数字的解析是整个程序最容易写错的地方。直接遍历字符串的每一个字符如果遇到数字或小数点就从当前位置连续往后取拼成一个完整的数。处理的时候有一个隐含规则遇到数字前可能跟着一个负号。比如-5 3这个负号并不是减号而是数字的一部分。所以我先扫描一遍判断当前位置是否是“一元负号”出现的位置如果是就给后面的数字加上负号。一元负号的判断条件有两个负号是表达式的第一个字符负号的前一个字符是左括号或者除右括号外的其他运算符。bool IsUnaryMinus(const string expr, int i) { if (expr[i] ! -) return false; if (i 0) return true; char prev expr[i - 1]; return prev ( || prev || prev - || prev * || prev /; }这个函数可以说是我踩坑最多的地方。最开始我没判断prev (这种情况导致(-3)被理解成“先算0减3再套括号”结果虽然碰巧相等但一旦写成(-3 5) * 2就会出错。所以遇到一元负号千万别急着当作减号处理。3.2 双栈计算流程数字栈numStack和运算符栈opStack并行的主循环逻辑如下bool ApplyOp(Stackdouble num, char op, double result) { if (num.IsEmpty()) return false; double b num.Pop(); if (num.IsEmpty()) return false; double a num.Pop(); switch (op) { case : result a b; break; case -: result a - b; break; case *: result a * b; break; case /: if (b 0.0) return false; result a / b; break; default: return false; } num.Push(result); return true; }主函数的处理分成四种情况数字解析完整数字压入数字栈。左括号直接压入运算符栈。右括号不断弹出运算符栈顶并计算直到遇到左括号。如果运算符栈已经空了还没遇到左括号说明输入不合法。四则运算符先处理一元负号如果不是一元负号就比较当前运算符与栈顶运算符的优先级。只要栈不空栈顶不是左括号并且当前运算符优先级不高于栈顶就弹出栈顶计算。最后把当前运算符压栈。整个表达式扫完后把运算符栈里剩下的运算符一个一个弹出来计算。如果最后数字栈恰好只剩一个数就把它作为计算结果输出如果只剩多于一个数说明表达式里有缺运算符问题。3.3 整体代码骨架把上面这些拼在一起完整的主函数长这样double EvaluateExpression(const string expr, bool ok) { Stackdouble numStack; Stackchar opStack; int i 0; int len (int)expr.size(); while (i len) { if (isspace(expr[i])) { i; continue; } if (isdigit(expr[i]) || expr[i] .) { int start i; int dotCount 0; while (i len (isdigit(expr[i]) || expr[i] .)) { if (expr[i] .) { dotCount; if (dotCount 1) { ok false; return 0; } } i; } string numStr expr.substr(start, i - start); double val stod(numStr); numStack.Push(val); continue; } if (IsUnaryMinus(expr, i)) { i; int start i; while (i len (isdigit(expr[i]) || expr[i] .)) i; string numStr expr.substr(start, i - start); if (numStr.empty()) { ok false; return 0; } numStack.Push(-stod(numStr)); continue; } if (expr[i] () { opStack.Push(expr[i]); i; continue; } if (expr[i] )) { bool foundLeft false; while (!opStack.IsEmpty()) { char op opStack.Pop(); if (op () { foundLeft true; break; } double tmp; if (!ApplyOp(numStack, op, tmp)) { ok false; return 0; } } if (!foundLeft) { ok false; return 0; } i; continue; } char curOp expr[i]; while (!opStack.IsEmpty() opStack.Top() ! ( Precedence(curOp) Precedence(opStack.Top())) { char topOp opStack.Pop(); double tmp; if (!ApplyOp(numStack, topOp, tmp)) { ok false; return 0; } } opStack.Push(curOp); i; } while (!opStack.IsEmpty()) { char op opStack.Pop(); if (op () { ok false; return 0; } double tmp; if (!ApplyOp(numStack, op, tmp)) { ok false; return 0; } } if (numStack.IsEmpty()) { ok false; return 0; } double result numStack.Pop(); if (!numStack.IsEmpty()) { ok false; return 0; } ok true; return result; }注意看我在做乘除法运算时返回了false并附带错误状态而不是直接用抛异常或者exit结束程序这样函数可以把错误原因一路传回main由调用方决定怎么提示。这也是工程代码里比较常见的做法库代码只负责返回状态界面才负责展示文案。4. 测试用例与调试实录4.1 常规表达式跑一遍我用下面几个用例验证程序正确性输入表达式期望结果实际输出12*377(12)*(34)212110-2*34/266-512/(24)-3-3(1.52.5)*288注意观察第二个和第三个用例。(12)*(34)里有两个括号程序在遇到右括号时会把括号内所有运算符处理完再回到主循环继续。这个过程调试时可以打印两个栈的实时内容非常直观。我在调试10-2*34/2时打印过栈状态遇到第一个*时运算符栈是[-]*的优先级比-高所以*直接压栈等到遇到第一个时当前优先级是1而栈顶*是2于是先把*弹出来计算2*3再把-和比较又弹出-计算10-6。最终栈里干净的只有一个结果。4.2 边界情况处理边界情况是最容易翻车的部分我重点测了这几类除法除零输入8/(4-4)程序在ApplyOp中检测到除数为0返回错误主函数输出“表达式不合法除数为零”而不是产生一个inf或者直接崩掉。括号不匹配输入(12扫描结束后运算符栈还剩一个(代码在最后的清空阶段检测到这个情况报错。连续数字误解析输入1..23解析第一段数字时发现小数点出现了两次报错。字符串末尾是运算符输入35*扫描结束后数字栈有两个数而运算符栈还有一个*最后判断numStack剩下的元素数量不是1于是报错。这些错误处理在书上的示例代码里经常被一笔带过但实际写工程时输入永远是不可控的。把错误处理写完整程序的健壮性会好很多这也是面试时“加分项”的体现。4.3 常见问题速查表现象原因解决办法计算结果少了最后一步表达式扫描完成后没有把运算符栈清空主循环后加while (!opStack.IsEmpty())结算逻辑(-35)*2结果不对一元负号被当成减号处理用IsUnaryMinus判断把负号合并进数字8/(4-4)输出inf没有检查除数是否为零在除法分支判断除数是否为0.01.12.2输出3.3000000000000003double浮点精度问题输出时用std::fixed std::setprecision(2)控制小数位括号多了或少了不报错忘记处理括号不配对的情况遇到)时检查是否找到(结束后检查栈内残留的(5-3这种写法不识别只处理了数字前的一元负号可以在解析完运算符后再判断下一个是否为-或并从当前位继续解析数字我把cout的输出格式固定为保留两位小数避免浮点数尾巴对使用者造成困扰。如果你需要在金融计算或精度要求更高的场合用建议换成boost::multiprecision::cpp_dec_float或者直接用整数分转元的方式计算。5. 避坑经验与扩展方向做完这个项目后我个人的体会是数据结构课上讲的“栈的典型应用”到真正落地实现时坑都藏在字符串处理和边界判断里而不是栈本身。栈的操作无非是Push、Pop、Top但什么时候压、什么时候弹背后是对表达式语法的理解这部分才是编程能力的体现。几个我觉得值得记住的经验每一步都先想“栈空不空”。很多崩溃都发生在空栈上执行Top或Pop我代码里几乎所有Pop之前都判断了栈是否是空。调试时打印栈内容比用眼睛扫代码快得多。我总会临时写一个小工具函数把两个栈的内容输出到控制台观察某个表达式的处理过程比靠猜高好几倍效率。先写正常路径再回头补异常路径。先把12*3这类简单用例跑通再一一把负号、除零、括号不匹配的 case 补进去这样思路不会乱。这个项目后续还有很多可以玩的方向比如加入sin、cos、log这类函数的调用需要把函数名也作为一个占位符压入运算符栈比如支持变量赋值用x 10这种语法以后相当于把计算器扩展成一个微型脚本引擎再比如把运算符扩展到幂运算^这时优先级就不再只是简单的数字比大小还得处理右结合的问题。顺着这些方向继续改计算器就会从一个作业题慢慢变成一个有点实用价值的工具。最后再说一个我自己写代码时的习惯遇到问题不要立刻去搜完整答案先用最笨的方式打印栈的信息。当你亲眼看着数字栈和运算符栈一步步变化时那些算法书里的伪代码就和真实运行的程序呼应起来了。这个项目的意义其实也就在这里。本文还有配套的精品资源点击获取
返回列表