LeetCode 770. 基本计算器 IV
题目描述
题意分析
给一个含有变量的表达式字符串,运算符只有
+、-、*和圆括号,操作数是非负整数或小写字母变量。另给两个数组evalvars和evalints,表示其中一部分变量已经被赋了具体的值。要求把表达式化简,输出一组「项」的字符串列表:每一项形如系数*变量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*b和b*a会映射到同一个键,同类项自动合并;如果保留原顺序,两者会变成两个不同的项,永远合不掉。在这个表示上,三种运算的定义都很自然:加法是把两张表按键累加;减法是按键相减;乘法是两张表的笛卡尔积——对每一对项,键做「合并后重新排序」,系数相乘,结果累加到同一个键上。三种运算之后都要执行一次清理,把系数为 $0$ 的键删掉。这一步不是可选的优化,而是维持不变量所必需的。
于是多项式对象维护的不变量是:
terms里的每个键都是变量升序排列的规范串,且每个键对应的系数非零。前半条保证同类项合并的正确性,后半条保证(a+b)-(a+b)这类抵消能真正得到空多项式,而不是留下一堆系数为 $0$ 的僵尸键干扰输出与后续乘法。第二层是表达式解析。 因为有括号和运算符优先级,且括号可以任意嵌套,所以用递归下降,按文法分三级:
parseExpr处理最低优先级的+/-:先解析一个 term,然后只要下一个符号是+或-就继续解析下一个 term 并合并。parseTerm处理更高优先级的*:先解析一个 factor,然后只要下一个符号是*就继续解析下一个 factor 并相乘。parseFactor处理最小单元:遇到(就吃掉括号递归调用parseExpr,遇到字母就读出变量名(若在eval表中则直接返回常数多项式,否则返回单变量多项式),否则读出一个整数返回常数多项式。这个三层分工正是「优先级越高的产生式嵌套得越深」这一经典结构:乘法被夹在加减的内部,所以乘法会先被完整算完,优先级自动正确,不需要任何显式的优先级表或双栈。
解析层同样有一条必须遵守的不变量:每个 parse 函数只消费属于自己的那一段字符,返回时游标
idx恰好停在这一段之后。这是递归下降的生命线,任何一个分支多吃或少吃一个字符(尤其是右括号),后面的解析全部错位。最后是输出。 把
terms的键取出来,按「次数降序、同次数字典序升序」排序,再拼成系数*变量串的形式;常数项只输出系数本身。次数就是键里变量的个数,用split("\\*").length得到,空键的次数是 $0$。由于不变量保证了系数非零,输出阶段不会产生多余项。
解题步骤
先把
evalvars与evalints装进一张哈希表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()取右操作数、按符号add或sub。理由:遇到)就退出是把括号的闭合交还给调用方(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。理由:题目要求的排序规则就是这两级;degree用split("\\*").length计算,空串特判为 $0$,因为 Java 里"".split("\\*")会返回长度为 $1$ 的数组,不特判会把常数项的次数误算成 $1$。以
expression = "(a+b)*(a-b)"、evalvars = []走一遍。parseExpr调parseTerm,parseTerm调parseFactor:看到(,吃掉后递归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$ 项能被抵消,正是因为mergeKey把b*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,正确答案是空列表。- 错误写法:
degree用key.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. 为运算表达式设计优先级 | 中等 | 反过来枚举所有加括号方式,返回值是结果集合,练习分治式的表达式拆解 |