表达式计算4

GitHub跳转原题关系图返回列表

用算符优先法(操作数栈 + 运算符栈)扫一遍表达式,靠栈顶优先级比较实现优先级、括号与左结合,并单独处理一元正负号和多余括号。

OJ: roj

题目 ID: 3047

难度:提高

标签:栈表达式求值字符串

创建: 2026-10-01 12:05

更新: 2026-10-07 13:50

形式化题目

给定一个中缀表达式串,字符只可能来自数字 0-9、二元运算符 + - * / ^ 和括号 ( )(写在数字之间的 +/- 视为一元正负号)。约定:

  • / 是整除,按 C/C++ 语义向零取整(−7/2=−3-7/2 = -3,而不是 Python 的 −4-4);
  • ^ 是乘方;
  • 括号可能出现多余:左括号多于右括号、右括号多于左括号、或无意义的嵌套都可能出现;
  • 表达式中可能出现负数(一元正负号)。

求这个表达式的值。保证答案绝对值小于 2312^{31}。

题面未规定 ^ 的结合性。本文按常见实现取左结合(2^3^2 = (2^3)^2 = 64),并说明如何一个字符改成右结合。

正解

思路

先看最直观的做法。 要算一个表达式,就在最外层(不被任何括号包裹的位置)找优先级最低的运算符,用它把串切成左右两半,递归求值再合并——因为优先级最低的那个运算符本来就是最后才算的。+ - 低于 * /、* / 低于 ^,所以先找 + -,找不到再找 * /,再找不到才找 ^;都找不到就说明这一整段是一个数字、或者被一对括号整体包住,剥掉括号继续。

这个分治写法是正确的,但有两个麻烦:

  • 重复扫描。每一层递归都要把当前片段从头扫一遍才能定位切分点,左右两半又被各自重扫。串越长浪费越多,最坏(如 1+2+3+…)是 O(n2)O(n^2)。
  • 多余括号让"最外层"的判定变脏。判断某个运算符是否在括号里要靠括号计数;遇到 ((((7+9)^(1+3)) 这种左括号多出来的串,还得先"剥离整体包裹的括号"再重试,代码里到处是特判。

换个角度。 把上面的递归顺序摊开看会发现:选择切分点的顺序,就是各个运算真正的求值顺序;而这个顺序在读到切分点时已经完全确定,不依赖右边还没读到的内容。 一个运算符什么时候能结算,只取决于两件事:它左边的括号是否已闭合,以及后面会不会出现优先级更高、要跟它抢操作数的运算符。

于是不必先定位切分点再递归,改成从左往右扫一遍、边读边结算已经定序的运算,每个字符就只看一次。这就是算符优先法:维护操作数栈 values 和运算符栈 ops(( 也放在里面)。

  • 读到数字:读完整个多位数,压入 values。
  • 读到 (:压入 ops。它挡住比较,等价于优先级 00。
  • 读到 ):反复结算栈顶直到栈顶是 (,然后丢掉这个 (——括号内部必须整体先算完。
  • 读到二元运算符 c:只要 ops 栈顶不是 ( 且优先级 ⩾c\geqslant c 的优先级,就先结算栈顶;然后压入 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 的习惯一致:

−22=−(22)=−4,(−2)2=4,−(2+3)2=−25-2^2 = -(2^2) = -4, \qquad (-2)^2 = 4, \qquad -(2+3)^2 = -25

结算 ~ 时只弹 1 个操作数并取反(二元运算符弹 2 个)。因为它的优先级高于 * /,-2*3 里负号只作用到 2 上,得到 -6。一元 + 不改值,直接忽略。

(3)多余括号分别处理。

  • 右边多出来的 )(左边没有 ( 可配对)在扫描前一次性删掉,否则"结算到 (" 的循环会在空栈上取下标而崩溃。
  • 左边多出来的 ( 不用预处理:它们压在 ops 里,结算函数遇到 ( 什么都不做,收尾清栈时自然被跳过。

下表用真实数据 case 4 的表达式 2^10-1000+24-(5^3)+(3^2) 走一遍,展示两个栈如何随扫描变化((...) 是操作数栈,自左向右是栈底到栈顶;[...] 是运算符栈):

扫描到 操作数栈 运算符栈 这一步为什么这么做
读到数字 2 (2) [] 读完整个多位数,直接压操作数栈
读到 ^ (2) [^] 弹出优先级 ⩾\geqslant 4 的栈顶后压入 ^(栈空,直接压)
读到数字 10 (2, 10) [^] 读完整个多位数,直接压操作数栈
读到 - (1024) [-] ^ 优先级 4 ⩾\geqslant 1,先结算 210=10242^{10}=1024,再压 -
读到数字 1000 (1024, 1000) [-] 读完整个多位数,直接压操作数栈
读到 + (24) [+] - 优先级 1⩾11 \geqslant 1,结算 1024−1000=241024-1000=24,再压 +
读到数字 24 (24, 24) [+] 读完整个多位数,直接压操作数栈
读到 - (48) [-] + 优先级 1⩾11 \geqslant 1,结算 24+24=4824+24=48,再压 -
读到 ( (48) [- (] ( 压栈;它挡住比较,等价于优先级 0
读到数字 5 (48, 5) [- (] 读完整个多位数,直接压操作数栈
读到 ^ (48, 5) [- ( ^] 栈顶是 (,不比较,直接压 ^
读到数字 3 (48, 5, 3) [- ( ^] 读完整个多位数,直接压操作数栈
读到 ) (48, 125) [-] 结算到 (,算出 53=1255^3=125,丢掉 (
读到 + (-77) [+] - 优先级 1⩾11 \geqslant 1,结算 48−125=−7748-125=-77,再压 +
读到 ( (-77) [+ (] ( 压栈
读到数字 3 (-77, 3) [+ (] 读完整个多位数,直接压操作数栈
读到 ^ (-77, 3) [+ ( ^] 栈顶是 (,不比较,直接压 ^
读到数字 2 (-77, 3, 2) [+ ( ^] 读完整个多位数,直接压操作数栈
读到 ) (-77, 9) [+] 结算到 (,算出 32=93^2=9,丢掉 (
扫描结束 (-68) [ ] 收尾清栈,结算 −77+9=−68-77+9=-68

观察三处:第 4 行说明优先级比较会立刻把 ^ 结算掉,指数不会拖到后面;第 9、14、17 行说明 ( 挡住了所有跨括号的结算,括号内部必须自成一个整体;最后一行说明剩下的运算符按栈顶到栈底的顺序收尾,正是"优先级低的最后算"。全程每个字符只被处理一次,所以是线性的。

代码

Python 版:

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++ 版(同一算法):

cpp
/**
 * 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;
}

复杂度

  • 时间:O(n)O(n),nn 是表达式长度。drop_extra_close 扫一遍;主循环每个字符读一次;每个运算符最多入栈一次、出栈一次,每次结算只做常数次算术。这比递归分治的 O(n2)O(n^2) 严格更优。
  • 空间:O(n)O(n),两个栈各最多 O(n)O(n) 个元素,加上预处理后的串。

正确性

  • 括号不参与比较:循环条件里 ops[-1] != '(' 把左括号单独挡掉,等价于优先级 00,所以 ( 之上的运算符永远不会被跨过 ( 结算,3*(2+1) 不会算成 3*2+1。
  • 左结合:弹出条件用的是 ⩾\geqslant,同级运算符左边的先算,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 显式括号给出同样的值

总结

算符优先法的核心,是发现递归分治选择切分点的顺序恰好就是求值顺序,而这个顺序在读到运算符时就已完全确定,于是不必回头递归,只要一个操作数栈加一个运算符栈、从左往右扫一遍即可。三个题面"坑"各对应一条规则:多余括号靠"左边多的跳过、右边多的删掉"化解;负数靠"操作数位置上的 +/- 当一元运算符"区分;优先级与左结合统一成"栈顶优先级 ⩾\geqslant 当前就弹出"这一次比较。这与表达式转后缀(逆波兰)求值是同一件事的两种说法:本实现只是把"先生成后缀式、再求值"合并成了一步。

图示解析

这张图串起本题从读入到得到答案的主线:

text
中缀表达式串(可能有多余括号、一元正负号)
`- 预处理:删掉没有 '(' 可配对的 ')'
   `- 从左往右扫一遍,维护 values(操作数栈)与 ops(运算符栈)
      |- 数字 → 读完整个数,压 values
      |- '(' → 压 ops,挡住一切跨括号比较
      |- ')' → 结算到 '(',丢掉 '('
      |- 操作数位置上的 '-' → 压一元 '~'(只弹 1 个操作数)
      `- 二元运算符 c → 弹出所有优先级 >= c 的栈顶,再压 c
         `- 等号 → 同级从左到右(左结合)
   `- 扫描结束:清空 ops,孤立的 '(' 直接跳过
      `- 答案 = values[-1]

先看第一个节点:输入同时带来优先级、括号和一元符号三种结构,直接递归分类讨论会写出很多特判。 第二个节点是本题唯一的建模决定——把"递归选择切分点"改写成"扫描时用栈顶比较决定结算时机",每个字符因此只被看一次。 中间五个分支是同一套规则在各字符上的落地,注意 ( 和一元 ~ 都是"特殊优先级":前者最低(挡住比较),后者在 * / 与 ^ 之间。 最后一个节点说明收尾清栈的处理,以及多余括号如何被无声地消化掉。