用栈实现综合计算器:中缀转后缀与表达式求值实战
发布时间:2026/10/18 3:03:22 锦皓数字建站

栈这个数据结构很多人学的时候觉得简单不就是后进先出嘛但真到用它解决实际问题的时候往往就卡壳了。我见过不少朋友面试被问到怎么用栈实现一个计算器脑子里第一反应是用栈把数字和符号分开存然后写到一半发现运算符优先级处理不了括号嵌套更是直接懵掉。这个项目标题——用栈实现综合计算器——看起来是个教科书级的练习题但它背后牵扯的东西其实相当扎实中缀表达式转后缀表达式、运算符优先级比较、多位数与小数解析、括号匹配、异常输入处理每一个环节都有坑。我打算把整个实现过程从头到尾拆一遍包括我踩过的坑和最后跑通的完整方案。不管你是正在学数据结构的学生还是想复习一下栈的实战用法的开发者这篇内容都能让你直接抄作业跑起来。1. 为什么选栈来做计算器1.1 栈的特性与计算器需求的天然匹配栈的核心特性就两个后进先出LIFO和只允许在栈顶操作。这两个特性放在表达式求值这个场景里简直是量身定做。你想想当我们计算3 4 * 5的时候读到*的时候不能立刻算3 4因为*的优先级比高得先把4 * 5算出来。这意味着我们需要一个暂存区把还没轮到计算的运算符和操作数按顺序存起来等遇到合适的时机再取出来算。栈就是这个暂存区。更具体地说表达式求值有两种经典思路。第一种是双栈法一个栈存操作数一个栈存运算符读到运算符时比较优先级决定是否弹出栈顶运算符进行计算。第二种是中缀转后缀再求值先把人类习惯的中缀表达式3 4 * 5转成后缀表达式3 4 5 * 后缀表达式的好处是没有括号、不需要优先级判断从左到右扫描一遍就能算出结果。两种方法都用到了栈但第二种在工程上更常见因为它把解析和计算两个阶段解耦了调试起来方便很多。我选择第二种方案作为主线原因是第一中缀转后缀的过程本身就是栈的经典应用能完整体现栈的价值第二后缀求值逻辑极其简单不容易出错第三两个阶段可以独立测试出了问题容易定位。双栈法虽然看起来代码量少但优先级比较和括号处理的逻辑揉在一起一旦有bug很难排查。1.2 中缀、后缀表达式的本质区别先把这个概念说清楚不然后面没法聊。中缀表达式就是我们日常写的数学式子运算符在操作数中间比如(1 2) * 3。后缀表达式也叫逆波兰表达式把运算符放在操作数后面同样的式子写成1 2 3 *。前缀表达式则是运算符在前面写成* 1 2 3。为什么后缀表达式适合用栈计算因为它有一个关键性质从左到右扫描遇到操作数就压栈遇到运算符就弹出两个操作数计算把结果压回去。整个过程不需要考虑优先级不需要处理括号因为优先级和括号的信息已经在转换成后缀的时候编码进顺序里了。举个例子3 4 5 * 的求值过程是压3压4压5遇到*弹出5和4算得20压入遇到弹出20和3算得23。结果23完全正确。那转换过程怎么用栈规则是这样的从左到右扫描中缀表达式遇到数字直接输出遇到运算符时如果栈为空或栈顶是左括号直接压栈否则比较当前运算符和栈顶运算符的优先级如果当前优先级更高就压栈否则弹出栈顶运算符输出直到栈顶优先级低于当前运算符或栈为空再把当前运算符压栈。遇到左括号直接压栈遇到右括号则弹出栈顶运算符输出直到遇到左括号把左括号弹出丢弃。扫描完后把栈里剩余的运算符依次弹出输出。这套规则看起来简单但细节特别多我在第3节会逐行拆解。1.3 方案选型数组栈 vs 链式栈栈的实现方式有两种基于数组和基于链表。在这个计算器场景里我强烈建议用数组实现。原因很直接表达式求值过程中栈的深度是可预测的不会出现无限增长的情况。一个长度为n的表达式运算符栈的最大深度不会超过n操作数栈同理。数组栈的push和pop都是O(1)而且内存连续缓存友好实际跑起来比链式栈快。链式栈的优势在于动态扩容但在这个场景里用不上。你可能会说万一表达式特别长呢那数组也可以动态扩容Java里用ArrayList或者自己写一个扩容逻辑都行。C语言里可以预设一个足够大的数组比如表达式长度的两倍绝对够用。所以我的方案是用数组实现栈预设容量为表达式长度附带一个简单的扩容机制作为保险。注意如果你用Java的Stack类它底层是数组但加了synchronized单线程场景下性能有损耗。建议自己写一个简单的ArrayStack或者用ArrayDeque。C里用std::vector配合push_back和pop_back就行不需要额外的栈类。2. 核心细节拆解与实现要点2.1 多位数与小数点的解析逻辑这是第一个大坑。很多教程里的计算器只能处理个位数因为它们的解析逻辑是读一个字符判断是不是数字是就压栈。但实际表达式里123 456这种多位数太常见了必须处理。正确的做法是扫描到数字字符时不能立刻压栈而是继续往后读直到遇到非数字字符运算符、括号、空格或字符串结束把这一整段数字字符串截取出来转换成数值再压栈。这里要注意小数点的处理如果数字串里包含小数点要用浮点数解析否则用整数解析。我一般统一用double处理避免整数除法的问题。还有一个边界情况负号。-3 5里的负号是单目运算符和减号3 - 5里的双目减号长得一样怎么区分判断规则是如果负号出现在表达式开头或者出现在左括号后面或者出现在另一个运算符后面那它就是单目负号表示取负。处理方式可以是在转换前预处理把-3替换成(0-3)或者在解析时特殊标记。我倾向于在转换阶段处理遇到单目负号时在输出里补一个0然后把负号当减号处理。这样-3 5就变成了0 3 - 5 结果正确。2.2 运算符优先级的比较规则优先级比较是转换算法的核心。我定义三个级别加减是1级乘除是2级单目负号可以设为3级比乘除高。左括号在栈内时优先级设为0因为任何运算符遇到左括号都应该直接压栈不应该弹出左括号。比较逻辑是这样的当读到当前运算符op时循环检查栈顶运算符topOp如果topOp不是左括号且priority(topOp) priority(op)就弹出topOp输出。注意这里是大于等于不是大于。为什么因为同级运算符要从左到右计算比如3 - 4 5先算3 - 4再算 5所以读到的时候栈顶的-优先级相同应该弹出先算。如果用大于就会变成先算4 5结果就错了。这个细节我在第一次实现的时候就搞错了测试10 - 5 2得到的结果是3而不是7排查了半天才发现是优先级比较用了大于而不是大于等于。所以这里特别提醒同级运算符必须弹出栈顶保证从左到右的结合性。2.3 括号匹配与异常检测括号处理看起来简单但异常情况特别多。左括号直接压栈右括号弹出直到遇到左括号。但如果没有匹配的左括号呢比如3 4)扫描到右括号时栈里没有左括号这时候应该报错。反过来如果扫描完了栈里还有左括号说明左括号没闭合也要报错。除了括号不匹配还有几种异常需要检测表达式为空、包含非法字符比如字母、特殊符号、运算符连续出现比如3 * 4、除数为零。这些异常如果不处理程序要么崩溃要么给出错误结果。我的做法是在转换阶段和维护阶段都加校验一旦发现异常就抛出带有明确信息的异常而不是返回一个错误码让调用方猜。实操心得异常信息一定要具体。不要只抛一个表达式错误而要说明位置5处右括号没有匹配的左括号。这样调试的时候能省大量时间。我在项目里定义了一个ExpressionException携带错误位置和错误类型排查效率提升非常明显。3. 完整实操流程与代码实现3.1 整体架构设计整个计算器分三个模块解析器Parser、转换器Converter、求值器Evaluator。解析器负责把输入字符串拆成Token序列数字、运算符、括号转换器把中缀Token序列转成后缀Token序列求值器对后缀序列求值。三个模块之间通过Token列表传递数据每个模块都可以独立测试。Token我定义成一个简单的类包含类型NUMBER、OPERATOR、LEFT_PAREN、RIGHT_PAREN和值数字用double运算符用char。这样转换器和求值器都不需要再处理字符串解析逻辑清晰很多。class Token { enum Type { NUMBER, OPERATOR, LEFT_PAREN, RIGHT_PAREN } Type type; double numValue; char opValue; Token(double v) { type Type.NUMBER; numValue v; } Token(char c) { if (c () type Type.LEFT_PAREN; else if (c )) type Type.RIGHT_PAREN; else { type Type.OPERATOR; opValue c; } } }这个设计的好处是解析阶段的复杂度被隔离了。如果以后要支持更多运算符比如幂运算^只需要在解析器和优先级函数里加逻辑转换器和求值器的框架不用动。3.2 解析器实现字符串到Token序列解析器的核心是一个字符扫描循环。我用一个索引i遍历输入字符串遇到空格跳过遇到数字就往后读直到非数字遇到运算符或括号就生成对应的Token。ListToken parse(String expr) { ListToken tokens new ArrayList(); int i 0; while (i expr.length()) { char c expr.charAt(i); if (c ) { i; continue; } if (Character.isDigit(c) || c .) { StringBuilder sb new StringBuilder(); while (i expr.length() (Character.isDigit(expr.charAt(i)) || expr.charAt(i) .)) { sb.append(expr.charAt(i)); i; } tokens.add(new Token(Double.parseDouble(sb.toString()))); } else if (c || c - || c * || c /) { // 判断是否是单目负号 if (c - (tokens.isEmpty() || tokens.get(tokens.size()-1).type Token.Type.OPERATOR || tokens.get(tokens.size()-1).type Token.Type.LEFT_PAREN)) { tokens.add(new Token(0.0)); } tokens.add(new Token(c)); i; } else if (c ( || c )) { tokens.add(new Token(c)); i; } else { throw new ExpressionException(非法字符: c 位置: i); } } return tokens; }这段代码里单目负号的处理值得说一下。当遇到-且前一个Token是运算符或左括号或不存在时说明这是单目负号我在Token序列里插入一个0然后把-当普通减号处理。这样-3 5的Token序列就变成了[0, -, 3, , 5]后续转换和求值完全不用特殊处理。3.3 中缀转后缀栈的核心应用这是整个项目最核心的部分。我用一个运算符栈遍历Token序列按规则输出到后缀列表。ListToken infixToPostfix(ListToken tokens) { ListToken output new ArrayList(); DequeToken opStack new ArrayDeque(); for (Token t : tokens) { switch (t.type) { case NUMBER: output.add(t); break; case OPERATOR: while (!opStack.isEmpty() opStack.peek().type Token.Type.OPERATOR priority(opStack.peek().opValue) priority(t.opValue)) { output.add(opStack.pop()); } opStack.push(t); break; case LEFT_PAREN: opStack.push(t); break; case RIGHT_PAREN: boolean foundLeft false; while (!opStack.isEmpty()) { Token top opStack.pop(); if (top.type Token.Type.LEFT_PAREN) { foundLeft true; break; } output.add(top); } if (!foundLeft) { throw new ExpressionException(右括号没有匹配的左括号); } break; } } while (!opStack.isEmpty()) { Token top opStack.pop(); if (top.type Token.Type.LEFT_PAREN) { throw new ExpressionException(左括号没有匹配的右括号); } output.add(top); } return output; } int priority(char op) { switch (op) { case : case -: return 1; case *: case /: return 2; default: return 0; } }这段代码有几个关键点。第一运算符入栈前的while循环用的是保证同级运算符从左到右结合。第二右括号处理时如果栈空了还没找到左括号说明括号不匹配抛异常。第三扫描结束后如果栈里还有左括号也说明不匹配。第四左括号在栈内时priority返回0任何运算符的优先级都大于0所以不会弹出左括号这保证了括号内的运算符不会影响到括号外。3.4 后缀求值简单但要注意细节后缀求值逻辑很直接遍历后缀Token列表数字压栈运算符弹出两个数计算后压回。double evaluate(ListToken postfix) { DequeDouble stack new ArrayDeque(); for (Token t : postfix) { if (t.type Token.Type.NUMBER) { stack.push(t.numValue); } else { if (stack.size() 2) { throw new ExpressionException(运算符缺少操作数); } double b stack.pop(); // 注意先弹出的是右操作数 double a stack.pop(); double result; switch (t.opValue) { case : result a b; break; case -: result a - b; break; case *: result a * b; break; case /: if (Math.abs(b) 1e-10) { throw new ExpressionException(除数不能为零); } result a / b; break; default: throw new ExpressionException(未知运算符: t.opValue); } stack.push(result); } } if (stack.size() ! 1) { throw new ExpressionException(表达式不完整); } return stack.pop(); }这里最容易出错的地方是操作数的顺序。栈是后进先出所以先弹出的是右操作数b后弹出的是左操作数a。减法a - b和除法a / b的顺序绝对不能搞反。我第一次写的时候就把顺序搞反了10 - 3算出来是-7排查了好一会儿。另外除数为零的判断我用了一个极小值1e-10而不是直接 0因为浮点数运算可能有精度误差直接比较零不可靠。3.5 完整调用链路与测试用例把三个模块串起来主流程就是parse - infixToPostfix - evaluate。double calculate(String expr) { if (expr null || expr.trim().isEmpty()) { throw new ExpressionException(表达式为空); } ListToken tokens parse(expr); ListToken postfix infixToPostfix(tokens); return evaluate(postfix); }我准备了一组测试用例覆盖各种情况输入表达式期望结果测试目的3 47基本加法10 - 5 27同级运算符左结合3 4 * 523优先级处理(1 2) * 39括号优先级((2 3) * 4) - 515嵌套括号12.5 * 225.0小数运算-3 52单目负号100 / 4 / 55连续除法左结合3 异常表达式不完整(3 4异常左括号未闭合3 4)异常右括号未匹配10 / 0异常除数为零这组用例跑通基本可以认为实现是正确的。我建议你把这组用例写成单元测试每次改代码都跑一遍防止回归。4. 常见问题与排查技巧实录4.1 优先级比较用大于还是大于等于这是最高频的bug来源。我再强调一遍必须用大于等于。原因在2.2节说过同级运算符要从左到右计算。如果你用了大于10 - 5 2会先算5 2得到7再算10 - 7得到3而正确答案是10 - 5 55 2 7。这个bug很隐蔽因为单独的3 4或者3 * 4测试不出来只有混合同级运算符才会暴露。排查方法专门测试a - b c和a / b * c这种同级混合的表达式对比手算结果。如果结果不对八成是优先级比较的问题。4.2 多位数被拆成单个数字如果你发现12 3算出来是6123说明解析器没有正确处理多位数。问题出在扫描数字时只读了一个字符就停了。解决方法是读到数字后继续往后读直到遇到非数字字符。注意小数点的处理3.14要作为一个整体解析。还有一种情况是12.5.6这种非法输入包含多个小数点。我的做法是在解析数字时检查是否已经有小数点如果有就抛异常。这个校验加上去之后用户输入错误能立刻发现而不是等到求值阶段才报一个莫名其妙的错。4.3 括号嵌套导致的栈状态混乱嵌套括号是另一个容易出问题的地方。比如((2 3) * 4) - 5处理内层右括号时弹出运算符直到遇到内层左括号处理外层右括号时弹出直到遇到外层左括号。如果左括号的优先级设置不对或者右括号处理时没有正确弹出左括号就会导致栈状态混乱。我的经验是左括号在栈内的优先级必须设为最低我设为0这样任何运算符都不会把它弹出来。右括号处理时弹出运算符直到遇到左括号然后把左括号也弹出丢弃。这两个操作必须成对出现不能只弹运算符不弹左括号。排查方法在转换过程中打印栈的状态和输出序列对比手工推导的结果。我调试的时候就是在每次push和pop时打印日志一眼就能看出哪一步不对。4.4 浮点数精度问题浮点数运算有精度误差0.1 0.2不等于0.3而是0.30000000000000004。这在计算器场景里可能不是大问题但如果你要做结果比较或者显示格式化就需要注意。我的做法是在最终输出时用String.format(%.10f, result)然后去掉末尾的零这样既能保证精度又能让显示结果干净。除数为零的判断也要用极小值而不是直接等于零原因同上。我用的是Math.abs(b) 1e-10这个阈值可以根据实际需求调整。4.5 常见问题速查表问题现象可能原因排查方向解决方法同级运算结果错误优先级比较用了大于测试a-bc改为大于等于多位数被拆分数字扫描逻辑不完整测试123循环读取连续数字括号表达式结果错误左括号优先级设置不当打印栈状态左括号优先级设为0减法结果符号相反操作数弹出顺序搞反检查pop顺序先弹b后弹a除零崩溃未做除零校验测试10/0加极小值判断单目负号报错未识别单目负号测试-35前补0转双目表达式为空崩溃未做空值校验测试空字符串入口处校验非法字符未报错解析器缺少校验测试3a加else分支抛异常实操心得调试表达式求值的时候最有效的方法是把中缀Token序列、后缀Token序列、每一步的栈状态都打印出来。我一般会在转换器和求值器里加一个debug开关打开就打印详细日志。这样出问题的时候对比手工推导的中间结果很快就能定位到是哪一步出了偏差。比盲目加断点效率高得多。5. 性能优化与扩展方向5.1 时间复杂度与空间复杂度分析整个算法的时间复杂度是O(n)n是表达式的长度。解析阶段扫描一遍字符串是O(n)转换阶段每个Token最多入栈一次出栈一次也是O(n)求值阶段同理。空间复杂度也是O(n)主要是Token列表和栈的存储开销。这个复杂度对于日常使用完全够用。一个长度为1000的表达式整个计算过程在毫秒级别。如果你要处理超长表达式比如几万个字符可以考虑流式处理边解析边转换边求值减少中间存储。但说实话这种场景很少见没必要过度优化。5.2 支持更多运算符当前实现支持加减乘除和括号。如果要支持幂运算^只需要改两个地方在解析器里把^识别为运算符在优先级函数里给^设为3级比乘除高。注意幂运算是右结合的2^3^2应该算成2^(3^2)而不是(2^3)^2所以优先级比较时对^要用大于而不是大于等于。这个细节和加减乘除相反容易搞错。如果要支持取模%优先级和乘除同级解析和求值各加一个case就行。如果要支持三角函数、对数等函数就需要引入函数Token类型处理逻辑会复杂一些但整体框架不用变。5.3 从计算器到表达式引擎这个计算器本质上是一个简易的表达式引擎。如果你把数字和运算符抽象成变量和操作它就能用来做规则引擎、公式计算、报表统计等。我在实际项目里就基于这个框架做过一个简单的公式计算模块把变量名映射到实际数值用户配置公式系统自动计算。核心逻辑和这个计算器一模一样只是解析器需要支持标识符变量名。扩展的关键是把Token类型丰富化增加VARIABLE、FUNCTION等类型然后在求值阶段用一个上下文Context来解析变量的值。这个改造工作量不大但实用性提升很多。5.4 图形界面与交互优化如果要做成一个完整的计算器应用可以加一个简单的图形界面。输入框接收表达式按钮触发计算结果区域显示结果。历史记录可以用一个列表保存支持点击回填。这些UI层面的东西和核心算法无关但能提升用户体验。我个人的建议是先把核心算法跑通、测试覆盖到位再考虑UI。很多人一上来就搞界面结果核心逻辑一堆bug调试起来特别痛苦。算法是根界面是叶根扎稳了叶子自然茂盛。6. 我踩过的坑与实战建议第一个坑是优先级比较的大于和大于等于。这个我在前面反复强调了因为它真的太容易错了。我当时的测试用例只有34*5这种不同优先级的没测同级混合的结果上线后用户输入10-52就出问题了。后来我补了一组同级混合的测试用例才把这个bug彻底堵住。第二个坑是单目负号的处理。我一开始没考虑-35这种情况解析器遇到-就当减号处理结果Token序列变成[-, 3, , 5]转换的时候第一个Token就是运算符直接报错。后来加了前补0的逻辑才解决。这个问题的教训是解析阶段要考虑所有可能的输入形式不能只考虑正常的表达式。第三个坑是浮点数的显示。0.10.2算出来是0.30000000000000004用户看到这个结果会觉得程序有问题。我后来加了一个格式化函数保留10位小数然后去掉末尾的零显示成0.3用户就满意了。这个虽然不是算法问题但影响用户体验值得注意。第四个坑是异常信息的粒度。我一开始所有异常都抛表达式错误调试的时候完全不知道哪里错了。后来改成携带位置和具体原因比如位置5处右括号没有匹配的左括号排查效率提升了好几个档次。这个经验在任何一个项目里都适用异常信息越具体调试成本越低。最后一个建议一定要写单元测试。这个计算器的逻辑分支很多手动测试很容易漏。我把前面那张测试用例表里的每一行都写成了单元测试每次改代码跑一遍心里踏实。特别是优先级比较和括号处理这两块回归测试能帮你挡住大部分低级错误。这个用栈实现的计算器代码量不大但涵盖的知识点很密集栈的基本操作、中缀后缀转换、优先级处理、括号匹配、异常处理、浮点数精度。把它彻底搞懂你对栈的理解会上一个台阶。而且这套思路可以迁移到很多场景比如编译器里的表达式解析、配置文件里的公式计算、甚至Excel里的公式引擎底层逻辑都是相通的。
锦
锦皓数字建站
深耕本土企业品牌数字化升级,专注原创端正雅致商务官网,从视觉设计到稳定运维全程保驾护航。