目录

题目描述

736. Lisp 语法解析

题意分析

给一个用类 Lisp 语法写成的表达式字符串,求它的整数值。表达式有三类形态:整数字面量(可带负号)、变量名(小写字母开头,后面可跟字母和数字),以及三种带括号的调用——(add e1 e2) 求两个子表达式之和,(mult e1 e2) 求积,(let v1 e1 v2 e2 ... expr) 依次把变量绑定到子表达式的值,最后返回 expr 的值。

语法信号非常明确:表达式是自嵌套的——addmult 的操作数本身可以是任意表达式,let 的绑定值和返回体也可以是任意表达式。凡是「结构可以无限嵌套且用括号界定边界」的输入,一律要按照语法规则逐层展开来处理,而不是用一个扁平的栈去凑。

第二个关键信号来自 let 的语义细节。题面强调:绑定是顺序生效的,后面的表达式能看见前面刚绑定的变量(如 (let x 2 y x z 3 x) 中的 y 取到 $2$);而且内层的同名变量会遮蔽外层,内层作用域一结束,外层的绑定必须原样恢复(如 (let x 2 (let x 3 x) x) 的答案是 $2$ 而不是 $3$)。这两条合起来说明:变量环境不是一张全局的表,而是一叠有生命周期的作用域,进入 let 时压入一层,离开时弹出一层,查找时从内向外逐层找。

约束方面:表达式长度不超过 $2000$,保证语法合法、变量在使用前一定已被绑定、所有中间结果和最终结果都落在 $32$ 位有符号整数范围内。这些保证很重要——它们意味着不需要做错误处理、不需要判断未定义变量、也不用担心溢出,可以把全部精力放在语法结构的正确翻译上。

边界上要覆盖:单个数字或单个负数(如 "-12");let 一个绑定都没有只有返回体(如 (let x 5 x) 是最小形式,但 (let a1 3 b2 (add a1 1) b2) 才体现顺序依赖);let 的返回体是一个数字而不是变量(如 (let x 3 x 4 x) 里最后的 x 是返回体,而不是新一轮绑定的变量名);变量名含数字(如 x2);嵌套遮蔽。其中最难缠的是如何判断 let 参数列表里当前读到的 token 是「又一个绑定的变量名」还是「最后的返回体」,这是整道题的核心歧义点。

解法:递归下降解析

核心思路

先排除两条走不通的路。第一条是用正则或字符串切分预处理成 token 数组再处理——括号嵌套无法用正则匹配,切分之后还是要重建树结构,白做一遍。第二条是用单个显式栈做「遇到 ) 就归约」的经典计算器写法——它对 add / mult 尚可,但 let 的作用域需要在进入括号时就建立环境,而栈式归约只在离开括号时才动手,时机对不上,会让变量遮蔽极难实现。

瓶颈在于:这道题的语法是递归定义的,而作用域的生命周期恰好与递归调用的生命周期重合。既然如此,最自然的做法就是让函数调用栈直接充当语法树——这就是递归下降。

于是设计一个带游标的解析器,把状态定死为三样东西:待解析的字符串 s、当前读到的位置 idx、以及一叠作用域 scopes(每层是一张 变量名 -> 值 的哈希表)。核心函数 eval() 的语义是:idx 开始读出一个完整的表达式,返回它的值,并把 idx 推进到这个表达式结束的下一个位置

这条语义就是整个解法的不变量。它有两个半边,缺一不可:「返回正确的值」和「游标恰好停在该表达式之后」。所有子函数(parseAtomparseTokenskipSpaces)都必须遵守同样的契约——只消费自己该消费的字符,一个不多一个不少。一旦某个分支多吃或少吃一个字符(尤其是右括号),后续解析全部错位,且错误往往在很远的地方才暴露出来,这是本题最难调试的部分。

eval() 的分支结构由第一个非空格字符决定:不是 '(' 就说明是原子(数字或变量),交给 parseAtom;是 '(' 就吃掉左括号,读出操作符 token,再分三路:

  • add / mult:连续调用两次 eval() 取两个操作数(递归天然处理了任意嵌套),跳过空格后吃掉配对的 ')',返回和或积。
  • let:先 scopes.push(new HashMap<>()) 开一层新作用域,然后进入一个循环反复读取。

let 循环里的歧义判断是关键。循环每轮先看当前字符:如果是 ')',说明括号提前结束(在合法输入下不会走到,作为兜底返回 $0$);如果是 '(''-' 或数字,说明这一项不可能是变量名,只能是返回体表达式,于是求值、吃掉右括号、弹出作用域、返回该值;否则先用 parseToken() 读出一个标识符,再往后偷看一眼:若跳过空格后紧跟的是 ')',说明这个标识符就是返回体(形如 ... x)),解析它的值后收尾返回;若不是 ')',说明它是一个变量名,后面还跟着绑定值,于是 eval() 求出值并写进栈顶作用域,继续下一轮循环。

「读一个 token 再偷看下一个字符」这一步就是消解「变量名 vs 返回体」歧义的全部手段——因为绑定总是成对出现,只有返回体是孤零零地贴着右括号的那一个。

变量查找 resolve 从栈顶向栈底逐层找,第一个命中即返回,这就实现了内层遮蔽外层。作用域在返回前 pop,恰好与 let 表达式的词法范围对齐,实现了「离开即恢复」。

解题步骤

  • 把解析器封装成一个持有 sidxscopes 的对象,而不是用一堆全局变量或到处传参。理由:递归下降的每个函数都要读写同一个游标,把它做成成员变量能让所有函数共享推进进度;如果用参数传递 idx 就必须同时返回「值」和「新位置」,签名会变得很难看。

  • skipSpaces(),把 idx 推过所有连续空格。理由:题目允许 token 之间有空格,但空格本身没有语义;把跳过空格集中成一个函数,在每个「即将读取下一个语法单元」的位置调用一次,能避免在十几处手写 while 且漏掉某处。

  • parseToken():从当前位置读到遇见空格或 ')' 为止,返回中间的子串。理由:token 的终止符只有这两个(左括号只会出现在 token 之前),把边界条件收敛成一处,操作符名和变量名就能共用同一个读取函数。

  • parseAtom():跳空格后看首字符,若是 '-' 或数字则按符号加逐位累加解析出整数;否则读一个 token 当作变量名,交给 resolve 查值。理由:负号只可能出现在数字最前面(题目没有一元减运算),所以「首字符是 -」是判定数字的充分条件之一;逐位累加而不是 Integer.parseInt 是为了让游标推进和解析同步完成,符合前面定下的契约。

  • eval() 先跳空格,看首字符是否为 '(':不是就直接返回 parseAtom()。理由:这是递归出口,把「原子表达式」这一最简形态单独剥离,后面就只需处理括号形态。

  • '(' 时先 idx++ 吃掉左括号,跳空格后 parseToken() 读出操作符。理由:三种操作符长度不同(addmultlet),用 token 读取而不是固定长度截取才安全。

  • add 分支:int a = eval(); int b = eval(); 然后跳空格、idx++ 吃掉右括号,返回 a + b。理由:两次 eval() 的调用顺序保证了游标按左到右推进;结尾必须显式吃掉自己这一层的右括号,否则外层看到的将是多余的 )mult 分支同理,只把加号换成乘号。

  • let 分支先压入一个空作用域,再进入循环。理由:必须在解析任何绑定值之前就建好新层,否则第一个绑定会写进外层作用域,污染上层环境。

  • 循环内先跳空格取当前字符 c:若 c'(''-' 或数字,说明是返回体,求值后吃右括号、弹作用域、返回。理由:变量名必须以小写字母开头,所以这三类首字符能百分之百排除「变量名」的可能,判定是确定性的而非启发式的。

  • 否则 parseToken() 读出标识符后跳空格,检查下一个字符是不是 ')':是则该标识符是返回体,resolve 取值后吃右括号、弹作用域、返回;不是则它是变量名,调用 eval() 求出绑定值并写入栈顶作用域,进入下一轮。理由:这是消解歧义的唯一手段,「后面还有东西」等价于「这是个待绑定的变量」。写入栈顶而非某个固定层,保证了顺序绑定对后续表达式立刻可见。

  • resolve(var) 从栈顶向栈底遍历,命中即返回。理由:内层作用域在栈顶,先查内层就自动实现了遮蔽;Java 的 ArrayDeque 配合 push 时迭代顺序正好是从头(最新压入)到尾,天然满足要求,Go 里则要显式用下标从 len-1 递减。

  • expression = "(let x 2 (mult x (let x 3 y 4 (add x y))))" 走一遍。eval'(',吃掉后读到 let,压入作用域 $S_1$。循环第一轮:字符是 x,是字母,parseToken 读出 "x",偷看下一个非空格字符是 2 不是 ')',所以 x 是变量名;eval() 解析出 $2$,写入 $S_1$,此时 $S_1 = {x: 2}$。第二轮:字符是 '(',命中「返回体」分支,调用 eval()。这个内层 eval 读到 mult,两次递归求操作数:第一次 eval() 读到变量 xresolve 在 $S_1$ 找到 $2$;第二次 eval()'(' 读到 let,压入 $S_2$,循环第一轮读 "x" 后偷看是 3,绑定 $x = 3$ 到 $S_2$(遮蔽了 $S_1$ 的 $x$),第二轮读 "y" 后偷看是 4,绑定 $y = 4$,第三轮字符是 '(' 走返回体分支,eval()(add x y)resolve("x") 从栈顶 $S_2$ 找到 $3$(不是 $S_1$ 的 $2$),resolve("y") 找到 $4$,和为 $7$;随后吃掉 let 的右括号、弹出 $S_2$,返回 $7$。回到 mult,两个操作数是 $2$ 和 $7$,吃掉右括号返回 $14$。回到最外层 let 的返回体分支,吃掉右括号、弹出 $S_1$,返回 $14$。最终答案 $14$——过程中 $S_2$ 的弹出使得 x 的遮蔽在内层结束时准确失效。

代码实现

class Solution {
    public int evaluate(String expression) {
        Parser parser = new Parser(expression);
        return parser.eval();
    }

    private static class Parser {
        private final String s;
        private int idx;
        private final Deque<Map<String, Integer>> scopes = new ArrayDeque<>();

        Parser(String s) {
            this.s = s;
            this.idx = 0;
        }

        int eval() {
            skipSpaces();
            char ch = s.charAt(idx);
            if (ch == '(') {
                idx++;
                skipSpaces();
                String op = parseToken();
                if (op.equals("add")) {
                    int a = eval();
                    int b = eval();
                    skipSpaces();
                    idx++;
                    return a + b;
                }
                if (op.equals("mult")) {
                    int a = eval();
                    int b = eval();
                    skipSpaces();
                    idx++;
                    return a * b;
                }

                scopes.push(new HashMap<>());
                while (true) {
                    skipSpaces();
                    char c = s.charAt(idx);
                    if (c == ')') {
                        idx++;
                        scopes.pop();
                        return 0;
                    }

                    if (c == '(' || c == '-' || Character.isDigit(c)) {
                        int val = eval();
                        skipSpaces();
                        idx++;
                        scopes.pop();
                        return val;
                    }

                    String var = parseToken();
                    skipSpaces();
                    if (s.charAt(idx) == ')') {
                        int val = resolve(var);
                        idx++;
                        scopes.pop();
                        return val;
                    }

                    int val = eval();
                    scopes.peek().put(var, val);
                }
            }

            return parseAtom();
        }

        private int parseAtom() {
            skipSpaces();
            char ch = s.charAt(idx);
            if (ch == '-' || Character.isDigit(ch)) {
                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 sign * num;
            }

            String var = parseToken();
            return resolve(var);
        }

        private int resolve(String var) {
            for (Map<String, Integer> scope : scopes) {
                if (scope.containsKey(var)) {
                    return scope.get(var);
                }
            }
            return 0;
        }

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

        private void skipSpaces() {
            while (idx < s.length() && s.charAt(idx) == ' ') {
                idx++;
            }
        }
    }
}
type Parser struct {
    s      string
    idx    int
    scopes []map[string]int
}

func evaluate(expression string) int {
    p := &Parser{s: expression, idx: 0}
    return p.eval()
}

func (p *Parser) eval() int {
    p.skipSpaces()
    ch := p.s[p.idx]
    if ch == '(' {
        p.idx++
        p.skipSpaces()
        op := p.parseToken()
        if op == "add" {
            a := p.eval()
            b := p.eval()
            p.skipSpaces()
            p.idx++
            return a + b
        }
        if op == "mult" {
            a := p.eval()
            b := p.eval()
            p.skipSpaces()
            p.idx++
            return a * b
        }

        p.scopes = append(p.scopes, map[string]int{})
        for {
            p.skipSpaces()
            c := p.s[p.idx]
            if c == ')' {
                p.idx++
                p.scopes = p.scopes[:len(p.scopes)-1]
                return 0
            }

            if c == '(' || c == '-' || (c >= '0' && c <= '9') {
                val := p.eval()
                p.skipSpaces()
                p.idx++
                p.scopes = p.scopes[:len(p.scopes)-1]
                return val
            }

            varName := p.parseToken()
            p.skipSpaces()
            if p.s[p.idx] == ')' {
                val := p.resolve(varName)
                p.idx++
                p.scopes = p.scopes[:len(p.scopes)-1]
                return val
            }

            val := p.eval()
            p.scopes[len(p.scopes)-1][varName] = val
        }
    }

    return p.parseAtom()
}

func (p *Parser) parseAtom() int {
    p.skipSpaces()
    ch := p.s[p.idx]
    if ch == '-' || (ch >= '0' && ch <= '9') {
        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 sign * num
    }

    varName := p.parseToken()
    return p.resolve(varName)
}

func (p *Parser) resolve(name string) int {
    for i := len(p.scopes) - 1; i >= 0; i-- {
        if val, ok := p.scopes[i][name]; ok {
            return val
        }
    }
    return 0
}

func (p *Parser) parseToken() string {
    start := p.idx
    for p.idx < len(p.s) {
        ch := p.s[p.idx]
        if 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 d)$,其中 $n$ 是表达式长度、$d$ 是 let 的最大嵌套深度。游标 idx 只单调前进从不回退,因此字符扫描总量是 $O(n)$;额外开销来自 resolve,每次查变量最坏要沿作用域栈走 $d$ 层,而变量引用次数不超过 $O(n)$。由于 $n \le 2000$ 且嵌套深度受括号数限制,实际运算量很小。
  • 空间复杂度:$O(n)$。递归深度与表达式的括号嵌套深度同阶,最坏是 $O(n)$;作用域栈的层数等于 let 嵌套深度,所有层中存储的变量总数也不超过表达式中出现的绑定次数,同样是 $O(n)$;解析过程本身不复制字符串,只做子串截取。

关键点总结

  • 面对递归定义的语法,递归下降几乎总是最优选择:让函数调用栈充当语法树,语法规则一条对应一个分支,代码结构和文法定义一一对应,可读性远超手工维护的显式栈。判断标准是「输入是否可以无限嵌套」,一旦是,就不要试图用扁平的栈去凑。
  • 递归下降的生命线是游标契约:每个解析函数「只消费自己那一段,返回时游标停在段尾之后」。写代码时逐个函数确认这条契约,比写完之后调试要高效一个量级——因为游标错位的症状会在很远处才显现,几乎无法通过打断点定位。
  • 变量作用域天然对应「进入时压栈、离开时弹栈」的结构,而在递归下降里这个时机与函数的进入/返回完全重合,所以直接把 push 放在分支开头、pop 放在每个 return 之前即可。查找从栈顶向栈底走就实现了遮蔽语义,无需任何额外机制。
  • 语法歧义要靠前瞻消解,而不是靠猜。本题「变量名 vs 返回体」的区分手段是「读完 token 后偷看下一个非空格字符是否为右括号」,这是一个确定性的 LL(1) 判断;用「计数绑定对数的奇偶」之类的间接推断则会在嵌套时失效。
  • 首字符能提供强判定信息时要充分利用。本题里 '(''-' 和数字三者都不可能是变量名的开头,所以看一眼就能立刻走返回体分支,省掉一次 token 读取和回退。
  • 面试视角:字节考这题主要看能否把非平凡文法翻译成结构清晰的代码,而不是看你能不能想出巧思。答题的正确姿势是先在白板上把文法写出来(expr := int | var | (add expr expr) | (mult expr expr) | (let (var expr)* expr)),指着它说明每条产生式对应哪个分支,再写代码。写之前主动声明游标契约,写完主动举 (let x 2 (let x 3 x) x) 这个遮蔽用例验证作用域弹出,能极大提升说服力。若被追问「怎么支持更多操作符」,回答是把分支表改成 Map<String, 处理函数> 即可扩展,不影响主框架。

易错点总结

  • 错误写法:add / mult 分支求完两个操作数后忘记吃掉自己的右括号(漏掉 idx++)→ 用例 "(add 1 2)" → 外层解析器随后读到多余的 ')',在嵌套场景如 "(mult (add 1 2) 3)" 中会把 ) 当成 token 起点,解析彻底错位。
  • 错误写法:let 分支的 pop 只写在其中一个返回路径上 → 用例 "(let x 2 (let x 3 x) x)" → 内层 let 通过「首字符是 (」路径返回时若漏了 pop,外层再查 x 会先命中残留的内层作用域返回 $3$,正确答案是 $2$。
  • 错误写法:用一张全局哈希表代替作用域栈,let 结束时不恢复 → 用例 "(let x 2 (let x 3 x) x)" → 内层把 x 改成 $3$ 之后再没改回来,最终返回 $3$,正确答案是 $2$。
  • 错误写法:resolve 从作用域栈底向栈顶查找(Java 里误用 descendingIterator,或 Go 里下标从 $0$ 递增)→ 用例 "(let x 2 (let x 3 x))" → 先命中外层的 $2$,返回 $2$,正确答案是 $3$,遮蔽方向完全反了。
  • 错误写法:把 let 的绑定值写进栈底或某个固定作用域而不是栈顶 → 用例 "(let x 2 (let y 3 (add x y)))" → 内层的 y 被写到外层,虽然本例侥幸能算对,但换成 "(let x 1 (let x 2 x) x)" 时内层绑定污染外层,最终返回 $2$ 而非 $1$。
  • 错误写法:判断「返回体」时只检查首字符是不是 '(',忘记数字和负号 → 用例 "(let x 2 5)" → 读到 5 时把它当成变量名,parseToken 读出 "5" 后偷看是 ')',走 resolve("5") 返回默认值 $0$,正确答案是 $5$。
  • 错误写法:读 token 的终止条件只判空格不判 ')' → 用例 "(let x 2 x)" → 最后的 x) 被整体读成 token "x)"resolve 查不到返回 $0$,且游标已经越过右括号导致后续全乱。
  • 错误写法:解析数字时不处理前导负号,或把 '-' 当成减法运算符 → 用例 "(add 1 -2)" → 要么解析出 $2$ 得到答案 $3$,要么试图找第三个操作数而崩溃,正确答案是 $-1$。
  • 错误写法:用 Integer.parseInt(s.substring(start, end)) 解析数字却没有同步推进 idx → 用例 "(add 12 3)" → 游标仍停在 1 上,下一次读取重复消费同一段字符,陷入死循环或读出错误的 token。
  • 错误写法:let 循环里读完变量名后不跳空格就检查下一个字符是否为 ')' → 用例 "(let x 2 x )"(右括号前有空格)→ 检查到的是空格不是 ')',误判 x 为变量名并调用 eval() 去读 ),解析崩溃。

相似题目

题目 难度 考察点
1106. 解析布尔表达式 困难 同为括号嵌套的递归下降,但操作数个数可变且没有变量作用域
772. 基本计算器 III 困难 中缀表达式带运算符优先级,需在递归下降中分层处理加减与乘除
394. 字符串解码 中等 嵌套结构更简单,可用显式栈归约,是理解「递归 vs 栈」两种写法的入门对照
726. 原子的数量 困难 括号带乘数因子,返回的是计数表而非单值,考察递归返回复合结果的合并
385. 迷你语法分析器 中等 解析嵌套列表并构造对象树,重点在游标推进与容器构造的配合