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
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 countsdef 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 countsTemplate 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.
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)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.
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]]")) # accaccaccdef 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]]")) # accaccaccThe 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.
| Time | Space | |
|---|---|---|
| Bracket matching | worst case (all openers) | |
| RPN evaluation | ||
| Nested decoding |
The variant map
| Variant | What the stack holds | Canonical problem |
|---|---|---|
| Matched pairs | The unclosed openers | 20 Valid Parentheses |
| Count repairs needed | Just a counter plus one for unmatched closers | 921 Minimum Add to Make Parentheses Valid |
| Postfix evaluation | Operands awaiting an operator | 150 Evaluate RPN |
| Infix with precedence | Running value + the last operator, or two stacks | 227 · 224 |
| Nested decoding | Suspended (context, multiplier)(context, multiplier) | 394 Decode String |
| Adjacent cancellation | Characters, popping on a match | 1047 Remove All Adjacent Duplicates |
| Path resolution | Resolved directory names; .... pops | 71 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 . Space — 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 . Space .
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 — you cannot beat that, since you must produce every character. Space .
Two details that break naive attempts:
- Multi-digit counts.
"10[a]""10[a]"must give tenaas.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 thatcurrentcurrentmay already hold characters when[[arrives, which is exactly why the pushed tuple storescurrentcurrentalongside the count, and why the merge isprev + current * kprev + current * krather than justcurrent * 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
| # | Problem | Difficulty | The twist |
|---|---|---|---|
| 20 | Valid Parentheses | Easy | The base template; guard the empty stack and the leftovers |
| 1047 | Remove All Adjacent Duplicates In String | Easy | Push, and pop instead when the incoming char equals the top |
| 150 | Evaluate Reverse Polish Notation | Medium | Pop order, and truncation toward zero |
| 394 | Decode String | Medium | Save-and-restore; multi-digit counts |
| 71 | Simplify Path | Medium | Split on //; .... pops, .. and empty segments are skipped |
| 921 | Minimum Add to Make Parentheses Valid | Medium | A counter is enough — count unmatched closers plus leftover openers |
| 227 | Basic Calculator II | Medium | Apply ** and // eagerly, defer ++ and -- to a final sum |
| 224 | Basic Calculator | Hard | Parentheses: push the running total and the sign, restore on )) |
| 32 | Longest Valid Parentheses | Hard | Push indices, not characters, and measure the gap to the last unmatched one |
Interview follow-ups
| They ask | What they’re checking | The answer |
|---|---|---|
| “Could you use a counter instead of a stack?” | Whether you know the boundary | Only 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 spec | The problem truncates toward zero; Python’s //// floors toward negative infinity, so they differ on negative results |
| “Now do it iteratively / recursively” | Flexibility | A stack is the explicit form of the call stack — the save-and-restore template is the mechanical conversion |
| “What’s the space complexity?” | Precision | worst case for matching (all openers); for decoding, which can exceed the input |
| “Handle malformed input” | Defensiveness | Guard every pop()pop() with a non-empty check, and verify the stack is empty at the end |
| “Add precedence / parentheses to the calculator” | Depth | Apply 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 returnFalseFalse, not raiseIndexErrorIndexError. - 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()returnsFalseFalsefor 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 preserveprevprev. - 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 withcount * 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 coffeeWas this page helpful?
Let us know how we did
