DSA sheet · Stack · Pattern 4: parentheses & scoring
Valid Parentheses
This video opens the fourth stack pattern of the sheet: parentheses and scoring. The teacher calls it her favourite pattern, because once the one idea clicks ("every closing bracket must pair with the nearest unmatched opening bracket"), the whole family of problems becomes easy. She first solves it with a brute force that deletes matched pairs from the string, works out why it is slow, and then replaces the deleting with a stack. Every later parentheses problem (min add, score, longest valid, min remove) builds on this page.
Every part below follows the same order:
① the question in simple words → ② what the constraints tell us → ③ intuition → ④ building the logic from examples → ⑤ approach steps → ⑥ code → ⑦ code line by line → ⑧ dry run → ⑨ complexity & remember
- Part 0 · Stacks from scratch + the parentheses pattern
- Part A · Brute force: delete matching neighbours
- Part B · Optimal: a stack of open brackets
- Part C · Revision page
Part 0 · Before starting
What is a stack?
A stack is a pile of plates. You can only put a new plate on top, and you can only take the plate that is on top. The plate you put last is the first one you take out. This rule is called LIFO: Last In, First Out.
| operation | meaning | Python (a list used as a stack) | cost |
|---|---|---|---|
| push | put an item on top | stack.append(x) | O(1) |
| pop | remove the top item and give it back | stack.pop() | O(1) |
| peek / top | look at the top item without removing it | stack[-1] | O(1) |
| is empty? | is there nothing in the pile? | not stack (True when empty) | O(1) |
stack = [] # empty pile
stack.append('(') # push -> ['(']
stack.append('[') # push -> ['(', '['] (right end = top)
print(stack[-1]) # peek -> '['
x = stack.pop() # pop -> x = '[', stack = ['(']
if stack: # ALWAYS check before pop / peek
print(stack[-1]) # '('stack.pop() or stack[-1] on an empty list crashes with IndexError. So before every pop or peek, ask "is the stack empty?" In this problem that check is not only about safety: an empty stack at the wrong moment is itself the answer (False).In all drawings on this page the stack is written left = bottom, right = top. In the vertical pictures, the top item is red.
The parentheses pattern
A bracket string is "balanced" when every opening bracket gets its own closing bracket, of the same kind ("family"), and in the right place. "Right place" means: when a closer arrives, it must close the most recent opener that is still waiting. The most recent unfinished thing is exactly what a stack keeps on top. So:
- Opener → push it. It is now "waiting for its partner".
- Closer → look at the top. If it is the matching opener, pop it (the pair is done). If the stack is empty, or the top is a different family, the string is broken.
- End of string → anything still in the stack never found a partner.
Counter trick (space optimisation), for later pages: when there is only one kind of bracket, e.g. only ( and ), every waiting opener looks the same. Then we don't need to remember which openers are waiting, only how many. A single integer open_count replaces the whole stack and space drops from O(n) to O(1). That trick does not work in this problem, because here there are three families and we must know the kind of the top opener. You will see the counter version in Problems 15, 16 and 17.
Part A · Brute force: delete matching neighbours
LeetCode 20
1The question in simple words
You get a string s made only of the six characters ( ) [ ] { }. Return True if it is valid, otherwise False. Valid means:
- every opening bracket is closed by a bracket of the same family (round with round, square with square, curly with curly),
- brackets are closed in the correct order: the one opened last is closed first,
- every closing bracket has an opening bracket before it.
The teacher's opening example shows why position matters, not just counts:
] closes the nearest opener [, then ) closes () arrives while [ is the nearest openerBoth strings have one round pair and one square pair. Only the order differs. So we cannot just count; whenever a closer appears it must match the nearest still-open bracket (the "top" one). That is already a hint towards a stack.
2What the constraints tell us
1 ≤ s.length ≤ 10⁴→ the string is never empty, but it can have odd length (then it can never be valid).shas only bracket characters → no letters or spaces to skip.- Speed check (the teacher's rule of thumb): n = 10⁴, so an O(n²) idea does about 10⁴ × 10⁴ = 10⁸ steps. Around 10⁸ the code usually still passes (even 4–5 × 10⁸ can squeeze through), it is just slow. Near 8–9 × 10⁸ or 10⁹ you get TLE (Time Limit Exceeded). So the brute force will be accepted but slow, and we should still optimise. Her point: know when code is "slow but OK" and when it is "TLE".
3Intuition: cancel the innermost pairs first
Look for an opener that is immediately followed by its own closer, like () or []. Such a pair is certainly correct, and nothing else can sit between them. So rub it out. After rubbing it out, two brackets that were apart may now be neighbours, and maybe they form a pair too. Keep rubbing out pairs. If the whole string disappears, it was valid. If something stays behind, some bracket had no partner.
In Python a string can't be changed in place (it is immutable), so we copy it into a list of characters that we can delete from. (In Java the teacher uses a StringBuilder for the same reason.)
4Building the logic from examples
We read two neighbours at a time: sb[i] and sb[i+1].
Check 1: an opener followed by a closer must be from the same family
→ Safe. The closer
] sits right after (, so whatever happens later, the nearest opener for that ] is the (, which is the wrong family. The teacher says "return false then and there". Her final code just moves on (i += 1) instead, and the leftover characters make the answer False at the end anyway. Both give the same answer; the early return only saves time.Example 1: ()[] → where should i go after a deletion?
At first the teacher thinks "after deleting two characters, jump i forward by two". Then she corrects herself: after the deletion, everything on the right slides left by two, so i is already standing on the next unchecked character. If i is 0, we simply stay.
Example 2: (() → step back after a deletion when i > 0
→ The deletion can create a new pair from the bracket on the left and the bracket that just slid in. Example:
([]). At i = 1 we delete [], and now ( (at 0) and ) are neighbours. If we don't step back to i = 0, we never compare them. We only step back when i > 0, because at i = 0 there is nothing on the left.Example 3: ()) → a leftover closer
The final rule
We only ever delete when sb[i] is an opener and sb[i+1] is its own closer. Otherwise we move right. At the end, empty list ⇒ valid, anything left ⇒ not valid. That one check covers all three examples: True for 1, False for 2 and 3.
5Approach steps
- Copy the string into a list
sb. Seti = 0. - While
i < len(sb) − 1(we need bothsb[i]andsb[i+1]): - If
sb[i]is an opener andsb[i+1]is its matching closer → delete both. Then ifi > 0, doi −= 1; otherwise stay. - Otherwise →
i += 1. - After the loop, return
len(sb) == 0.
6Code (Python)
class Solution:
def isValid(self, s: str) -> bool:
sb = list(s) # strings are immutable, lists are not
close_of = {'(': ')', '[': ']', '{': '}'} # opener -> its closer
i = 0
while i < len(sb) - 1: # we read sb[i] and sb[i+1]
if sb[i] in close_of and close_of[sb[i]] == sb[i + 1]:
del sb[i:i + 2] # delete the pair (i and i+1)
if i > 0:
i -= 1 # a new pair may have formed on the left
else:
i += 1
return len(sb) == 07Code line by line
| line | what it means |
|---|---|
| sb = list(s) | A changeable copy. Python strings can't be edited in place. |
| close_of = {...} | For each opener, which closer belongs to its family. |
| while i < len(sb) - 1: | Stop at the second-last index, because we always read i+1 too. len(sb) is re-read every time, because the list keeps shrinking. |
| if sb[i] in close_of and close_of[sb[i]] == sb[i + 1]: | "sb[i] is an opener, and the next one is exactly its closer." |
| del sb[i:i + 2] | Remove indexes i and i+1 (the end index i+2 is not included). Everything to the right shifts left by 2. This shifting is the hidden O(n) cost. |
| if i > 0: i -= 1 | Go back one step so the bracket on the left can be compared with the bracket that slid in. If i is 0, stay (there's nothing on the left). |
| else: i += 1 | No pair here, move right. |
| return len(sb) == 0 | Everything paired up ⇒ True; any leftover ⇒ False. |
→ No, it contradicts her own walkthrough of Example 1, where she keeps i in place. With
i += 1, the string ()() would become () with i = 1, the loop would end, and we would wrongly return False. The correct rule after a deletion is: step back if i > 0, otherwise stay. The i += 1 belongs only to the "no pair" branch. The code above does this, and the tests check ()().8Dry run: ([)] and {[()]}
Run 1: ([)], the "wrong position" string from the start.
| i | sb[i], sb[i+1] | pair? | action | sb after |
|---|---|---|---|---|
| 0 | (, [ | no (next isn't a closer) | i = 1 | ( [ ) ] |
| 1 | [, ) | no (different family) | i = 2 | ( [ ) ] |
| 2 | ), ] | no (sb[i] isn't an opener) | i = 3 | ( [ ) ] |
| 3 | 3 < 4 − 1 is false → loop ends. 4 characters left → False ✓ | |||
Run 2: {[()]}, deeply nested, shows the step-back working three times.
| i | sb[i], sb[i+1] | pair? | action | sb after |
|---|---|---|---|---|
| 0 | {, [ | no | i = 1 | { [ ( ) ] } |
| 1 | [, ( | no | i = 2 | { [ ( ) ] } |
| 2 | (, ) | yes | delete, i = 1 | { [ ] } |
| 1 | [, ] | yes | delete, i = 0 | { } |
| 0 | {, } | yes | delete, stay at 0 | (empty) |
| 0 | 0 < −1 is false → loop ends. Empty → True ✓ | |||
9Complexity & remember
- Time O(n²). The pointer moves through the string only about once, but every
delon a list (like on a StringBuilder, which is a character array underneath) must shift all the characters on its right to close the gap. That shift is O(n), and it can happen up to n/2 times. - Space O(n) for the list copy.
- For n = 10⁴ that is about 10⁸ steps: accepted on LeetCode, but slow. Worth optimising.
Part B · Optimal: a stack of open brackets
1The question (same as Part A)
Same input, same output. We want to get rid of the costly deletions.
2What the constraints tell us
- n up to 10⁴: an O(n) solution does about 10⁴ steps, which is instant.
- Only 3 bracket families: a lookup table of 3 pairs is constant space, O(3) = O(1).
3Intuition: what was the brute force really doing?
Each time the brute force deleted a pair, it then went back one step to look at the bracket before. Going back to the latest unfinished thing is the "undo" feeling we met in the previous pattern (backspace, adjacent duplicates). Whenever we keep needing "the most recent unfinished item", the teacher's rule is: use a stack.
Instead of keeping openers inside the string and deleting them later, we save each opener on a stack the moment we see it. When a closer comes, its partner must be the nearest unmatched opener, which is exactly the stack's top. Match → pop. No array shifting at all.
The teacher also notes: the brute force looked at "opener at i, closer at i+1", while the stack looks at "closer now, opener before it". It is the same check, seen from the other side.
4Building the logic from examples
A long valid example: [((){})()]
Openers [ ( ( are pushed. The first ) sees ( on top → pop. { is pushed, } sees { → pop. The next ) sees the second ( → pop. Then ( push, ) pop. Finally ] sees [ → pop. The stack is empty at the end → valid. Closers are never pushed; they only remove things.
False case 1: top is the wrong family
) → top is [ → not its family → return False immediatelyFalse case 2: something is left at the end, e.g. (()
This is the stack version of "the string must end up empty" from Part A. So the final answer is stack is empty, not just True.
False case 3: a closer comes when the stack is empty, e.g. )(
) needs an opener before it, but there is none → return False right away→ Nothing later can fix a closer that came too early: openers that come later are on its right, and a closer can only close something on its left. So the answer is already decided. Returning early saves the rest of the work. (It also avoids popping an empty list, which would crash.)
How do we know a character is a closer, and which opener it wants?
You could write a few if/else branches. The teacher prefers a small map with the closer as the key and the opener as the value:
match = {')': '(', ']': '[', '}': '{'}→ Because the moment we need the map is when we're holding a closer. With closers as keys, one lookup answers both questions: "is this a closer?" (
ch in match) and "which opener should be on top?" (match[ch]). The map has only 3 entries, so it costs constant space.5Approach steps
- Make an empty stack and the map closer → opener.
- For each character
ch: - If it is not a key of the map, it's an opener → push it.
- Otherwise it's a closer: if the stack is empty → return False.
- Pop the top. If it isn't
match[ch]→ return False. - After the loop, return True only if the stack is empty.
6Code (Python)
class Solution:
def isValid(self, s: str) -> bool:
stack = []
match = {')': '(', ']': '[', '}': '{'} # closer -> opener
for ch in s:
if ch not in match: # an opener
stack.append(ch)
else: # a closer
if not stack: # nothing to close
return False
if stack.pop() != match[ch]: # wrong family on top
return False
return not stack # leftover openers -> False7Code line by line
| line | what it means |
|---|---|
| stack = [] | Will hold only openers that are still waiting for their partner. |
| match = {')': '(', ...} | Closer as key, opener as value: lets us test "is it a closer?" and find "its opener" in one place. |
| if ch not in match: stack.append(ch) | Not a closer ⇒ an opener ⇒ push. It waits for its closer. |
| if not stack: return False | A closer with nobody waiting (like )(). Check this before popping, or pop() crashes. |
| if stack.pop() != match[ch]: return False | Remove the nearest waiting opener. If it's from another family (like [ for )), the order is broken. Popping before comparing is fine, because if it doesn't match we return anyway. |
| return not stack | True only if every opener found a partner. Leftovers (like (() ⇒ False. |
8Dry run: [((){})()]
| i | ch | what we pop (and why) | what we push | stack after (bottom → top) | answer so far |
|---|---|---|---|---|---|
| 0 | [ | — | [ (opener) | [ | still OK |
| 1 | ( | — | ( | [ ( | still OK |
| 2 | ( | — | ( | [ ( ( | still OK |
| 3 | ) | (: top matches ) | — | [ ( | still OK |
| 4 | { | — | { | [ ( { | still OK |
| 5 | } | {: matches } | — | [ ( | still OK |
| 6 | ) | (: matches | — | [ | still OK |
| 7 | ( | — | ( | [ ( | still OK |
| 8 | ) | (: matches | — | [ | still OK |
| 9 | ] | [: matches | — | (empty) | loop ends |
| end | stack is empty | True | |||
Quick runs of the three False cases:
| s | what happens | answer |
|---|---|---|
([)] | push (, push [; ) pops [ ≠ ( | False (wrong family) |
(() | push, push, pop → stack ( left at the end | False (leftover) |
)( | ) with an empty stack | False (nothing to close) |
9Complexity & remember
- Time O(n): one pass, and each push/pop is O(1). No shifting.
- Space O(n): in the worst case (all openers, like
(((() every character goes on the stack. The map is O(3) = O(1).
Teacher's advice: write it yourself from scratch (no copy-paste), and dry run it on your own strings with mixed brackets. That's how the pattern sticks.
Part C · Revision page
| Brute force (delete pairs) | Stack | |
|---|---|---|
| where unmatched openers live | inside the string (a list) | on the stack |
| what we do on a match | delete sb[i], sb[i+1], step back | pop the top |
| what "valid" means at the end | list is empty | stack is empty |
| early False | opener followed by a wrong-family closer | empty stack on a closer, or wrong family on top |
| time | O(n²) (deletion shifts) | O(n) |
| space | O(n) | O(n) |
| reason for False | example | caught by |
|---|---|---|
| wrong family / wrong order | ([)], (] | stack.pop() != match[ch] |
| closer before any opener | )(, ] | if not stack |
| opener never closed | ((), [ | return not stack |
2. Push openers only; closers never go on the stack.
3. Closer + empty stack → False. Closer + wrong top → False.
4. At the end, valid ⇔ the stack is empty.
5. Brute force deletes pairs and is O(n²) because deleting shifts the array; the stack is O(n).
(( would pass)✗ popping or peeking without checking for empty first (crash on
))✗ only counting brackets (counts can't see
([)] is wrong)✗ in the brute force, moving i forward after a deletion (misses new pairs)
✗ map in the wrong direction (opener → closer makes the closer lookup awkward)
s = Solution()
print(s.isValid("()[]{}")) # True
print(s.isValid("[((){})()]")) # True
print(s.isValid("{[()]}")) # True
print(s.isValid("([)]")) # False wrong order
print(s.isValid("(()")) # False leftover opener
print(s.isValid(")(")) # False closer first
print(s.isValid("(]")) # False wrong familyBased on this video: Valid Parentheses | Stack pattern: parentheses & scoring