ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

栈实现综合计算器:双栈分工与优先级处理详解

栈实现综合计算器:双栈分工与优先级处理详解

栈这个结构,很多人学的时候觉得简单,不就是后进先出嘛,拿个数组或者链表模拟一下,push、pop 一写就完事了。但真到了要拿它解决一个稍微像样点的问题,比如做一个能算加减乘除、带括号、还能处理多位数的计算器,就会发现事情没那么简单。我见过不少同学,栈的基本操作背得滚瓜烂熟,一到"用栈实现综合计算器"这种题就卡壳,核心原因不是栈没学好,而是没想清楚"栈到底在这里扮演什么角色"。

这篇内容就是围绕这个经典问题展开的。我会把用栈实现综合计算器的完整思路拆开讲,从为什么需要两个栈、每一步的判定逻辑怎么设计、多位数字怎么处理、括号怎么收尾,到实际写代码时容易踩的坑,都会给到可以直接复现的细节。适合已经了解栈的基本概念、想把它真正用起来的中初级开发者,也适合正在准备算法练习、想把"栈的应用"这块补扎实的朋友。读完你至少能独立写出一个支持四则运算和括号的整数计算器,并且明白每一步为什么这么设计。

1. 为什么一个栈不够用:从表达式求值的本质说起

1.1 中缀表达式带来的天然难题

我们平时写的算式,比如3 + 4 * 2 - 1,叫做中缀表达式,运算符夹在两个操作数中间。这种写法对人友好,但对计算机不友好。问题出在优先级和结合性上:4 * 2必须先算,尽管它出现在+后面。如果从左往右一路算过去,结果就错了。

要正确求值,计算机必须有能力"记住"前面还没轮到处理的运算符和操作数,等遇到更高优先级的运算时先处理掉。这个"记住"的需求,正好就是栈的强项——后进先出,最近遇到的运算符优先级最高,先被处理。

但只用一个栈是不够的。因为表达式里同时存在两类需要暂存的东西:一类是数字(操作数),一类是运算符。它们的处理时机和规则完全不同。数字要等运算符确定后才参与计算,运算符要等比较完优先级才决定是入栈还是立即执行。把这两类东西混在一个栈里,逻辑会变得极其混乱,你得不断判断栈顶到底是数字还是符号。

1.2 双栈分工:数栈与符号栈的职责边界

所以标准解法是开两个栈:一个数栈(numberStack)专门存操作数,一个符号栈(operatorStack)专门存运算符。两者各司其职,配合完成求值。

分工逻辑是这样的:

  • 扫描表达式,遇到数字就压入数栈;
  • 遇到运算符,先和符号栈栈顶的运算符比优先级;
  • 如果当前运算符优先级小于等于栈顶运算符,说明栈顶那个运算可以先算了,于是从数栈弹出两个数、从符号栈弹出一个运算符,执行计算,把结果压回数栈,然后继续拿当前运算符和新的栈顶比较;
  • 如果当前运算符优先级大于栈顶,或者符号栈为空,就直接把当前运算符压入符号栈;
  • 遇到左括号直接入符号栈,遇到右括号则不断执行计算直到弹出左括号为止。

这个流程的核心思想是:保证符号栈里的运算符优先级严格递增。一旦要压入的运算符破坏了递增关系,就先把前面能算的都算掉。这样当整个表达式扫描完,符号栈里剩下的运算符一定是按优先级从低到高排列的,从栈顶依次弹出计算即可。

提示:这里说的"优先级递增"是从栈底到栈顶的方向。栈顶永远是当前优先级最高的待处理运算符,所以它最先被消费。

1.3 一个具体例子走一遍

拿3 + 4 * 2 - 1来手动跑一遍,感受双栈的节奏:

步骤读到数栈(底→顶)符号栈(底→顶)动作说明
133空数字入数栈
2+3+符号栈空,直接入
343, 4+数字入数栈
4*3, 4+, ** 优先级高于 +,入栈
523, 4, 2+, *数字入数栈
6-3, 4, 2+, *- 优先级低于,先算 42=8
7-3, 8+继续比,- 等于 +,算 3+8=11
8-11空符号栈空,- 入栈
9111, 1-数字入数栈
10结束11, 1-弹出 -,算 11-1=10

最终结果 10,和手算一致。这个过程里,符号栈始终维持着"栈顶优先级最高"的状态,这就是它能正确工作的根本原因。

2. 优先级判定与多位数解析:两个最容易翻车的细节

2.1 优先级比较函数的写法与边界

优先级比较看起来简单,但边界处理不好就会出错。我一般用一个函数priority(op)返回运算符的优先级数值:

// 返回运算符优先级,数值越大优先级越高 public static int priority(int op) { if (op == '*' || op == '/') { return 1; } else if (op == '+' || op == '-') { return 0; } else { return -1; // 假定只有 + - * / 四种 } }

这里有个关键点:判定条件是"小于等于"而不是"小于"。为什么?因为四则运算满足左结合性,3 - 2 - 1必须从左往右算成(3-2)-1 = 0,而不是3-(2-1) = 2。当读到第二个-时,它和栈顶的-优先级相等,此时必须先把栈顶的-算掉,才能保证左结合。如果写成"小于"才计算,相等时直接入栈,就会变成右结合,结果就错了。

这个细节我在早期写的时候栽过跟头,当时测试10 - 2 - 3得到 11 而不是 5,排查了半天才发现是判定条件写成了严格小于。所以记住:同级运算符相遇,先算栈顶的,保证从左到右。

2.2 多位数拼接:不能读一位算一位

另一个高频错误是处理多位数。表达式123 + 45里,123是三个字符,如果你读一个字符1就当成数字 1 入栈,结果必然错。正确做法是:遇到数字字符时,先不急着入栈,而是继续往后看,把连续的数字字符拼成一个完整的数。

// 处理多位数:循环读取连续数字字符 String keepNum = ""; int index = 0; while (index < expression.length()) { char ch = expression.charAt(index); if (ch >= '0' && ch <= '9') { keepNum += ch; // 如果已经是最后一位,直接入栈 if (index == expression.length() - 1) { numStack.push(Integer.parseInt(keepNum)); } else { // 看下一位是不是数字,不是就说明当前数读完了 if (expression.charAt(index + 1) < '0' || expression.charAt(index + 1) > '9') { numStack.push(Integer.parseInt(keepNum)); keepNum = ""; // 清空,准备读下一个数 } } } // ... 处理运算符的逻辑 index++; }

这段代码的逻辑是:只要下一位还是数字,就继续拼;一旦下一位不是数字(是运算符、括号或已到末尾),说明当前这个多位数读完了,转成整数入栈,然后清空临时变量。

注意:keepNum每次入栈后必须清空,否则下一个数字会拼到上一个数后面。这个 bug 很隐蔽,因为只有连续出现多个数字时才会暴露。

2.3 用生活类比理解"延迟入栈"

多位数拼接的本质是"延迟决策"。就像你在超市结账,收银员扫一个商品不会立刻让你付款,而是把所有商品扫完再一起算总价。数字字符一个个读进来,先攒着,等确认这个数完整了再一次性入栈。这种"攒够了再处理"的思路,在处理不定长数据时非常常见,值得记住。

3. 括号的处理:让符号栈学会"就地清算"

3.1 左括号直接入栈的理由

括号是表达式里优先级最高的结构,但它本身不参与运算,只负责改变运算顺序。遇到左括号(时,直接把它压入符号栈,不做任何优先级比较。为什么?因为左括号相当于一个"隔离标记",它后面的运算符优先级再低,也要等括号内的内容全部算完才能和括号外的比较。

你可以把左括号理解成一道墙,墙内的运算自成一体,墙外的运算符暂时够不着墙内的东西。所以左括号入栈时不需要比较,它天然就是"最高优先级"的占位符。

3.2 右括号触发的连续清算

遇到右括号)时,逻辑就反过来了:它表示"括号内的运算到此结束,该结算了"。此时要不断从数栈和符号栈弹出元素做计算,直到符号栈栈顶是左括号为止,最后把左括号弹出丢弃。

// 遇到右括号,循环计算直到遇到左括号 while (!operatorStack.isEmpty() && operatorStack.peek() != '(') { int num2 = numStack.pop(); int num1 = numStack.pop(); int op = operatorStack.pop(); int res = cal(num1, num2, op); numStack.push(res); } // 弹出左括号 operatorStack.pop();

这里有个容易忽略的点:弹出两个数时,顺序不能反。先弹出的是右操作数(num2),后弹出的是左操作数(num1),计算时是num1 op num2。因为栈是后进先出,先入栈的左操作数在下面,后入栈的右操作数在上面,所以先弹出的是右操作数。减法、除法对顺序敏感,搞反了结果就错。

3.3 括号嵌套时的表现

对于嵌套括号,比如2 * (3 + (4 - 1)),双栈机制依然成立。每遇到一个左括号就入栈,每遇到一个右括号就清算到最近的左括号。因为栈的后进先出特性,最内层的括号最先被清算,天然符合"从内到外"的求值顺序。这也是栈结构优雅的地方——它自动帮你处理了嵌套层级,你不需要额外维护层级计数。

我实测过几层嵌套的表达式,只要括号匹配正确,逻辑完全不用改。唯一要注意的是括号不匹配的情况,比如多了一个右括号或者少了右括号,需要在代码里加校验,否则会抛异常或者死循环。

4. 完整实现与逐段拆解

4.1 核心计算函数的封装

先把最基础的计算逻辑抽出来,输入两个操作数和一个运算符,返回结果:

public static int cal(int num1, int num2, int op) { int res = 0; switch (op) { case '+': res = num1 + num2; break; case '-': res = num1 - num2; break; case '*': res = num1 * num2; break; case '/': res = num1 / num2; break; default: throw new RuntimeException("不支持的运算符: " + (char) op); } return res; }

注意参数顺序是num1在前、num2在后,调用时务必保证num1是左操作数。除法这里用的是整数除法,如果需要小数结果,把返回类型和栈的元素类型改成double即可,但要注意浮点精度问题。

4.2 主循环的完整骨架

把前面所有片段串起来,主流程如下:

public static int calculate(String expression) { Stack<Integer> numStack = new Stack<>(); Stack<Character> operatorStack = new Stack<>(); String keepNum = ""; int index = 0; while (index < expression.length()) { char ch = expression.charAt(index); if (ch >= '0' && ch <= '9') { // 多位数处理逻辑(见 2.2 节) keepNum += ch; if (index == expression.length() - 1) { numStack.push(Integer.parseInt(keepNum)); } else if (expression.charAt(index + 1) < '0' || expression.charAt(index + 1) > '9') { numStack.push(Integer.parseInt(keepNum)); keepNum = ""; } } else if (ch == '(') { operatorStack.push(ch); } else if (ch == ')') { // 右括号清算逻辑(见 3.2 节) while (operatorStack.peek() != '(') { int num2 = numStack.pop(); int num1 = numStack.pop(); int op = operatorStack.pop(); numStack.push(cal(num1, num2, op)); } operatorStack.pop(); // 弹出左括号 } else { // 运算符处理 while (!operatorStack.isEmpty() && operatorStack.peek() != '(' && priority(ch) <= priority(operatorStack.peek())) { int num2 = numStack.pop(); int num1 = numStack.pop(); int op = operatorStack.pop(); numStack.push(cal(num1, num2, op)); } operatorStack.push(ch); } index++; } // 扫描结束,清算剩余运算符 while (!operatorStack.isEmpty()) { int num2 = numStack.pop(); int num1 = numStack.pop(); int op = operatorStack.pop(); numStack.push(cal(num1, num2, op)); } return numStack.pop(); }

4.3 收尾清算为什么不能省

很多人写完主循环就以为结束了,忘了最后一步:表达式扫描完后,符号栈里可能还残留运算符。比如3 + 4,扫描完时数栈是[3, 4],符号栈是[+],如果不做收尾清算,直接返回数栈栈顶,得到的是 4 而不是 7。

收尾清算的逻辑和右括号清算几乎一样,只是没有左括号作为终止条件,而是一直清到符号栈为空。这一步是必须的,漏掉就是典型的结果错误。

提示:收尾清算时同样要注意弹出顺序,先弹右操作数,再弹左操作数。

5. 实测中暴露的坑与排查思路

5.1 空栈弹出导致的异常

最常见的运行时错误是EmptyStackException,通常发生在两个地方:一是表达式以运算符开头或结尾,比如+3或3+;二是括号不匹配,比如(3+4少了右括号,收尾清算时符号栈里还留着左括号,弹出时逻辑就乱了。

排查这类问题的思路是:在每次pop之前先判断栈是否为空,或者在入口处对表达式做一次合法性校验。我一般会写一个简单的校验函数,检查括号是否配对、首尾是否是合法字符。虽然多写几行代码,但能省下大量调试时间。

5.2 优先级相等时的处理顺序

前面提过,同级运算符必须"先算栈顶",否则左结合会变成右结合。这个坑的隐蔽之处在于,它只在连续同级运算时暴露,比如8 / 4 / 2。如果判定条件写错,会算成8 / (4 / 2) = 4,而正确答案是(8 / 4) / 2 = 1。测试用例一定要覆盖连续同级运算,尤其是减法和除法。

5.3 多位数与运算符紧邻的边界

还有一种情况是数字和运算符紧邻,比如12+3。处理12时,读到1发现下一位2还是数字,继续拼;读到2发现下一位是+,不是数字,于是把12入栈并清空。这个逻辑是对的。但如果表达式末尾是数字,比如12+3,处理最后的3时index已经是最后一位,必须走"已到末尾直接入栈"的分支,否则3永远不会入栈。这个边界我在第一次写的时候漏了,导致结果少算了最后一位。

5.4 一个实用的调试技巧

调试计算器时,最有效的方法是在每一步打印两个栈的状态。就像 1.3 节那张表一样,把每次读到的字符、数栈内容、符号栈内容、执行的动作都打出来,一眼就能看出哪一步逻辑跑偏了。这比单步调试快得多,因为你能看到整个状态流转的全貌。

常见现象可能原因排查方向
结果差最后一位数末尾数字未入栈检查是否处理了 index 到末尾的分支
连续减法结果偏大同级运算顺序错误检查优先级判定是否为小于等于
抛空栈异常括号不匹配或首尾非法加表达式合法性校验
多位数被拆开未做连续数字拼接检查 keepNum 逻辑与清空时机
括号内结果错误右括号清算未弹左括号检查清算循环终止条件

6. 从整数到浮点、从四则到更多运算的扩展方向

6.1 支持小数与负数

当前实现只支持非负整数。要支持小数,把数栈类型改成double,多位数拼接时把小数点.也纳入连续读取的范围即可。要支持负数,情况会复杂一些,因为-既可能是减号也可能是负号。判断方法是看-前面是不是运算符或左括号或表达式开头,如果是,那它就是负号,可以把它和后面的数字一起处理,或者用一个特殊标记。

6.2 引入更多运算符

如果想支持取模%、幂运算^,只需要在priority函数里给它们分配更高的优先级,并在cal函数里加上对应的计算分支。幂运算是右结合的,2^3^2应该算成2^(3^2),这时候优先级判定条件就要针对幂运算特殊处理,不能简单套用"小于等于"。这说明优先级和结合性是两个独立的概念,扩展时要分别考虑。

6.3 中缀转后缀的另一种思路

除了双栈直接求值,还有一种经典做法是先把中缀表达式转成后缀表达式(逆波兰表达式),再对后缀表达式求值。后缀表达式的优点是求值时不需要考虑优先级,从左往右遇到运算符就弹出两个数计算即可。转换过程本身也用栈,但逻辑和双栈求值不同。两种方法各有适用场景,双栈求值更直接,中缀转后缀在需要复用表达式(比如同一个表达式多次代入不同变量求值)时更有优势。

我个人在实际项目中更倾向双栈直接求值,因为代码量少、逻辑集中,调试时状态一目了然。但如果表达式需要被解析成语法树做进一步分析,那中缀转后缀或者直接构建 AST 会更合适。选哪种取决于你的实际需求,没有绝对的好坏。

6.4 性能与栈实现的选型

用 Java 的Stack类还是自己用数组模拟栈,在计算器这种场景下性能差异可以忽略,因为表达式通常不长。但如果是批量处理大量表达式,自己用数组实现栈能减少对象创建开销。另外Stack类是同步的,单线程场景下用ArrayDeque替代会更高效。这些都是实际工程里值得注意的小细节,虽然不影响正确性,但体现了对性能的敏感度。

写到这里,这个用栈实现综合计算器的完整思路就交代清楚了。核心其实就三件事:双栈分工、优先级比较用小于等于、多位数要拼接。把这三件事想明白,剩下的就是代码细节和边界处理。我建议你先把 1.3 节那张手动推演表自己走一遍,再动手写代码,写完用几组带括号和连续同级运算的表达式测一测,基本就能掌握扎实了。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进