最近刷 LeetCode 时,我被第 736 题 Lisp 语法分析卡了整整一个晚上。题目原型一眼看去并不复杂,就是那个public int evaluate(String expression),但它要求处理(let x 2 (mult x 5))这样的 Lisp 风格表达式,还要管好变量作用域。说实话,第一次看到 Hard 标记时我是有点不服气的,直到自己动手实现才发现坑比想象中多。这篇文章把我从理解题意、设计思路、完整编码到用千问AI做代码 review 的整个过程都记录下来,希望能帮同样被这道题折磨的人彻底打通思路。
1. 题目与难点拆解
1.1 这道题到底在问什么
LeetCode 736 要求实现一个解释器:输入是一个字符串形式的 Lisp 表达式,输出这个表达式求完值后的整数结果。表达式由三种操作构成:
let:绑定变量,比如(let x 2 (mult x 5))表示x = 2,然后计算(mult x 5),结果是 10。add:对两个表达式求和,比如(add 1 2)结果是 3。mult:对两个表达式求积,比如(mult 3 (add 2 3))结果是 15。
表达式里的“值”可以是整数,也可以是一个已经绑定过的变量,还可以是另一个完整的复合表达式。变量名由小写字母组成,可以嵌套绑定,内层绑定会遮蔽外层同名变量。
举个例子:
(let x 2 (let x 3 (add x x)))
最外层的let把x绑成 2,但进入内层let后,x被重新绑成 3,所以内层(add x x)算的是3 + 3 = 6。等内层结束,外层的x才恢复成 2。这就是这道题最核心的考点:变量作用域。
1.2 为什么这题是 Hard 级
从表面上看,语法规则少,操作符也就三个,很多人第一反应是“字符串替换”或者“正则匹配”。但真要写起来,难点集中在这几处:
- 表达式是递归嵌套的,括号内可能有括号,括号内还可能套着
let和表达式。 - 变量遮蔽的规则要求你在离开内层作用域时把变量值恢复回去,漏掉一步就会出现脏数据。
- Token 之间的分隔是空格,但括号和操作符的边界容易切错。
- 数字可能是负数,负号
-不能被你当成普通分隔符处理。 let的绑定数量是不固定的,它支持(let x 1 y 2 (add x y))这种多变量绑定,也支持只绑定一个变量就接最终表达式的写法。
这些点单拎出来都不难,合在一起就非常容易在某一个地方翻车。正因为它是“解释器”类型的题目,考察的是对递归、栈、作用域这几个核心概念的综合运用,所以直接上 Hard。
1.3 解法总览:递归下降而不是建 AST
面对这种嵌套表达式,最简单直接的思路是递归下降:从头到尾扫描字符串,遇到(就进入一个新的子表达式,遇到数字或变量就直接返回值。你不需要先把整个表达式解析成一棵抽象语法树,而是可以在扫描的同时完成求值。
选择这种方案的关键原因有两个:
- 表达式结构天然递归,递归求值代码量最少,逻辑最容易对应到原题规则。
- 题目不需要你保留语法树做二次分析,直接求值即可,没必要引入额外的复杂度。
为了配合递归,代码里需要维护一个全局的扫描位置索引,以及一个保存所有变量当前值的哈希表。哈希表的值不能只是一个Integer,而应该是一个栈结构,因为同一个变量名可能在同一时刻被多层let绑定。
2. 核心设计:Token 边界、作用域栈与求值流程
2.1 不要把整个字符串按空格 split
不少新手会误以为可以直接expression.split(" ")把字符串变成数组,然后慢慢处理。这个思路乍看可行,真落地会非常痛苦,因为括号和操作符是紧挨着的,比如(let x 2 (mult x 5))里(let和(mult都被空格分隔,split 结果似乎整洁。但一旦出现(add 1 (mult 2 3))这样的嵌套,split 后得到的数组元素里既有普通 token,又有带括号前缀的 token,处理起来非常别扭。
更合理的做法是维护一个整数索引idx,写一个小型扫描器:
- 当前位置是
(,就进入复合表达式分支。 - 当前位置是数字或
-,就连续读取直到遇到空格或右括号,把这个子串转成整数。 - 当前位置是小写字母,就连续读取直到遇到空格或右括号,得到变量名。
- 当前位置是
),表示当前子表达式结束。
每个eval()调用都负责“读懂”一个完整的表达式,并停在下一个分隔符前。这个分隔符可能是空格,也可能是右括号,由调用方决定是否要吃掉它。
2.2 用Map<String, Deque<Integer>>实现变量遮蔽
变量遮蔽的经典做法是“栈式作用域”。我定义一个成员变量:
private Map<String, Deque<Integer>> vars = new HashMap<>();每当进入一个let绑定,就把变量新值压入这个变量对应的双端队列头部。离开这个let表达式时,把刚才压入的值弹出。如果队列弹空了,就把这个变量从vars中移除。
因为 Java 里没有现成的“变量栈”结构,Deque是最顺手的替代品,用push和pop就能模拟栈行为。
这里有一个容易踩坑的点:如果你直接把变量名映射到一个普通Integer,那么内层let绑定时会把外层的值覆盖掉,等内层表达式算完,外层值已经找不回来了。所以必须用栈结构保存历史值。
2.3eval()的递归求值流程
整个算法的核心是一个私有方法int eval()。它的大致流程如下:
如果当前字符不是 '(': 如果是数字或负号,解析整数并返回。 如果是字母,解析变量名,并从 vars 中取栈顶值返回。 如果当前字符是 '(': 跳过 '('。 读取操作符名称(let/add/mult)。 按操作符类型进入不同分支。let分支:循环读取“变量名 值表达式”对。每读完一个表达式,就把变量压栈,直到遇到下一个字符不是变量名,而是一个(开头的表达式,说明到了let的最后一部分,读取它作为最终结果。返回前必须把本轮压入的变量全部弹出。add分支:读取第一个表达式,跳过空格,读取第二个表达式,跳过右括号,返回两者之和。mult分支:和add完全一样,只是把加法换成乘法。
这种设计最优雅的地方在于:子表达式递归求值时会自动处理它自己的作用域,你不需要在add或mult里管变量清理,清理逻辑只属于let。
3. 完整实现:evaluate(String expression) 逐段解析
3.1 全局状态与入口函数
首先定义成员变量:
import java.util.*; class Solution { private int idx; private String s; private Map<String, Deque<Integer>> vars = new HashMap<>(); public int evaluate(String expression) { s = expression; idx = 0; return eval(); } }idx表示当前扫描位置,s保存完整输入字符串,vars保存变量作用域栈。入口函数只是做初始化,真正的逻辑全在eval()里。
3.2 基础表达式:数字和变量
eval()最开始需要判断当前字符:
private int eval() { char c = s.charAt(idx); if (c != '(') { if (c == '-' || Character.isDigit(c)) { StringBuilder sb = new StringBuilder(); while (idx < s.length() && (s.charAt(idx) == '-' || Character.isDigit(s.charAt(idx)))) { sb.append(s.charAt(idx++)); } return Integer.parseInt(sb.toString()); } else { StringBuilder sb = new StringBuilder(); while (idx < s.length() && Character.isLetter(s.charAt(idx))) { sb.append(s.charAt(idx++)); } return vars.get(sb.toString()).peek(); } } // 复合表达式分支... }这里有个细节:负号-必须和数字连在一起读取。题目里的操作符只有let、add、mult,不存在一元负号操作,所以只要看到-,它必然是负整数的开头。读取时循环条件是“是负号或者数字”,因此-123能完整读出来。
变量名的读取条件是Character.isLetter(),题目保证变量名全小写,所以不会和数字混淆。读取完变量名后,直接从vars对应栈中peek()栈顶值,这就是当前作用域下这个变量的值。
3.3 复合表达式:跳过左括号并读取操作符
当前字符是(时,说明进入复合表达式。第一步是idx++跳过左括号,然后读取操作符:
idx++; StringBuilder op = new StringBuilder(); while (s.charAt(idx) != ' ') { op.append(s.charAt(idx++)); } idx++;操作符一定是let、add、mult三个单词之一,后面跟一个空格,所以这里用“遇到空格停止”来切分非常稳定。
3.4let分支的实现细节
let分支是最复杂的部分,我先把代码放出来,再逐步说明:
if (op.toString().equals("let")) { int res = 0; List<String> pushedVars = new ArrayList<>(); while (true) { // 如果当前位置是左括号,说明这是最后要求值的表达式 if (s.charAt(idx) == '(') { res = eval(); break; } // 读取变量名 StringBuilder name = new StringBuilder(); while (idx < s.length() && Character.isLetter(s.charAt(idx))) { name.append(s.charAt(idx++)); } // 如果变量名后紧跟右括号,说明这是 let 的最终表达式,直接取该变量值 if (s.charAt(idx) == ')') { res = vars.get(name.toString()).peek(); idx++; break; } idx++; // 跳过变量名后的空格 int val = eval(); // 计算绑定值 vars.computeIfAbsent(name.toString(), k -> new ArrayDeque<>()).push(val); pushedVars.add(name.toString()); if (s.charAt(idx) == ')') { res = val; idx++; break; } idx++; // 跳过绑定值后的空格,继续下一个变量名 } // 离开 let 前,清理本次引入的变量绑定 for (String var : pushedVars) { vars.get(var).pop(); if (vars.get(var).isEmpty()) { vars.remove(var); } } return res; }执行逻辑可以拆成两个阶段:
第一阶段:读取绑定对。let x 2这种,读取变量名x,跳过空格,读取表达式2,然后压栈并记到pushedVars中。接着判断当前位置:如果是右括号,说明let只有这一个绑定且后面没有最终表达式(极特殊情况);如果还有空格,就继续循环读取下一个变量名。
第二阶段:读取最终表达式。当循环进入下一次迭代时,如果当前位置是(,说明接下来的一个复合表达式就是let函数的返回值,直接递归调用eval()。如果当前位置是字母,说明最终表达式是一个变量名,读取后返回它的当前值。
pushedVars这个列表很关键。它记录了当前let层新增绑定的变量名,等整个表达式求值完成后,需要逐个弹出栈顶值。如果变量栈为空,就把这个变量从vars中移除,避免残留脏值影响后续表达式。
3.5add和mult的简洁实现
这两个分支结构一致,只是运算符不同:
} else if (op.toString().equals("add")) { int v1 = eval(); idx++; int v2 = eval(); idx++; return v1 + v2; } else if (op.toString().equals("mult")) { int v1 = eval(); idx++; int v2 = eval(); idx++; return v1 * v2; }第一次调用eval()解析第一个操作数,此时idx会停在空格处,所以idx++跳过空格。第二次调用eval()解析第二个操作数,此时idx停在右括号处,idx++跳过右括号,整个子表达式结束。
这里最需要注意的就是“谁负责跳过什么”。如果add分支在第一个eval()返回后没有正确跳过空格,第二个eval()就会在空格处进入字母分支,导致解析失败。这也是很多初学递归下降最容易写错的地方。
完整代码整合起来就是几十行,虽然简短,但每个字符的位置都需要精确。
4. 常见错误与排查经验
4.1 表达式分割的致命坑
我第一次自己写的时候,图省事用了expression.split(" "),结果遇到嵌套表达式直接崩溃。比如:
(let x 1 (add x (mult x 2)))
split 后数组里会有(add、x、(mult这样的元素,每个元素内部混合了括号和 token,你必须自己再去判断字符串是否以(开头、是否包含),非常容易出错。而且负数的负号会被 split 错误拆分,因为-123内部没有空格才得以保留,但如果你不小心写成(- 1)或类似形式,直接乱套。
所以我很建议大家从一开始就用“全局索引扫描法”,不要偷懒做全量 split。这也是千问AI在帮我 review 时第一个指出的问题:不要用 split,改用一个idx指针配合charAt做状态扫描。
4.2 作用域清理遗漏
还有一个高频 bug 是忘记在let结束时清理变量。如果你把一个变量压栈后没有弹出,那么后面处理外层let时可能读到旧值。
举个具体场景:
(let x 2 (let x 3 (add x x)) (mult x x))
正确结果是外层x=2,所以(mult x x)应该是 4。但如果内层let结束时没有把x=3弹出,那么后续(mult x x)会错误地算成 9。
我在实际调试时发现,使用Deque栈确实能避免覆盖问题,但前提是每个let分支都要在 return 之前弹出本轮压栈的值。为了确保不遗漏,我每次都会用一个pushedVars列表记录变量名,并在 return 前统一清理。这个方法简单有效,不容易漏变量。
4.3 变量名与数字边界的处理
另一个容易忽略的细节是变量名解析和数字解析的终止条件。我一开始写变量名解析时用的是“遇到空格停止”,但没考虑右括号的情况。比如表达式(let x 2 x)中,最后一个x后面直接跟右括号,中间没有空格。如果你只在遇到空格时停止,x就会把)也当作变量名的一部分读进去,导致变量名变成x),查询时直接报空指针。
正确做法是读取变量名时判断“不是空格并且不是右括号”,或者像我的代码一样只读Character.isLetter()字符,因为题目的变量名全小写,这种方法很干净。
同样,数字解析时也要用循环读取连续的数字字符,确保123能被完整读成一个整数,而不是只读一个1。对于负数来说,循环条件里除了Character.isDigit()还要加上对-的判断。
4.4 用千问AI做代码审查的体会
我自己写了第一版实现后,跑了几个用例发现结果不对,但怎么看都觉得逻辑没问题。后来把代码发给千问AI,它很快指出了两个问题:
第一,我在读取变量名时用了s.charAt(idx) != ' '作为终止条件,但没有处理右括号的情况,导致最后一个变量名被读错。
第二,我在let分支里把变量压栈后,没有用一个列表记录本次压入的变量名,导致清理时不知道要 pop 掉哪些变量。这个建议非常实用,直接让代码从“碰运气”变成“结构化正确”。
这里我最大的体会是:AI 工具很适合做“第二双眼睛”,但它不能替代你自己对题目的理解。即使让 AI 生成了整份代码,你也必须能逐行解释每个idx++和每个pop()的理由,否则到了面试环节你依然讲不清。
5. 借助千问AI高效刷题的复盘心得
5.1 向AI提问的正确姿势
刷题时遇到难题,直接甩一句“给我代码”通常拿不到太好的结果。我在用千问AI时,会按照“题目理解 → 思路设计 → 代码实现 → 错误排查”四个阶段分别提问。
比如第一轮我会问:“LeetCode 736 这道题的输入输出格式是什么?变量遮蔽怎么理解?”先把题目吃透。第二轮我会问:“如果不用递归下降,还有哪些可行方案?各自优缺点是什么?”把思路打开。第三轮再问:“帮我写一个 Java 实现,要求使用 Map 和 Deque 处理作用域。”最后如果真的跑挂了,再把报错信息或错误输出贴给它,请它帮忙定位逻辑漏洞。
这种分阶段提问比一次性索要整篇答案有效得多,因为你会在每一轮中得到思考材料,而不是一个拿来就忘的最终结果。
5.2 如何验证AI给出来的代码
AI 生成的代码不能直接信,必须自己动手做三件事:
- 读懂每一行,尤其是索引移动的位置。
- 手动在草稿纸上推演几个例子,例如
(let x 2 (mult x 5))和(let x 2 (let x 3 (add x x)) (mult x x))。 - 在原题上跑几个边界用例,比如只有一个变量的
let,负数参与的运算,多层嵌套的let。
我在 review 千问AI给出的第一版代码时,发现它用了类似peek()的操作读取变量值,但没有先判断变量是否存在。虽然题目保证了命名变量一定已绑定,但从健壮性角度,我还是在调用前加了保险判断。这类细节自己动手敲一遍才真正有感觉。
5.3 独立复盘与变式扩展
有 AI 帮忙之后,最忌讳的是“看完答案就当自己会了”。我自己的复盘习惯是:关掉所有参考资料,从头写一遍完整代码,然后对照之前的实现找差异。如果哪里卡住了,就回顾思路设计,不急着看答案。
因为 LeetCode 736 的本质是“解释器”,这类题日后很容易变形成其他语法规则,比如支持if条件、支持自定义函数、支持字符串类型等。我在刷完这道题后,又拿同样的递归下降框架去做了另一个支持if和let的题,发现只要掌握了核心框架,扩展并不难。
还有一个很不错的变式练习:把变量作用域的清理逻辑从“列表记录”换成“进入 let 前记录每个栈的当前深度,离开时截断到该深度”。这个思路在更复杂的解释器里很常见,值得体会。千问AI也跟我提过这种“深度快照”方案,虽然这道题里pushedVars已经够了,但多看一种方案能加深理解。
我个人在实际操作中的体会是,这道题最值得学的不是那几十行代码本身,而是“递归下降 + 作用域栈”这套组合拳。解释器类型的问题在算法面试里出现频率不低,把这道题嚼透,很多类似题目都能迎刃而解。最后再分享一个小技巧:写递归求值时一定要明确一个约定——每个eval()调用返回后,扫描位置停在哪个字符上。只要这个约定清晰,处理空格和右括号就不会混乱。这是我在反复调试中总结出的最重要经验,希望对你有用。