DSA sheet · Stack · Greedy + Stack pattern

Minimum Remove to Make Valid Parentheses

The last problem of the Stack + Greedy pattern. It's a cousin of Minimum Add to Make Parentheses Valid from earlier in the sheet: that one only asked for a count, while this one asks you to return the cleaned-up string. The teacher first explains a brute force (try removing 1 bracket, then 2, …) and why it explodes. Then she builds a stack of indices solution that marks bad brackets with #, and finally a two-pass counter version that drops the stack to save space.

Why it matters: it combines two ideas you'll reuse often. A stack matches each closer with the nearest unmatched opener, and for a single bracket type, a counter can replace the stack.

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 · Before starting

What is a stack?

A stack is a pile: add on the top (push), look at the top (peek), remove from the top (pop). The last thing in is the first thing out: LIFO.

a Python list as a stack
stack = []
stack.append(3)       # push
top = stack[-1]       # peek
x = stack.pop()       # pop
if stack: ...         # empty check: [] counts as False
while stack:          # pop everything that is left
    stack.pop()

Left = bottom, right = top. Never pop or peek without checking that the stack isn't empty, or Python raises IndexError.

The parentheses pattern

A bracket string is valid when every ( has a matching ) after it, and every ) has a matching ( before it. The classic way to check:

The stack always pairs a closer with the nearest unmatched opener (the top), which is exactly how nested brackets work: ( ( ) ) pairs inner with inner and outer with outer.

Counter-based space optimisation

When there's only one type of bracket, the stack only ever holds ( characters, all the same. Then we don't need the stack itself, just how many openers are waiting: open += 1 on (, open -= 1 on a matched ). That's O(1) space instead of O(n). (With several bracket types, like ()[]{}, you do need the stack to know which type is on top.)

is the string valid? (counter version, used in Part A)
def is_valid(t):
    open_count = 0
    for ch in t:
        if ch == '(':
            open_count += 1
        elif ch == ')':
            if open_count == 0:      # closer with no partner
                return False
            open_count -= 1
    return open_count == 0           # no opener left waiting

When to think "stack"

The teacher's reminder: whenever you need to go back to check something you saw earlier (a match, a bigger or smaller item), a stack turns that "walk back" from O(n) into O(1).

Part A · Brute force: try every set of removals

LeetCode 1249

1The question in simple words

You get a string with lowercase letters and the brackets ( and ). Remove the fewest brackets so that what's left is valid. Letters are never removed. Return the resulting string (any correct answer is accepted).

slee(t(c)o)de)the last ) has no partner
answerlee(t(c)o)de"lee(t(c)o)de"

LeetCode notes that "lee(t(co)de)" and "lee(t(c)ode)" are also correct; any of them is accepted. Other examples: "a)b(c)d" → "ab(c)d", and "))((" → "" (everything goes).

2What the constraints tell us

3Intuition: the letters don't matter

Compare the input and output of example 1: every letter is still there, in place. Only the brackets change. So while reading the string, a letter is always kept, and all the thinking is about brackets.

The beginner's way: remove one bracket and check whether the rest is valid. If no single removal works, try every pair of brackets, then every triple, and so on. The first valid string found uses the fewest removals.

4Building the logic

The teacher's point: for "lee(t(c)o)de)" a single removal (the last )) is enough. But on harder strings, no single removal works, then no pair works, and so on, so you end up trying all combinations, because the question doesn't tell you how many brackets to remove. To check each candidate, use the validity check from the Valid Parentheses problem (the counter in Part 0).

5Approach steps

  1. List the positions of all brackets.
  2. For r = 0, 1, 2, …: for every way to choose r bracket positions to remove →
  3. Build the string without them and check is_valid.
  4. Return the first valid one (fewest removals).

6Code (Python)

Brute force: remove 0, then 1, then 2 … brackets
from itertools import combinations

class Solution:
    def minRemoveToMakeValid(self, s):
        brackets = [i for i, ch in enumerate(s) if ch in '()']
        for r in range(len(brackets) + 1):              # how many to remove
            for drop in combinations(brackets, r):      # which ones
                gone = set(drop)
                t = ''.join(ch for i, ch in enumerate(s) if i not in gone)
                if self.is_valid(t):
                    return t
        return ''.join(ch for ch in s if ch not in '()')

    def is_valid(self, t):
        open_count = 0
        for ch in t:
            if ch == '(':
                open_count += 1
            elif ch == ')':
                if open_count == 0:
                    return False
                open_count -= 1
        return open_count == 0

7Code line by line

linewhat it means
brackets = [i for i, ch in ... if ch in '()']Only brackets can be removed, so only their positions are choices.
for r in range(len(brackets) + 1):Try the smallest number of removals first. The first hit is a minimum.
combinations(brackets, r)Every way to pick r of those positions.
if self.is_valid(t): return tValid with the fewest removals → done.
return ''.join(...)A safety net. Removing every bracket is always valid, so the loop already returns at r = all brackets at the latest.

8Dry run: s = "a)b(c)d"

rremoved positionsstringvalid?
0nonea)b(c)dno (")" at index 1 has no partner)
11ab(c)dyes → return

On "lee(t(c)o)de)" the brute force tries removing the bracket at index 7 before the one at index 12, so it returns "lee(t(co)de)". That's one of the other correct answers LeetCode lists, with the same single removal.

9Complexity & remember

RememberBrute force = remove 1, 2, 3 … brackets in every combination and check validity. Correct, but exponential.

Part B · Stack of indices + mark with #

1The question (same)

Remove the minimum number of brackets so the string is valid, in O(n).

2What the constraints tell us

3Intuition

Read left to right. A letter → keep. An opener ( → remember it somewhere, because a closer may need it later. A closer ) → go back and ask "did I save an unused opener?" If yes, the two are a valid pair and both stay. If no, this closer can never be matched, so it must go.

4Building the logic

Why a stack and not a pointer walking back?

You could stand at a closer and move a pointer j backwards, character by character, until you find an unused opener. But the string also has letters (and already-used brackets) in between, so each walk back can cost O(n), making the whole thing O(n²). Storing the openers separately in a stack costs a little extra space but makes "is there an unused opener?" an O(1) look at the top. We spend space to save time.

Every pop "uses up" an opener

In "lee(t(c)o)de)", the first ) pairs with the opener before c, and we pop it. The second ) pairs with the opener before t, and we pop that one too. When the last ) arrives, the stack is empty: both openers are already used. If we didn't pop, two openers would wrongly "match" three closers. Each opener can match only one closer.

Extra openers: the stack must hold indices, not brackets

The teacher's second test: "lee((((t)". Four openers, one closer.

index012345678
slee((((t)the yellow ones never get a partner

The closer at 8 uses the opener at 6. Openers 3, 4, 5 are still in the stack at the end. A left-to-right pass catches extra closers right away, but extra openers only show up at the end. To remove them we need to know where they are, so we push indices (3, 4, 5, 6), not the "(" characters themselves.

Mark, don't delete

Deleting a character from the middle of a string or array shifts everything after it one step left, which is O(n) per deletion. Instead, turn the string into a list and overwrite bad brackets with #. At the end, build the answer from everything that isn't #. One clean pass.

Doubt 1: why is this the minimum number of removals?
→ A closer that arrives when no opener is waiting can't be matched by anything to its left. All the earlier openers are already paired with closers, so at least one closer must go for each such moment. In the same way, each opener left over at the end has no closer after it that is still free. So removals = unmatched closers + unmatched openers, and we can't do with fewer. Matching each closer with the nearest waiting opener never wastes a pair.
Doubt 2: Python strings can't be changed. How do we "mark" them?
→ arr = list(s) makes a list of characters that we can change. Her Java code uses a char array, which plays the same role.

5Approach steps

  1. arr = list(s), stack = [] (it holds indices of waiting openers).
  2. For each i: if arr[i] is "(" → push i.
  3. If arr[i] is ")": if the stack isn't empty → pop (a valid pair); else → arr[i] = "#".
  4. Letters: do nothing.
  5. After the loop: pop every leftover index and set arr[index] = "#".
  6. Join all characters that aren't "#".

6Code (Python)

Stack of opener indices + # marks
class Solution:
    def minRemoveToMakeValid(self, s):
        stack = []                 # indices of '(' still waiting for a partner
        arr = list(s)              # a list we are allowed to change
        for i, ch in enumerate(arr):
            if ch == '(':
                stack.append(i)
            elif ch == ')':
                if stack:
                    stack.pop()    # matched with the nearest waiting '('
                else:
                    arr[i] = '#'   # no partner: mark for removal
        while stack:               # openers that never found a ')'
            arr[stack.pop()] = '#'
        ans = []
        for ch in arr:
            if ch != '#':
                ans.append(ch)
        return ''.join(ans)

7Code line by line

linewhat it means
stack.append(i)Save where the opener is, so we can remove it later if it stays unmatched.
if stack: stack.pop()There's a waiting opener, so this closer is valid. Use that opener up.
else: arr[i] = '#'No opener waiting → this closer is extra. Mark it instead of deleting it (no shifting).
while stack: arr[stack.pop()] = '#'Whatever indices are left are extra openers. Mark each one.
if ch != '#': ans.append(ch)Collect only the kept characters. Her Java version does the same with a StringBuilder.

8Dry run

Example 1: s = "lee(t(c)o)de)"

index0123456789101112
slee(t(c)o)de)
icharwhat we pop (and why)pushstack after (bottom → top)marks
0–2l e eletters: nothing—empty
3(—33
4tletter—3
5(—53 5
6cletter—3 5
7)pop 5 (pairs with "(" at 5)—3
8oletter—3
9)pop 3 (pairs with "(" at 3)—empty
10–11d eletters—empty
12)stack empty → no partner—emptyarr[12] = #
after i = 5
35
after i = 7
3
at i = 12
(empty)

The stack is empty at the end, so no extra openers. Skip the # → "lee(t(c)o)de" ✓

Example 2: s = "lee((((t)" (extra openers)

icharwhat we pop (and why)pushstack after
3(—33
4(—43 4
5(—53 4 5
6(—63 4 5 6
8)pop 6 (nearest opener)—3 4 5
endpop 5, 4, 3 → mark each #—empty
after i = 6
3456
after i = 8
345
arrlee###(t)→ "lee(t)"

9Complexity & remember

RememberPush indices of "(". On ")": pop if possible, else mark #. At the end, mark every leftover index #. Join the rest.

Part C · Space-optimised: two passes with counters

1The question (same)

Same result, but without a stack.

2What the constraints tell us

Only one bracket type → the stack only held "(" anyway. A count of waiting openers carries the same information, like in Minimum Add to Make Parentheses Valid earlier in the sheet.

3Intuition

4Building the logic on the teacher's example: ")(((le(c))"

Pass 1, left to right (removes extra ")")

charopen beforedecisionopen afterkept so far
)0no opener waiting → drop0
(0keep, open += 11(
(1keep2((
(2keep3(((
l, e3letters: always keep3(((le
(3keep4(((le(
c4keep4(((le(c
)4open > 0 → keep, open −= 13(((le(c)
)3keep2(((le(c))

Every ")" that's left is needed (removing one would be an extra deletion, and the question asks for the minimum). But open = 2 at the end: two openers are extra. Which two? We can't tell from this pass, so we run the next one.

Pass 2, right to left over "(((le(c))" (removes extra "(")

char (from the right)close beforedecisionclose after
)0keep, close += 11
)1keep2
c2keep2
(2close > 0 → keep, close −= 11
e, l1keep1
(1keep0
(0no closer waiting → drop0
(0drop0

We collected the kept characters from right to left: ))c(el(. Reverse them → "(le(c))" ✓, which is valid, with 3 removals (1 closer + 2 openers).

Doubt: why reverse at the end?
→ Pass 2 reads from the right, so the rightmost character is appended first. The list comes out backwards; reversing it restores the original order.
Doubt: in pass 2, do we read s or the result of pass 1?
→ The result of pass 1 (first). The teacher stresses this: the extra closers are already gone, and pass 2 only has to clean up the openers.

5Approach steps

  1. Pass 1 (left → right) with open = 0: "(" → keep, open += 1. ")" → keep and open −= 1 only if open > 0, else drop. Letter → keep.
  2. Pass 2 (right → left over pass 1's result) with close = 0: ")" → keep, close += 1. "(" → keep and close −= 1 only if close > 0, else drop. Letter → keep.
  3. Reverse pass 2's result and join.

6Code (Python)

Two passes with counters (no stack)
class Solution:
    def minRemoveToMakeValid(self, s):
        # pass 1: left to right, drop ')' that have no '(' before them
        first = []
        open_count = 0
        for ch in s:
            if ch == '(':
                open_count += 1
                first.append(ch)
            elif ch == ')':
                if open_count > 0:
                    open_count -= 1
                    first.append(ch)
            else:
                first.append(ch)          # letters are always kept

        # pass 2: right to left, drop '(' that have no ')' after them
        ans = []
        close_count = 0
        for ch in reversed(first):
            if ch == ')':
                close_count += 1
                ans.append(ch)
            elif ch == '(':
                if close_count > 0:
                    close_count -= 1
                    ans.append(ch)
            else:
                ans.append(ch)
        ans.reverse()                     # we built it backwards
        return ''.join(ans)

7Code line by line

linewhat it means
open_count += 1Replaces "push (": one more opener is waiting.
if open_count > 0: open_count -= 1Replaces "pop": a waiting opener is used up by this closer.
(no else for ')')A closer with open_count = 0 is simply not appended. That's the removal.
for ch in reversed(first):Mirror pass over the pass-1 result.
if close_count > 0: ...An opener is kept only if a closer to its right is still free.
ans.reverse()Undo the backwards building.

8Dry run

The two tables in step 4 are the full dry run for ")(((le(c))". On example 1, "lee(t(c)o)de)": pass 1 drops only the last ")" (open is 0 there) and ends with open = 0. Pass 2 keeps everything → "lee(t(c)o)de" ✓.

pass 1lee(t(c)o)de✗last ")" dropped, open = 0
pass 2lee(t(c)o)denothing extra to drop

9Complexity & remember

RememberLeft → right with open removes bad ")". Right → left with close removes bad "(". Reverse at the end.

Part D · Revision page

Brute forceStack of indices + #Two-pass counters
ideatry removing 1, 2, 3… brackets, check validitymatch ")" with the top index; mark unmatched as #L→R drops bad ")", R→L drops bad "("
extra ")" foundby tryingstack empty when ")" comesopen = 0 when ")" comes
extra "(" foundby tryingindices left in the stack at the endclose = 0 when "(" comes (in pass 2)
timeexponentialO(n)O(n)
extra spaceO(n)stack + array + outputoutput only
inputanswer (stack version)why
lee(t(c)o)de)lee(t(c)o)delast ")" has no partner
a)b(c)dab(c)dfirst ")" has no partner
))(((empty)nothing can pair
lee((((t)lee(t)3 extra openers
(((())))(((())))already valid
If you remember only 5 lines 1. Letters are always kept; only brackets are decided.
2. A ")" with no waiting "(" must go; a "(" that never meets a ")" must go.
3. Stack version: push indices of "(", pop on ")", mark the bad ones with #, then skip the #s.
4. Mark instead of delete: no O(n) shifting.
5. One bracket type → counters instead of a stack: L→R pass, then R→L pass, then reverse.
Mistakes to avoid ✗ pushing "(" characters instead of indices (you can't find the extra ones)
✗ forgetting the leftover openers after the loop
✗ deleting from the middle of the string inside the loop (O(n²))
✗ popping an empty stack on a ")"
✗ running pass 2 on s instead of pass 1's result
✗ forgetting to reverse after the right-to-left pass
test it yourself (outputs shown are from Parts B and C; the brute force may print another valid answer)
s = Solution()
print(s.minRemoveToMakeValid("lee(t(c)o)de)"))   # lee(t(c)o)de
print(s.minRemoveToMakeValid("a)b(c)d"))         # ab(c)d
print(repr(s.minRemoveToMakeValid("))((")))      # ''
print(s.minRemoveToMakeValid("lee((((t)"))       # lee(t)
print(s.minRemoveToMakeValid("(((())))"))        # (((())))
print(s.minRemoveToMakeValid(")(((le(c))"))      # (le(c))

Based on this video: Minimum Remove to Make Valid Parentheses | Stack + Greedy