LeetCode 736 - Parse Lisp Expression
The problem asks us to evaluate a Lisp-like expression represented as a string. The expression can contain integers, variables, and three special operations: let, add, and mult. An expression evaluates to a single integer value.
Difficulty: 🔴 Hard
Topics: Hash Table, String, Stack, Recursion
Solution
Problem Understanding
The problem asks us to evaluate a Lisp-like expression represented as a string. The expression can contain integers, variables, and three special operations: let, add, and mult.
An expression evaluates to a single integer value. The challenge is not arithmetic itself, but correctly handling nested scopes and variable shadowing.
A let expression introduces variable assignments. These assignments are processed sequentially, and each assignment becomes visible to later expressions inside the same let. Variables can also shadow outer variables. This means a variable defined in an inner scope temporarily overrides a variable with the same name from an outer scope.
For example:
(let x 2 (mult x (let x 3 y 4 (add x y))))
Inside the inner let, x becomes 3, even though an outer x = 2 already exists. Therefore:
(add x y) = 3 + 4 = 7
(mult x 7) = 2 * 7 = 14
The input is always a valid expression, and every variable reference is guaranteed to resolve correctly. This guarantee simplifies error handling because we never need to deal with malformed syntax or undefined variables.
The constraints are relatively small:
- Expression length is at most
2000 - Nested expressions are possible
- Variable scopes can overlap deeply
A naive parser that repeatedly copies environments or reparses substrings can become inefficient. We therefore want a solution that parses the string incrementally and maintains scope efficiently.
Several edge cases are important:
- Nested scopes where variables shadow outer values
- Sequential assignments inside
let - Negative integers
- Expressions consisting of only a single integer
- Expressions consisting of only a variable lookup
- Deeply nested recursive expressions
The input is guaranteed to be syntactically correct, which means we can focus entirely on evaluation logic.
Approaches
Brute Force Approach
A brute force solution would repeatedly parse substrings and construct entirely new variable environments for every nested expression.
For each let expression, we could:
- Copy the current scope map
- Apply assignments
- Recursively evaluate the final expression
Similarly, every recursive call might create new substrings for tokens and nested expressions.
This approach works because each recursive evaluation sees a correct snapshot of the variable environment. However, copying entire hash maps at every nested scope becomes expensive, especially when expressions are deeply nested.
Repeated substring creation also increases overhead because string slicing and reparsing may revisit the same characters multiple times.
Optimal Approach
The key insight is that Lisp expressions naturally form a recursive structure, and scope behaves like a stack.
Instead of copying entire environments, we maintain:
- A parsing pointer into the string
- A hash map from variable name to a stack of values
When entering a new scope, we push new variable values onto stacks. When leaving the scope, we pop them off. This exactly matches lexical scoping behavior.
For example:
x = 2
(let x 3 ...)
The stack for x becomes:
[2, 3]
When the inner scope ends, we pop 3 and restore 2.
This allows efficient scope management without copying large maps repeatedly.
| Approach | Time Complexity | Space Complexity | Notes |
|---|---|---|---|
| Brute Force | O(n²) | O(n²) | Repeated environment copying and substring parsing |
| Optimal | O(n) | O(n) | Single-pass recursive parser with scoped stacks |
Algorithm Walkthrough
- Maintain a global parsing index into the expression string.
Instead of creating substrings repeatedly, we move a pointer through the original string. Every recursive call continues parsing from the current position. 2. Use a hash map where each variable maps to a stack of values.
This structure models nested scope naturally. The most recent assignment is always the active value.
3. Define a recursive parse() function.
This function evaluates the expression starting at the current index and returns its integer value.
4. If the current character is not '(', parse either:
- An integer
- A variable name
If it is a variable, return the top value from its stack.
5. If the current character is '(', parse an operation.
Skip '(', then read the operation name:
addmultlet
- For
add:
- Parse first expression
- Parse second expression
- Return their sum
- For
mult:
- Parse first expression
- Parse second expression
- Return their product
- For
let:
Process tokens sequentially.
Each iteration can be either:
- A variable assignment pair
- The final expression
- For every variable assignment:
- Parse variable name
- Parse assigned expression
- Push value onto that variable's stack
- Record the variable so we can pop it later
- Once the final expression is reached:
- Evaluate it
- Pop all variables introduced in this scope
- Return the result
- Continue recursively until the entire expression is evaluated.
Why it works
The correctness comes from maintaining a stack of values for each variable. The top of the stack always represents the innermost visible scope. Entering a scope pushes values, and leaving a scope pops them. Since recursive parsing follows the exact nesting structure of the expression, variable visibility always matches Lisp scoping rules.
Python Solution
from collections import defaultdict
from typing import DefaultDict, List
class Solution:
def evaluate(self, expression: str) -> int:
index = 0
scope: DefaultDict[str, List[int]] = defaultdict(list)
def parse() -> int:
nonlocal index
# Integer or variable
if expression[index] != '(':
if expression[index] == '-' or expression[index].isdigit():
start = index
if expression[index] == '-':
index += 1
while index < len(expression) and expression[index].isdigit():
index += 1
return int(expression[start:index])
start = index
while (
index < len(expression)
and expression[index] not in [' ', ')']
):
index += 1
variable = expression[start:index]
return scope[variable][-1]
# Skip '('
index += 1
start = index
while expression[index] != ' ':
index += 1
operation = expression[start:index]
index += 1
if operation == "add":
first = parse()
index += 1
second = parse()
index += 1
return first + second
if operation == "mult":
first = parse()
index += 1
second = parse()
index += 1
return first * second
# let expression
assigned_variables = []
while True:
if expression[index] == '(':
result = parse()
break
start = index
while (
index < len(expression)
and expression[index] not in [' ', ')']
):
index += 1
token = expression[start:index]
# Final expression
if expression[index] == ')':
result = scope[token][-1]
break
index += 1
# Detect final expression instead of assignment
if (
expression[index] == '('
or expression[index] == '-'
or expression[index].isdigit()
):
result = parse()
break
value = parse()
scope[token].append(value)
assigned_variables.append(token)
if expression[index] == ' ':
index += 1
for variable in assigned_variables:
scope[variable].pop()
index += 1
return result
return parse()
The implementation revolves around recursive descent parsing.
The index variable tracks the current parsing position globally. This avoids repeatedly creating substrings and enables a true single-pass parser.
The scope hash map stores stacks of values for variables. Each variable may have multiple active bindings due to nested scopes.
The parse() function evaluates one expression at the current index.
If the expression does not begin with '(', it must be either:
- An integer
- A variable reference
Integers are parsed directly. Variables are resolved by reading the top value from their stack.
For compound expressions, the parser first reads the operation name.
The add and mult operations are straightforward because they always contain exactly two expressions.
The let operation is more complicated because it mixes assignments and a final expression. The parser processes tokens sequentially until it detects the final expression.
Every assigned variable is recorded so its scope can later be removed correctly when exiting the let.
This implementation matches Lisp scoping rules exactly.
Go Solution
package main
import (
"strconv"
)
func evaluate(expression string) int {
index := 0
scope := map[string][]int{}
var parse func() int
parse = func() int {
// Integer or variable
if expression[index] != '(' {
// Integer
if expression[index] == '-' || (expression[index] >= '0' && expression[index] <= '9') {
start := index
if expression[index] == '-' {
index++
}
for index < len(expression) &&
expression[index] >= '0' &&
expression[index] <= '9' {
index++
}
value, _ := strconv.Atoi(expression[start:index])
return value
}
// Variable
start := index
for index < len(expression) &&
expression[index] != ' ' &&
expression[index] != ')' {
index++
}
variable := expression[start:index]
values := scope[variable]
return values[len(values)-1]
}
// Skip '('
index++
start := index
for expression[index] != ' ' {
index++
}
operation := expression[start:index]
index++
if operation == "add" {
first := parse()
index++
second := parse()
index++
return first + second
}
if operation == "mult" {
first := parse()
index++
second := parse()
index++
return first * second
}
// let expression
assigned := []string{}
var result int
for {
if expression[index] == '(' {
result = parse()
break
}
start := index
for index < len(expression) &&
expression[index] != ' ' &&
expression[index] != ')' {
index++
}
token := expression[start:index]
// Final variable expression
if expression[index] == ')' {
values := scope[token]
result = values[len(values)-1]
break
}
index++
// Final expression
if expression[index] == '(' ||
expression[index] == '-' ||
(expression[index] >= '0' && expression[index] <= '9') {
result = parse()
break
}
value := parse()
scope[token] = append(scope[token], value)
assigned = append(assigned, token)
if expression[index] == ' ' {
index++
}
}
for _, variable := range assigned {
values := scope[variable]
scope[variable] = values[:len(values)-1]
}
index++
return result
}
return parse()
}
The Go implementation closely mirrors the Python version, but several language-specific details differ.
Go does not have Python's defaultdict, so the scope map is initialized manually using map[string][]int.
Slice operations naturally support stack behavior. Appending pushes values, and slicing removes the top value.
Integer parsing uses strconv.Atoi.
Unlike Python, Go requires explicit character comparisons for digit checks.
Go integers are safe here because the problem guarantees all intermediate values fit within 32-bit signed integer range.
Worked Examples
Example 1
(let x 2 (mult x (let x 3 y 4 (add x y))))
| Step | Current Expression | Scope State | Result |
|---|---|---|---|
| 1 | let x 2 ... | x = [2] | |
| 2 | mult x ... | x = [2] | |
| 3 | evaluate x | x = [2] | 2 |
| 4 | inner let x 3 | x = [2, 3] | |
| 5 | y 4 | x = [2, 3], y = [4] | |
| 6 | add x y | x = [2, 3], y = [4] | 7 |
| 7 | mult 2 7 | x = [2] | 14 |
Final result:
14
Example 2
(let x 3 x 2 x)
| Step | Action | Scope |
|---|---|---|
| 1 | assign x = 3 | x = [3] |
| 2 | assign x = 2 | x = [3, 2] |
| 3 | evaluate x | x = [3, 2] |
The visible value is the top of the stack:
2
Example 3
(let x 1 y 2 x (add x y) (add x y))
| Step | Action | Scope |
|---|---|---|
| 1 | x = 1 | x = [1] |
| 2 | y = 2 | x = [1], y = [2] |
| 3 | x = add(x, y) = 3 | x = [1, 3], y = [2] |
| 4 | evaluate add(x, y) | x = [1, 3], y = [2] |
Final computation:
3 + 2 = 5
Complexity Analysis
| Measure | Complexity | Explanation |
|---|---|---|
| Time | O(n) | Each character is parsed at most once |
| Space | O(n) | Recursive calls and scope stacks may store all variables |
The parser advances through the string exactly once. Every token and character participates in constant work, making the overall runtime linear.
Space usage comes from two sources:
- Recursive call stack for nested expressions
- Variable stacks for active scopes
In the worst case, deeply nested expressions may require linear auxiliary memory.
Test Cases
sol = Solution()
assert sol.evaluate("1") == 1 # single integer
assert sol.evaluate("-5") == -5 # negative integer
assert sol.evaluate("(add 1 2)") == 3 # basic addition
assert sol.evaluate("(mult 3 4)") == 12 # basic multiplication
assert sol.evaluate("(let x 2 x)") == 2 # simple variable assignment
assert sol.evaluate(
"(let x 2 (mult x (let x 3 y 4 (add x y))))"
) == 14 # nested scope shadowing
assert sol.evaluate(
"(let x 3 x 2 x)"
) == 2 # sequential reassignment
assert sol.evaluate(
"(let x 1 y 2 x (add x y) (add x y))"
) == 5 # assignment using previous expressions
assert sol.evaluate(
"(let x 2 y (add x 3) (mult y 2))"
) == 10 # variable depending on previous variable
assert sol.evaluate(
"(let a1 3 b2 (mult a1 2) (add b2 a1))"
) == 9 # variables containing digits
assert sol.evaluate(
"(mult (add 2 3) (add 4 5))"
) == 45 # nested arithmetic expressions
assert sol.evaluate(
"(let x 1 (let x 2 (let x 3 x)))"
) == 3 # deeply nested shadowing
assert sol.evaluate(
"(let x 5 (add x (mult x 2)))"
) == 15 # variable reuse
assert sol.evaluate(
"(let x -2 y 3 (add x y))"
) == 1 # negative values inside expressions
| Test | Why |
|---|---|
"1" |
Validates direct integer parsing |
"-5" |
Validates negative integer handling |
"(add 1 2)" |
Tests addition |
"(mult 3 4)" |
Tests multiplication |
"(let x 2 x)" |
Tests simple variable lookup |
| Nested shadowing example | Tests lexical scoping |
| Sequential reassignment | Tests let evaluation order |
| Assignment from expression | Tests dependent assignments |
| Variables with digits | Tests variable parsing rules |
| Nested arithmetic | Tests recursive parsing |
| Deep shadow nesting | Tests multiple scope levels |
| Variable reuse | Tests repeated variable access |
| Negative values | Tests arithmetic with negatives |
Edge Cases
One important edge case is variable shadowing across nested scopes. Expressions like:
(let x 1 (let x 2 x))
can easily produce incorrect results if scope restoration is not handled properly. A naive implementation might overwrite the outer value permanently. Using a stack for each variable solves this cleanly because inner values are pushed and later popped automatically.
Another tricky case is sequential assignment inside a single let expression. In:
(let x 1 y (add x 2) y)
the assignment to y must see the already assigned value of x. Processing assignments sequentially is therefore essential. The implementation evaluates and stores each assignment immediately before moving to the next token.
Negative integers are also easy to mishandle. Since variable names contain lowercase letters and digits, a leading - unambiguously indicates an integer. The parser explicitly checks for '-' before digit parsing, ensuring negative values are interpreted correctly.
A final subtle edge case is distinguishing between the final expression of a let and another assignment pair. The implementation determines this by examining what follows the token. If the next component represents an expression instead of a variable assignment, parsing switches to evaluating the final expression directly.