☰
机试高频题:双栈法实现简单计算器,吃透表达式求值核心
2026/10/14 8:55:15 网站建设 项目流程

最近很多准备大厂机试的朋友都在刷一类题,名字朴素得很——“实现一个简单的计算器”。我一开始也觉得这题没难度,不就是算个数嘛,结果自己动手写了几遍才发现,这题一点都不简单。它能红遍机试圈是有原因的:代码量不大,但把字符串处理、数据结构、优先级思维、边界条件全考了个遍,效果拉满。刷完这一道,很多看似更吓人的题,比如带括号的表达式解析、逆波兰表达式,都会顺手很多。

这篇就用“实现一个简单的计算器”这个经典机试题目当引子,把从读题到写代码再到调试的完整过程掰开揉碎。适合正在准备软件开发岗机试的朋友,也适合想系统练一练栈应用和表达式求值的同学。看完之后你不仅能背下一套能跑的代码,还能明白每一步为什么这么写,踩坑的时候知道往哪个方向排查。

1. 先别急着敲代码:这类计算器题到底在考什么

1.1 题目常见的形态和隐含条件

先说题目本身。机试里的“简单计算器”形态比较固定,我见过的大多长这样:

给定一个字符串表达式,其中只包含非负整数、+、-、*、/、空格和括号(),请计算出表达式的结果。

听起来很简单,但有几个隐含信息特别关键。第一是“非负整数”,这意味着负号只可能是运算符,或者是一元负号,不需要考虑正负数字面量。第二是“空格”,空格是合法的,输入里可能出现在任意位置,比如" 3 + 5 "这种。第三是“括号”,有括号就意味着要考虑括号优先级,最外层表达式可能还套着多层括号。

有些题目还会明确告诉你“除法只保留整数部分”,甚至取消括号。但不管怎么变,核心骨架是一样的:你得把一串字符变成计算机能算的数值结果。很多朋友一上来就直接对着字符串从左往右扫,遇到+就算,扫到*就懵了——这就是没想明白优先级这回事。

1.2 为什么大厂机试偏爱这道题

我总结了一下,这题能考察的能力点非常密集。首先当然是栈的应用,这是数据结构里最基础也最常用的东西。然后是运算符优先级的处理,这是很多实际项目里都会遇到的问题,比如解析配置文件、计算Excel公式、实现规则引擎,本质都是表达式求值。

再来是字符串的鲁棒处理。机试的在线评测系统不会跟你讲情面,输入中多一个空格、少一个右括号、突然来个超大数字,都可能让你的代码直接崩溃。处理字符串的边界能力,恰恰是实际工程里最需要的素质。最后是除法取整和溢出问题,这能考察你对数据类型的敏感度。所以它真的不只是一个“算数题”,它是一道综合能力题。

1.3 拿到题目后先做这三件事

我每次做这类题,拿到手不会马上写代码,先做三个确认。一是确认运算规则:除法是整数除法还是实数除法,负数除法向零取整还是向下取整。二是确认输入范围:数字是不是非负整数,中间有没有空格,括号会不会不匹配。三是确认输出要求:结果用int还是long。

这三个确认直接决定代码写法。举个例子,C++ 的int除法对负数来说是向零取整的,和 Python 的//行为不一样。如果题目只要求非负整数,其实不会遇到负数除法问题,但-3 / 2这种子表达式在带括号的表达式里是可能出现的,一旦出现,不同语言的取整规则不同,结果就会不一致。所以看题时要注意,到底是“非负整数参与运算”还是“中间过程也可能出现负数”,这决定了你要不要处理负数除法。

2. 核心方案选型:为什么主流解法都用“双栈求值”

2.1 三种常见思路对比

解决表达式求值,业内常见的思路有三条:递归下降解析、中缀转后缀再求值、双栈直接求值。我把它们放在一起对比过,各有优劣。

递归下降是编译器设计的正统思路,把表达式拆成“项”“因子”这种层级,用子函数递归解析。这种思路最符合人类对语法的理解,代码写出来也最规整,但是函数调用多,机试有限时,有些人对递归有心理障碍,而且出问题时不容易在脑子里跑通。

中缀转后缀(逆波兰表达式)是另一种经典思路。先把中缀表达式转换成后缀表达式,然后再用一个栈直接遍历后缀表达式求值。好处是两个阶段职责清晰,求值部分几乎没有优先级判断。坏处是要额外维护一个输出序列,代码量偏长。

双栈直接求值就是在遍历中缀表达式的过程中,用一个栈存数字、一个栈存运算符,边读边算。这是最直观、代码量最短、也是机试最推荐的方式。它的优点是只遍历一趟字符串,时间复杂度是 O(n),空间复杂度是 O(n),而且能顺手处理括号,不需要额外生成中间表达式。我建议大部分人都用这种方案。

2.2 双栈求值的核心机制:优先级表与触发条件

双栈法要成立,关键在于一张优先级表。假设我们用1表示加减的优先级,用2表示乘除的优先级,那么运算符之间什么时候该结算,规则就一句话:

当新来的运算符优先级 <= 栈顶运算符优先级时,先把栈里的运算符算掉,再把新运算符入栈。

我们算一个最简单的例子"3 + 2 * 2"。扫描3,数字栈压入3。扫描+,运算符栈为空,直接压栈。扫描2,数字栈压入2。扫描*,看到栈顶是+,当前*优先级是 2,栈顶+优先级是 1,2 > 1,不结算,直接把*压栈。最后扫描2,压入数字栈。表达式结束,开始清空运算符栈,先弹出*,从数字栈弹出两个数算2 * 2得4,压回数字栈。再弹出+,弹出3和4,算3 + 4得7。

如果上面例子中扫描到的第二个运算符不是*而是+,那情况就反过来了。"3 + 2 + 2"扫描到第二个+时,栈顶+的优先级是 1,当前+的优先级也是 1,满足“<=”条件,于是先结算栈里的+,再把当前的+入栈。这样才能保证表达式从左到右计算符合预期。

2.3 括号、单目负号怎么处理才优雅

括号的处理也在这套机制里加一条规则:遇到(直接压入运算符栈,它不需要参与优先级比较。遇到)则一直弹出运算符并结算,直到弹到(为止,然后把这个左括号丢弃。

单目负号是个经典的坑。比如输入是"1+(-2*3)",扫描到(后面的-时,它既不是两个数字之间的减法,数字栈里也没有对应的左操作数。这时候最优雅的解法是在(后面补一个0,让负号变成0 - x的二元运算。具体做法是:如果当前字符是-且它的左边是(或者它位于表达式开头,就往数字栈压一个0,然后正常把-当作运算符处理。这样(-2*3)就等价于(0-2*3),语义完全不变。

我见过有些人用INT_MIN做哨兵值来区分一元负号,这个思路也能跑但很绕,容易引入额外bug。补零法最简单,而且不影响时间复杂度,强烈推荐。

3. 手写代码的完整流程与关键细节

3.1 整体框架:先跑通再优化

下面这份代码是我根据机试场景整理的通用版本,支持加减乘除、括号、空格、多位整数和一元负号。用的数据类型是long long,这是我在实际提交中学乖后的选择,很多评测数据会塞一个2^31级别的中间结果,int根本扛不住。

#include <iostream> #include <string> #include <stack> #include <unordered_map> #include <cctype> using namespace std; class SimpleCalculator { public: long long calculate(string s) { stack<long long> nums; stack<char> ops; // 用 unordered_map 存运算符优先级 unordered_map<char, int> priority = { {'+', 1}, {'-', 1}, {'*', 2}, {'/', 2} }; int n = s.size(); int i = 0; // 避免表达式开头就是负号,比如 "-3+2" if (n > 0 && s[0] == '-') nums.push(0); while (i < n) { if (s[i] == ' ') { i++; } else if (isdigit(s[i])) { long long num = 0; while (i < n && isdigit(s[i])) { num = num * 10 + (s[i] - '0'); i++; } nums.push(num); } else if (s[i] == '(') { // 如果左括号后直接跟负号,比如 "1+(-2)",补一个 0 if (i + 1 < n && s[i + 1] == '-') nums.push(0); ops.push(s[i]); i++; } else if (s[i] == ')') { // 一直结算到左括号 while (!ops.empty() && ops.top() != '(') { calcTop(nums, ops); } if (!ops.empty()) ops.pop(); // 弹出左括号 i++; } else { // 运算是 + - * / // 如果栈顶运算符优先级 >= 当前运算符,先结算 while (!ops.empty() && ops.top() != '(' && priority[ops.top()] >= priority[s[i]]) { calcTop(nums, ops); } ops.push(s[i]); i++; } } // 扫描完表达式后,结算剩余运算符 while (!ops.empty()) { calcTop(nums, ops); } return nums.top(); } private: void calcTop(stack<long long>& nums, stack<char>& ops) { if (nums.size() < 2) return; long long b = nums.top(); nums.pop(); long long a = nums.top(); nums.pop(); char op = ops.top(); ops.pop(); long long result = 0; if (op == '+') result = a + b; else if (op == '-') result = a - b; else if (op == '*') result = a * b; else if (op == '/') result = a / b; nums.push(result); } }; int main() { SimpleCalculator solver; string expr; // 机试一般要求多组输入,用 getline 逐行读 while (getline(cin, expr)) { cout << solver.calculate(expr) << endl; } return 0; }

这个代码是能直接跑的,但不要只背代码,要把里面几个关键判断理解透。下面我拆开讲。

3.2 状态机思维:扫描过程其实在分角色

整个扫描过程可以理解成一个小的状态机。表达式里的每个字符都归属四类:数字、空格、括号、运算符。每一类字符的处理方式完全不同,我用表格列一下:

字符类型处理动作关键理由
空格直接跳过空格不影响语义,只影响位置
数字循环读完整个数字,压入数字栈数字可能是多位,不能只读一位
(压入运算符栈左括号标记一个子表达式的开始
)结算直到弹出左括号括号内的运算符优先级被强制抬高
+ - * /按优先级决定先结算还是先入栈保证乘除先于加减、同级从左到右

很多新手问题出在“数字读取”这一步。比如输入"123+45",如果只读一位,数字栈里会变成1、2、3、4、5,结果完全不对。所以遇到isdigit之后必须用while循环把连续的数字全部消费掉,同时注意i指针要跟着移动到位。

calcTop这个辅助函数也要注意:它弹出数字栈时,先弹出的是右操作数b,再弹出的是左操作数a。因为栈是先进后出,1+2压栈时1先入、2后入,弹出时先拿到2后拿到1,所以计算要用a op b而不是b op a。减法和除法对顺序极其敏感,很多人在这里翻车。

3.3 容易忽视的除法取整与类型溢出

针对除法,机试里面常见的规则是“结果取整数部分”。我实测发现大多数机试的数据里不会出现负数除法,但如果你的代码逻辑里出现了-3 / 2这种子表达式,C++ 的int除法和 Java 一样都是向零取整,得到-1,而 Python 的//向下取整得到-2。如果你用 Python 刷题,就要特别留意评测端预期到底是哪一种。

类型溢出这一点,我多说两句。别看题目写的是“简单计算器”,实际评测数据经常塞一些看似离谱的用例,比如"999999999 + 999999999"这种,两个数都在int范围内,但加起来就超过了。用long long能覆盖绝大多数情况,如果是 Java 就考虑long。我在实际机试环境里吃过这个亏,交了一版int的代码,用例跑挂,后来全换long long才稳定通过。

3.4 不带括号的版本要怎么裁剪

有些机试题目为了降低难度,会明确说明“表达式不含括号”,比如只支持+ - * /和空格。这时你不需要处理(和),代码可以省掉好几段。但裁剪的时候要小心,运算符结算的while条件里原本用ops.top() != '('来防止弹出左括号,去掉括号后这个条件也没了,但结算逻辑整体还是要保留。

如果只需要处理加减而不需要乘除,比如某些场景只让算"1+2+3-4",其实连栈都可以不用,直接split然后累加。但这么做会把题做窄了,我不建议为了省事牺牲思路的通用性。你自己刷题时,宁可多想想通用版本,面试时如果能从通用版本裁剪出题目需要的版本,比一上来就写特例要加分得多。

4. 边界问题与调试经验:实测最容易挂的几种情况

4.1 测试用例怎么设计才靠谱

我把这道题经常踩的坑整理成了一张表,每一类我都给出测试用例,建议你拿自己的代码挨个跑一遍。能全部通过的,基本就稳了。

用例说明预期结果
"3+2*2"基本优先级7
" 3/2 "空格干扰、整数除法1
" 3+5 / 2 "空格和除法混在一起5
"1+((2+3)*4)-5"多层嵌套括号16
"1+(-2*3)"左括号后的一元负号-5
"-3+4"表达式开头的一元负号1
"2147483647+1"int 溢出边界2147483648(用 long long)
"0-2147483648"减法产生大负数-2147483648
"(1+2)"最外层包了括号3
"1+2)"右括号不匹配(正常不会出现)看题意是否保证合法

4.2 高频 bug 清单与排查方向

我在写这题以及帮同学看代码的过程中,发现大部分报错都集中在几个点上。

第一个是栈越界或空栈操作。结算时如果数字栈里不足两个数,直接pop就会崩溃。这种情况多半出现在括号异常或一元负号没处理干净的用例中。我在calcTop里加了if (nums.size() < 2) return;作为保护,代码更稳。但要记住这只是兜底方案,真正输入不合法的时候,正确做法是返回错误或者按题目约定处理。

第二个是字符判断顺序的问题。有些同学先判断运算符再判断括号,或者用if (s[i] == '+' || s[i] == '-' || ...)把括号漏了,导致遇到(的时候走进运算符分支。排查这种问题最好的办法是在每个分支里临时输出当前字符和两个栈的状态,肉眼观察哪个分支没有走到。

第三个是重复读取字符。数字循环读取时i没有正确递增,或者遇到空格时忘了i++,都会让程序死循环。我一直强调,机试环境不像 IDE 里有调试器,很多人就在while循环里卡死,看着像代码没有输出,其实是一直没跑完。

4.3 机试现场的时间分配和提交经验

机试的时间其实很宝贵,我习惯按照这样的节奏来分配这道题:前 5 分钟读题和确认边界规则,中间 15 到 20 分钟写代码和自测,最后 5 分钟专门检查输入输出格式和数据类型。这套节奏帮我避免过很多低级失误。

还有一个重要的细节,机试评测的输入输出必须严格匹配。很多题目是多组输入,需要用while (getline(cin, expr))循环读取,不能用只读一行的写法。输出也不要加任何提示文字,什么“结果为:”都不能有,直接输出数字,不然评测就判错误。这个问题非常经典,我见过不止一个人挂在多余输出上。另外一个经验是输出用\n而不是endl,endl会多一次刷新缓冲区的操作,在多层循环下拖慢速度,虽然计算器题不太在意,但养成好习惯总是对的。

5. 从这道题延伸出去:后续还能怎么练

5.1 变体一:把四则运算换成自定义运算优先级

很多系统设计题里会出现自定义运算符优先级的场景,比如实现一个支持&、|、!的逻辑表达式计算器,或者支持>>、<<的位运算计算器。套路跟四则运算一模一样,唯一的区别是把优先级表换掉。我用这题打底,后来去处理规则引擎里的布尔表达式,基本没费什么劲,因为核心思想都通了。

双栈法的精髓在于“用数据驱动计算顺序”,而不是硬编码优先级的判断逻辑。你只要把priority这个表扩展一下,任何二元运算符都能往里塞。明白了这一点,四则运算只是这些能力的一个最小载体。

5.2 变体二:表达式合法性校验

有些题目会混入“判断表达式是否合法”的要求,比如括号是否匹配、运算符是否连续出现、除数是否为零。我发现与其在计算过程中零散判断,不如先做一个独立的语法检查函数。用一个小栈专门检查括号是否配对,再用一次扫描确认相邻字符的语义是否合理,比如运算符后面必须跟数字或左括号。

这个做法看着多了一次遍历,但代码清晰度提升很大。计算函数里不需要到处塞非法判断,出 bug 的概率也显著降低。机试评分本来就是按用例给分,一次通过的命中率比少一次遍历更重要。

5.3 刷题时怎么利用这道题锻炼工程思维

最后分享一点我个人的刷题习惯。拿到这题时,我先在纸上画两个栈和一个输入指针,手动模拟一遍"1+2*3"的完整过程,然后再动手写代码。很多同学直接开写,写完觉得没问题就交,结果跑挂了还不知道为什么。手推一遍样例的收益非常大,一次手动推演能暴露你在什么地方理解得不对。

等代码能跑了,再逼着自己想想:如果输入是超大字符串呢?如果表达式有几万个括号嵌套呢?这题的时间复杂度是多少,空间占用能不能接受。顺着这些问题去优化,比如提前reserve栈空间,或者减少unordered_map的查表次数。虽然机试不一定需要这一步,但这种深度思考的能力,对你之后做真正的工程项目很有帮助。

我个人有个心得,计算器这道题刷三遍都不嫌多。第一遍照着思路写下来,能跑通就行。第二遍把代码简化到最短,去掉所有多余判断。第三遍尝试给它加功能,比如处理负数、处理括号不匹配。每一遍都会发现不同的坑。如果时间紧只能刷一遍,那至少把边界用例和调试方法记住,这都是我这几年实打实踩出来的经验。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询