题目描述
给定一个表达式,求其分数计算结果。
表达式的限制如下:
- 所有的输入数字皆为正整数(包括0)
- 仅支持四则运算(±*/)和括号
- 结果为整数或分数,分数必须化为最简格式(比如6,3/4,7/8,90/7)
- 除数可能为0,如果遇到这种情况,直接输出"ERROR"
- 输入和最终计算结果中的数字都不会超出整型范围
用例输入一定合法,不会出现括号不匹配的情况
输入描述
字符串格式的表达式,仅支持±*/,数字可能超过两位,可能带有空格,没有负数
长度小于200个字符
输出描述
表达式结果,以最简格式表达
- 如果结果为整数,那么直接输出整数
- 如果结果为负数,那么分子分母不可再约分,可以为假分数,不可表达为带分数
- 结果可能是负数,符号放在前面
用例1
输入:
1 + 5 * 7 / 8
输出:
43/8
用例2
输入:
1 / (0 - 5)
输出:
-1/5
说明:
符号需要提到最前面
用例3
输入:
1 * (3*4/(8-(7+0)))
输出:
12
说明:
注意括号可以多重嵌套
思考
求字符串格式的四则运算表达式结果,感觉和括号匹配类似,表达式包括+-*/()
,有优先级,特别是括号,要先计算最内层的括号中的表达式结果,再计算外层,应该用到栈对不对?我尝试用栈来处理括号,定义 +-*/
运算符优先级映射,然后遍历表达式中的每个计算单位(数字或运算符)处理。题目说明字符串中可能包括空格,需要把输入字符串空格去掉,JavaScript 用 str.replaceAll(" ", “”) 处理很简单。然后数字可能是两位数以上,遍历字符串是每个字符进行遍历的,所以直接遍历字符串还是不方便。要把字符串中的计算单位依次截取放到数组中操作更好。对于数字可以用双指针进行截取,这个考察了字符串处理和双指针算法的边界处理能力,很容易写错!双指针截取数字为了简单,我先判断字符串长度为1的例子,再把左指针 i 设为 0, 右指针 j 设为 1, 从 j 开始遍历字符串,让 i 指向 数字的起始位置,j 指向 数字的结束位置 + 1,就是数字右边第一个运算符位置,这样 str.substring(i, j) 就是一个数字计算单位了。好了,完成了表达式序列的生成,我开始利用栈来处理表达式序列的计算结果。我先判断表达式中有没有括号,有括号就先处括号表达式,对每个括号表达式进行单独计算,利用栈解决嵌套的括号问题,然后你还得记录每个括号表达式在整个表达式中的位置索引,方便和其它表达式一起合并结果!我用每个括号表达式的第一个计算单位在计算序列中的位置索引作为其位置。对于非括号表达式按*/
优先级大于+-
来计算。好吧,最终代码写不下去了,方法可能错了。想想 Windows 计算器软件是怎么解决这个问题的?经查阅资料知有个逆波兰表达式(又叫后缀表达式)可以处理数学表达式的解析和计算。简要说下这个逆波兰表达式知识点:
- 数学表达式格式可以写成三种形式:1)前缀表达式(波兰表达式);2)中缀表达式;3)后缀表达式(逆波兰表达式);
- 对于
3 + 4 * 5
,这个式子,它默认的样子就是中缀表达式,运算符位于数字之间,比如4 * 5
中的 * 在 4 和 5 之间; - 那么后缀表达式顾名思义,运算符放到数字之后(右边),
3 4 5 * +
,这个先找最贴近数字的符号,如*
,4 5*
表示4*5
,然后报4*5
看成一个整体f(4,5,*)
(或计算结果 ),如3 f(4,5,*) +
,表示3 + f(4,5,*)
,即3 + (4 * 5)
; - 同理前缀表达式,运算符放到数字前面(左边),
+ 3 * 4 5
经过仔细观察,发现逆波兰表达式(后缀表达式)比较方便用于计算。 计算顺序与人类习惯的“从左到右”一致。扫描表达式时,遇到操作数直接入栈,遇到运算符则弹出栈顶两个操作数计算,结果再入栈。整个过程无需回溯,逻辑线性且直观。
例:3 4 5 * +
计算步骤:
- 3、4、5 入栈 →
[3,4,5]
- 遇
*
:弹出 4、5 计算4*5=20
,结果入栈 →[3,20]
- 遇
+
:弹出 3、20 计算3+20=23
→ 最终结果 23。
而前缀表达式(波兰式)计算顺序需“从右到左”,扫描时需先定位运算符对应的操作数,可能需要多次回溯或预读,逻辑更复杂。
例:+ 3 * 4 5
计算步骤:
- 从右向左扫描,先遇到 5、4,再遇
*
→ 计算4*5=20
- 继续向左遇到 3、
+
→ 计算3+20=23
。
因此本题比较合适的解法是先把题目给的中缀表达式转换为后缀表达式,然后利用栈的性质求解后缀表达式的值。
-
中缀表达式转后缀表达式
这个过程也不简单,一般要用两个栈辅助。一个栈 S1 用于暂存运算符,一个栈 S2 作为结果栈。遍历中缀序列时,遇到数字就压入 S2,记住生成后缀表达式过程中都是优先把数字(或运算数)放到左边,运算符放到右边。遇到操作符时,对于左括号直接入栈 S1,左括号优先级设为最低,遇到右括号,需要将离栈顶最近的左括号右边的运算符都依次弹出栈 S1 并压入 S2,同时丢弃 S1 中那个左括号和当前遍历的右括号。如果遇到优先级高于栈顶的操作符情况(比如*/
大于+-
、+-
大于(
),则直接入栈 S1,否则先把栈 S1 中所有优先级小于当前遍历的操作符的操作符依次弹出并入栈 S2,然后再把当前运算符入栈 S1。当完成序列的遍历,最后把不为空的栈 S1 中的元素依次弹出再入栈 S2, S2 就是最终的后缀表达式。 -
计算后缀表达式值
这个过程相对简单些。定义一个辅助栈 Stack 和三个变量 a 和 b、ans,变量后缀表达式,如果是数字就要压入栈中。由于计算结果可能包含分数,且要化简,因此有必要思考怎么表示分数和后面分数之间的运算。我用数组表示分数,C++可以考虑用std::pair<2>,数组第一个元素是分子,第二个元素是分母。顶一个加减乘除四个函数处理两个分数之间的元素,对于除法就是第一个分数乘以第二个分数的倒数。由于最终结果如果有负号要提到前面去,因此考虑对每次的分数运算结果进行验证,如果分母是负数就把分子分母都乘以-1。起始出现负数的地方就是减法(小数减大数产生负数)和除法(分子分母倒置可能会把原来的负数分子弄到现在的分母上了),其它不用处理了。分数化简需要计算分母的最大公约数 gcd,这个不知道需要做个笔记,经常看看。继续之前的遍历,如果遇到操作符,就从栈中弹出两个数进行计算。计算完了一定要把结果 ans 入栈参与下次计算。如果遇到除法运算时除数为0就打印 ERROR,最终完成后缀表达式的遍历和计算,返回 ans的字符串表示。
算法过程
步骤1:输入预处理
- 目的:将原始输入字符串转换为结构化的中缀表达式(拆分数字、运算符、括号)。
- 操作:
- 去除空格:使用
replaceAll(' ', '')
清除输入中的所有空格,避免干扰解析。 - 解析计算单位:通过双指针法遍历处理后的字符串,拆分出多位数数字和单字符运算符/括号:
- 左指针
i
标记数字起始位置,右指针j
向右遍历,直到遇到非数字字符(运算符或括号)。 - 当
j
遇到非数字时,截取[i, j)
作为数字,加入中缀表达式列表midExp
;同时将当前非数字字符(运算符/括号)加入midExp
,并更新i
至j+1
。 - 遍历结束后,若
i < n
(字符串长度),将剩余部分(最后一个数字)加入midExp
。
- 左指针
- 示例:输入
"12 - 8/4+3* 20"
处理后,midExp
为[12, '-', 8, '/', 4, '+', 3, '*', 20]
。
- 去除空格:使用
步骤2:中缀表达式转后缀表达式(逆波兰式)
- 目的:消除中缀表达式中的括号和优先级歧义,转换为便于计算的后缀形式(运算符在操作数后)。
- 工具:两个栈
S1
(暂存运算符)和S2
(存储后缀表达式结果)。 - 规则:
- 处理数字:直接将数字推入
S2
。 - 处理左括号
(
:直接推入S1
(优先级暂定为最低,确保后续运算符可入栈)。 - 处理右括号
)
:从S1
弹出运算符并推入S2
,直到遇到左括号(
,弹出左括号但不加入S2
(丢弃括号)。 - 处理运算符
±*/
:- 若
S1
为空或栈顶是(
,直接将当前运算符推入S1
。 - 否则,比较当前运算符与
S1
栈顶运算符的优先级(*/
优先级为2,±
为1):- 若当前运算符优先级 高于 栈顶,推入
S1
。 - 若当前运算符优先级 小于或等于 栈顶,从
S1
弹出运算符并推入S2
,重复此过程直到满足入栈条件,再将当前运算符推入S1
。
- 若当前运算符优先级 高于 栈顶,推入
- 若
- 遍历结束后:将
S1
中剩余运算符全部弹出并推入S2
,S2
即为后缀表达式。 - 示例:中缀表达式
[3, '+', 4, '*', 5]
转换为后缀表达式[3, 4, 5, '*', '+']
。
- 处理数字:直接将数字推入
步骤3:计算后缀表达式(分数运算)
- 目的:通过栈计算后缀表达式结果,全程以分数形式存储(分子+分母),确保精度并化简。
- 工具:一个栈
stack
(存储分数,格式为[分子, 分母]
)。 - 操作:
- 初始化分数:数字以
[num, 1]
形式入栈(整数视为分母为1的分数)。 - 处理运算符:
- 弹出栈顶两个分数
b
(后一个操作数)和a
(前一个操作数)。 - 根据运算符执行对应分数运算:
- 加法:
a + b = (a分子*b分母 + b分子*a分母) / (a分母*b分母)
- 减法:
a - b = (a分子*b分母 - b分子*a分母) / (a分母*b分母)
- 乘法:
a * b = (a分子*b分子) / (a分母*b分母)
- 除法:
a / b = (a分子*b分母) / (a分母*b分子)
(若b分子
为0,输出ERROR
)。
- 加法:
- 分数化简:对运算结果的分子和分母求最大公约数(GCD),分子分母同除以GCD,确保最简。
- 符号处理:若分母为负数,分子和分母同乘
-1
(保证分母为正,符号仅保留在分子)。 - 将化简后的分数推入栈。
- 弹出栈顶两个分数
- 最终结果:栈中仅剩一个分数,若分母为1则输出分子(整数),否则输出
分子/分母
。 - 示例:后缀表达式
[1, 5, 7, '*', 8, '/', '+']
计算过程:- 1 →
[1,1]
,5→[5,1]
,7→[7,1]
,*
运算得[35,1]
,8→[8,1]
,/
运算得[35,8]
,+
运算得[43,8]
,输出43/8
。
- 1 →
- 初始化分数:数字以
时间复杂度分析
- 输入预处理:遍历字符串一次(长度
n
),双指针操作均为线性,时间复杂度 O(n)。 - 中缀转后缀表达式:每个计算单位(数字、运算符、括号)入栈和出栈各一次,总操作次数为
O(m)
(m
为解析后表达式长度,m ≤ n
),时间复杂度 O(m) = O(n)。 - 后缀表达式计算:每个元素处理一次(
O(m)
),分数运算中GCD计算时间为O(log(min(分子, 分母)))
(因分子分母为整数,范围有限,可视为常数级),整体时间复杂度 O(m) = O(n)。
综合时间复杂度:O(n),其中 n
为输入字符串长度(≤200),效率可满足题目要求。
空间复杂度分析
- 输入预处理和表达式转换过程中,存储解析后的表达式和栈的空间均与
n
线性相关,空间复杂度 O(n)。
参考代码
function isNum(n) {
return !'()+-*/'.includes(n);
}
function gcd(a, b) {
return b === 0 ? a : gcd(b, a % b);
}
function addFrac(a, b) {
const result = [a[0] * b[1] + b[0] * a[1], a[1] * b[1]];
const d = gcd(...result);
result[0] /= d;
result[1] /= d;
return result;
}
function subFrac(a, b) {
const result = [a[0] * b[1] - b[0] * a[1], a[1] * b[1]];
const d = gcd(...result);
result[0] /= d;
result[1] /= d;
if (result[1] < 0) {
result[0] *= -1;
result[1] *= -1;
}
return result;
}
function mulFrac(a, b) {
const result = [a[0] * b[0], a[1] * b[1]];
const d = gcd(...result);
result[0] /= d;
result[1] /= d;
return result;
}
function divFrac(a, b) {
const result = [a[0] * b[1], a[1] * b[0]];
const d = gcd(...result);
result[0] /= d;
result[1] /= d;
if (result[1] < 0) {
result[0] *= -1;
result[1] *= -1;
}
return result;
}
// S1 栈内运算符优先级
const gradeMapping = {
'(': 0,
'+': 1,
'-': 1,
'*': 2,
'/': 2
};
/**
* 中缀表达式转后缀表达式(逆波兰表达式)
*/
function middleExpToRPN(arr) {
const s1 = [], s2 = [];
for (let a of arr) {
if (isNum(a)) {
s2.push(parseInt(a));
continue;
}
if (s1.length === 0 || a === '(') {
s1.push(a);
} else if (a === ')') {
while (s1[s1.length-1] !== '(') {
s2.push(s1.pop());
}
s1.pop(); // 丢弃左括号
} else if (gradeMapping[a] > gradeMapping[s1[s1.length-1]]) {
s1.push(a);
} else {
while (s1.length && gradeMapping[s1[s1.length-1]] >= gradeMapping[a]) {
s2.push(s1.pop());
}
s1.push(a);
}
}
while (s1.length) {
s2.push(s1.pop());
}
return s2;
}
function solution() {
const str = readline().replaceAll(' ', '');
const midExp = [];
let i = 0, n = str.length;
if (n === 1) {
if (isNum(str)) {
console.log(parseInt(str));
}
return;
}
// 双指针截取数字和运算符这些计算单位
for (let j = 1; j < n; j++) {
if (!isNum(str[j])) {
if (i < j) {
midExp.push(parseInt(str.substring(i, j)));
i = j + 1;
} else {
i++;
}
midExp.push(str[j]);
}
}
if (i < n) {
midExp.push(str.substring(i));
}
// console.log("midExp: ", midExp.join(' '));
const rpnExp = middleExpToRPN(midExp);
// console.log('rpnExp: ', rpnExp.join(' '));
let a, b, ans = 0;
const stack = [];
for (let token of rpnExp) {
if (isNum(token)) {
stack.push([parseInt(token), 1]); // 用数组表示分数形式的数 [分子, 分母]
continue;
}
b = stack.pop();
a = stack.pop();
switch(token) {
case '+':
ans = addFrac(a, b);
break;
case '-':
ans = subFrac(a, b);
break;
case '*':
ans = mulFrac(a, b);
break;
case '/':
if (b === 0) {
console.log('ERROR');
return;
}
ans = divFrac(a, b);
break;
}
stack.push(ans);
}
if (ans[1] === 1) {
console.log(ans[0]);
return;
}
console.log(ans.join('/'));
}
const cases = [
`12 - 8/4+3* 20`,
`1 + 5 * 7 / 8`,
`1 / (0 - 5)`,
`1 * (3*4/(8-(7+0)))`
];
let caseIndex = 0;
let lineIndex = 0;
const readline = (function () {
let lines = [];
return function () {
if (lineIndex === 0) {
lines = cases[caseIndex]
.trim()
.split("\n")
.map((line) => line.trim());
}
return lines[lineIndex++];
};
})();
cases.forEach((_, i) => {
caseIndex = i;
lineIndex = 0;
solution();
});