目录

题目描述

770. 基本计算器 IV

题意分析

给一个含有变量的表达式字符串,运算符只有 +-* 和圆括号,操作数是非负整数或小写字母变量。另给两个数组 evalvarsevalints,表示其中一部分变量已经被赋了具体的值。要求把表达式化简,输出一组「项」的字符串列表:每一项形如 系数*变量1*变量2*...,常数项直接写数字。输出必须按变量个数(次数)从大到小排序,次数相同的按字典序排列,系数为 $0$ 的项要丢弃。

与前三版计算器最大的不同在于:结果不再是一个数字,而是一个多项式。这意味着整个求值链条上传递的「值」必须换成一个能表示多项式的对象,加减乘三种运算也要重新定义在这个对象上。这是本题的第一个也是最重要的算法信号。

第二个信号是输出格式对项内变量顺序的要求:"a*b""b*a" 是同一项,必须合并。所以多项式的键不能是变量的原始出现顺序,而要是一个规范化(排好序)的表示,否则同类项合不掉。

第三个信号来自 evalvars:已赋值的变量在解析时就应该被替换成常数,而不是留到最后再代入。提前替换能让后续的加减乘都在更小的多项式上进行,也免去了「常数与变量混合」的特判。

约束方面:表达式长度不超过 $250$,变量名是长度不超过 $4$ 的小写字母串,且题目保证表达式合法、答案的系数不会溢出 $32$ 位整数。规模很小,所以不需要考虑多项式乘法的性能优化,代码清晰度优先。

边界上要覆盖:结果为空(所有项系数都被抵消成 $0$,此时返回空列表而不是 ["0"]);纯常数表达式(输出单个数字字符串);括号嵌套且括号外有乘法(如 (a+b)*(a-b),会展开出四项再合并);同一变量重复相乘(如 a*a,键是 "a*a",次数为 $2$);减法产生负系数。

解法:多项式 + 递归解析

核心思路

先明确不能走的路。把表达式当成普通计算器用两个栈处理数值——不行,因为操作数不是数,加减乘的结果也不是数。把变量当成占位符做字符串拼接——也不行,因为要合并同类项、要抵消、要按次数排序,纯字符串没有代数结构。

瓶颈在于「值」的类型。一旦把值从 int 换成一个能做加减乘的代数对象,整道题就退化成一个标准的表达式求值问题。于是解法分成正交的两层,各自独立设计:

第一层是多项式的数据表示。 用一张 Map<String, Integer>,键是排好序、用 * 连接的变量串(常数项的键是空串),值是该项的系数。比如 $3ab - 2$ 表示成 {"a*b": 3, "": -2}。之所以要求键内变量升序,是因为这样 a*bb*a 会映射到同一个键,同类项自动合并;如果保留原顺序,两者会变成两个不同的项,永远合不掉。

在这个表示上,三种运算的定义都很自然:加法是把两张表按键累加;减法是按键相减;乘法是两张表的笛卡尔积——对每一对项,键做「合并后重新排序」,系数相乘,结果累加到同一个键上。三种运算之后都要执行一次清理,把系数为 $0$ 的键删掉。这一步不是可选的优化,而是维持不变量所必需的。

于是多项式对象维护的不变量是:terms 里的每个键都是变量升序排列的规范串,且每个键对应的系数非零。前半条保证同类项合并的正确性,后半条保证 (a+b)-(a+b) 这类抵消能真正得到空多项式,而不是留下一堆系数为 $0$ 的僵尸键干扰输出与后续乘法。

第二层是表达式解析。 因为有括号和运算符优先级,且括号可以任意嵌套,所以用递归下降,按文法分三级:

  • parseExpr 处理最低优先级的 + / -:先解析一个 term,然后只要下一个符号是 +- 就继续解析下一个 term 并合并。
  • parseTerm 处理更高优先级的 *:先解析一个 factor,然后只要下一个符号是 * 就继续解析下一个 factor 并相乘。
  • parseFactor 处理最小单元:遇到 ( 就吃掉括号递归调用 parseExpr,遇到字母就读出变量名(若在 eval 表中则直接返回常数多项式,否则返回单变量多项式),否则读出一个整数返回常数多项式。

这个三层分工正是「优先级越高的产生式嵌套得越深」这一经典结构:乘法被夹在加减的内部,所以乘法会先被完整算完,优先级自动正确,不需要任何显式的优先级表或双栈。

解析层同样有一条必须遵守的不变量:每个 parse 函数只消费属于自己的那一段字符,返回时游标 idx 恰好停在这一段之后。这是递归下降的生命线,任何一个分支多吃或少吃一个字符(尤其是右括号),后面的解析全部错位。

最后是输出。terms 的键取出来,按「次数降序、同次数字典序升序」排序,再拼成 系数*变量串 的形式;常数项只输出系数本身。次数就是键里变量的个数,用 split("\\*").length 得到,空键的次数是 $0$。由于不变量保证了系数非零,输出阶段不会产生多余项。

解题步骤

  • 先把 evalvarsevalints 装进一张哈希表 eval。理由:解析变量时要 $O(1)$ 判断它是否已被赋值;如果留到最后再代入,中间过程的多项式会大很多,乘法笛卡尔积的规模也会膨胀。

  • 定义 Poly 类,内部只有一张 Map<String, Integer> terms;提供三个构造入口:空多项式、常数多项式(常数为 $0$ 时不插入任何键)、单变量多项式。理由:常数 $0$ 不插键正是「系数非零」这条不变量在构造阶段的落实,否则 0 会以 {"": 0} 的形式混进去,后面乘法时会生成一堆零系数的垃圾键。

  • 实现 add / sub:先把自己的项全部拷进结果,再遍历对方的项按键加或减,最后调用 cleanup() 删掉零系数键。理由:必须新建结果对象而不是原地修改,因为同一个 Poly 可能被多处引用(比如 a*a 里左右两侧);cleanup 是抵消场景下唯一能让键真正消失的地方。

  • 实现 mul:双重循环遍历两边所有项,键用 mergeKey 合并、系数相乘,累加进结果,最后 cleanup()。理由:多项式乘法就是分配律的展开,笛卡尔积是它的直接翻译;累加而不是覆盖,因为不同的项对可能产生同一个键(如 $(a+b)(a+b)$ 里两个 $ab$)。

  • 实现 mergeKey(a, b):任一为空串时直接返回另一个;否则把两边按 * 拆开、合并到一个列表、整体排序、再用 * 连回去。理由:排序是保证「同类项键唯一」的关键;不能简单拼接,"b" + "*" + "a""a*b" 会成为两个键。允许重复元素(a*a),所以用列表排序而不是集合。

  • Parser 持有字符串、eval 表和游标 idx,并提供 skipSpaces()parseToken()。理由:把游标做成成员变量,所有 parse 函数才能共享推进进度;parseToken 的终止符是空格、)+-* 五种,覆盖了变量名之后所有可能出现的字符。

  • parseExpr():先 parseTerm(),然后循环——跳空格后若已到串尾或遇到 ) 就退出;若下一个字符不是 + 也不是 - 也退出;否则吃掉运算符、parseTerm() 取右操作数、按符号 addsub。理由:遇到 ) 就退出是把括号的闭合交还给调用方(parseFactor)去消费,这样括号的吃入与吐出成对出现在同一层,不会错位。

  • parseTerm():先 parseFactor(),然后循环——跳空格后若不是 * 就退出,否则吃掉 *mul 上下一个 factor。理由:把乘法收在比加减更内层的循环里,a + b * c 会先在 parseTerm 里把 b * c 算完再返回给 parseExpr 做加法,优先级天然正确。

  • parseFactor():跳空格后看首字符。是 (idx++ 吃掉左括号、递归 parseExpr()、跳空格后再 idx++ 吃掉右括号并返回。是字母就 parseToken() 读变量名,在 eval 中则返回常数多项式,否则返回单变量多项式。其余情况按整数解析(含前导负号的容错)。理由:parseFactor 是唯一消费括号的地方,配对的吃入与吃出都写在这一个函数里,最不容易漏。

  • toList():取出所有键,滤掉系数为 $0$ 的(双保险),按 degree 降序、字典序升序排序,然后拼串——空键输出 String.valueOf(coef),非空键输出 coef + "*" + key。理由:题目要求的排序规则就是这两级;degreesplit("\\*").length 计算,空串特判为 $0$,因为 Java 里 "".split("\\*") 会返回长度为 $1$ 的数组,不特判会把常数项的次数误算成 $1$。

  • expression = "(a+b)*(a-b)"evalvars = [] 走一遍。parseExprparseTermparseTermparseFactor:看到 (,吃掉后递归 parseExpr 解析 a+b——parseTerm 得到 {a:1},看到 + 吃掉,再 parseTerm 得到 {b:1},相加得 {a:1, b:1};随后遇到 ) 退出内层 parseExpr,回到 parseFactor 吃掉右括号并返回该多项式。回到 parseTerm,跳空格看到 *,吃掉后再调 parseFactor:同样解析出 a-b,即 {a:1, b:-1}。执行 mul:四对项分别是 $a \cdot a$ 得键 "a*a" 系数 $1$;$a \cdot (-b)$ 得键 "a*b" 系数 $-1$;$b \cdot a$ 合并排序后同样得键 "a*b",系数 $+1$ 累加上去变成 $0$;$b \cdot (-b)$ 得键 "b*b" 系数 $-1$。cleanup 把系数为 $0$ 的 "a*b" 删掉,剩下 {"a*a": 1, "b*b": -1}。回到最外层 parseExpr,串尾退出。toList 排序:两项次数都是 $2$,按字典序 "a*a" 在前,输出 ["1*a*a", "-1*b*b"]——注意 $ab$ 项能被抵消,正是因为 mergeKeyb*a 规范化成了 a*b,两者落到同一个键上。

代码实现

class Solution {
    public List<String> basicCalculatorIV(String expression, String[] evalvars, int[] evalints) {
        Map<String, Integer> eval = new HashMap<>();
        for (int i = 0; i < evalvars.length; i++) {
            eval.put(evalvars[i], evalints[i]);
        }

        Parser parser = new Parser(expression, eval);
        Poly poly = parser.parseExpr();
        return poly.toList();
    }

    private static class Parser {
        private final String s;
        private final Map<String, Integer> eval;
        private int idx;

        Parser(String s, Map<String, Integer> eval) {
            this.s = s;
            this.eval = eval;
            this.idx = 0;
        }

        Poly parseExpr() {
            Poly res = parseTerm();
            while (true) {
                skipSpaces();
                if (idx >= s.length() || s.charAt(idx) == ')') {
                    break;
                }

                char op = s.charAt(idx);
                if (op != '+' && op != '-') {
                    break;
                }
                idx++;

                Poly right = parseTerm();
                if (op == '+') {
                    res = res.add(right);
                } else {
                    res = res.sub(right);
                }
            }
            return res;
        }

        Poly parseTerm() {
            Poly res = parseFactor();
            while (true) {
                skipSpaces();
                if (idx >= s.length() || s.charAt(idx) != '*') {
                    break;
                }
                idx++;

                Poly right = parseFactor();
                res = res.mul(right);
            }
            return res;
        }

        Poly parseFactor() {
            skipSpaces();
            char ch = s.charAt(idx);
            if (ch == '(') {
                idx++;
                Poly res = parseExpr();
                skipSpaces();
                idx++;
                return res;
            }

            if (Character.isLetter(ch)) {
                String var = parseToken();
                if (eval.containsKey(var)) {
                    return new Poly(eval.get(var));
                }
                return Poly.ofVar(var);
            }

            int sign = 1;
            if (ch == '-') {
                sign = -1;
                idx++;
            }

            int num = 0;
            while (idx < s.length() && Character.isDigit(s.charAt(idx))) {
                num = num * 10 + (s.charAt(idx) - '0');
                idx++;
            }
            return new Poly(sign * num);
        }

        String parseToken() {
            int start = idx;
            while (idx < s.length()) {
                char ch = s.charAt(idx);
                if (ch == ' ' || ch == ')' || ch == '+' || ch == '-' || ch == '*') {
                    break;
                }
                idx++;
            }
            return s.substring(start, idx);
        }

        void skipSpaces() {
            while (idx < s.length() && s.charAt(idx) == ' ') {
                idx++;
            }
        }
    }

    private static class Poly {
        private final Map<String, Integer> terms = new HashMap<>();

        Poly() {
        }

        Poly(int constant) {
            if (constant != 0) {
                terms.put("", constant);
            }
        }

        static Poly ofVar(String var) {
            Poly p = new Poly();
            p.terms.put(var, 1);
            return p;
        }

        Poly add(Poly other) {
            Poly res = new Poly();
            res.terms.putAll(this.terms);
            for (Map.Entry<String, Integer> e : other.terms.entrySet()) {
                res.terms.put(e.getKey(), res.terms.getOrDefault(e.getKey(), 0) + e.getValue());
            }
            res.cleanup();
            return res;
        }

        Poly sub(Poly other) {
            Poly res = new Poly();
            res.terms.putAll(this.terms);
            for (Map.Entry<String, Integer> e : other.terms.entrySet()) {
                res.terms.put(e.getKey(), res.terms.getOrDefault(e.getKey(), 0) - e.getValue());
            }
            res.cleanup();
            return res;
        }

        Poly mul(Poly other) {
            Poly res = new Poly();
            for (Map.Entry<String, Integer> a : this.terms.entrySet()) {
                for (Map.Entry<String, Integer> b : other.terms.entrySet()) {
                    String key = mergeKey(a.getKey(), b.getKey());
                    int val = a.getValue() * b.getValue();
                    res.terms.put(key, res.terms.getOrDefault(key, 0) + val);
                }
            }
            res.cleanup();
            return res;
        }

        List<String> toList() {
            List<String> keys = new ArrayList<>(terms.keySet());
            keys.removeIf(k -> terms.get(k) == 0);
            Collections.sort(keys, (a, b) -> {
                int da = degree(a);
                int db = degree(b);
                if (da != db) {
                    return db - da;
                }
                return a.compareTo(b);
            });

            List<String> res = new ArrayList<>();
            for (String key : keys) {
                int coef = terms.get(key);
                if (coef == 0) {
                    continue;
                }
                if (key.isEmpty()) {
                    res.add(String.valueOf(coef));
                } else {
                    res.add(coef + "*" + key);
                }
            }
            return res;
        }

        private void cleanup() {
            terms.entrySet().removeIf(e -> e.getValue() == 0);
        }

        private int degree(String key) {
            if (key.isEmpty()) {
                return 0;
            }
            return key.split("\\*").length;
        }

        private String mergeKey(String a, String b) {
            if (a.isEmpty()) {
                return b;
            }
            if (b.isEmpty()) {
                return a;
            }

            String[] pa = a.split("\\*");
            String[] pb = b.split("\\*");
            List<String> list = new ArrayList<>();
            Collections.addAll(list, pa);
            Collections.addAll(list, pb);
            Collections.sort(list);
            return String.join("*", list);
        }
    }
}
type Poly struct {
    terms map[string]int
}

func newPoly() Poly {
    return Poly{terms: make(map[string]int)}
}

func constPoly(val int) Poly {
    p := newPoly()
    if val != 0 {
        p.terms[""] = val
    }
    return p
}

func varPoly(name string) Poly {
    p := newPoly()
    p.terms[name] = 1
    return p
}

func (p Poly) add(o Poly) Poly {
    res := newPoly()
    for k, v := range p.terms {
        res.terms[k] = v
    }
    for k, v := range o.terms {
        res.terms[k] += v
    }
    res.cleanup()
    return res
}

func (p Poly) sub(o Poly) Poly {
    res := newPoly()
    for k, v := range p.terms {
        res.terms[k] = v
    }
    for k, v := range o.terms {
        res.terms[k] -= v
    }
    res.cleanup()
    return res
}

func (p Poly) mul(o Poly) Poly {
    res := newPoly()
    for ak, av := range p.terms {
        for bk, bv := range o.terms {
            key := mergeKey(ak, bk)
            res.terms[key] += av * bv
        }
    }
    res.cleanup()
    return res
}

func (p Poly) toList() []string {
    keys := make([]string, 0, len(p.terms))
    for k, v := range p.terms {
        if v != 0 {
            keys = append(keys, k)
        }
    }

    sort.Slice(keys, func(i, j int) bool {
        di := degree(keys[i])
        dj := degree(keys[j])
        if di != dj {
            return di > dj
        }
        return keys[i] < keys[j]
    })

    res := make([]string, 0, len(keys))
    for _, key := range keys {
        coef := p.terms[key]
        if key == "" {
            res = append(res, strconv.Itoa(coef))
        } else {
            res = append(res, strconv.Itoa(coef)+"*"+key)
        }
    }
    return res
}

func (p Poly) cleanup() {
    for k, v := range p.terms {
        if v == 0 {
            delete(p.terms, k)
        }
    }
}

func degree(key string) int {
    if key == "" {
        return 0
    }
    return len(strings.Split(key, "*"))
}

func mergeKey(a string, b string) string {
    if a == "" {
        return b
    }
    if b == "" {
        return a
    }

    parts := append(strings.Split(a, "*"), strings.Split(b, "*")...)
    sort.Strings(parts)
    return strings.Join(parts, "*")
}

type Parser struct {
    s    string
    idx  int
    eval map[string]int
}

func basicCalculatorIV(expression string, evalvars []string, evalints []int) []string {
    eval := make(map[string]int)
    for i := 0; i < len(evalvars); i++ {
        eval[evalvars[i]] = evalints[i]
    }

    p := Parser{s: expression, idx: 0, eval: eval}
    poly := p.parseExpr()
    return poly.toList()
}

func (p *Parser) parseExpr() Poly {
    res := p.parseTerm()
    for {
        p.skipSpaces()
        if p.idx >= len(p.s) || p.s[p.idx] == ')' {
            break
        }
        op := p.s[p.idx]
        if op != '+' && op != '-' {
            break
        }
        p.idx++
        right := p.parseTerm()
        if op == '+' {
            res = res.add(right)
        } else {
            res = res.sub(right)
        }
    }
    return res
}

func (p *Parser) parseTerm() Poly {
    res := p.parseFactor()
    for {
        p.skipSpaces()
        if p.idx >= len(p.s) || p.s[p.idx] != '*' {
            break
        }
        p.idx++
        right := p.parseFactor()
        res = res.mul(right)
    }
    return res
}

func (p *Parser) parseFactor() Poly {
    p.skipSpaces()
    ch := p.s[p.idx]
    if ch == '(' {
        p.idx++
        res := p.parseExpr()
        p.skipSpaces()
        p.idx++
        return res
    }

    if ch >= 'a' && ch <= 'z' {
        name := p.parseToken()
        if val, ok := p.eval[name]; ok {
            return constPoly(val)
        }
        return varPoly(name)
    }

    sign := 1
    if ch == '-' {
        sign = -1
        p.idx++
    }

    num := 0
    for p.idx < len(p.s) {
        c := p.s[p.idx]
        if c < '0' || c > '9' {
            break
        }
        num = num*10 + int(c-'0')
        p.idx++
    }

    return constPoly(sign * num)
}

func (p *Parser) parseToken() string {
    start := p.idx
    for p.idx < len(p.s) {
        ch := p.s[p.idx]
        if ch == ' ' || ch == ')' || ch == '+' || ch == '-' || ch == '*' {
            break
        }
        p.idx++
    }
    return p.s[start:p.idx]
}

func (p *Parser) skipSpaces() {
    for p.idx < len(p.s) && p.s[p.idx] == ' ' {
        p.idx++
    }
}

复杂度分析

  • 时间复杂度:$O(n \cdot T^2 \cdot L \log L)$ 量级,其中 $n$ 是表达式长度、$T$ 是中间多项式的最大项数、$L$ 是单项内变量个数。解析本身游标只前进不回退,是 $O(n)$;主要开销在乘法上:每次 mul 做 $T^2$ 次键合并,每次合并要拆分、排序、重连,代价约 $O(L \log L)$;乘法最多发生 $O(n)$ 次。由于表达式长度不超过 $250$,实际项数和次数都很小,运算量完全可控。
  • 空间复杂度:$O(T \cdot L + d)$。多项式表存 $T$ 个键、每个键长约 $L$;递归深度 $d$ 与括号嵌套层数同阶,最坏为 $O(n)$;此外每次 add / sub / mul 都会新建一个结果对象,但旧对象随即可回收,同时存活的多项式个数与递归深度同阶。

关键点总结

  • 当表达式求值的「值」不再是标量时,先把值抽象成一个支持所需运算的代数对象,再套用标准的求值框架。本题把 int 换成多项式,框架(递归下降 + 三级优先级)一行没改。这个分层思路在符号计算、区间运算、矩阵表达式求值里完全通用。
  • 需要合并同类项时,键必须规范化。把变量排序后拼串是最简单的规范形式;它把「集合相等」的判断转化成了「字符串相等」,从而可以直接用哈希表。凡是「顺序无关但要判同」的场景(字母异位词分组、原子计数)都是同一招。
  • 每次运算后清除零系数项,是一条必须显式维护的不变量,而不是可选优化。不清理会导致抵消后的多项式仍非空,进而在乘法中产生大量零系数垃圾键,并可能让输出多出不该有的项。
  • 递归下降表达式解析的三级结构(expr → term → factor)直接编码了运算符优先级:优先级越高的产生式嵌套越深。记住这个映射,就能在几分钟内为任意优先级层次写出解析器,不需要背双栈模板。
  • 括号的吃入与吃出要写在同一个函数(parseFactor)里,让它们成对出现;parseExpr 见到 ) 只负责退出而不消费。这条约定能消除绝大部分游标错位问题。
  • 面试视角:字节和腾讯考这题看的是抽象能力而非编码技巧。理想的作答顺序是:先指出「和基本计算器 I/II/III 的唯一区别是值的类型变了」,再花大部分时间设计多项式的表示与三种运算,最后说「解析部分就是标准的递归下降,和计算器 III 一样」。主动说明「键要排序才能合并同类项」和「零系数必须清除」这两点,基本就答到了考点上。如果时间不够,可以只写出 Poly 的接口和 mul 的实现,解析部分口述框架。

易错点总结

  • 错误写法:mergeKey 直接拼接两个键而不重新排序 → 用例 "(a+b)*(a-b)" → $ab$ 与 $ba$ 落到 "a*b""b*a" 两个不同的键上,无法抵消,输出多出 1*b*a-1*a*b 两项,正确答案只有两项。
  • 错误写法:加减乘之后不清除系数为 $0$ 的键 → 用例 "a - a"terms 变成 {"a": 0},虽然 toList 里滤掉了,但若这个结果继续参与乘法,会产生 {"a*b": 0} 这类垃圾键并被误判为「多项式非空」,在嵌套表达式中输出多余项。
  • 错误写法:常数 $0$ 也插入 {"": 0} → 用例 "0 * a" → 乘法会生成键 "a" 系数 $0$,若清理不彻底则输出 0*a,正确答案是空列表。
  • 错误写法:degreekey.split("\\*").length 却不特判空串 → 用例 "7" → Java 里 "".split("\\*") 返回长度为 $1$ 的数组,常数项次数被算成 $1$,排序时排到了一次项前面,输出顺序错误。
  • 错误写法:排序只按字典序不按次数降序 → 用例 "a*b + c" → 输出 ["1*a*b", "1*c"] 恰好正确,但换成 "c + a*b" 时若只按字典序会得到 ["1*a*b", "1*c"] 或反过来取决于实现,一旦出现 "z + a*b" 就会把一次项 1*z 排到二次项前面,违反「次数降序优先」。
  • 错误写法:parseExpr 遇到 ) 时把它一并消费掉 → 用例 "(a+b)*c" → 内层 parseExpr 吃掉右括号后返回,parseFactor 又吃一次,把 * 当成了括号消费掉,后续解析彻底错位。
  • 错误写法:把 *+ - 放在同一层循环里按出现顺序依次计算 → 用例 "a + b * c" → 变成 $(a+b) \cdot c$,输出 ["1*a*c", "1*b*c"],正确答案是 ["1*b*c", "1*a"];优先级必须靠嵌套层次保证。
  • 错误写法:变量已在 evalvars 中却仍返回单变量多项式,打算最后统一代入 → 用例 expression = "e + 8 - a + 5"evalvars = ["e"]evalints = [1] → 最终结果里仍带着 e 这个符号项,无法与常数 $8$、$5$ 合并,输出 ["1*e", "13", "-1*a"],正确答案是 ["-1*a", "14"]
  • 错误写法:parseToken 的终止符只判空格和 ),漏掉 + - * → 用例 "a+b" → 变量名被读成 "a+b" 整体,eval 查不到,生成一个键为 "a+b" 的荒谬项,且游标已经越过运算符导致后续解析崩溃。
  • 错误写法:add / sub 直接在 this.terms 上原地修改并返回 this → 用例 "(a+b) + (a+b)" → 左右两侧若指向同一个对象,第一次加法就把它自身改掉了,第二次加法基于被污染的数据,结果变成 $4a+4b$ 而不是 $2a+2b$。

相似题目

题目 难度 考察点
772. 基本计算器 III 困难 同样的三级递归下降框架,但值是整数且多了除法,是本题去掉符号计算后的原型
224. 基本计算器 困难 只有加减和括号,可用单栈处理符号位,适合对照理解「何时才必须递归」
726. 原子的数量 困难 返回值同样是一张计数表而非标量,且要求按元素名排序输出,合并逻辑高度相似
736. Lisp 语法解析 困难 前缀语法且带变量作用域,考察递归下降中环境栈的进出时机
241. 为运算表达式设计优先级 中等 反过来枚举所有加括号方式,返回值是结果集合,练习分治式的表达式拆解