305 lines
13 KiB
YAML
305 lines
13 KiB
YAML
title: Check if a Parentheses String Can Be Valid
|
|
slug: check-if-a-parentheses-string-can-be-valid
|
|
difficulty: medium
|
|
leetcode_id: 2116
|
|
leetcode_url: https://leetcode.com/problems/check-if-a-parentheses-string-can-be-valid/
|
|
categories:
|
|
- strings
|
|
- stack
|
|
patterns:
|
|
- greedy
|
|
|
|
function_signature: "def can_be_valid(s: str, locked: str) -> bool:"
|
|
|
|
test_cases:
|
|
visible:
|
|
- input: { s: "))()))", locked: "010100" }
|
|
expected: true
|
|
- input: { s: "()()", locked: "0000" }
|
|
expected: true
|
|
- input: { s: ")", locked: "0" }
|
|
expected: false
|
|
hidden:
|
|
- input: { s: "()", locked: "11" }
|
|
expected: true
|
|
- input: { s: ")(", locked: "00" }
|
|
expected: true
|
|
- input: { s: "((", locked: "11" }
|
|
expected: false
|
|
- input: { s: "))", locked: "11" }
|
|
expected: false
|
|
- input: { s: "((()", locked: "0000" }
|
|
expected: true
|
|
- input: { s: "((()))", locked: "111111" }
|
|
expected: true
|
|
|
|
description: |
|
|
A parentheses string is a **non-empty** string consisting only of `'('` and `')'`. It is valid if **any** of the following conditions is **true**:
|
|
|
|
- It is `()`.
|
|
- It can be written as `AB` (`A` concatenated with `B`), where `A` and `B` are valid parentheses strings.
|
|
- It can be written as `(A)`, where `A` is a valid parentheses string.
|
|
|
|
You are given a parentheses string `s` and a string `locked`, both of length `n`. `locked` is a binary string consisting only of `'0'`s and `'1'`s. For **each** index `i` of `locked`:
|
|
|
|
- If `locked[i]` is `'1'`, you **cannot** change `s[i]`.
|
|
- But if `locked[i]` is `'0'`, you **can** change `s[i]` to either `'('` or `')'`.
|
|
|
|
Return `true` *if you can make `s` a valid parentheses string*. Otherwise, return `false`.
|
|
|
|
constraints: |
|
|
- `n == s.length == locked.length`
|
|
- `1 <= n <= 10^5`
|
|
- `s[i]` is either `'('` or `')'`
|
|
- `locked[i]` is either `'0'` or `'1'`
|
|
|
|
examples:
|
|
- input: 's = "))()))", locked = "010100"'
|
|
output: "true"
|
|
explanation: "locked[1] == '1' and locked[3] == '1', so we cannot change s[1] or s[3]. We change s[0] and s[4] to '(' while leaving s[2] and s[5] unchanged to make s valid."
|
|
- input: 's = "()()", locked = "0000"'
|
|
output: "true"
|
|
explanation: "We do not need to make any changes because s is already valid."
|
|
- input: 's = ")", locked = "0"'
|
|
output: "false"
|
|
explanation: "locked permits us to change s[0]. Changing s[0] to either '(' or ')' will not make s valid."
|
|
- input: 's = "(((())(((())", locked = "111111010111"'
|
|
output: "true"
|
|
explanation: "locked permits us to change s[6] and s[8]. We change s[6] and s[8] to ')' to make s valid."
|
|
|
|
explanation:
|
|
intuition: |
|
|
Think of parentheses validation like a balance scale. Each `'('` adds weight to one side, and each `')'` removes it. For the string to be valid, the scale must never tip negative (more closing than opening) at any point, and must end perfectly balanced.
|
|
|
|
The twist here is that some characters are "wild" (unlocked) — they can become either `'('` or `')'`. This gives us flexibility, but we need to use it wisely.
|
|
|
|
The key insight is to think in terms of **ranges** rather than exact counts. At any position, we don't need to know the *exact* balance — we need to know the *possible range* of balances. An unlocked character contributes uncertainty: it could add +1 (if we treat it as `'('`) or -1 (if we treat it as `')'`).
|
|
|
|
By tracking the minimum and maximum possible balance as we scan left-to-right, we can determine if there's *any* valid assignment. If our minimum ever exceeds our maximum (impossible range), or if we can't reach exactly zero balance at the end, the answer is false.
|
|
|
|
approach: |
|
|
We use a **Two-Pass Greedy** approach, checking validity from both directions:
|
|
|
|
**Step 1: Check the length parity**
|
|
|
|
- If the string length is odd, return `false` immediately — valid parentheses always have even length
|
|
|
|
|
|
|
|
**Step 2: Left-to-right pass (check for excess closing brackets)**
|
|
|
|
- Track `balance`: increment for `'('`, decrement for `')'`
|
|
- Track `wildcards`: count of unlocked positions we can use as `'('`
|
|
- For each locked `')'`, we need either a prior `'('` or an available wildcard
|
|
- If `balance + wildcards < 0`, we have too many unmatched `')'` — return `false`
|
|
|
|
|
|
|
|
**Step 3: Right-to-left pass (check for excess opening brackets)**
|
|
|
|
- Reset and scan from right to left
|
|
- Now track closing brackets `')'` and wildcards that could become `')'`
|
|
- For each locked `'('`, we need either a later `')'` or an available wildcard
|
|
- If `balance + wildcards < 0`, we have too many unmatched `'('` — return `false`
|
|
|
|
|
|
|
|
**Step 4: Return the result**
|
|
|
|
- If both passes succeed, return `true` — a valid assignment exists
|
|
|
|
|
|
|
|
The two-pass approach works because the left-to-right pass ensures we never have excess closing brackets, while the right-to-left pass ensures we never have excess opening brackets.
|
|
|
|
common_pitfalls:
|
|
- title: Single Pass Insufficient
|
|
description: |
|
|
A common mistake is attempting a single left-to-right pass. While this detects excess `')'` brackets, it fails to catch excess `'('` brackets that have no matching `')'` later.
|
|
|
|
For example, `s = "((("` with `locked = "111"` would pass a left-only check but is clearly invalid.
|
|
wrong_approach: "Single left-to-right pass only"
|
|
correct_approach: "Two passes: left-to-right AND right-to-left"
|
|
|
|
- title: Forgetting the Odd Length Check
|
|
description: |
|
|
Valid parentheses strings must have even length — each `'('` needs exactly one matching `')'`. If `n` is odd, no amount of character swapping can make it valid.
|
|
|
|
Checking this upfront saves computation and handles edge cases cleanly.
|
|
wrong_approach: "Proceeding without checking length parity"
|
|
correct_approach: "Return false immediately if length is odd"
|
|
|
|
- title: Treating Wildcards as Both Simultaneously
|
|
description: |
|
|
An unlocked position can be `'('` OR `')'`, not both. Some approaches incorrectly count wildcards twice, leading to false positives.
|
|
|
|
Each wildcard can only satisfy one need — either an unmatched `'('` or an unmatched `')'`, but not both in the same validation.
|
|
wrong_approach: "Counting wildcards for both opening and closing"
|
|
correct_approach: "Track wildcards separately, using them greedily in each pass"
|
|
|
|
key_takeaways:
|
|
- "**Two-pass validation**: When checking constraints from both ends, separate passes can be cleaner than tracking complex ranges"
|
|
- "**Greedy wildcard usage**: Treat flexible elements as satisfying the most urgent need at each step"
|
|
- "**Parity check first**: Simple invariants (like even length) can eliminate impossible cases early"
|
|
- "**Extend to similar problems**: This pattern applies to any matching problem with flexible elements"
|
|
|
|
time_complexity: "O(n). We traverse the string exactly twice — once left-to-right and once right-to-left."
|
|
space_complexity: "O(1). We only use a constant number of variables (`balance`, `wildcards`) regardless of input size."
|
|
|
|
solutions:
|
|
- approach_name: Two-Pass Greedy
|
|
is_optimal: true
|
|
code: |
|
|
def can_be_valid(s: str, locked: str) -> bool:
|
|
n = len(s)
|
|
|
|
# Valid parentheses must have even length
|
|
if n % 2 == 1:
|
|
return False
|
|
|
|
# Left-to-right: ensure we never have excess ')'
|
|
balance = 0 # Count of unmatched '('
|
|
wildcards = 0 # Unlocked chars that could become '('
|
|
|
|
for i in range(n):
|
|
if locked[i] == '0':
|
|
# Unlocked: could be either, save as potential '('
|
|
wildcards += 1
|
|
elif s[i] == '(':
|
|
# Locked '(': adds to balance
|
|
balance += 1
|
|
else:
|
|
# Locked ')': needs matching '(' or wildcard
|
|
if balance > 0:
|
|
balance -= 1
|
|
elif wildcards > 0:
|
|
wildcards -= 1
|
|
else:
|
|
# No way to match this ')'
|
|
return False
|
|
|
|
# Right-to-left: ensure we never have excess '('
|
|
balance = 0 # Count of unmatched ')'
|
|
wildcards = 0 # Unlocked chars that could become ')'
|
|
|
|
for i in range(n - 1, -1, -1):
|
|
if locked[i] == '0':
|
|
# Unlocked: could be either, save as potential ')'
|
|
wildcards += 1
|
|
elif s[i] == ')':
|
|
# Locked ')': adds to balance
|
|
balance += 1
|
|
else:
|
|
# Locked '(': needs matching ')' or wildcard
|
|
if balance > 0:
|
|
balance -= 1
|
|
elif wildcards > 0:
|
|
wildcards -= 1
|
|
else:
|
|
# No way to match this '('
|
|
return False
|
|
|
|
return True
|
|
explanation: |
|
|
**Time Complexity:** O(n) — Two linear passes through the string.
|
|
|
|
**Space Complexity:** O(1) — Only constant extra space used.
|
|
|
|
The two-pass approach leverages greedy matching: in the left-to-right pass, we ensure every locked `')'` can be matched by a prior `'('` or wildcard. In the right-to-left pass, we ensure every locked `'('` can be matched by a later `')'` or wildcard. If both constraints are satisfiable, a valid assignment exists.
|
|
|
|
- approach_name: Range Tracking (Single Pass)
|
|
is_optimal: true
|
|
code: |
|
|
def can_be_valid(s: str, locked: str) -> bool:
|
|
n = len(s)
|
|
|
|
# Valid parentheses must have even length
|
|
if n % 2 == 1:
|
|
return False
|
|
|
|
# Track the range of possible balances
|
|
min_balance = 0 # Minimum possible open count
|
|
max_balance = 0 # Maximum possible open count
|
|
|
|
for i in range(n):
|
|
if locked[i] == '0':
|
|
# Unlocked: could be '(' (+1) or ')' (-1)
|
|
min_balance -= 1
|
|
max_balance += 1
|
|
elif s[i] == '(':
|
|
# Locked '(': must add 1
|
|
min_balance += 1
|
|
max_balance += 1
|
|
else:
|
|
# Locked ')': must subtract 1
|
|
min_balance -= 1
|
|
max_balance -= 1
|
|
|
|
# Can't have negative balance (more ')' than '(')
|
|
if max_balance < 0:
|
|
return False
|
|
|
|
# Keep min_balance non-negative (we choose '(' for wildcards when needed)
|
|
min_balance = max(min_balance, 0)
|
|
|
|
# Must be able to reach exactly 0 balance
|
|
return min_balance == 0
|
|
explanation: |
|
|
**Time Complexity:** O(n) — Single pass through the string.
|
|
|
|
**Space Complexity:** O(1) — Only two variables for tracking range.
|
|
|
|
This elegant approach tracks the *range* of possible balances rather than a single value. Unlocked characters expand the range (could be +1 or -1), while locked characters shift it. If `max_balance` ever goes negative, even the most optimistic assignment fails. If `min_balance` can reach 0 at the end, a valid assignment exists.
|
|
|
|
- approach_name: Stack-Based Simulation
|
|
is_optimal: false
|
|
code: |
|
|
def can_be_valid(s: str, locked: str) -> bool:
|
|
n = len(s)
|
|
|
|
# Valid parentheses must have even length
|
|
if n % 2 == 1:
|
|
return False
|
|
|
|
# Stacks to track indices
|
|
locked_opens = [] # Indices of locked '('
|
|
wildcards = [] # Indices of unlocked characters
|
|
|
|
for i in range(n):
|
|
if locked[i] == '0':
|
|
# Unlocked character
|
|
wildcards.append(i)
|
|
elif s[i] == '(':
|
|
# Locked '('
|
|
locked_opens.append(i)
|
|
else:
|
|
# Locked ')': match with '(' or wildcard
|
|
if locked_opens:
|
|
locked_opens.pop()
|
|
elif wildcards:
|
|
wildcards.pop()
|
|
else:
|
|
return False
|
|
|
|
# Match remaining locked '(' with wildcards
|
|
while locked_opens and wildcards:
|
|
# Wildcard must come AFTER the '(' to act as ')'
|
|
if locked_opens[-1] < wildcards[-1]:
|
|
locked_opens.pop()
|
|
wildcards.pop()
|
|
else:
|
|
return False
|
|
|
|
# Any remaining locked '(' can't be matched
|
|
if locked_opens:
|
|
return False
|
|
|
|
# Remaining wildcards must pair with each other (even count)
|
|
return len(wildcards) % 2 == 0
|
|
explanation: |
|
|
**Time Complexity:** O(n) — Single pass to process, plus cleanup of stacks.
|
|
|
|
**Space Complexity:** O(n) — Stacks could hold up to n/2 indices each.
|
|
|
|
This approach explicitly tracks positions using stacks. While correct, it uses more space than necessary. The stack-based method is useful for understanding the matching process but the range-tracking approach is more elegant for this problem.
|