736. Parse Lisp Expression - Detailed Explanation

Free Coding Questions Catalog
Boost your coding skills with our essential coding questions catalog. Take a step towards a better tech career now!

Problem Statement

You are given a string expression representing a Lisp-like expression, and you need to evaluate it and return its integer value. The expression can be one of the following types :

  • Integer Literal: e.g., "42" or "-7" (an integer can be positive or negative).

  • Variable: a lowercase letter followed by optional letters or digits (e.g., "x", "y1"). Variables are assigned values in let-expressions. The special names "add", "let", and "mult" are reserved and will not be used as variable names.

  • Add-expression: of the form "(add e1 e2)" – returns the sum of evaluating e1 and e2.

  • Mult-expression: of the form "(mult e1 e2)" – returns the product of evaluating e1 and e2.

  • Let-expression: of the form "(let v1 e1 v2 e2 ... vn en expr)" – assigns each variable vi the value of expression ei sequentially, then evaluates and returns the value of the final expression expr in the context of those assignments. Variables assigned in a let-expression are only valid within that expression’s scope (inner parentheses). When evaluating a variable, the innermost scope (most recently assigned value) is used.

Constraints:

  • 1 <= expression.length <= 2000
  • The expression is well-formatted with no leading/trailing spaces, and tokens separated by a single space.
  • Intermediate and final results fit in a 32-bit signed integer.
  • Every variable will have a value by the time it is evaluated (expressions are guaranteed to be legal).

Example Test Cases:

  1. Input: (add 1 2)
    Output: 3
    Explanation: Simple addition of two integers.

  2. Input: (mult 3 (add 2 3))
    Output: 15
    Explanation: The expression (add 2 3) evaluates to 5, then mult multiplies 3 * 5.

  3. Input: (let x 2 (mult x 5))
    Output: 10
    Explanation: A let assigns x = 2. The final expression (mult x 5) uses this x (value 2), so result is 2*5=10.

  4. Input: (let x 2 (mult x (let x 3 y 4 (add x y))))
    Output: 14
    Explanation: Outer let sets x = 2. Inside the mult, the second operand is another let-expression that sets a new x = 3 and y = 4, then returns (add x y) = 3+4 = 7. This inner x shadows the outer x within its scope. The outer mult then computes outer x (2) * inner result (7) = 14. (After the inner let, x in the outer scope is still 2.)

  5. Input: (let x 3 x 2 x)
    Output: 2
    Explanation: In a let, assignments are processed sequentially. Here x is first assigned 3, then reassigned 2. The final expression is just x, which evaluates to the latest value 2.

  6. Input: (let x 1 y 2 x (add x y) (add x y))
    Output: 5
    Explanation: Assign x=1, y=2. Then assign x = (add x y) = 3. Finally evaluate (add x y) with the updated x=3 and y=2, giving 5.

  7. Input: (let x 2 (add (let x 3 (let x 4 x)) x))
    Output: 6
    Explanation: The inner (let x 4 x) returns 4, but its scope is limited to inside the (let x 3 ...). That inner (let x 3 ... ) returns 4, and the outer x remains 2. So we compute add(4, outer x=2) = 6 .

  8. Input: (let a1 3 b2 (add a1 1) b2)
    Output: 4
    Explanation: a1 and b2 are variable names with digits. Here a1=3, then b2 = (add a1 1) = 4. The final result is the value of b2, which is 4 .

These examples illustrate correct scoping and evaluation: use the most recently assigned value of a variable in the current context, and process let assignments one by one.

Hints for Solving the Problem

  • Identify Expression Types: When parsing the string, first determine if the expression is an add, mult, let, a number, or a variable. The structure of the string will guide this (e.g., it starts with "(add" for addition, "(let" for a let-expression, etc.). Treat each case separately.

  • Use Recursion for Nested Expressions: The expression may contain nested sub-expressions in parentheses. A natural way to handle this is using recursion – parse and evaluate inner parentheses first, then use their results in the outer expression. Each recursive call can handle a smaller sub-expression (just like how a Lisp interpreter would evaluate inner () first).

  • Manage Variable Scope (Environment): For let expressions, keep track of variable assignments. When you enter a let (or any new parenthesized expression), you can treat it as a new scope. One approach is to use a stack or dictionary to store variable values. When you assign a variable in a let, that value should be used for any evaluations until you exit that let-expression, then the variable’s old value (if any) should be restored. This mimics how variables scope works – inner definitions override outer ones only temporarily.

  • Parsing Strategy: Rather than splitting the string naively by spaces (which fails with nested parentheses), parse character by character. You can maintain an index pointer moving through the string and extract tokens (like numbers, variable names, or parenthesized sub-expressions) one at a time. This helps handle cases where an argument is itself a complex expression in parentheses.

  • Sequential Assignment in let: Be careful with the format of let. It can have multiple variable value pairs. Process them in order, updating the variable environment each time. The final token after these pairs is the expression whose value will be returned as the let result (this final expression is not an assignment, just an expression to evaluate with all the assignments in effect).

  • Base Cases: If the expression is just an integer (no parentheses) or a single variable name, you can return its value directly. Ensure your parser recognizes when it’s at a base case (e.g., a token without surrounding parentheses).

Using these hints, try to break down the problem: parse the input string, evaluate step by step handling each type of expression, and maintain a proper mapping of variables to values. Think of it as implementing a simple interpreter for a tiny Lisp-like language.

Multiple Approaches

We will discuss two approaches to solve this problem:

  1. A Brute Force Approach with simple parsing – easier to implement conceptually, but possibly less efficient.
  2. An Optimized Approach using recursion with careful environment (scope) tracking – more efficient and closer to how a real interpreter might work.

Approach 1: Brute Force Parsing (Iterative or Naive Recursion)

Idea: Evaluate the expression from the innermost parentheses outward or by straightforward recursion. This approach explicitly breaks the problem into smaller subproblems by finding sub-expressions, evaluating them, and substituting results back in.

One brute-force strategy is iterative simplification: find the deepest parenthesized expression, evaluate it, and replace it in the string with its result, and repeat until the whole expression is simplified. However, implementing string replacement and parsing each sub-expression from scratch can be tedious and inefficient (due to repeated scanning).

A simpler implementation uses recursion with a dictionary for variables:

  • Parse and evaluate: Write a recursive function that evaluates the expression string. If the current expression is a simple number or variable, handle it directly. If it's in the form "(op ...)", parse out the operator and operands.
  • Tokenization: Break the expression into tokens (taking care to keep sub-expressions intact). For example, in "(add 1 (mult 2 3))", you want to recognize "(mult 2 3)" as one operand token, not split it by spaces. You can do this by counting parentheses or by a pointer that extracts one token at a time.
  • Evaluate add/mult: Once you have the two operand tokens, recursively evaluate each (they could be nested expressions or simple values), then perform the addition or multiplication.
  • Evaluate let: For a let-expression "(let v1 e1 v2 e2 ... expr)":
    1. Create a local copy of the current variables environment (so that assignments in this let don’t leak outside).

    2. Parse each vi and its corresponding ei expression token in sequence. For each pair, evaluate the expression ei (using the current environment which includes any previously assigned variables in this let), and assign that value to vi in the local environment.

    3. After processing all pairs, evaluate the final expr using this updated local environment. That result is the value of the whole let-expression.

    4. Return that result (and discard the local environment, restoring the previous scope).

This approach is considered “brute force” because we might be parsing parts of the string multiple times and copying environments frequently, which could be costly for very large or deeply nested expressions. However, given the input size is at most 2000 characters, this straightforward method is manageable.

Complexity (Approach 1): In the worst case, the time complexity can be around O(n²), where n is the length of the expression. This might happen if we repeatedly evaluate many nested sub-expressions or copy large parts of the string for parsing. Each let may copy the environment (which could have up to O(n) variables) and evaluate a sub-expression. Typical expressions will be faster than the worst-case. The space complexity is O(n) for the recursion stack and storing variables in the environment. Each recursive call adds a new scope or processes a portion of the string.

Python Implementation (Brute Force)

Python3
Python3

. . . .

How this works: The evaluate function checks the expression type. For add and mult, it uses parse_token to safely extract the two operands, then recursively evaluates them. For let, it tokenizes everything after the "let" keyword into a list, then processes the list: the last item is the final expression, and preceding items come in pairs of variable and expression. It updates a local_env copy for each assignment so that previous outer scope values are preserved outside the let. Finally, it evaluates the last expression with the local assignments in effect and returns that value.

Java Implementation (Brute Force)

In Java, we can implement a similar logic. We’ll write a helper to parse tokens and a recursive evaluator. We will manage scope by passing a map (HashMap) of variables to values, copying it for let scopes so changes don’t propagate outward.

Java
Java

. . . .

Explanation: The Java code follows the same logic as the Python code. We use a helper parseToken to extract a token (accounting for nested parentheses). The evaluate function distinguishes add, mult, and let and handles each accordingly. A HashMap env represents the current scope of variables. For a let, we clone this map into localEnv and use it to process assignments, then evaluate the final expression with localEnv. The original env remains unchanged when we return (ensuring correct scoping). The main method demonstrates usage with a couple of examples.

Approach 2: Optimized Recursion with Scope Tracking

Idea: Evaluate the expression in one pass using recursion and maintaining a stack of variable scopes. This approach is more efficient by avoiding repeated parsing of the same parts of the string. We use a single recursive descent parser that moves through the string character by character, and a data structure to manage variable scopes.

Key points of this approach:

  • We keep a global index pointer that scans through the string. Each recursive call processes the expression starting at the current pointer position.

  • We maintain a map (or dictionary) of variables to values, but instead of copying it for each new scope, we use a structure that can push and pop variable values. For example, a Map<String, Deque<Integer>> in Java or a defaultdict(list) in Python can hold stacks of values for each variable. When we assign a variable in a new scope, we push the new value. When exiting the scope, we pop it to reveal the old value.

  • The recursive function will parse according to the grammar:

    • If the current expression part is an integer or variable (no leading (), return its value (for variable, the current value from the scope stack).

    • If it starts with (, then read the operator right after ( which will be "add", "mult", or "let" (the problem guarantees these are the only operations).

    • For add/mult: recursively evaluate the two sub-expressions inside and then compute the sum or product.

    • For let: iterate through each assignment vi = ei. For each assignment, recursively evaluate ei first, then assign it to vi (push the value onto that variable’s stack). When you reach the final expression part after the assignments, recursively evaluate it to get the result of the let. After that, pop all the assignments to restore the previous scope values before returning.

  • Because we use a single pointer moving through the string, we naturally skip over characters as they are processed. This ensures each part of the string is parsed and evaluated exactly once, making it efficient.

This approach essentially interprets the expression on the fly. We don’t explicitly build token arrays or modify the original string, we just traverse it. Managing the scope with a stack structure ensures we handle nested scopes properly without copying large maps around.

Complexity (Approach 2): The time complexity is close to O(n) for most expressions, since we parse each character of the string once in the recursion. In pathological cases with many nested scopes or a lot of variables, there might be some overhead (like searching for variable values or managing long stacks), but it’s generally efficient (worst-case could approach O(n²) in extremely nested scenarios). Space complexity is O(n) due to recursion depth (in a deeply nested expression, the recursion stack and scope stacks could grow in proportion to n). However, this approach avoids making multiple copies of large data structures, so it’s more memory-efficient than the brute force approach in practice.

Python Implementation (Optimized)

Python3
Python3

. . . .

How this works: We use a pointer i that moves through the string. The helper functions parse_var and parse_int read a variable name or integer from the current position. The eval_expr function is the core recursive evaluator:

  • If the current position is at a number or variable, it returns the value directly (for variables, looks up the latest value in scope).
  • If it encounters a '(', it determines the type by peeking at the substring ("let", "add", or "mult"):
    • For a let-expression, it loops to read assignments. It collects each assigned variable name in vars_assigned. For each assignment, it evaluates the next expression and pushes the value onto that variable’s stack in scope. If it finds a ")" right after a variable name, that means there are no more assignments and that variable name is actually the start of the final expression (as in the case "(let x 2 x)"). The loop breaks when the final expression is evaluated (or when it detects that scenario). After computing the result of the let, it pops the variables in vars_assigned to restore outer values.

    • For add and mult, it simply evaluates the two sub-expressions (operands) recursively and then adds or multiplies them.

  • We increment and decrement i appropriately to skip over spaces and parentheses as we parse. By the time eval_expr() returns, i will have advanced past the entire sub-expression it evaluated.

This method evaluates the expression in one pass without re-scanning any portion of the string more than once. It also cleanly handles nested scopes by using the scope stacks to keep track of variable values.

Java Implementation (Optimized)

Java
Java

. . . .

Explanation: The Java optimized solution uses a class with state: expr (the input string), index (current parse position), and a scope map from variable names to a stack (Deque) of integer values. The evalExpr method closely mirrors the Python version:

  • If the current position points to a number or variable (not starting with '('), it returns the value (for variable, scope.get(varName).peek() gives the latest assigned value).

  • If it sees (, it checks whether the next part is "let", "add", or "mult" and handles each case:

    • For "let", it reads tokens inside until it finds the final expression. For each assignment, it pushes the new value onto the variable’s stack in scope. It uses scope.computeIfAbsent to ensure a stack exists for a new variable. After finishing the let, it pops the values for all variables that were assigned in this let to restore the outer scope.
    • For "add" and "mult", it evaluates the two operands and computes the result.
  • The use of startsWith on the substring helps identify the operation keyword. We carefully move the index to skip processed characters (like the "add" or "let" keywords and the spaces and parentheses).

  • The main method demonstrates evaluating an expression; it should print 14 for the given example.

This approach avoids copying the entire environment on each scope entry. Instead, it updates the scope map in place (pushing new values) and then reverts changes by popping. This is more efficient in both time and space.

Complexity Analysis

  • Brute Force Approach: Time complexity is roughly O(n²) in the worst case, where n is the length of the expression. This is because we may repeatedly parse parts of the string or copy the environment for nested let scopes. For example, a deeply nested expression with many let assignments could lead to a lot of repeated work. However, for most reasonable inputs (including the examples), it will run in near-linear time relative to the expression size. Space complexity is O(n) due to recursion depth and environment copies. Each recursive call handles a subset of the expression or a new scope.

  • Optimized Approach: Average time complexity is O(n), since we make one pass through the string and do constant work (or small overhead) for each character. The parser efficiently handles nested expressions without re-scanning already processed parts. In the worst case, extremely nested or contrived inputs might introduce some overhead (like many nested scopes causing a lot of push/pop operations or repeated checks), but it’s still much more efficient than re-parsing. Space complexity is O(n), primarily from the recursion stack in a deeply nested expression and the space used by the scope stacks. Each variable can have at most as many values on its stack as the nesting depth of scopes, which in worst case is O(n) if each character introduces a new scope (though that’s an unlikely scenario given the syntax).

In summary, the optimized approach is more scalable for larger expressions and ensures we don’t redo work. The brute force approach is simpler to implement but might struggle with very large or deeply nested inputs due to the repeated parsing and copying.

Step-by-Step Walkthrough of an Example

Let’s walk through a complex example to see how the evaluation works step by step. Consider the input:

(let x 2 (mult x (let x 3 y 4 (add x y))))

We will evaluate this expression by hand:

  1. Outer Expression: (let x 2 (mult x ( ... ))). This is a let expression.

    • We start with an empty environment {}.
    • Assignment 1: x = 2. After this, in the let’s local scope, x is 2. (Outer env: { x: 2 })
    • Now, after assignments, we must evaluate the final expression part: (mult x (let x 3 y 4 (add x y))) with the current environment.
  2. Evaluate (mult x (let x 3 y 4 (add x y))): This is a mult expression with two operands:

    • Operand 1: x – a variable. We look up x in the current environment. The innermost assignment for x is 2 (from the outer let). So this operand evaluates to 2.
    • Operand 2: (let x 3 y 4 (add x y)) – another let-expression (nested inside the mult).
  3. Inner Let Expression: (let x 3 y 4 (add x y)). We evaluate this inside the context of the outer let:

    • When we enter this inner let, we create a new scope layered on top of the outer scope. At the moment before the inner assignments, x is still 2 (from outer scope) and is remembered.
    • Assignment 1 (inner): x = 3. This shadows the outer x. Now in the inner scope, x is 3 (outer x=2 is still there but hidden underneath).
    • Assignment 2 (inner): y = 4. Now in inner scope, we have x = 3, y = 4.
    • Now evaluate the inner let’s final expression: (add x y) in the context of the inner scope.
      • x in this context is 3 (the inner assignment) and y is 4.
      • So (add x y) = 3 + 4 = 7.
    • The inner let-expression returns 7. Before leaving this let, we pop the inner assignments, restoring any outer values. (So after this, x goes back to 2 as in outer scope, and y is no longer defined in the outer scope.)
  4. Back to the mult: Now we have the results of both operands:

    • Operand 1 was 2 (outer x).
    • Operand 2 (the inner let) evaluated to 7.
    • So (mult 2 7) = 14.
  5. Outer Let Result: The mult returned 14. Since that was the final expression of the outer let, the outer let-expression evaluates to 14. Any variables defined in the outer let (here x) would be popped if there were an outer scope (in this case, it was the top level, so we’re done).

The final output is 14, which matches the expected result. Throughout this process, note how scoping works:

  • Inside the inner (add x y), the value of x was the inner one (3), not the outer one (2).

  • After finishing the inner let, the outer x value (2) is still in effect for the remainder of the outer expression.

  • Assignments in each let are local and sequential, and once their scope is finished, those assignments don’t exist anymore.

Common Mistakes and How to Avoid Them

  • Incorrect Token Splitting: A frequent mistake is to split the expression by spaces naively. This breaks down when expressions are nested. For example, splitting "(mult x (add 1 2))" by spaces yields ["(mult", "x", "(add", "1", "2))"], which is incorrect because "(add 1 2)" is one operand. Always parse taking parentheses into account (e.g., using a stack or pointer to identify whole sub-expressions).

  • Not Handling Negative Numbers Properly: Remember that -5 is a single integer token, not two tokens. If you parse character by character, ensure that a '-' followed by digits is recognized as one number token. In our approaches, we handled this in parse_int. Forgetting this could lead to errors or treating - as an operator (which it isn’t in this language).

  • Variable Scoping Errors: A common logical error is not restoring the previous value of a variable after leaving a scope. For instance, if a variable x exists in an outer scope and you assign x in a let, you need to revert to the old value when that let finishes. Using a stack for each variable (or copying the environment) helps manage this. If you simply use a global map and don't revert changes, you’ll carry incorrect values into outer scopes.

  • Sequential Let Assignments: Misinterpreting the structure of let can cause mistakes. Each vi ei pair should be processed in order. Some might erroneously try to evaluate all ei first before assigning, which would be wrong because later expressions can depend on earlier assigned variables. For example, in (let x 1 x 2 (....)), the second assignment’s expression could refer to x which should still be 1 at that moment. Our evaluation evaluates and assigns one pair at a time, reflecting the sequential nature.

  • Missing Base Cases: Not handling simple expressions (like just a number or just a variable by itself) can cause issues. Although the problem input always wraps complex expressions in parentheses, after recursion you will hit cases where you evaluate a substring that's just "5" or "x". Our code checks for expression[0] != '(' to cover this case. Forgetting to do so might lead your code to try to treat a number as an expression and fail.

  • Off-by-One Errors in Parsing: When manually moving an index through the string, it’s easy to skip one character too many or too few (for example, not skipping the space after reading an operand, or skipping the closing parenthesis at the wrong time). Careful incrementing of the index and thorough testing on provided examples (and edge cases) can help catch these. In our optimized approach, we incremented i at specific points to consume spaces and parentheses; a mistake there could scramble the parsing.

To avoid these mistakes, it’s helpful to test your solution on the example cases and also on simpler cases:

  • Just an integer (e.g., "7") or just a variable with an outer assignment (though standalone variable might not be valid input unless within a let).
  • Expressions with negative numbers (e.g., "(add -1 -2)" should yield -3).
  • Nested lets that reuse variable names to ensure your scope handling is correct.

Edge Cases to Consider

  • Single Number or Variable: The input could be just an integer (e.g., "5") or a variable that was assigned in an outer context. According to the problem, the expression is always legal, so a standalone variable would imply it was assigned in an outer let. But typically, you won’t get an input like "x" alone since there’s no outer context. Nonetheless, ensure your evaluation handles base tokens correctly.

  • Negative Values: Ensure that negative integers are parsed as one token and their arithmetic works. Also, variables can hold negative values from calculations, which is fine as long as arithmetic is correct.

  • Deep Nesting: The expression could be deeply nested (imagine many layers of let inside each other). Our recursive approach and scope stack should handle this up to the input length limit. In Python, extremely deep recursion could hit recursion limits, but with max length 2000 and the nature of the grammar, it’s unlikely to exceed Python’s recursion limit (which is usually around 1000) significantly. If needed, one could convert recursion to iteration with an explicit stack to avoid that, but it's usually not necessary here.

  • Variables with Similar Names: Variables like a and add – the latter is reserved and won’t appear as a variable, but something like a and aa could both exist. Our parsing by spaces and boundaries ensures we get full names, so this should be fine. Just be careful not to confuse a prefix of a variable name as a separate token. For example, if the code looked for keywords by exact match (which we did with startsWith but also checking the context), ensure it doesn’t misidentify a variable named "letdown" as a "let" keyword (in this problem, such a name won't appear because "let" is reserved, but this is a general caution).

  • No Extra Spaces: The input has no leading/trailing spaces and single spaces between tokens. If you were to handle arbitrary spacing, you’d need to trim and normalize spaces. The provided input guarantee simplifies parsing (we can reliably use space as a delimiter except when inside nested expression which we handle by counting parentheses).

By considering these edge cases, you can ensure your solution is robust.

This problem is essentially about parsing and evaluating expressions with variables and nested scopes. Here are some related problems and variations to practice:

  • LeetCode 224: Basic Calculator I – Evaluate a mathematical expression containing +, -, (, ), and integers . This is simpler (no variables or custom operations) but also requires parsing with correct order of operations.

  • LeetCode 227: Basic Calculator II – Similar to Basic Calculator I but without parentheses, and adds * and /. Emphasizes parsing and operator precedence.

  • LeetCode 772: Basic Calculator III – A follow-up that combines parentheses, +,-,*,/. It’s a bit more complex, involving parsing and possibly using a stack or recursion to handle precedence and parentheses.

  • LeetCode 439: Ternary Expression Parser – Parse a string like "T?2:3" (a ternary conditional expression) and evaluate it. It requires parsing a different expression format with ? and : operators and can also be solved with recursion or stack.

  • LeetCode 150: Evaluate Reverse Polish Notation – Here the expression is given in Reverse Polish (postfix) form. The parsing is trivial (tokens separated by spaces) but it’s about using a stack to evaluate in the correct order. It’s a different notation but also an expression evaluation problem.

  • LeetCode 385: Mini Parser – Parse a nested integer list specified as a string (e.g., "[123,[456,[789]]]"). This involves recursion to handle nested brackets and is conceptually similar to parsing nested parentheses in this problem.

TAGS
leetcode
CONTRIBUTOR
Design Gurus Team
-

GET YOUR FREE

Coding Questions Catalog

Design Gurus Newsletter - Latest from our Blog
Boost your coding skills with our essential coding questions catalog.
Take a step towards a better tech career now!
Explore Answers
What is a low-level design interview?
Why should I work for Dell?
Does OpenAI use Python?
Related Courses
Image
Grokking the Coding Interview: Patterns for Coding Questions
Grokking the Coding Interview Patterns in Java, Python, JS, C++, C#, and Go. The most comprehensive course with 476 Lessons.
Image
Grokking Modern AI Fundamentals
Master the fundamentals of AI today to lead the tech revolution of tomorrow.
Image
Grokking Data Structures & Algorithms for Coding Interviews
Unlock Coding Interview Success: Dive Deep into Data Structures and Algorithms.
Image
One-Stop Portal For Tech Interviews.
Copyright © 2025 Design Gurus, LLC. All rights reserved.
;