用算符优先法(操作数栈 + 运算符栈)扫一遍表达式,靠栈顶优先级比较实现优先级、括号与左结合,并单独处理一元正负号和多余括号。
OJ: roj
题目 ID: 3047
难度:提高
标签:栈表达式求值字符串
创建: 2026-10-01 12:05
更新: 2026-10-07 13:50
形式化题目
给定一个中缀表达式串,字符只可能来自数字 0-9、二元运算符 + - * / ^ 和括号 ( )(写在数字之间的 +/- 视为一元正负号)。约定:
/是整除,按 C/C++ 语义向零取整(,而不是 Python 的 ); ^是乘方;- 括号可能出现多余:左括号多于右括号、右括号多于左括号、或无意义的嵌套都可能出现;
- 表达式中可能出现负数(一元正负号)。
求这个表达式的值。保证答案绝对值小于
题面未规定 ^ 的结合性。本文按常见实现取左结合(2^3^2 = (2^3)^2 = 64),并说明如何一个字符改成右结合。
正解
思路
先看最直观的做法。 要算一个表达式,就在最外层(不被任何括号包裹的位置)找优先级最低的运算符,用它把串切成左右两半,递归求值再合并——因为优先级最低的那个运算符本来就是最后才算的。+ - 低于 * /、* / 低于 ^,所以先找 + -,找不到再找 * /,再找不到才找 ^;都找不到就说明这一整段是一个数字、或者被一对括号整体包住,剥掉括号继续。
这个分治写法是正确的,但有两个麻烦:
- 重复扫描。每一层递归都要把当前片段从头扫一遍才能定位切分点,左右两半又被各自重扫。串越长浪费越多,最坏(如
1+2+3+…)是。 - 多余括号让"最外层"的判定变脏。判断某个运算符是否在括号里要靠括号计数;遇到
((((7+9)^(1+3))这种左括号多出来的串,还得先"剥离整体包裹的括号"再重试,代码里到处是特判。
换个角度。 把上面的递归顺序摊开看会发现:选择切分点的顺序,就是各个运算真正的求值顺序;而这个顺序在读到切分点时已经完全确定,不依赖右边还没读到的内容。 一个运算符什么时候能结算,只取决于两件事:它左边的括号是否已闭合,以及后面会不会出现优先级更高、要跟它抢操作数的运算符。
于是不必先定位切分点再递归,改成从左往右扫一遍、边读边结算已经定序的运算,每个字符就只看一次。这就是算符优先法:维护操作数栈 values 和运算符栈 ops(( 也放在里面)。
- 读到数字:读完整个多位数,压入
values。 - 读到
(:压入ops。它挡住比较,等价于优先级。 - 读到
):反复结算栈顶直到栈顶是(,然后丢掉这个(——括号内部必须整体先算完。 - 读到二元运算符
c:只要ops栈顶不是(且优先级的优先级,就先结算栈顶;然后压入 c。 - 扫完后把
ops清空,依次结算剩下的运算符;孤立的(直接跳过。
这里有三处设计要点:
(1)等号保证左结合。 栈顶优先级等于 c 的优先级时也要弹出,于是左边的同级运算符先算:10-2-3 读到第二个 - 时先结算 2-3 所在的左半边 10-2=8,得到 8-3=5。如果改成严格大于,10-2-3 就会算成 10-(2-3)=11。
(2)一元正负号需要一个新运算符。 +/- 出现在操作数位置(串首、( 之后、另一个运算符之后)时是正负号而不是二元运算符。用一个输入里绝不会出现的记号 ~ 表示"取负号",优先级定在 * / 之上、^ 之下,与数学和 C/Python 的习惯一致:
结算 ~ 时只弹 1 个操作数并取反(二元运算符弹 2 个)。因为它的优先级高于 * /,-2*3 里负号只作用到 2 上,得到 -6。一元 + 不改值,直接忽略。
(3)多余括号分别处理。
- 右边多出来的
)(左边没有(可配对)在扫描前一次性删掉,否则"结算到(" 的循环会在空栈上取下标而崩溃。 - 左边多出来的
(不用预处理:它们压在ops里,结算函数遇到(什么都不做,收尾清栈时自然被跳过。
下表用真实数据 case 4 的表达式 2^10-1000+24-(5^3)+(3^2) 走一遍,展示两个栈如何随扫描变化((...) 是操作数栈,自左向右是栈底到栈顶;[...] 是运算符栈):
| 扫描到 | 操作数栈 | 运算符栈 | 这一步为什么这么做 |
|---|---|---|---|
读到数字 2 |
(2) |
[] |
读完整个多位数,直接压操作数栈 |
读到 ^ |
(2) |
[^] |
弹出优先级 ^(栈空,直接压) |
读到数字 10 |
(2, 10) |
[^] |
读完整个多位数,直接压操作数栈 |
读到 - |
(1024) |
[-] |
^ 优先级 4 - |
读到数字 1000 |
(1024, 1000) |
[-] |
读完整个多位数,直接压操作数栈 |
读到 + |
(24) |
[+] |
- 优先级 + |
读到数字 24 |
(24, 24) |
[+] |
读完整个多位数,直接压操作数栈 |
读到 - |
(48) |
[-] |
+ 优先级 - |
读到 ( |
(48) |
[- (] |
( 压栈;它挡住比较,等价于优先级 0 |
读到数字 5 |
(48, 5) |
[- (] |
读完整个多位数,直接压操作数栈 |
读到 ^ |
(48, 5) |
[- ( ^] |
栈顶是 (,不比较,直接压 ^ |
读到数字 3 |
(48, 5, 3) |
[- ( ^] |
读完整个多位数,直接压操作数栈 |
读到 ) |
(48, 125) |
[-] |
结算到 (,算出 ( |
读到 + |
(-77) |
[+] |
- 优先级 + |
读到 ( |
(-77) |
[+ (] |
( 压栈 |
读到数字 3 |
(-77, 3) |
[+ (] |
读完整个多位数,直接压操作数栈 |
读到 ^ |
(-77, 3) |
[+ ( ^] |
栈顶是 (,不比较,直接压 ^ |
读到数字 2 |
(-77, 3, 2) |
[+ ( ^] |
读完整个多位数,直接压操作数栈 |
读到 ) |
(-77, 9) |
[+] |
结算到 (,算出 ( |
| 扫描结束 | (-68) |
[ ] |
收尾清栈,结算 |
观察三处:第 4 行说明优先级比较会立刻把 ^ 结算掉,指数不会拖到后面;第 9、14、17 行说明 ( 挡住了所有跨括号的结算,括号内部必须自成一个整体;最后一行说明剩下的运算符按栈顶到栈底的顺序收尾,正是"优先级低的最后算"。全程每个字符只被处理一次,所以是线性的。
代码
Python 版:
#!/usr/bin/env python3
# Author by Rainboy blog: https://rainboylv.com github: https://github.com/rainboylvx
# rbook: -> https://rbook.roj.ac.cn https://rbook2.roj.ac.cn
# rainboy的学习导航网站: https://idx.roj.ac.cn
# create_at: 2026-10-01 12:05
# update_at: 2026-10-01 12:05
import sys
# 运算符优先级:数字越大越先算。UNARY 是取负号('~'),它高于 '*' '/' 而低于 '^',
# 与数学及 C/Python 的习惯一致:-2^2 = -(2^2) = -4,而 (-2)^2 = 4。
UNARY = '~'
LEVEL = {'+': 1, '-': 1, '*': 2, '/': 2, UNARY: 3, '^': 4}
def trunc_div(a: int, b: int) -> int:
"""题面的 / 是整除:像 C++ 那样向零取整,而不是 Python 的向下取整。"""
return a // b if a * b >= 0 else -(-a // b)
def apply(op: str, a: int, b: int) -> int:
"""按二元运算符 op 计算操作数 a、b 的结果。
'^' 用 int(a ** b):指数非负时是精确整数;指数为负(如 2^-2)时按 C++ 整型
语义截断成 0,而不是 Python 的浮点 0.25。
"""
return (a + b if op == '+' else
a - b if op == '-' else
a * b if op == '*' else
trunc_div(a, b) if op == '/' else
int(a ** b))
def drop_extra_close(expr: str) -> str:
"""丢掉左边没有 '(' 可配对的右括号;多余的左括号留到收尾时丢弃。"""
depth, kept = 0, []
for c in expr:
if c == ')' and depth == 0: # 没人能跟它配对,直接删
continue
depth += (c == '(') - (c == ')')
kept.append(c)
return ''.join(kept)
def evaluate(expr: str) -> int:
"""算符优先法求值:从左往右扫一趟,遇到已经定序的运算就立刻结算。"""
s = drop_extra_close(expr)
values: list[int] = [] # 操作数栈
ops: list[str] = [] # 运算符栈(含 '(')
expect_operand = True # 该位置应出现操作数:开头、'(' 后、运算符后
i, n = 0, len(s)
def reduce_top() -> None:
"""结算栈顶:取负号弹 1 个操作数,二元运算符弹 2 个,孤立的 '(' 只丢弃。"""
op = ops.pop()
if op == '(':
return
if op == UNARY:
values[-1] = -values[-1]
return
b, a = values.pop(), values.pop()
values.append(apply(op, a, b))
while i < n:
c = s[i]
if c.isdigit():
j = i
while j < n and s[j].isdigit(): # 多位数
j += 1
values.append(int(s[i:j]))
i, expect_operand = j, False
continue
i += 1
if c == '(':
ops.append(c)
expect_operand = True
elif c == ')':
while ops[-1] != '(': # 括号内部先算干净
reduce_top()
ops.pop() # 丢掉配对的 '('
expect_operand = False
elif expect_operand: # 操作数位置上的正负号:一元运算符
if c == '-':
ops.append(UNARY) # 本轮不出栈:负号只能作用于紧跟其后的运算数
# 正号不改值,直接忽略
else:
# 栈顶优先级 >= 当前运算符,说明它先算;取等号让同级从左到右结合。
while ops and ops[-1] != '(' and LEVEL[ops[-1]] >= LEVEL[c]:
reduce_top()
ops.append(c)
expect_operand = True
while ops: # 收尾:清空运算符栈
reduce_top()
return values[-1]
def solve() -> None:
expr = ''.join(sys.stdin.read().split()) # 表达式内部无空白,去掉换行等字符
print(evaluate(expr))
if __name__ == "__main__":
solve()C++ 版(同一算法):
/**
* Author by Rainboy blog: https://rainboylv.com github: https://github.com/rainboylvx
* rbook: -> https://rbook.roj.ac.cn https://rbook2.roj.ac.cn
* rainboy的学习导航网站: https://idx.roj.ac.cn
* create_at: 2026-10-06 16:01
* update_at: 2026-10-06 16:01
*/
#include <bits/stdc++.h>
using namespace std;
typedef long long ll;
// 表达式计算4:算符优先法(操作数栈 + 运算符栈)从左往右扫一遍
// 处理三个坑:优先级与左结合、一元正负号、多余括号
string str; // 原始表达式(已去掉所有空白字符)
vector<ll> values; // 操作数栈
vector<char> ops; // 运算符栈,'(' 也压在里面
// 运算符优先级:数字越大越先算。'~' 是一元负号,高于 '*' '/' 而低于 '^',
// 与数学及 C/Python 的习惯一致:-2^2 = -(2^2) = -4,而 (-2)^2 = 4
int level(char c) {
if (c == '+' || c == '-') return 1;
if (c == '*' || c == '/') return 2;
if (c == '~') return 3;
if (c == '^') return 4;
return 0; // 其它字符('(')不参与优先级比较
}
// 计算 a^b,b >= 0;快速幂写法
ll pow_ll(ll a, ll b) {
ll res = 1;
while (b > 0) {
if (b & 1) res *= a;
a *= a;
b >>= 1;
}
return res;
}
// 结算运算符栈顶:取负号只弹 1 个操作数,二元运算符弹 2 个,孤立的 '(' 只丢弃
void reduce_top() {
char op = ops.back();
ops.pop_back();
if (op == '(') return; // 多余的左括号:无害跳过
if (op == '~') { // 一元负号
values.back() = -values.back();
return;
}
ll b = values.back(); values.pop_back();
ll a = values.back(); values.pop_back();
ll res;
if (op == '+') res = a + b;
else if (op == '-') res = a - b;
else if (op == '*') res = a * b;
else if (op == '/') res = a / b; // C++ 整除本来就向零取整:-7/2 = -3
else res = pow_ll(a, b); // '^' 乘方
values.push_back(res);
}
// 丢掉左边没有 '(' 可配对的右括号,防止"结算到 '('"的循环在空栈上取栈顶崩溃;
// 多余的左括号不用预处理,收尾清栈时自然被跳过
string drop_extra_close() {
string kept;
int depth = 0; // 当前未配对的 '(' 个数
for (size_t i = 0; i < str.size(); i++) {
char c = str[i];
if (c == ')' && depth == 0) continue; // 没人能跟它配对,直接删
if (c == '(') depth++;
if (c == ')') depth--;
kept += c;
}
return kept;
}
int main() {
// cin >> 自动跳过空白,把整行表达式拼进 str
char ch;
while (cin >> ch) str += ch;
string s = drop_extra_close();
size_t n = s.size();
// 该位置应出现操作数:串首、'(' 之后、运算符之后
// 此时读到的 '+'/'-' 是一元正负号,而不是二元运算符
bool expect_operand = true;
size_t i = 0;
while (i < n) {
char c = s[i];
if (c >= '0' && c <= '9') { // 读完整个多位数
ll num = 0;
while (i < n && s[i] >= '0' && s[i] <= '9') {
num = num * 10 + (s[i] - '0');
i++;
}
values.push_back(num);
expect_operand = false;
continue;
}
i++;
if (c == '(') { // '(' 压栈,挡住跨括号的比较
ops.push_back('(');
expect_operand = true;
}
else if (c == ')') { // 括号内部先算干净,再丢掉配对的 '('
while (ops.back() != '(') reduce_top();
ops.pop_back();
expect_operand = false;
}
else if (expect_operand) { // 操作数位置上的正负号:一元运算符
if (c == '-') ops.push_back('~');
// 一元正号不改值,直接忽略
}
else {
// 栈顶优先级 >= 当前运算符说明它先算;取等号让同级从左到右(左结合)
while (!ops.empty() && ops.back() != '(' && level(ops.back()) >= level(c))
reduce_top();
ops.push_back(c);
expect_operand = true;
}
}
while (!ops.empty()) reduce_top(); // 收尾清空运算符栈
cout << values.back() << endl;
return 0;
}复杂度
- 时间:
, 是表达式长度。 drop_extra_close扫一遍;主循环每个字符读一次;每个运算符最多入栈一次、出栈一次,每次结算只做常数次算术。这比递归分治的严格更优。 - 空间:
,两个栈各最多 个元素,加上预处理后的串。
正确性
- 括号不参与比较:循环条件里
ops[-1] != '('把左括号单独挡掉,等价于优先级,所以 (之上的运算符永远不会被跨过(结算,3*(2+1)不会算成3*2+1。 - 左结合:弹出条件用的是
,同级运算符左边的先算, 10-2-3 = 5、100/10/5 = 2。 - 一元符号只作用于紧跟的运算数:
~单独入栈且优先级最高(在*/之上)、结算时只弹 1 个操作数;3--2 = 5、-2*3 = -6、-2^2 = -4。 - 多余的左括号被跳过:
reduce_top碰到'('直接返回,收尾清栈时它们被无害地丢弃,((((((-1)得到-1。
| 边界 | 输入 | 输出 | 说明 |
|---|---|---|---|
| 多余左括号 | ((((((-1) |
-1 |
收尾清栈跳过所有 ( |
| 多余右括号 | 1) |
1 |
drop_extra_close 先删掉 |
| 单个数 | 5 |
5 |
循环不进,直接取 values[-1] |
| 整体包裹 | (((1+2))) |
3 |
括号只影响结算时机 |
| 多位数 | 1000000+2000000 |
3000000 |
数字分支读完整个数 |
| 右结合对照 | (2^3)^2 |
64 |
显式括号给出同样的值 |
总结
算符优先法的核心,是发现递归分治选择切分点的顺序恰好就是求值顺序,而这个顺序在读到运算符时就已完全确定,于是不必回头递归,只要一个操作数栈加一个运算符栈、从左往右扫一遍即可。三个题面"坑"各对应一条规则:多余括号靠"左边多的跳过、右边多的删掉"化解;负数靠"操作数位置上的 +/- 当一元运算符"区分;优先级与左结合统一成"栈顶优先级
图示解析
这张图串起本题从读入到得到答案的主线:
中缀表达式串(可能有多余括号、一元正负号)
`- 预处理:删掉没有 '(' 可配对的 ')'
`- 从左往右扫一遍,维护 values(操作数栈)与 ops(运算符栈)
|- 数字 → 读完整个数,压 values
|- '(' → 压 ops,挡住一切跨括号比较
|- ')' → 结算到 '(',丢掉 '('
|- 操作数位置上的 '-' → 压一元 '~'(只弹 1 个操作数)
`- 二元运算符 c → 弹出所有优先级 >= c 的栈顶,再压 c
`- 等号 → 同级从左到右(左结合)
`- 扫描结束:清空 ops,孤立的 '(' 直接跳过
`- 答案 = values[-1]先看第一个节点:输入同时带来优先级、括号和一元符号三种结构,直接递归分类讨论会写出很多特判。
第二个节点是本题唯一的建模决定——把"递归选择切分点"改写成"扫描时用栈顶比较决定结算时机",每个字符因此只被看一次。
中间五个分支是同一套规则在各字符上的落地,注意 ( 和一元 ~ 都是"特殊优先级":前者最低(挡住比较),后者在 * / 与 ^ 之间。
最后一个节点说明收尾清栈的处理,以及多余括号如何被无声地消化掉。