字符串表达式运算(某高校机试真题)
字符串表达式运算这个题目,国内但凡以算法考核为主的机试,几乎都绕不开它。我印象里某高校的机试就出过一道很典型的题目:输入一个只包含数字、加减乘除和括号的字符串表达式,要求计算出它的结果,并且要考虑运算优先级、括号嵌套、多位数、负号、空格等一系列细节。题目本身不难,但能在半小时内写出鲁棒、简洁、能在边界用例下不翻车的解,真得靠平时扎实的基本功。这篇文章我就完整拆解这道题,从思路演到最终代码,再到测试用例和机试时常见的坑,一步步带你把这道题吃透。
适合看这篇内容的读者,主要是两类:一类是准备机试、面试、算法竞赛,需要刷“字符串处理 + 栈”这个高频考点的同学;另一类是平时写代码不太注意边界情况、总被表达式校验搞到崩溃的开发者。我会尽量把每一步的“为什么”都讲清楚,而不是直接扔一段代码让你背。
1. 题目解读与整体设计思路
1.1 核心需求解析
先来看这道题最朴素的表述:给定一个字符串,里面包含非负整数、加减乘除(+ - * /)、括号(( )),偶尔还会混入空格,要求你计算这个表达式的结果。比如输入"2*(3+4)-5/2",输出12.5;输入" 1 + 2 ",输出3。
但机试题目从来不会只考这么一句。真实的考核点藏在题目细节里:
- 表达式里的数字可能是多位数,比如
"123*4+5",而不是只有个位数。 - 可能有嵌套括号,比如
"((1+2)*(3+4))"。 - 可能存在负数,比如
"-3+5"或"1+(-2*3)",题目有时会明确说支持负号,有时不说但测试用例里藏着。 - 可能有空格,甚至全角空格(如果系统输入是中文环境)。
- 可能输入为空字符串,或只含空格。
- 可能除数为零,这需要明确约定(通常题目会给约定,不给我们就要按浮点数处理或者抛错)。
- 可能括号不匹配,或者运算符连续(
"3++4"),题目可能没有明说,但考验你的防御性编程能力。
这其实就是真实机试中“字符串运算”类题目的完整画像。它的核心难点不是“计算”本身,而是“解析”——你要把一个扁平结构的字符串,按数学规则转换为计算机内部的数据结构(栈或树),再按规则计算。
1.2 为什么栈是这类问题的不二选择
遇到表达式求值,最常见的两种方案是:双栈法(操作数栈 + 运算符栈)和递归下降解析法(把表达式看作是递归定义的文法)。还有一个经典做法是把中缀表达式转成后缀表达式(逆波兰式)再求值。
对于机试场景,双栈法是性价比最高的方案。原因有三:
- 它不需要构建额外的 Expression 树节点,内存占用小,遍历次数也少。
- 它天然支持括号、优先级、多位数,不需要像递归下降那样为每个文法规则写函数。
- 它的代码量相对克制,在一张A4纸的范围内能写完,加上注释也就七八十行,非常适合限时考核。
递归下降解析其实也很优雅,但它的工程复杂度更高,尤其要处理带符号的一元负号时,需要额外设计parseUnary()之类的逻辑。对于大多数机试场景,杀鸡用牛刀反而容易写错。
双栈法的核心思想,可以拿“堆盘子”做类比:你有一个放数字的盘子堆,一个放运算符的盘子堆。碰到一个运算符时,如果它比栈顶运算符优先级低或相同,就把栈顶的运算符先“算掉”,再把新运算符压栈。括号则像特殊标记,见到右括号就把匹配的左括号之间的所有运算做完。这个过程保证了乘除永远优先于加减,括号里的内容永远先完成。
1.3 流程总览
整个算法的处理顺序可以拆成 4 个阶段:
- 预处理:去掉空格、统一符号。空格直接跳过就行,不需要真的删除字符串,遍历时
if ch == ' '就continue。 - 数字解析:遇到数字字符时,连续读若干位直到遇到非数字,把它拼成一个完整的整数或浮点数。这里千万不能只解析一位。
- 运算符处理:遇到
+ - * /,根据优先级决定是否要先计算栈里的旧运算符,然后把当前运算符压栈;遇到(直接压栈,遇到)则一直弹栈计算,直到弹出对应的(。 - 收尾清算:表达式遍历结束后,把操作数栈和运算符栈里剩余的部分全部计算完毕,栈顶就是最终结果。
这个流程本身很简单,但里面暗藏很多细节,接下来逐一展开。
2. 双栈算法的核心细节与关键设计
2.1 运算符优先级表的设计
优先级是整个算法的灵魂。我们需要定义+和-为一级优先级,*和/为二级优先级。括号是特殊标记,左括号只有在遇到右括号时才弹栈,平时它就像一个屏障把左右两侧隔开。
这里有个很多初学者会绕进去的地方:两个同优先级运算符连续出现时,到底谁先算?比如"8-3-2",如果你不弹栈,先压入减号,再压入第二个减号,最后从右往左算,就变成了8-(3-2)=7,结果是错的。正确的是从左往右算,(8-3)-2=3。所以代码里判断“如果栈顶运算符优先级大于等于当前运算符优先级,就弹出栈顶并计算”,这个“大于等于”是关键。
我在代码里用一个函数返回运算符的等级:
def precedence(op): if op in ('+', '-'): return 1 if op in ('*', '/'): return 2 return 0 # 括号当0处理有两个地方会用到它:一是判断while是否继续弹栈;二是在计算弹出栈顶运算符时,确定运算方向。
2.2 负数到底怎么处理
负数是个极易翻车的点。题目里如果出现"-3+2",你直接在数字解析阶段就会卡住,因为字符串第一个字符不是数字。我的处理方式分两种情况:
- 如果
-是表达式的第一个字符,或者它紧跟在一个运算符或左括号后面(比如"1*(-2+3)"),那么这个负号不是二元减法,而是一元取负。处理方式是往操作数栈里压一个 0,把表达式变成"0-2"这种形式,再走正常的减法路线。 - 如果
-在两个操作数之间(比如"3-2"),它就是二元减法,按普通运算符处理。
这种“缺省补零”的技巧很实用,它避免了你为负号专门写一套解析逻辑。同类问题还有正号+,比如"+3+2",不过机试里这个场景极少见,我一般只在代码注释里提一下。
补零的思路也可以扩展:如果题目支持“一元正号”,你完全可以在同样位置补零或不处理,直接跳过也行。
2.3 括号匹配与弹栈时机
括号处理是整个流程里最容易写出 bug 的地方。左括号(是直接无条件压栈的——它不参与任何优先级比较,也无视栈顶的运算符。为什么?因为括号意味着“新开一个计算区域”,在遇到右括号之前,栈内原有的高优先级运算符已经完成了它们该做的计算,新栈内只积累括号里的运算符。
当遇到右括号)时,你的弹栈条件是:
while op_stack and op_stack[-1] != '(': # 弹出一个运算符,弹出两个操作数,计算结果压回操作数栈 ... # 弹出左括号 op_stack.pop()每次弹出一个二元运算符,就从操作数栈弹出右操作数和左操作数,注意顺序:栈底是左边,栈顶是右边,所以先弹出的数字其实是右操作数。这个顺序如果在写代码时搞反了,减法除法会瞬间全错,而且很难排查。
一个常见的防御性处理是:如果遇到右括号时,运算符栈里根本没有左括号(栈为空,或栈顶不是左括号),说明表达式括号不匹配。有些机试环境不会给你保证,这就可以直接抛异常或者返回错误码。我一般会加一个检查,避免线上暴力测试直接崩掉。
2.4 运算函数的设计与除法细节
基本的四则运算函数很简单,唯一要留心的是除法。机试通常有两种要求:
- 如果只要求整数结果,用整数除法。这种情况下遇到
5/2结果是2,是向下取整还是向零取整要分语言。Python 里需要写int(left / right)来向零取整,直接用//在-7/2这类场景会得到-4,不符合数学直觉,容易踩坑。 - 如果要求浮点数结果,全流程用浮点数。我习惯把所有数字都
float()处理,最终输出时再根据题目要求格式化。
我在实际写的时候,为了通用性,统一采用浮点数计算,最后如果结果跟整数相等,就保留成整数形式输出;如果不相等,保留合适的小数位。有些题目会允许误差,那就不必操心,直接输出浮点数即可。
def apply_op(op, a, b): if op == '+': return a + b if op == '-': return a - b if op == '*': return a * b if op == '/': if abs(b) < 1e-12: raise ValueError("除以零") return a / b raise ValueError("未知运算符")3. 完整实现与机试现场实操
3.1 一套可直接参考的完整实现
我先给出一份 Python 版本的完整答案。选 Python 是因为它在机试中调试速度快、能迅速验证思路;如果你用的是 C/C++,整个逻辑完全一样,只需要把浮点数类型换成 double,把栈用标准库替代即可。
def calculate(expression: str) -> float: # 去空格 + 统一处理负数 expression = expression.replace(' ', '') n = len(expression) i = 0 nums = [] ops = [] def apply(): if len(nums) < 2 or not ops: return op = ops.pop() b = nums.pop() a = nums.pop() nums.append(apply_op(op, a, b)) while i < n: ch = expression[i] if ch.isdigit() or (ch == '-' and (i == 0 or expression[i-1] in '(+-*/')): # 处理负号:缺省补零 if ch == '-' and (i == 0 or expression[i-1] in '(+-*/'): nums.append(0.0) ops.append('-') i += 1 continue # 解析多位数/小数 j = i while j < n and (expression[j].isdigit() or expression[j] == '.'): j += 1 nums.append(float(expression[i:j])) i = j continue if ch in '+-*/': while ops and ops[-1] != '(' and precedence(ops[-1]) >= precedence(ch): apply() ops.append(ch) elif ch == '(': ops.append(ch) elif ch == ')': while ops and ops[-1] != '(': apply() if not ops: raise ValueError("括号不匹配") ops.pop() # 弹出左括号 i += 1 while ops: apply() if len(nums) != 1: raise ValueError("非法表达式") return nums[0]代码里有个细节值得注意:ch == '-' and (i == 0 or expression[i-1] in '(+-*/')这个判断,准确识别了负号出现的时机。它解决的是"(-3)"、"1+(-2*3)"这类用例。如果题目明确不需要支持负数,这个分支可以直接删掉,代码还能短一截,但我建议保留,因为防御性编程在机试里永远不会扣分。
3.2 关键步骤现场拆解
我带你看一遍"2*(3+4)-5/2"这个表达式的完整计算过程,你就明白为什么栈能保证优先级不出错。
| 步骤 | 读取字符 | 操作 | 操作数栈(底→顶) | 运算符栈(底→顶) |
|---|---|---|---|---|
| 1 | 2 | 解析数字压栈 | [2] | [] |
| 2 | * | 栈顶无优先级更高的运算符,直接压栈 | [2] | [*] |
| 3 | ( | 压栈 | [2] | [*, (] |
| 4 | 3 | 压栈 | [2, 3] | [*, (] |
| 5 | + | 左括号在栈顶,不弹;压栈 | [2, 3] | [*, (, +] |
| 6 | 4 | 压栈 | [2, 3, 4] | [*, (, +] |
| 7 | ) | 弹+,取 3、4 得 7 压回;弹出左括号 | [2, 7] | [*] |
| 8 | - | *的优先级大于-,先弹*,取 2、7 得 14;再压- | [14] | [-] |
| 9 | 5 | 压栈 | [14, 5] | [-] |
| 10 | / | -优先级等于/?不,-优先级1小于/的2,直接压栈 | [14, 5] | [-, /] |
| 11 | 2 | 压栈 | [14, 5, 2] | [-, /] |
| 12 | 结束 | 弹/,取 5、2 得 2.5;弹-,取 14、2.5 得 11.5 | [11.5] | [] |
最终结果是 11.5,和手算完全一致。你仔细看第 8 步:因为*优先级高于随后的-,所以在压入-之前要把栈里的*先算掉。这是整个算法最精妙的一步,也是所有双栈表达式的核心逻辑。
3.3 机试环境下的边界测试用例
有了代码,紧接着要做的就是测试。机试没有在线调试器,你必须养成写用例自测的习惯。以下是我每次写完这个题必测的list:
| 输入 | 期望结果 | 测试意图 |
|---|---|---|
2*(3+4)-5/2 | 11.5 | 常规混合运算 |
1+2*3 | 7 | 乘法优先级 |
(1+2)*(3+4) | 21 | 括号嵌套 |
-3+5 | 2 | 表达式开头负号 |
1+(-2*3) | -5 | 括号内负号 |
10/2/5 | 1 | 连续同优先级除法,从左到右 |
8-3-2 | 3 | 连续同优先级减法方向 |
2 + 3 * 4 | 14 | 空格乱入 |
123*456+78 | 56166 | 多位数 |
(1+2)*(3+4)/(2-1) | 21 | 综合嵌套 |
1/0 | 抛异常或约定值 | 除零防御 |
输出格式方面,如果题目没有额外要求,11.5可以打印成11.5;如果要求整数形式,则必须判断小数部分是否为零,做一次格式化再输出。这个小细节看起来不起眼,但实际机试时因为输出格式不匹配被判错的人,远比算法写错的人多。
3.4 语言选型带来的微妙差异
用 Python 写这个题有一个优势:数字解析非常自然,float()强转即可,不用担心字符到数字的繁琐转换。但它也有坑,比如float('1.2.3')会崩,虽然我们的解析循环已经规避了连续两个小数点的问题,但你写的正则在面对"1."时是否也要容忍?这个看题目要求,通常机试不会出这种变态输入。
如果换 C++,数字解析用stod配合substr会麻烦一点,但控制力更强。操作数栈用std::vector<double>,运算符栈用std::vector<char>,性能也比 Python 好不少。不过在机试时间有限的情况下,我强烈建议选择你最熟练的语言,而不是选“理论上性能最好”的语言。
提示:无论你用哪门语言,请在写完代码后立刻用上面的测试用例跑一遍。我见过太多人“感觉写对了”,结果栽在第一步的边界检查上。
4. 调试实录与常见问题速查
4.1 我实测中踩过的几个真实大坑
每次写这个题,我都能在注释里发现至少一个容易忽略的陷阱。它们不是题目本身难,而是“考虑不周”导致的全军覆没。
第一坑:括号里的栈管理混乱。有的同学在遇到左括号时也做了优先级比较,直接导致"1+(2*3)"里左括号后的2跑到外层表达式里去了。左括号就应该无条件压栈,没有任何例外。它充当的是“保护墙”,墙外的东西跟墙内的运算不产生交集。
第二坑:表达式结束后忘记清空栈。最典型的场景是"1+2+3",你的代码在循环里依次处理完1、+、2、+、3后,ops栈里还有两个加号,nums栈里有三个操作数。很多新手看到这里就返回nums[0],结果出来是1而不是6。收尾清算必须写到while ops:循环里。
第三坑:右括号弹出左括号后没有检查栈是否为空。如果输入是"1+2)",右括号前面没有左括号,你如果直接把栈清空了,再执行后面的步骤就会崩。防御性做法是遇到右括号时,在弹栈前先检查栈里是否有左括号。
第四坑:除零判断遗漏。机试环境里,如果输入是"1/0",你不做检查,Python 会抛ZeroDivisionError,C++ 会直接程序终止,两种情况都会导致你的解答被当作运行错误处理。遇到这种情况,要么题目约定输出特定值(比如0、-1),要么约定输入合法不包含除零,否则你必须自己处理。
4.2 常见问题速查表
| 问题现象 | 可能原因 | 解决思路 |
|---|---|---|
3-5-2结果为0而非-4 | 同优先级运算符未弹栈,从右往左计算了 | 弹栈时用>=,保证左结合 |
1+2*3结果为9而非7 | 只压栈不弹优先级更高的旧运算符 | 压入前检查并弹出栈顶高优先级运算符 |
(-3+2)解析失败 | 负号没考虑一元场景 | 在负号位置补0,变成0-3+2 |
2*(3+4)结果出错 | 左括号处理做了优先级比较 | 左括号无条件压栈 |
| 表达式末尾多一个运算符 | 缺少合法性校验 | 最终检查len(nums) == 1 |
| 小数输出格式不对 | 未根据题目约定格式化 | 先判断结果是否是整数再输出 |
这张表基本覆盖了这个题八成以上的代码 bug。如果你写完代码先对照这张表自查一遍,再去跑样例,调试时间能省下一大半。
4.3 机试现场的一些实战建议
到了真实的机试环境,时间是最大的敌人。我习惯的做法是:先花两分钟画一个简单的流程草图(甚至可以用注释在编辑器里写下来),再动手写代码。写完之后绝不马上去看下一题,而是先跑题目自带的样例,再用自己补充的边界样例验证。机试系统通常只告诉你编译或者运行失败,如果结果错误,你只能自己靠肉眼找问题。
还有一个小技巧:先把表达式里的空格全去掉,能够极大地降低调试难度。不去掉也完全可以,但你在判断字符是否是数字时,就得额外检查i < n和expression[i].isdigit()组合逻辑,麻烦一点。
注意:机试垂直难度不大,但横向覆盖度很广。字符串表达式运算一般不会单独考,而是隐藏在“简化路径”“基本计算器”“逆波兰式求值”这些变种题目里。掌握这道题的解法,等于掌握了整个表达式家族的钥匙。
5. 从这道题延伸出的变体与进阶
5.1 支持变量与自定义函数
如果你去刷更高级的题目,会发现表达式运算有三个必然的变体方向:变量替换、函数调用、逻辑运算。
变量替换是最常见的延伸。题目可能给你一个dict,里面存了x=3, y=4,然后输入"x+y*2",要求计算结果。这种情况下,你只需要在数字解析阶段发现字母时去查表替换即可——前提是保证变量名不能和运算符冲突。实现时把当前读取到的连续字母序列作为一个变量名,从表里取值再压栈。这个改造量很小,但能体现出你对原算法的理解深度。
函数调用则要复杂一点。比如输入"max(1,2) + 3",你需要识别max关键字、括号参数、逗号分隔符。这个过程本质上是在做一次简易的 token 解析。我的建议是扩展运算符栈——把max视为一个特殊运算符,在遇到)时不仅弹普通运算符,还要识别函数运算符并单独处理。
5.2 递归下降解析法作为第二方案
双栈法虽然好,但并不是唯一的答案。递归下降解析法在某些场景下反而更直观:把表达式定义为文法规则,然后用一组递归函数互相调用解析。它的好处是代码结构天然遵循“乘除优先于加减”的规则,阅读性好,扩展变量和函数也方便;缺点是在机试的高压环境下,递归函数的边界条件容易写漏。
如果追求极致简洁,还可以先转后缀表达式再求值。逻辑是:先把中缀表达式扫描一遍,按优先级输出到后缀队列,然后用一个栈直接对后缀队列求值。本质上双栈法是“扫描一遍就地求值”,后缀法是“扫描一遍转换 + 再扫描一遍求值”,后者更容易调试和分步验证,但时间上多了一次遍历。
以机试的时间考量,我还是推荐大家优先掌握双栈法。因为你只要理解透了,变种题目只需要在压栈和弹栈条件上做微调即可,不用改大框架。
5.3 实战训练建议
如果你想彻底吃透这道题,我建议你用三种不同语言各写一遍,并强制加上负数和空格支持。每一遍都是对思路的重塑:
- 用你最熟悉的语言写通,目标是能完整跑通所有种子用例。
- 换一种语言写,你会被迫重新思考类型转换和字符串处理细节,比如 C++ 的
isdigit和stod的交互。 - 最后去掉代码里的注释,再默写一遍,然后立刻用边界用例轰炸自己。
这个过程看起来麻烦,实际一晚上就能完成。经过这三遍,字符串表达式运算这个考点基本就跑不出你的知识框架了。
6. 收个尾:我自己的一些体会
我从第一次接触这道题到现在,大概写过十几个版本:从最早只支持个位数加减,到后来支持小数、负数、变量、幂运算、函数调用,每次重构都能发现新的边界。写这种题最爽的一点是,它像一把解剖刀,把“计算机如何理解数学表达式”这件事剥得清清楚楚。很多刚入门的同学会担心自己没接触过编译原理,会不会看不懂双栈法,其实完全不用担心。你只需要把“栈”理解成一个堆盘子的地方,每当出现一个运算符,就问问自己:栈顶的东西是不是应该先算?如果是,就算;如果不是,就先留着。就这么简单。
如果你在机试现场卡在字符串表达式上,不妨深呼吸,回到“栈 + 优先级”这两个关键词上。只要这两个点不乱,代码再长也不怕。