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:
  - slug: greedy
    is_optimal: true

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

    &nbsp;

    **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`

    &nbsp;

    **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`

    &nbsp;

    **Step 4: Return the result**

    - If both passes succeed, return `true` — a valid assignment exists

    &nbsp;

    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.
