Skip to content

678. Valid Parenthesis String (Medium)

Problem

Given a string s containing only '(', ')', and '*', return true if s is a valid parenthesis string. '*' may represent '(', ')', or the empty string.

Example

  • s = "()"true
  • s = "(*)"true
  • s = "(*))"true
  • s = "(("false

LeetCode 678 · Link · Medium

Try it yourself

idle

Starter code: this editor begins with intentional TODOs. Fill the function, run the embedded tests, then compare your solution with the worked approaches below.

Click Run Python to execute. First run downloads Python (~10 MB, cached after that).

Approach 1: Brute force, try every * interpretation

Enumerate 3^(count of *) interpretations; test each as a plain parenthesis string. Exponential, skip past tiny inputs.

from itertools import product
def check_valid_string(s):
star_positions = [i for i, ch in enumerate(s) if ch == '*']
# Each star can be '(', ')', or '' (empty)
for assignment in product('()_', repeat=len(star_positions)): # L1: 3^k combos
candidate = list(s)
for pos, ch in zip(star_positions, assignment):
candidate[pos] = '' if ch == '_' else ch
# Validate as plain parens
balance = 0
ok = True
for c in candidate:
if not c: continue
balance += 1 if c == '(' else -1
if balance < 0: ok = False; break
if ok and balance == 0:
return True
return False

For each of the 3^k assignments to k stars, run a linear validation. Total O(3kn)O(3^k · n). The DP and two-pointer approaches below are dramatically better.

Complexity

  • Time: O(3kn)O(3^k · n), where k = number of *.
  • Space: O(n)O(n).

Approach 2: Top-down DP on (index, open_count)

State: position and currently unclosed ( count. Transitions depend on the character.

from functools import lru_cache
def check_valid_string(s):
@lru_cache(maxsize=None)
def f(i, opens): # L1: O(n²) unique states
if opens < 0:
return False # L2: prune negative opens
if i == len(s):
return opens == 0 # L3: valid iff balanced
if s[i] == '(':
return f(i + 1, opens + 1) # L4: O(1) per memoized call
if s[i] == ')':
return f(i + 1, opens - 1) # L5: O(1)
# '*': try all three interpretations
return (f(i + 1, opens + 1) # L6: treat as '('
or f(i + 1, opens) # L7: treat as empty
or f(i + 1, opens - 1)) # L8: treat as ')'
return f(0, 0)

Where the time goes, line by line

Variables: n = len(s).

LinePer-call costTimes executedContribution
L1 (unique states)O(1)O(1)n * nO(n2)O(n²)
L6-L8 (star branches)O(1)O(1) memoizedup to n²O(n2)O(n²) ← dominates

There are O(n2)O(n²) distinct (i, opens) states; each is computed once due to memoization.

Complexity

  • Time: O(n2)O(n²). States = n × n, each O(1)O(1).
  • Space: O(n2)O(n²) for the memo table.

Try this approach:

idle
Click Run Python to execute. First run downloads Python (~10 MB, cached after that).

Approach 3: Two-pointer range of possible open counts (optimal)

Track lo and hi, the minimum and maximum possible number of unclosed ( so far:

  • ( → both lo and hi increase.
  • ) → both decrease; clamp lo at 0.
  • *lo can decrease (if * = )) and hi can increase (if * = ().

If hi < 0 at any point, there are unmatched ). At the end, 0 ∈ [lo, hi] means a valid interpretation exists.

def check_valid_string(s):
lo = hi = 0 # L1: O(1)
for ch in s: # L2: single pass, n iterations
if ch == '(':
lo += 1 # L3: O(1)
hi += 1 # L4: O(1)
elif ch == ')':
lo -= 1 # L5: O(1)
hi -= 1 # L6: O(1)
else:
lo -= 1 # L7: O(1), star acts as ')'
hi += 1 # L8: O(1), star acts as '('
if hi < 0: # L9: too many unmatched ')'
return False
if lo < 0:
lo = 0 # L10: clamp, star already absorbed the deficit
return lo == 0 # L11: O(1)

Where the time goes, line by line

Variables: n = len(s).

LinePer-call costTimes executedContribution
L1 (init)O(1)O(1)1O(1)O(1)
L2-L10 (scan)O(1)O(1)nO(n)O(n) ← dominates
L11 (final check)O(1)O(1)1O(1)O(1)

A single pass over the string; all operations per character are O(1)O(1).

Complexity

  • Time: O(n)O(n), driven by L2/L3-L10 (single linear scan).
  • Space: O(1)O(1).

Two-stack alternative

Push positions of ( onto one stack and positions of * onto another; when you see ), pop from ( first, else from *. At the end, ensure remaining ( positions each have a later *. Same O(n)O(n), more bookkeeping.

Try this approach:

idle
Click Run Python to execute. First run downloads Python (~10 MB, cached after that).

Summary

ApproachTimeSpace
Enumerate interpretations3^kO(k)O(k)
DP on (i, opens)O(n2)O(n²)O(n2)O(n²)
Range of possible opensO(n)O(n)O(1)O(1)

The [lo, hi] range trick is the right answer; * expands the range, ( and ) shift it.

Test cases

func checkValidString(s string) bool {
lo, hi := 0, 0
for _, ch := range s {
if ch == '(' { lo++; hi++ } else if ch == ')' { lo--; hi-- } else { lo--; hi++ }
if hi < 0 { return false }
if lo < 0 { lo = 0 }
}
return lo == 0
}
  • Greedy Algorithms, the local choice pattern protected by an invariant about the best reachable future.
  • Stack Parsing, the last open, first closed model for nested syntax and reversible operations.