LeetCode #678 Medium

Valid Parenthesis String

A string contains (, ), and **, where ** may act as (, ), or an empty string. Return whether the string can be made valid.

Constraints
  • 1 <= s.length <= 100
  • s[i] is '(', ')' or '*'.
greedystringsstack
Open on LeetCode ↗
02

Intuition

Valid parenthesis string adds a wildcard * that can act as (, as ), or as an empty string. That flexibility breaks the plain stack solution, since a * cannot be classified when it is read. Trying all three interpretations at each * is 3ⁿ. The efficient approach tracks a range of possible open-bracket counts rather than a single value: - Maintain low and high — the fewest and most unclosed brackets possible given the choices so far — and the string is valid if zero stays within that range. On (, both bounds increase. On ), both decrease. On *, low decreases and high increases, since the wildcard could be either bracket. Two rules keep the range meaningful. low must never drop below zero. A negative count is impossible, and letting it go negative allows later brackets to be closed against imaginary openings. Clamping it at zero is essential. If high goes negative, the string is invalid immediately — even the most generous interpretation has more closings than openings, and no later character can repair it. At the end, the string is valid when low is zero. A non-zero low means unclosed brackets remain under every interpretation. The greedy is not obvious, and its correctness rests on the range being contiguous — every count between low and high is achievable, so checking the bounds suffices. A two-pass alternative scans left to right treating * as (, then right to left treating it as ). Both must pass. Equally valid and easier to reason about. One pass gives O(n) time and O(1) space.

How to spot this pattern

Track a range of possible open-bracket counts rather than one number. A * widens the range in both directions; low clamps at zero because a wildcard can always be spent as an empty string. Valid means zero stays inside the range at the end.

03

Approach

Try it first

Before reading on: price up what counting everything costs here, then ask why the locally best choice is safe to commit to and never revisit. Aim for O(n) time and O(1) space.

1

See why the stack fails

A * cannot be classified when read — it may be an opening, a closing, or nothing. Trying all three interpretations is exponential.

2

Track a range of open counts

Keep low and high as the fewest and most unclosed brackets possible. Every count between them is achievable, which is what makes the bounds sufficient.

3

Update both bounds per character

( raises both, ) lowers both, and * lowers low while raising high — covering all three of its meanings at once.

4

Clamp low at zero

A negative open count is impossible. Letting low go negative lets later brackets close against openings that never existed.

5

Fail immediately on negative high

If high drops below zero, no interpretation works — even the most generous has excess closings, and no later character can repair it.

6

Require low to end at zero

The string is valid exactly when low is 0 at the end. A positive low means unclosed brackets under every interpretation.

7

Cost of the approach

One pass with two counters gives O(n) time and O(1) space, against 3^n for trying every wildcard assignment.

04

Solution & live demo

▶1class Solution:
▶2 def checkValidString(self, s):
▶3 low = high = 0
▶4 for ch in s:
▶5 if ch == '(':
▶6 low += 1
▶7 high += 1
▶8 elif ch == ')':
▶9 low -= 1
▶10 high -= 1
▶11 else:
▶12 low -= 1
▶13 high += 1
▶14 if high < 0:
▶15 return False
▶16 low = max(low, 0)
▶17 return low == 0
05

Common pitfalls

Letting low go negative

✗ Wrong
low -= 1
✓ Right
low = max(low, 0)

A negative low implies more closers than openers on some interpretation, but that interpretation simply isn't chosen — the wildcards absorb it. Without the clamp, valid strings like "(*)" are rejected.

Checking low < 0 as a failure

✗ Wrong
if low < 0: return False
✓ Right
if high < 0: return False

Failure means no interpretation works, which is exactly high < 0 — even treating every wildcard as an opener leaves unmatched closers. low going negative is recoverable, high is not.

Returning low <= 0

✗ Wrong
return low <= 0
✓ Right
return low == 0

Since low is clamped at zero it can never be negative, so <= 0 is the same test written misleadingly — but the real requirement is that a balanced interpretation exists, meaning low has actually reached 0 rather than merely being non-positive by clamping. Stating == 0 keeps the intent exact.

06

Edge cases

All stars

Every star can be empty, so low stays clamped at 0 and the answer is true.

Leading close bracket, e.g. ")("

high goes negative on the first character, returning false at once.

Forgetting to clamp low

low drifts negative and the final low == 0 test fails on valid strings like "(*)".

Unmatched opens, e.g. "(((*)"

low ends above 0, so no assignment balances the string and false is correct.

07

Complexity

Time
O(n)
Space
O(1)
Two integers replace an O(n^2) DP or an O(3^n) search.