p02 — Valid Parentheses

Source: LeetCode 20 · Easy · Topics: String, Stack Companies: Amazon (high), Google (medium), Meta (medium), Microsoft (high), Bloomberg (high), Salesforce (very high) Loop position: phone screen, sometimes paired with p80 (Basic Calculator II) at onsite

1. Quick Context

The canonical stack problem. Looks trivial; the trap is the early-return logic and the empty-stack-on-close case. Senior candidates lose points by writing 15 lines when 8 suffice, by forgetting to check the stack is non-empty at end, or by not asking whether non-bracket characters can appear.

What it tests: Disciplined use of the right data structure (stack), not loop-and-counter hacks. Anti-signal: counting opens and closes separately (“balanced count”) — that fails on "(]". If you proposed this and didn’t catch it yourself, you’ve failed the cognitive-trap check.


🔗 https://leetcode.com/problems/valid-parentheses/

STOP. Set a 12-min timer. Solve cold. Do not read on until done or timed out.


3. Prerequisite Concepts

  • LIFO semantics of a stack; why “matching last-opened” is intrinsically a stack property — phase-01 §5
  • Mapping closed → open via a constant dict (O(1) lookup, more idiomatic than chained if)

4. How to Approach

Restate: “Given a string of ()[]{} characters, return true iff every opener has a matching closer of the right type, in the right nesting order.”

Clarify:

  • “Can the string contain other characters?” (LC: no, only brackets — but ASK, real interviews vary.)
  • “Empty string?” (LC: valid = true. Yes, common gotcha.)
  • “Maximum length?” (Implies whether we care about stack overflow in recursive solutions; iterative + explicit stack avoids that anyway.)

Constraints: N up to 10⁴. O(N) expected.

Examples to build:

  • "()[]{}" → true (sequential pairs)
  • "([{}])" → true (full nesting)
  • "(]" → false ← the counter-fail case
  • "]" → false (close-first; empty-stack case)
  • "((" → false (unmatched open at end; non-empty-stack-at-end case)
  • "" → true (empty)

Brute force: Repeatedly scan and remove adjacent (), [], {} pairs until no change. If empty, valid. O(N²).

Pattern: “Match most recent unclosed thing” → stack. Period.

Optimal: One pass. For each char: if opener, push. If closer, pop and verify match. At end, stack must be empty.

Proof of correctness: Stack invariant — at any point, the stack contains exactly the unmatched openers in order of opening. A closer matches iff it pairs with the top (most recent opener). If a closer arrives with empty stack, it has no match → false. If non-closer remains at end → unmatched openers → false.


5. Progressive Hints

If stuck >5 min: hints.md. One at a time.


6. Deeper Insight — Why It Works

Why a stack and not a counter? A counter (opens - closes) is sufficient for a single bracket type — but it cannot detect "(]". The stack is necessary because we need to remember not just that a bracket is open, but which type. Each push commits the type; each pop verifies the type. The stack is the minimum data structure that captures both “how many open” and “which order/type.”

Why we check empty BEFORE popping: If we blindly pop an empty stack on a closer, we error out (or in some languages, get undefined behavior). The check if not stack: return False handles "]", "})", etc.

Why we check empty at END: The string "((" walks through pushing twice; loop ends; stack non-empty; without the final check, we’d return True. The end-check is the second necessary correctness step.


7. Anti-Pattern Analysis

Wrong-but-tempting #1 — Count-and-compare:

if s.count('(') == s.count(')') and s.count('[') == s.count(']') and ...:
    return True

Fails on "(]" (each count is 1, returns True). This is the single most common bug junior candidates ship. If you proposed this, you missed the type-ORDER requirement.

Wrong-but-tempting #2 — Regex / replace loop:

while '()' in s or '[]' in s or '{}' in s:
    s = s.replace('()','').replace('[]','').replace('{}','')
return s == ''

Works but O(N²) and signals you reached for a hammer. Interviewer’s note: “didn’t recognize stack pattern.”

Wrong-but-tempting #3 — Forgetting end-check:

for c in s:
    if c in '([{': stack.append(c)
    elif c in ')]}':
        if not stack or pairs[c] != stack.pop(): return False
return True   # ← BUG: returns True for "((" because stack non-empty but no closer caught it

The fix is return not stack. Forgetting this is the #2 bug here.


8. Skills & Takeaways

Generalizable pattern: “Match most recent unresolved item” → stack. Applies to:

  • LC 32 — Longest Valid Parentheses (stack of indices, harder cousin)
  • LC 84 — Largest Rectangle in Histogram (monotonic stack — same family)
  • LC 739 — Daily Temperatures (monotonic stack — same family)
  • LC 71 — Simplify Path (each segment as stack frame)
  • LC 394 — Decode String (stack of (count, prefix) frames)
  • LC 1249 — Minimum Remove to Make Parentheses Valid (stack of indices)

When NOT to use: Single bracket type, just balance counting → counter is sufficient and uses O(1) space.


9. When to Move On

  • Solved unaided in <8 min on second attempt
  • Tested "(]", "]", "((", "" without prompting
  • Can articulate why a counter fails for multiple bracket types
  • Implemented with constant closer → opener map (not chained ifs)
  • Stress test in solution.py passes
  • Solved LC 1249 and recognized the same pattern with index tracking

10. Company Context

Amazon

  • The framing: Often paired with “and write the matched pairs as (open_idx, close_idx)” — extends to index-tracking variant.
  • Misleading example: They give "{[()]}" and let you confirm true. Then sneak in "{[(])}" — looks balanced character-counts-wise; trips candidates who didn’t catch the type-order failure.
  • Extension: “Now allow <> too.” → tests whether your code generalizes (constant dict makes it 1-line; chained ifs make it 4 new branches).

Salesforce (this is THEIR favorite)

  • The framing: Often appears in onsite, not phone screen — they care about the cleanliness more than the algorithm.
  • What they want to hear: “I’ll use a stack and a dictionary mapping closers to openers.” That sentence alone is a green check.
  • Adversarial extension: “What if [ could match ) (mixed mode)?” → tests whether your code is data-driven enough to change one line of config.

Microsoft

  • The framing: Phone screen warmup, sometimes followed by p80 (Basic Calculator).
  • What they want to hear: Single-pass justification, end-of-loop empty-check stated out loud.

Bloomberg

  • The framing: Often as part of a JSON/parser problem — “validate this serialized message structure”.
  • Extension: “Parse the bracketed expression into an AST.”

11. Interviewer’s Lens

PhaseStrongWeakScorecard (strong)
ReadingAsks “other characters?” “empty string?” “max length?”Dives in“Clarifies the contract before coding”
Pre-codingNames the stack pattern explicitlySays “I’ll iterate and track”“Recognizes the canonical pattern”
CodingUses constant dict {')':'(', ']':'[', '}':'{'}Chains 6+ if-branches“Writes data-driven, extensible code”
Edge casesTests "(]", "]", "" proactivelyTests only "()"“Anticipates failure modes”
FinishStates complexity and stack invariantSays “done”“Owns the analysis”

12. Level Delta

LevelAnswer
MidStack solution, works, ~8 min. Tests only the given example.
Senior+ clarifies upfront + tests "(]" + uses constant dict + end-empty-check articulated.
Staff+ names “most-recent-unresolved” pattern family + connects to monotonic stack problems + addresses “what if char set were configurable” before being asked.
Principal+ asks production context (“are we validating Markdown? JSON? code?”) + identifies that real validators need position tracking for error messages + offers extension to track unmatched positions for IDE-style red squiggles + mentions that for adversarial input we’d cap stack depth to prevent DoS.

13. Follow-up Questions & Full Answers

Q1: “Return the index of the first invalid character instead of true/false.”

Signal: Can you adapt without rewriting? Answer: Track current index i; when popping mismatches or empty-pop happens, return i. At end if stack non-empty, return the index of the unmatched opener at stack top (so push (char, index) tuples, not just chars). One-pass O(N), no extra time cost.

Q2: “Mixed bracket modes: also accept [) and (] as valid in this protocol.”

Signal: Data-driven code awareness. Answer: Replace {')':'(', ']':'[', '}':'{'} with {')':'([', ']':'[(', '}':'{'} and change the match check from stack.pop() == pairs[c] to stack.pop() in pairs[c]. Zero algorithm change — only the config dict moves. Demonstrates extensibility.

Q3 (Hard): “Parse and evaluate the matched expression as a Lisp-like AST.”

Signal: Stack-of-frames extension. Answer: Each frame is a list of tokens accumulating between matching opens. On open: push current frame, start new. On close: pop, append the inner frame to the outer. Same stack discipline, now each entry is a list, not a char. This is the bridge from p02 to LC 394 (Decode String) and to p80 (Basic Calculator).

Q4: “Now there are millions of strings per second — same algorithm, but optimize for throughput.”

Signal: Production thinking. Answer: Three wins. (a) Replace Python list-as-stack with a fixed-size preallocated array + integer top pointer — eliminates allocation. (b) Use a 256-entry lookup table mapping char codes to (type, is_opener) instead of dict lookup — branch-free hot loop. (c) For truly hot path, write a SIMD-friendly state machine in C — but only after profiling shows this is the bottleneck.

Q5: “How would you test it?”

Signal: Testing discipline. Answer: (1) Unit: all 6 fail modes + valid + empty. (2) Property test: brute (replace-loop) vs optimal on random bracket strings, must agree. My solution.py does this. (3) Adversarial: deeply nested input (depth = 10⁵) — confirms iterative, not stack-overflowing. (4) Fuzz: random non-bracket chars to confirm the “ask about other characters” clarification.


14. Full Solution Walkthrough

See solution.py.

  • is_valid_brute: repeated replace loop. Correct but O(N²). Used as oracle.
  • is_valid: stack + closer→opener dict. Three correctness branches: (i) push on open, (ii) check-then-pop on close (empty + mismatch both → False), (iii) final stack-empty check.
  • stress_test: generates random bracket strings (balanced and unbalanced), asserts brute and optimal agree.

15. Beyond the Problem

Real systems this is the kernel of:

  • JSON / XML / YAML parsers — every structural parser uses this exact stack discipline. Errors in production parsers (e.g., “Unexpected end of input”) are the end-empty-check firing.
  • IDE bracket matching — your editor’s “matching bracket” highlight runs this algorithm on every keystroke, scoped to a window around the cursor.
  • Code linterseslint’s no-unbalanced-brackets rule is this with position tracking.
  • Network protocols with framing (e.g., BSON, MessagePack) use stack-based parsers.

Principal-engineer code review comment: “We have three places in the codebase that re-implement bracket matching (JSON parser, query parser, markdown renderer). They’ve each drifted to handle their edge cases differently. Extract a generic match_pairs(input, pair_table, on_unmatched) and centralize. The bug we hit last quarter was the JSON parser missing the end-check; the markdown one had the check but the wrong error message.”