Skip to content

Stack Parsing and Expression Evaluation

Whenever the most recently opened thing must be closed first, you are looking at a stack. Brackets, nested arithmetic, [3[a2[c]]][3[a2[c]]]-style encodings, directory paths, undo history — all the same shape, because all of them are last-in, first-out by definition.

This is a different use of a stack from Monotonic Stack. There, the stack holds a sorted set of candidates to answer next-greater-element questions. Here, the stack holds suspended context: work you must come back to once the current nested piece finishes.

What you’ll learn

  • Bracket matching, and the two failure modes people forget.
  • Why postfix (RPN) needs no precedence rules at all.
  • The save-and-restore template for nested encodings — push the outer context, start a fresh inner one, merge on close.
  • How to accumulate multi-digit numbers while scanning character by character.
  • Three real LeetCode problems solved in the browser: 20, 150, 394.

The cue

Template 1 — matched pairs

valid_parentheses.py
def is_valid(s):
    pairs = {")": "(", "]": "[", "}": "{"}      # closer -> its opener
    stack = []
 
    for ch in s:
        if ch in pairs:                        # a closer
            if not stack or stack.pop() != pairs[ch]:
                return False                   # nothing open, or wrong type
        else:                                  # an opener
            stack.append(ch)
 
    return not stack                           # nothing may be left open
 
 
print(is_valid("([])"))    # True
print(is_valid("([)]"))    # False -- correctly nested matters, not just counts
valid_parentheses.py
def is_valid(s):
    pairs = {")": "(", "]": "[", "}": "{"}      # closer -> its opener
    stack = []
 
    for ch in s:
        if ch in pairs:                        # a closer
            if not stack or stack.pop() != pairs[ch]:
                return False                   # nothing open, or wrong type
        else:                                  # an opener
            stack.append(ch)
 
    return not stack                           # nothing may be left open
 
 
print(is_valid("([])"))    # True
print(is_valid("([)]"))    # False -- correctly nested matters, not just counts

Template 2 — postfix evaluation, no precedence needed

Reverse Polish Notation puts operators after their operands, which removes precedence and parentheses from the problem entirely. Push operands; on an operator, pop two, combine, push the result.

eval_rpn.py
def eval_rpn(tokens):
    stack = []
    for t in tokens:
        if t in {"+", "-", "*", "/"}:
            b = stack.pop()            # SECOND operand pops FIRST
            a = stack.pop()
            if t == "+":   stack.append(a + b)
            elif t == "-": stack.append(a - b)
            elif t == "*": stack.append(a * b)
            else:          stack.append(int(a / b))   # truncate toward zero
        else:
            stack.append(int(t))
    return stack[0]
 
 
print(eval_rpn(["4", "13", "5", "/", "+"]))   # 6   -> 4 + (13 // 5)
eval_rpn.py
def eval_rpn(tokens):
    stack = []
    for t in tokens:
        if t in {"+", "-", "*", "/"}:
            b = stack.pop()            # SECOND operand pops FIRST
            a = stack.pop()
            if t == "+":   stack.append(a + b)
            elif t == "-": stack.append(a - b)
            elif t == "*": stack.append(a * b)
            else:          stack.append(int(a / b))   # truncate toward zero
        else:
            stack.append(int(t))
    return stack[0]
 
 
print(eval_rpn(["4", "13", "5", "/", "+"]))   # 6   -> 4 + (13 // 5)

Template 3 — save and restore for nested encodings

This is the pattern worth internalising, because it generalises to every “decode the nested thing” problem. Keep the current context in plain variables. On an opener, push the current context and reset. On a closer, pop and merge.

decode_string.py
def decode_string(s):
    stack = []            # suspended (text_before, repeat_count) pairs
    current = ""          # text being built at this nesting level
    count = 0             # digits seen just before the next '['
 
    for ch in s:
        if ch.isdigit():
            count = count * 10 + int(ch)     # build multi-digit numbers
        elif ch == "[":
            stack.append((current, count))   # suspend the outer level
            current, count = "", 0           # start a fresh inner level
        elif ch == "]":
            prev, k = stack.pop()            # resume the outer level
            current = prev + current * k     # merge the finished inner one
        else:
            current += ch
 
    return current
 
 
print(decode_string("3[a2[c]]"))   # accaccacc
decode_string.py
def decode_string(s):
    stack = []            # suspended (text_before, repeat_count) pairs
    current = ""          # text being built at this nesting level
    count = 0             # digits seen just before the next '['
 
    for ch in s:
        if ch.isdigit():
            count = count * 10 + int(ch)     # build multi-digit numbers
        elif ch == "[":
            stack.append((current, count))   # suspend the outer level
            current, count = "", 0           # start a fresh inner level
        elif ch == "]":
            prev, k = stack.pop()            # resume the outer level
            current = prev + current * k     # merge the finished inner one
        else:
            current += ch
 
    return current
 
 
print(decode_string("3[a2[c]]"))   # accaccacc

The count = count * 10 + int(ch)count = count * 10 + int(ch) line matters: "10[a]""10[a]" must repeat ten times, not once then zero times. Treating each digit character as a complete number is a real and common bug.

TimeSpace
Bracket matchingO(n)O(n)O(n)O(n) worst case (all openers)
RPN evaluationO(n)O(n)O(n)O(n)
Nested decodingO(output length)O(\text{output length})O(output length)O(\text{output length})

The variant map

VariantWhat the stack holdsCanonical problem
Matched pairsThe unclosed openers20 Valid Parentheses
Count repairs neededJust a counter plus one for unmatched closers921 Minimum Add to Make Parentheses Valid
Postfix evaluationOperands awaiting an operator150 Evaluate RPN
Infix with precedenceRunning value + the last operator, or two stacks227 · 224
Nested decodingSuspended (context, multiplier)(context, multiplier)394 Decode String
Adjacent cancellationCharacters, popping on a match1047 Remove All Adjacent Duplicates
Path resolutionResolved directory names; .... pops71 Simplify Path

Practice — real LeetCode problems

LC 20 — Valid Parentheses · Easy

Problem. Given a string containing only ((, )), {{, }}, [[, ]], determine whether the brackets are correctly closed, in the correct order, and each closer matches its opener type.

Constraints. 1 <= len(s) <= 10^41 <= len(s) <= 10^4.

Examples. "()""()" gives TrueTrue · "()[]{}""()[]{}" gives TrueTrue · "(]""(]" gives FalseFalse · "([)]""([)]" gives FalseFalse · "([])""([])" gives TrueTrue

Editorial — approach, complexity, follow-ups

Push openers; on a closer, the top of the stack must be its matching opener. Popping consumes the match. An empty stack at the end means every opener was closed.

Time O(n)O(n). Space O(n)O(n) — worst case "(((((""(((((".

"([)]""([)]" is why this is a stack problem rather than a counting problem: the counts of ((, )), [[, ]] are all balanced, yet the nesting is wrong. "]""]" tests the empty-stack guard, and """" should be TrueTrue (vacuously balanced) — though note LeetCode’s own constraints say len(s) >= 1len(s) >= 1, so the empty string is included here to test your guard rather than because the judge will send it.

Follow-ups you should expect: “Only one bracket type?” — an integer counter suffices; it must never go negative and must end at zero. “Return the index of the first invalid character?” — return ii at the point of failure. “Minimum insertions to make it valid (921)?” — count unmatched closers as you scan plus whatever openers remain. “Longest valid substring (32)?” — a genuinely harder problem: push indices and measure gaps, or use DP.

LC 150 — Evaluate Reverse Polish Notation · Medium

Problem. Evaluate an arithmetic expression in Reverse Polish Notation. Valid operators are ++, --, **, //. Division between two integers truncates toward zero.

Constraints. 1 <= len(tokens) <= 10^41 <= len(tokens) <= 10^4. The expression is always valid, and division by zero never occurs.

Examples. ["2","1","+","3","*"]["2","1","+","3","*"] gives 99 · ["4","13","5","/","+"]["4","13","5","/","+"] gives 66 · ["10","6","9","3","+","-11","*","/","*","17","+","5","+"]["10","6","9","3","+","-11","*","/","*","17","+","5","+"] gives 2222

Editorial — approach, complexity, follow-ups

Postfix notation encodes precedence in the token order, so evaluation is a single pass with no lookahead: operands accumulate on the stack, and each operator consumes exactly the top two. Because the input is guaranteed valid, the stack holds exactly one value at the end.

Time O(n)O(n). Space O(n)O(n).

Also note that a token like "-11""-11" is a negative operand, not a minus operator — checking membership in the operator set (rather than t.isdigit()t.isdigit(), which is FalseFalse for "-11""-11") handles it correctly. Using isdigit()isdigit() here is a classic bug.

Follow-ups you should expect: “Evaluate infix instead?” — LC 227/224, or convert infix to postfix with the shunting-yard algorithm and reuse this code. “Prefix (Polish) notation?” — scan right to left with the pop order swapped. “Add ^^ or unary minus?” — extend the operator set; unary operators pop only one value.

LC 394 — Decode String · Medium

Problem. Given an encoded string where k[encoded]k[encoded] means the bracketed substring repeats exactly kk times, return the decoded string. kk is guaranteed to be a positive integer, and the input is always valid.

Constraints. 1 <= len(s) <= 301 <= len(s) <= 30, 1 <= k <= 3001 <= k <= 300. Brackets may nest.

Examples. "3[a]2[bc]""3[a]2[bc]" gives "aaabcbc""aaabcbc" · "3[a2[c]]""3[a2[c]]" gives "accaccacc""accaccacc" · "2[abc]3[cd]ef""2[abc]3[cd]ef" gives "abcabccdcdcdef""abcabccdcdcdef"

Editorial — approach, complexity, follow-ups

The insight is to keep the current nesting level in ordinary variables and push only the suspended outer levels. On [[ you are descending, so save what you had and start clean. On ]] the inner level is complete, so pop the parent and splice the repeated result into it.

Time O(length of the output)O(\text{length of the output}) — you cannot beat that, since you must produce every character. Space O(output)O(\text{output}).

Two details that break naive attempts:

  • Multi-digit counts. "10[a]""10[a]" must give ten aas. count = count * 10 + int(ch)count = count * 10 + int(ch) accumulates digits; treating each digit as its own number gives "a""a" repeated once then zero times.
  • Text before a bracket. "abc3[cd]xyz""abc3[cd]xyz" shows that currentcurrent may already hold characters when [[ arrives, which is exactly why the pushed tuple stores currentcurrent alongside the count, and why the merge is prev + current * kprev + current * k rather than just current * kcurrent * k.

The recursive solution is equally valid and arguably more readable: parse a number, recurse on the bracketed segment, repeat and continue. The stack version is the iterative form of that same recursion, which is why “now do it without recursion” is such a common follow-up.

Follow-ups you should expect: “Do it recursively” (or iteratively, if you started recursive) — be ready to convert either way. “What if kk could be zero?” — the merge still works and drops the segment. “Encode rather than decode?” — a different, harder problem (run-length compression with nesting). “What if the input might be malformed?” — add the LC 20 guards: check the stack is non-empty before popping and empty at the end.

LeetCode problem set

#ProblemDifficultyThe twist
20Valid ParenthesesEasyThe base template; guard the empty stack and the leftovers
1047Remove All Adjacent Duplicates In StringEasyPush, and pop instead when the incoming char equals the top
150Evaluate Reverse Polish NotationMediumPop order, and truncation toward zero
394Decode StringMediumSave-and-restore; multi-digit counts
71Simplify PathMediumSplit on //; .... pops, .. and empty segments are skipped
921Minimum Add to Make Parentheses ValidMediumA counter is enough — count unmatched closers plus leftover openers
227Basic Calculator IIMediumApply ** and // eagerly, defer ++ and -- to a final sum
224Basic CalculatorHardParentheses: push the running total and the sign, restore on ))
32Longest Valid ParenthesesHardPush indices, not characters, and measure the gap to the last unmatched one

Interview follow-ups

They askWhat they’re checkingThe answer
“Could you use a counter instead of a stack?”Whether you know the boundaryOnly with a single bracket type; multiple types need to know what was opened, which "([)]""([)]" demonstrates
“Why int(a / b)int(a / b) and not a // ba // b?”Reading the specThe problem truncates toward zero; Python’s //// floors toward negative infinity, so they differ on negative results
“Now do it iteratively / recursively”FlexibilityA stack is the explicit form of the call stack — the save-and-restore template is the mechanical conversion
“What’s the space complexity?”PrecisionO(n)O(n) worst case for matching (all openers); O(output)O(\text{output}) for decoding, which can exceed the input
“Handle malformed input”DefensivenessGuard every pop()pop() with a non-empty check, and verify the stack is empty at the end
“Add precedence / parentheses to the calculator”DepthApply higher-precedence ops eagerly on a stack of terms; for parentheses, push and restore the running total and sign

Edge-case checklist

  • Closer with an empty stack"]""]" must return FalseFalse, not raise IndexErrorIndexError.
  • Unclosed opener at the end"(""("; the final emptiness check is the only thing that catches it.
  • Correct counts, wrong nesting"([)]""([)]"; the reason a counter is insufficient.
  • Empty input — decide what it means and be consistent.
  • Negative operands in RPN"-11""-11" is a number; isdigit()isdigit() returns FalseFalse for it, so test membership in the operator set instead.
  • Negative division in RPN["7","-3","/"]["7","-3","/"] gives -2-2, not -3-3.
  • Multi-digit repeat counts"10[a]""10[a]" gives ten characters.
  • Text before and after a bracket group"abc3[cd]xyz""abc3[cd]xyz"; the merge must preserve prevprev.
  • Deep nesting — a recursive solution can hit Python’s recursion limit; the iterative stack version cannot.

Recap

  • A stack is the right tool whenever the most recent open thing must be resolved first — brackets, nesting, suspended context.
  • Matched pairs: push openers, match on closers, and check both failure modes (empty stack mid-scan, leftovers at the end).
  • Postfix needs no precedence handling; just watch the pop order and truncate division toward zero.
  • Nested decoding uses save-and-restore: push (context, multiplier)(context, multiplier) on the way in, pop and merge on the way out. Accumulate multi-digit numbers with count * 10 + digitcount * 10 + digit.
  • This is stack-as-suspended-context; stack-as-sorted-candidates is Monotonic Stack.
  • Any recursive descent over nested input can be rewritten with this template — which is exactly what “now do it iteratively” is asking for.

Next: Frequency and Anagram Counting — the CounterCounter-based patterns behind anagram grouping and character-multiset comparison.

If this helped you, consider buying me a coffee ☕

Buy me a coffee

Was this page helpful?

Let us know how we did